diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
 
 ## Unreleased
 
+## 0.3.0.1 — 2026-07-14
+
+### Bug Fixes
+
+* A failure raised inside an opaque `runTransaction` body now preserves its
+  SQLSTATE and server message. Anything that was not a `stream_events_pkey`
+  duplicate previously went through the append-shaped mapper with a
+  `<transaction>` sentinel stream, so e.g. a foreign-key violation surfaced as
+  `StreamNotFound (StreamName "<transaction>")` and lost the real diagnostics.
+  Such failures now map through the generic mapper — the foreign-key case above
+  becomes `UnexpectedServerError "23503"` with the server's message intact.
+  Duplicate-event mapping is unchanged.
+
 ## 0.3.0.0 — 2026-07-11
 
 This release requires migrations `0003`–`0007` from `kiroku-store-migrations`.
diff --git a/kiroku-store.cabal b/kiroku-store.cabal
--- a/kiroku-store.cabal
+++ b/kiroku-store.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.0
 name:            kiroku-store
-version:         0.3.0.0
+version:         0.3.0.1
 synopsis:        High-performance PostgreSQL event store
 description:
   Kiroku is a PostgreSQL-backed event store for Haskell applications. It
diff --git a/src/Kiroku/Store/Error.hs b/src/Kiroku/Store/Error.hs
--- a/src/Kiroku/Store/Error.hs
+++ b/src/Kiroku/Store/Error.hs
@@ -183,8 +183,8 @@
 {- | Map a hasql 'UsageError' raised inside an opaque transaction body.
 
 The body has no stream context, so append duplicate violations are mapped
-directly to 'DuplicateEvent' and all other failures fall back to the standard
-append-shaped mapper with sentinel stream context.
+directly to 'DuplicateEvent'. Every other failure uses the generic mapper so
+its SQLSTATE and server message survive without inventing stream context.
 -}
 mapTransactionUsageError :: UsageError -> StoreError
 mapTransactionUsageError usageErr =
@@ -195,7 +195,7 @@
             | containsConstraint "stream_events_pkey" message detail ->
                 DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))
         _ ->
-            mapUsageError "<transaction>" AnyVersion usageErr
+            mapGenericUsageError usageErr
   where
     containsConstraint name message detail =
         name `T.isInfixOf` message || maybe False (T.isInfixOf name) detail
diff --git a/test/Test/Transaction.hs b/test/Test/Transaction.hs
--- a/test/Test/Transaction.hs
+++ b/test/Test/Transaction.hs
@@ -38,6 +38,21 @@
                 Right n -> n `shouldBe` (1 :: Int)
                 Left err -> expectationFailure ("Expected Right 1, got: " <> show err)
 
+        it "preserves SQLSTATE and message for an opaque foreign-key failure" $ \store -> do
+            createForeignKeyTables (store ^. #pool)
+            result <-
+                runStoreIO store $
+                    runTransaction $
+                        Tx.statement () insertMissingForeignKeyStmt
+            case result of
+                Left (UnexpectedServerError code message) -> do
+                    code `shouldBe` "23503"
+                    T.toLower message `shouldSatisfy` T.isInfixOf "foreign key"
+                Left (StreamNotFound (StreamName "<transaction>")) ->
+                    expectationFailure "foreign-key failure was misclassified as a missing <transaction> stream"
+                other ->
+                    expectationFailure ("Expected UnexpectedServerError 23503, got: " <> show other)
+
     describe "appendToStreamTx (driven inside runTransaction)" $ do
         it "appends events and a side-table row in one atomic transaction" $ \store -> do
             createSideTable (store ^. #pool)
@@ -326,6 +341,27 @@
         E.noParams
         D.noResult
 
+createForeignKeyParentStmt :: Statement () ()
+createForeignKeyParentStmt =
+    unpreparable
+        "CREATE TABLE IF NOT EXISTS test_tx_parent (id BIGINT PRIMARY KEY)"
+        E.noParams
+        D.noResult
+
+createForeignKeyChildStmt :: Statement () ()
+createForeignKeyChildStmt =
+    unpreparable
+        "CREATE TABLE IF NOT EXISTS test_tx_child (parent_id BIGINT NOT NULL REFERENCES test_tx_parent(id))"
+        E.noParams
+        D.noResult
+
+insertMissingForeignKeyStmt :: Statement () ()
+insertMissingForeignKeyStmt =
+    preparable
+        "INSERT INTO test_tx_child (parent_id) VALUES (999999)"
+        E.noParams
+        D.noResult
+
 {- | Insert a row into the side table. The @id@ doubles as the foreign
 key onto the stream the transaction appended to.
 -}
@@ -353,6 +389,14 @@
     case r of
         Left err -> error ("createSideTable failed: " <> show err)
         Right () -> pure ()
+
+createForeignKeyTables :: Pool -> IO ()
+createForeignKeyTables pool = do
+    parent <- Pool.use pool (Session.statement () createForeignKeyParentStmt)
+    child <- Pool.use pool (Session.statement () createForeignKeyChildStmt)
+    case (parent, child) of
+        (Right (), Right ()) -> pure ()
+        other -> error ("createForeignKeyTables failed: " <> show other)
 
 countSideTable :: Pool -> IO Int64
 countSideTable pool = do
