diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
 # Changelog
 
+## 0.14.0.0 — 2026-08-10
+
+This release adopts the application-defined dead-letter reason API from `shibuya-core` 0.9.
+The adapter's Haskell API is unchanged, while its PGMQ dead-letter payload gains structured,
+machine-queryable reason fields.
+
+### Breaking Changes
+
+- Requires `shibuya-core ^>=0.9.0.0`, up from `^>=0.8.0.1`. The dependency release extends
+  `DeadLetterReason` with `ApplicationFailure` and adds total public reason projections.
+
+### Dead-Letter Queues
+
+- Every new DLQ payload retains the compatibility field `dead_letter_reason` and adds
+  `dead_letter_reason_code` plus an always-present `dead_letter_reason_detail`. Reasons without
+  detail encode that last field as JSON `null`.
+- Application-owned dead-letter codes and details are transported verbatim through the public
+  Shibuya projections; the adapter no longer duplicates Shibuya's constructor renderer.
+
 ## 0.13.0.0 — 2026-08-09
 
 Driven by the `pgmq-hs` 0.5 release. The adapter remains paired with
diff --git a/shibuya-pgmq-adapter.cabal b/shibuya-pgmq-adapter.cabal
--- a/shibuya-pgmq-adapter.cabal
+++ b/shibuya-pgmq-adapter.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.12
 name: shibuya-pgmq-adapter
-version: 0.13.0.0
+version: 0.14.0.0
 synopsis: PGMQ adapter for the Shibuya queue processing framework
 description:
   A Shibuya adapter that integrates with pgmq (PostgreSQL Message Queue)
@@ -49,7 +49,7 @@
     pgmq-core ^>=0.5,
     pgmq-effectful ^>=0.5,
     pgmq-hasql ^>=0.5,
-    shibuya-core ^>=0.8.0.1,
+    shibuya-core ^>=0.9.0.0,
     stm ^>=2.5,
     streamly ^>=0.11,
     streamly-core ^>=0.3,
@@ -119,7 +119,7 @@
     pgmq-migration ^>=0.5,
     quickcheck-instances ^>=0.3,
     random,
-    shibuya-core ^>=0.8.0.1,
+    shibuya-core ^>=0.9.0.0,
     shibuya-pgmq-adapter,
     stm,
     streamly ^>=0.11,
diff --git a/src/Shibuya/Adapter/Pgmq/Convert.hs b/src/Shibuya/Adapter/Pgmq/Convert.hs
--- a/src/Shibuya/Adapter/Pgmq/Convert.hs
+++ b/src/Shibuya/Adapter/Pgmq/Convert.hs
@@ -25,7 +25,13 @@
 import Data.Text qualified as Text
 import Data.Text.Encoding qualified as TE
 import Pgmq.Types qualified as Pgmq
-import Shibuya.Core.Ack (DeadLetterReason (..))
+import Shibuya.Core.Ack
+  ( DeadLetterReason,
+    deadLetterCodeText,
+    deadLetterReasonCode,
+    deadLetterReasonDetail,
+    renderDeadLetterReason,
+  )
 import Shibuya.Core.Types (Attempt (..), Cursor (..), Envelope (..), MessageId (..), TraceHeaders)
 
 -- | Convert a pgmq MessageId to a Shibuya MessageId.
@@ -142,12 +148,17 @@
   -- | DLQ message body
   Pgmq.MessageBody
 mkDlqPayload msg reason includeMetadata =
-  Pgmq.MessageBody $
-    object $
-      [ "original_message" .= Pgmq.unMessageBody msg.body,
-        "dead_letter_reason" .= reasonToText reason
-      ]
-        ++ metadataFields
+  let rendered = renderDeadLetterReason reason
+      code = deadLetterCodeText (deadLetterReasonCode reason)
+      detail = deadLetterReasonDetail reason
+   in Pgmq.MessageBody $
+        object $
+          [ "original_message" .= Pgmq.unMessageBody msg.body,
+            "dead_letter_reason" .= rendered,
+            "dead_letter_reason_code" .= code,
+            "dead_letter_reason_detail" .= detail
+          ]
+            ++ metadataFields
   where
     metadataFields
       | includeMetadata =
@@ -158,9 +169,3 @@
             "original_headers" .= msg.headers
           ]
       | otherwise = []
-
-    reasonToText :: DeadLetterReason -> Text
-    reasonToText = \case
-      PoisonPill t -> "poison_pill: " <> t
-      InvalidPayload t -> "invalid_payload: " <> t
-      MaxRetriesExceeded -> "max_retries_exceeded"
diff --git a/test/Shibuya/Adapter/Pgmq/ChaosSpec.hs b/test/Shibuya/Adapter/Pgmq/ChaosSpec.hs
--- a/test/Shibuya/Adapter/Pgmq/ChaosSpec.hs
+++ b/test/Shibuya/Adapter/Pgmq/ChaosSpec.hs
@@ -20,9 +20,13 @@
 import Data.Vector qualified as Vector
 import Effectful (Eff, IOE, liftIO, runEff, (:>))
 import Effectful.Error.Static (Error, runErrorNoCallStack)
+import Hasql.Decoders qualified as D
 import Hasql.Pool qualified as Pool
+import Hasql.Session qualified as Session
+import Hasql.Statement qualified as Statement
 import Pgmq.Effectful (Pgmq, PgmqRuntimeError, runPgmq)
 import Pgmq.Effectful qualified as PgmqEff
+import Pgmq.Hasql.Encoders qualified as Encoders
 import Pgmq.Hasql.Sessions qualified as Sessions
 import Pgmq.Hasql.Statements.Types (ReadMessage (..), SendMessage (..), SendMessageWithHeaders (..))
 import Pgmq.Types (MessageBody (..), MessageHeaders (..), QueueName)
@@ -48,7 +52,7 @@
     runApp,
     stopAppGracefully,
   )
-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..))
+import Shibuya.Core.Ack (AckDecision (..), DeadLetterCode, DeadLetterReason (..), HaltReason (..), mkDeadLetterCode)
 import Shibuya.Core.AckHandle (AckHandle (..))
 import Shibuya.Core.Ingested (Ingested (..))
 import Shibuya.Handler (Handler)
@@ -205,6 +209,95 @@
             }
     Vector.length mainCount `shouldBe` 0
 
+  it "preserves an application reason as queryable JSONB fields" $ \TestFixture {pool, queueName, dlqName} -> do
+    let detail = "selected 101 recipients; configured limit is 100" :: Text.Text
+        rendered = "keiro.router.selection.recipient_overflow: " <> detail
+
+    runPgmqSession pool $ do
+      _ <-
+        Sessions.sendMessage $
+          SendMessage
+            { queueName = queueName,
+              messageBody = MessageBody (String "application-failure"),
+              delay = Just 0
+            }
+      pure ()
+
+    processedRef <- newIORef (0 :: Int)
+    let config =
+          (defaultConfig queueName)
+            { visibilityTimeout = 5,
+              batchSize = 1,
+              polling = StandardPolling {pollInterval = 0.1},
+              deadLetterConfig = Just $ directDeadLetter dlqName False
+            }
+
+    runAdapterIO pool $ runTracingNoop $ do
+      adapter <- requireAdapter pool config
+      let handler = applicationFailureHandler processedRef
+          processor = mkProcessor adapter handler
+
+      result <- runApp defaultAppConfig [(ProcessorId "application-dlq-test", processor)]
+      case result of
+        Left err -> liftIO $ expectationFailure $ "Failed to start app: " <> show err
+        Right appHandle -> do
+          liftIO $ waitForProcessed processedRef 1 3000000
+          _ <- stopAppGracefully ShutdownConfig {drainTimeout = 5} appHandle
+          pure ()
+
+    applicationInspection <- runPgmqSession pool $ inspectNextDlqPayload dlqName
+    applicationSize <- case applicationInspection of
+      Just (Just storedRendered, Just storedCode, Just storedDetail, storedSize) -> do
+        storedRendered `shouldBe` rendered
+        storedCode `shouldBe` "keiro.router.selection.recipient_overflow"
+        storedDetail `shouldBe` detail
+        storedSize `shouldSatisfy` (> 0)
+        pure storedSize
+      other -> expectationFailure ("expected queryable application DLQ fields, got " <> show other) >> pure 0
+
+    let legacyControl =
+          object
+            [ "original_message" .= String "application-failure",
+              "dead_letter_reason" .= rendered
+            ]
+    runPgmqSession pool $ do
+      _ <-
+        Sessions.sendMessage $
+          SendMessage
+            { queueName = dlqName,
+              messageBody = MessageBody legacyControl,
+              delay = Just 0
+            }
+      pure ()
+
+    legacyInspection <- runPgmqSession pool $ inspectNextDlqPayload dlqName
+    legacySize <- case legacyInspection of
+      Just (Just storedRendered, Nothing, Nothing, storedSize) -> do
+        storedRendered `shouldBe` rendered
+        storedSize `shouldSatisfy` (> 0)
+        pure storedSize
+      other -> expectationFailure ("expected legacy-shaped DLQ control, got " <> show other) >> pure 0
+
+    putStrLn $
+      "DLQ-PAYLOAD-SIZE-DIAG: dualWriteJsonb="
+        <> show applicationSize
+        <> "B legacyJsonb="
+        <> show legacySize
+        <> "B delta="
+        <> show (applicationSize - legacySize)
+        <> "B"
+
+    sourceMessages <-
+      runPgmqSession pool $
+        Sessions.readMessage $
+          ReadMessage
+            { queueName = queueName,
+              delay = 30,
+              batchSize = Just 1,
+              conditional = Nothing
+            }
+    Vector.length sourceMessages `shouldBe` 0
+
   it "preserves trace headers when moving to DLQ" $ \TestFixture {pool, queueName, dlqName} -> do
     -- Send a message with trace headers
     let traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" :: Text.Text
@@ -855,6 +948,42 @@
 deadLetterHandler processedRef _ = do
   liftIO $ atomicModifyIORef' processedRef (\n -> (n + 1, ()))
   pure $ AckDeadLetter (PoisonPill "Test dead-letter")
+
+applicationFailureHandler :: (IOE :> es) => IORef Int -> Handler es Value
+applicationFailureHandler processedRef _ = do
+  liftIO $ atomicModifyIORef' processedRef (\n -> (n + 1, ()))
+  pure $
+    AckDeadLetter $
+      ApplicationFailure
+        representativeDeadLetterCode
+        "selected 101 recipients; configured limit is 100"
+
+representativeDeadLetterCode :: DeadLetterCode
+representativeDeadLetterCode =
+  case mkDeadLetterCode "keiro.router.selection.recipient_overflow" of
+    Left err -> error (Text.unpack err)
+    Right code -> code
+
+inspectNextDlqPayload :: QueueName -> Session.Session (Maybe (Maybe Text.Text, Maybe Text.Text, Maybe Text.Text, Int32))
+inspectNextDlqPayload queueName = Session.statement queueName inspectDlqPayloadStatement
+
+inspectDlqPayloadStatement :: Statement.Statement QueueName (Maybe (Maybe Text.Text, Maybe Text.Text, Maybe Text.Text, Int32))
+inspectDlqPayloadStatement =
+  Statement.preparable sql Encoders.queueNameEncoder decoder
+  where
+    sql =
+      "SELECT message->>'dead_letter_reason', "
+        <> "message->>'dead_letter_reason_code', "
+        <> "message->>'dead_letter_reason_detail', "
+        <> "pg_column_size(message) "
+        <> "FROM pgmq.read($1, 30, 1, '{}'::jsonb)"
+    decoder =
+      D.rowMaybe $
+        (,,,)
+          <$> D.column (D.nullable D.text)
+          <*> D.column (D.nullable D.text)
+          <*> D.column (D.nullable D.text)
+          <*> D.column (D.nonNullable D.int4)
 
 -- | Handler that takes a specified delay before acking.
 slowHandler :: (IOE :> es) => IORef Int -> Int -> Handler es Value
diff --git a/test/Shibuya/Adapter/Pgmq/ConvertSpec.hs b/test/Shibuya/Adapter/Pgmq/ConvertSpec.hs
--- a/test/Shibuya/Adapter/Pgmq/ConvertSpec.hs
+++ b/test/Shibuya/Adapter/Pgmq/ConvertSpec.hs
@@ -7,7 +7,7 @@
 import Data.Time (UTCTime (..), fromGregorian)
 import Pgmq.Types qualified as Pgmq
 import Shibuya.Adapter.Pgmq.Convert
-import Shibuya.Core.Ack (DeadLetterReason (..))
+import Shibuya.Core.Ack (DeadLetterCode, DeadLetterReason (..), mkDeadLetterCode)
 import Shibuya.Core.Types (Attempt (..), Cursor (..), Envelope (..), MessageId (..))
 import Test.Hspec
 import Test.QuickCheck
@@ -339,6 +339,15 @@
         Object obj -> KeyMap.member "dead_letter_reason" obj `shouldBe` True
         _ -> expectationFailure "Expected Object"
 
+    it "always includes structured reason fields" $ do
+      let Pgmq.MessageBody payload = mkDlqPayload sampleMessage MaxRetriesExceeded False
+      case payload of
+        Object obj -> do
+          KeyMap.lookup "dead_letter_reason_code" obj
+            `shouldBe` Just (String "max_retries_exceeded")
+          KeyMap.lookup "dead_letter_reason_detail" obj `shouldBe` Just Null
+        _ -> expectationFailure "Expected Object"
+
     it "does not include original_message_id" $ do
       let Pgmq.MessageBody payload = mkDlqPayload sampleMessage MaxRetriesExceeded False
       case payload of
@@ -384,27 +393,65 @@
         Object obj -> KeyMap.member "last_read_at" obj `shouldBe` True
         _ -> expectationFailure "Expected Object"
 
-  describe "reason formatting" $ do
-    it "formats MaxRetriesExceeded" $ do
-      let Pgmq.MessageBody payload = mkDlqPayload sampleMessage MaxRetriesExceeded False
+    it "retains all reason fields unchanged" $ do
+      let Pgmq.MessageBody payload = mkDlqPayload sampleMessage MaxRetriesExceeded True
       case payload of
-        Object obj ->
+        Object obj -> do
           KeyMap.lookup "dead_letter_reason" obj
             `shouldBe` Just (String "max_retries_exceeded")
+          KeyMap.lookup "dead_letter_reason_code" obj
+            `shouldBe` Just (String "max_retries_exceeded")
+          KeyMap.lookup "dead_letter_reason_detail" obj `shouldBe` Just Null
         _ -> expectationFailure "Expected Object"
 
-    it "formats PoisonPill with message" $ do
-      let Pgmq.MessageBody payload = mkDlqPayload sampleMessage (PoisonPill "corrupt data") False
-      case payload of
-        Object obj ->
-          KeyMap.lookup "dead_letter_reason" obj
-            `shouldBe` Just (String "poison_pill: corrupt data")
-        _ -> expectationFailure "Expected Object"
+  describe "reason fields" $ do
+    let expectedPayload rendered code detail =
+          object
+            [ "original_message" .= object ["data" .= ("test" :: Text.Text)],
+              "dead_letter_reason" .= (rendered :: Text.Text),
+              "dead_letter_reason_code" .= (code :: Text.Text),
+              "dead_letter_reason_detail" .= (detail :: Maybe Text.Text)
+            ]
+        payloadFor reason =
+          let Pgmq.MessageBody payload = mkDlqPayload sampleMessage reason False
+           in payload
 
-    it "formats InvalidPayload with message" $ do
-      let Pgmq.MessageBody payload = mkDlqPayload sampleMessage (InvalidPayload "parse error") False
-      case payload of
-        Object obj ->
-          KeyMap.lookup "dead_letter_reason" obj
-            `shouldBe` Just (String "invalid_payload: parse error")
-        _ -> expectationFailure "Expected Object"
+    it "writes the exact PoisonPill object" $ do
+      payloadFor (PoisonPill "corrupt data")
+        `shouldBe` expectedPayload "poison_pill: corrupt data" "poison_pill" (Just "corrupt data")
+
+    it "writes the exact InvalidPayload object" $ do
+      payloadFor (InvalidPayload "parse error")
+        `shouldBe` expectedPayload "invalid_payload: parse error" "invalid_payload" (Just "parse error")
+
+    it "writes the exact MaxRetriesExceeded object with null detail" $ do
+      payloadFor MaxRetriesExceeded
+        `shouldBe` expectedPayload "max_retries_exceeded" "max_retries_exceeded" Nothing
+
+    it "writes the exact ApplicationFailure object" $ do
+      payloadFor (ApplicationFailure representativeDeadLetterCode "selected 101 recipients; configured limit is 100")
+        `shouldBe` expectedPayload
+          "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"
+          "keiro.router.selection.recipient_overflow"
+          (Just "selected 101 recipients; configured limit is 100")
+
+    it "preserves empty application detail as an empty string" $ do
+      payloadFor (ApplicationFailure representativeDeadLetterCode "")
+        `shouldBe` expectedPayload
+          "keiro.router.selection.recipient_overflow: "
+          "keiro.router.selection.recipient_overflow"
+          (Just "")
+
+    it "preserves Unicode, quotes, backslashes, and colons in structured detail" $ do
+      let detail = "選択失敗: recipient=\"north\" \\ retry" :: Text.Text
+      payloadFor (ApplicationFailure representativeDeadLetterCode detail)
+        `shouldBe` expectedPayload
+          ("keiro.router.selection.recipient_overflow: " <> detail)
+          "keiro.router.selection.recipient_overflow"
+          (Just detail)
+
+representativeDeadLetterCode :: DeadLetterCode
+representativeDeadLetterCode =
+  case mkDeadLetterCode "keiro.router.selection.recipient_overflow" of
+    Left err -> error (Text.unpack err)
+    Right code -> code
diff --git a/test/Shibuya/Adapter/Pgmq/PropertySpec.hs b/test/Shibuya/Adapter/Pgmq/PropertySpec.hs
--- a/test/Shibuya/Adapter/Pgmq/PropertySpec.hs
+++ b/test/Shibuya/Adapter/Pgmq/PropertySpec.hs
@@ -1,8 +1,8 @@
-{-# OPTIONS_GHC -Wno-orphans #-}
+{-# OPTIONS_GHC -Wno-orphans -Werror=incomplete-patterns #-}
 
 module Shibuya.Adapter.Pgmq.PropertySpec (spec) where
 
-import Data.Aeson (Value (..))
+import Data.Aeson (Value (..), toJSON)
 import Data.Aeson.KeyMap qualified as KeyMap
 import Data.Int (Int64)
 import Data.Maybe (isJust)
@@ -17,7 +17,15 @@
     pgmqMessageIdToCursor,
     pgmqMessageToEnvelope,
   )
-import Shibuya.Core.Ack (DeadLetterReason (..))
+import Shibuya.Core.Ack
+  ( DeadLetterCode,
+    DeadLetterReason (..),
+    deadLetterCodeText,
+    deadLetterReasonCode,
+    deadLetterReasonDetail,
+    mkDeadLetterCode,
+    renderDeadLetterReason,
+  )
 import Shibuya.Core.Types (Cursor (..), Envelope (..))
 import Test.Hspec
 import Test.QuickCheck
@@ -87,6 +95,21 @@
           Object obj -> KeyMap.member "dead_letter_reason" obj === True
           _ -> property False
 
+  it "writes all reason fields from Shibuya's total public projections" $ property $ \(reason :: DeadLetterReason) (includeMeta :: Bool) ->
+    let msg = mkTestMessage 1
+        Pgmq.MessageBody payload = mkDlqPayload msg reason includeMeta
+     in case payload of
+          Object obj ->
+            conjoin
+              [ KeyMap.lookup "dead_letter_reason" obj
+                  === Just (String (renderDeadLetterReason reason)),
+                KeyMap.lookup "dead_letter_reason_code" obj
+                  === Just (String (deadLetterCodeText (deadLetterReasonCode reason))),
+                KeyMap.lookup "dead_letter_reason_detail" obj
+                  === Just (toJSON (deadLetterReasonDetail reason))
+              ]
+          _ -> property False
+
   it "metadata keys present iff includeMeta is True" $ property $ \(includeMeta :: Bool) ->
     let msg = mkTestMessage 1
         Pgmq.MessageBody payload = mkDlqPayload msg MaxRetriesExceeded includeMeta
@@ -116,7 +139,8 @@
     oneof
       [ pure MaxRetriesExceeded,
         PoisonPill <$> arbitraryText,
-        InvalidPayload <$> arbitraryText
+        InvalidPayload <$> arbitraryText,
+        ApplicationFailure propertyDeadLetterCode <$> arbitraryText
       ]
     where
       arbitraryText :: Gen Text
@@ -125,3 +149,11 @@
   shrink MaxRetriesExceeded = []
   shrink (PoisonPill t) = MaxRetriesExceeded : [PoisonPill (Text.pack t') | t' <- shrink (Text.unpack t)]
   shrink (InvalidPayload t) = MaxRetriesExceeded : [InvalidPayload (Text.pack t') | t' <- shrink (Text.unpack t)]
+  shrink (ApplicationFailure _ t) =
+    MaxRetriesExceeded : [ApplicationFailure propertyDeadLetterCode (Text.pack t') | t' <- shrink (Text.unpack t)]
+
+propertyDeadLetterCode :: DeadLetterCode
+propertyDeadLetterCode =
+  case mkDeadLetterCode "test.property.application_failure" of
+    Left err -> error (Text.unpack err)
+    Right code -> code
