diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
 # Changelog
 
+## 0.9.0.0 — 2026-08-10
+
+### Breaking Changes
+
+- `DeadLetterReason` gains `ApplicationFailure DeadLetterCode Text`. Exhaustive
+  matches must handle the new constructor or migrate to the total
+  `deadLetterReasonCode`, `deadLetterReasonDetail`, and
+  `renderDeadLetterReason` functions. Under PVP this ships as 0.9.0.0;
+  downstream `shibuya-core ^>=0.8` bounds intentionally exclude it and must be
+  reviewed before widening.
+
+### New Features
+
+- Add opaque, startup-validated application dead-letter codes and canonical
+  code/detail projections. Existing poison, invalid-payload, and retry-exhaustion
+  render strings remain byte-for-byte unchanged.
+- Single-message processing spans now emit
+  `shibuya.dead_letter.reason.code` for dead-letter decisions and use the
+  canonical rendered reason as the error status description. Human detail is
+  not an attribute or metric label.
+
 ## 0.8.0.1 — 2026-07-04
 
 ### Other Changes
diff --git a/shibuya-core.cabal b/shibuya-core.cabal
--- a/shibuya-core.cabal
+++ b/shibuya-core.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.12
 name: shibuya-core
-version: 0.8.0.1
+version: 0.9.0.0
 synopsis: Supervised queue processing framework for Haskell
 description:
   A supervised queue processing framework inspired by Broadway (Elixir).
@@ -122,6 +122,7 @@
     Shibuya.Core.RetrySpec
     Shibuya.Core.TypesSpec
     Shibuya.PolicySpec
+    Shibuya.PublicApiSpec
     Shibuya.Runner.BatchProcessorSpec
     Shibuya.Runner.BatcherSpec
     Shibuya.Runner.PartitionOrderingSpec
diff --git a/src/Shibuya.hs b/src/Shibuya.hs
--- a/src/Shibuya.hs
+++ b/src/Shibuya.hs
@@ -33,7 +33,13 @@
     Handler,
     AckDecision (..),
     RetryDelay (..),
+    DeadLetterCode,
+    mkDeadLetterCode,
+    deadLetterCodeText,
     DeadLetterReason (..),
+    deadLetterReasonCode,
+    deadLetterReasonDetail,
+    renderDeadLetterReason,
     HaltReason (..),
     ProcessorHalt (..),
 
@@ -114,7 +120,18 @@
     waitApp,
   )
 import Shibuya.Batch
-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))
+import Shibuya.Core.Ack
+  ( AckDecision (..),
+    DeadLetterCode,
+    DeadLetterReason (..),
+    HaltReason (..),
+    RetryDelay (..),
+    deadLetterCodeText,
+    deadLetterReasonCode,
+    deadLetterReasonDetail,
+    mkDeadLetterCode,
+    renderDeadLetterReason,
+  )
 import Shibuya.Core.AckHandle (AckHandle (..))
 import Shibuya.Core.Error (ConfigError (..), HandlerError (..), PolicyError (..), RuntimeError (..))
 import Shibuya.Core.Ingested (Ingested (..), Message (..), mkIngested, toMessage)
diff --git a/src/Shibuya/Core/Ack.hs b/src/Shibuya/Core/Ack.hs
--- a/src/Shibuya/Core/Ack.hs
+++ b/src/Shibuya/Core/Ack.hs
@@ -6,7 +6,13 @@
     RetryDelay (..),
 
     -- * Dead Letter
+    DeadLetterCode,
+    mkDeadLetterCode,
+    deadLetterCodeText,
     DeadLetterReason (..),
+    deadLetterReasonCode,
+    deadLetterReasonDetail,
+    renderDeadLetterReason,
 
     -- * Halt
     HaltReason (..),
@@ -16,21 +22,121 @@
   )
 where
 
+import Data.Char (isAsciiLower)
+import Data.Text qualified as Text
 import Shibuya.Prelude
 
 -- | Delay before retry.
 newtype RetryDelay = RetryDelay {unRetryDelay :: NominalDiffTime}
   deriving stock (Eq, Show)
 
+-- | A stable, machine-queryable application dead-letter identifier.
+--
+-- The constructor is intentionally private. Validate a finite set of codes
+-- during application startup, retain the resulting values in configuration,
+-- and reuse them in handlers rather than validating on every message.
+newtype DeadLetterCode = DeadLetterCode Text
+  deriving stock (Eq, Ord, Show)
+
+-- | Validate an application-owned dead-letter code.
+--
+-- A valid code is at most 128 ASCII characters and contains at least two
+-- dot-separated segments. Each segment starts with a lowercase ASCII letter
+-- and then contains only lowercase ASCII letters, digits, or underscores. The
+-- first segment @shibuya@ is reserved for framework-owned codes.
+mkDeadLetterCode :: Text -> Either Text DeadLetterCode
+mkDeadLetterCode code
+  | Text.null code = invalid "must not be empty"
+  | Text.length (Text.take 129 code) > 128 = invalid "must contain at most 128 ASCII characters"
+  | length segments < 2 = invalid "must contain at least two dot-separated segments"
+  | Just segment <- firstInvalidSegment segments =
+      invalid $ Text.concat ["segment \"", segment, "\" must match [a-z][a-z0-9_]*"]
+  | hasReservedFirstSegment segments = invalid "must not use the reserved first segment \"shibuya\""
+  | otherwise = Right (DeadLetterCode code)
+  where
+    segments = Text.splitOn "." code
+    invalid rule = Left $ Text.concat ["invalid dead-letter code \"", code, "\": ", rule]
+
+-- | Unwrap a validated dead-letter code for storage, tracing, or logging.
+deadLetterCodeText :: DeadLetterCode -> Text
+deadLetterCodeText (DeadLetterCode code) = code
+
 -- | Why a message is being dead-lettered.
 data DeadLetterReason
-  = -- | Message is permanently unprocessable
+  = -- | The message is permanently unprocessable, despite retries.
     PoisonPill !Text
-  | -- | Message payload failed validation/parsing
+  | -- | The message payload failed parsing or structural validation.
     InvalidPayload !Text
-  | -- | Retry limit exceeded
+  | -- | The framework's retry limit was exceeded.
     MaxRetriesExceeded
+  | -- | A syntactically valid message was permanently rejected by
+    -- application policy.
+    --
+    -- The application owns the stability of the code. Detail is transported
+    -- verbatim for operators and must not contain secrets, unrestricted
+    -- backend errors, raw SQL, or full payloads.
+    ApplicationFailure !DeadLetterCode !Text
   deriving stock (Eq, Show, Generic)
+
+-- | Return the stable machine-facing code for any dead-letter reason.
+deadLetterReasonCode :: DeadLetterReason -> DeadLetterCode
+deadLetterReasonCode (PoisonPill _) = poisonPillCode
+deadLetterReasonCode (InvalidPayload _) = invalidPayloadCode
+deadLetterReasonCode MaxRetriesExceeded = maxRetriesExceededCode
+deadLetterReasonCode (ApplicationFailure code _) = code
+
+-- | Return human-facing detail when the reason carries it.
+--
+-- Detail is transported verbatim. Applications must keep it operationally
+-- bounded and exclude secrets, raw payloads, raw SQL, and unrestricted
+-- backend error text.
+deadLetterReasonDetail :: DeadLetterReason -> Maybe Text
+deadLetterReasonDetail (PoisonPill detail) = Just detail
+deadLetterReasonDetail (InvalidPayload detail) = Just detail
+deadLetterReasonDetail MaxRetriesExceeded = Nothing
+deadLetterReasonDetail (ApplicationFailure _ detail) = Just detail
+
+-- | Render a reason in Shibuya's canonical compatibility format.
+--
+-- Built-in strings retain their historical encoding. Adapters with
+-- structured storage should prefer 'deadLetterReasonCode' and
+-- 'deadLetterReasonDetail' separately.
+renderDeadLetterReason :: DeadLetterReason -> Text
+renderDeadLetterReason reason =
+  let code = deadLetterCodeText (deadLetterReasonCode reason)
+   in case deadLetterReasonDetail reason of
+        Nothing -> code
+        Just detail -> Text.concat [code, ": ", detail]
+
+firstInvalidSegment :: [Text] -> Maybe Text
+firstInvalidSegment [] = Nothing
+firstInvalidSegment (segment : rest)
+  | validSegment segment = firstInvalidSegment rest
+  | otherwise = Just segment
+
+validSegment :: Text -> Bool
+validSegment segment =
+  case Text.uncons segment of
+    Nothing -> False
+    Just (first, suffix) ->
+      isAsciiLower first && Text.all validSegmentSuffix suffix
+
+validSegmentSuffix :: Char -> Bool
+validSegmentSuffix char =
+  isAsciiLower char || ('0' <= char && char <= '9') || char == '_'
+
+hasReservedFirstSegment :: [Text] -> Bool
+hasReservedFirstSegment (first : _) = first == "shibuya"
+hasReservedFirstSegment [] = False
+
+poisonPillCode :: DeadLetterCode
+poisonPillCode = DeadLetterCode "poison_pill"
+
+invalidPayloadCode :: DeadLetterCode
+invalidPayloadCode = DeadLetterCode "invalid_payload"
+
+maxRetriesExceededCode :: DeadLetterCode
+maxRetriesExceededCode = DeadLetterCode "max_retries_exceeded"
 
 -- | Why processing should halt.
 data HaltReason
diff --git a/src/Shibuya/Internal/Runner/Supervised.hs b/src/Shibuya/Internal/Runner/Supervised.hs
--- a/src/Shibuya/Internal/Runner/Supervised.hs
+++ b/src/Shibuya/Internal/Runner/Supervised.hs
@@ -51,7 +51,14 @@
 import OpenTelemetry.Trace.Core qualified as OTel
 import Shibuya.Adapter (Adapter (..))
 import Shibuya.Batch (BatchConfig, BatchHandler)
-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))
+import Shibuya.Core.Ack
+  ( AckDecision (..),
+    HaltReason (..),
+    RetryDelay (..),
+    deadLetterCodeText,
+    deadLetterReasonCode,
+    renderDeadLetterReason,
+  )
 import Shibuya.Core.Error (HandlerError (..), handlerErrorToText)
 import Shibuya.Core.Ingested (Ingested (..), toMessage)
 import Shibuya.Core.Metrics
@@ -95,6 +102,7 @@
     attrMessagingOperation,
     attrMessagingSystem,
     attrShibuyaAckDecision,
+    attrShibuyaDeadLetterReasonCode,
     attrShibuyaInflightCount,
     attrShibuyaInflightMax,
     attrShibuyaPartition,
@@ -642,8 +650,12 @@
             case decision' of
               AckOk -> setStatus traceSpan OTel.Ok
               AckRetry _ -> setStatus traceSpan OTel.Ok
-              AckDeadLetter reason ->
-                setStatus traceSpan $ OTel.Error $ showDeadLetterReason reason
+              AckDeadLetter reason -> do
+                addAttribute
+                  traceSpan
+                  attrShibuyaDeadLetterReasonCode
+                  (deadLetterCodeText (deadLetterReasonCode reason))
+                setStatus traceSpan $ OTel.Error $ renderDeadLetterReason reason
               AckHalt reason ->
                 setStatus traceSpan $ OTel.Error $ showHaltReason reason
           Left err -> do
@@ -680,11 +692,6 @@
     showAckDecision (AckRetry _) = "ack_retry"
     showAckDecision (AckDeadLetter _) = "ack_dead_letter"
     showAckDecision (AckHalt _) = "ack_halt"
-
-    showDeadLetterReason :: DeadLetterReason -> Text
-    showDeadLetterReason (PoisonPill t) = "poison_pill: " <> t
-    showDeadLetterReason (InvalidPayload t) = "invalid_payload: " <> t
-    showDeadLetterReason MaxRetriesExceeded = "max_retries_exceeded"
 
     showHaltReason :: HaltReason -> Text
     showHaltReason (HaltOrderedStream t) = "halt_ordered_stream: " <> t
diff --git a/src/Shibuya/Telemetry/Semantic.hs b/src/Shibuya/Telemetry/Semantic.hs
--- a/src/Shibuya/Telemetry/Semantic.hs
+++ b/src/Shibuya/Telemetry/Semantic.hs
@@ -21,6 +21,7 @@
     attrShibuyaInflightCount,
     attrShibuyaInflightMax,
     attrShibuyaAckDecision,
+    attrShibuyaDeadLetterReasonCode,
     attrShibuyaPartition,
     attrShibuyaBatchKey,
     attrShibuyaBatchSize,
@@ -131,6 +132,14 @@
 -- Shibuya-specific: there is no upstream key for an explicit ack decision.
 attrShibuyaAckDecision :: Text
 attrShibuyaAckDecision = "shibuya.ack.decision"
+
+-- | The stable dead-letter reason code
+-- (@shibuya.dead_letter.reason.code@).
+--
+-- Shibuya-specific: codes are bounded, machine-queryable identifiers. The
+-- potentially high-cardinality human detail is deliberately excluded.
+attrShibuyaDeadLetterReasonCode :: Text
+attrShibuyaDeadLetterReasonCode = "shibuya.dead_letter.reason.code"
 
 -- | A generic partition identifier (@shibuya.partition@).
 --
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -10,6 +10,7 @@
 import Shibuya.Core.RetrySpec qualified
 import Shibuya.Core.TypesSpec qualified
 import Shibuya.PolicySpec qualified
+import Shibuya.PublicApiSpec qualified
 import Shibuya.Runner.BatchProcessorSpec qualified
 import Shibuya.Runner.BatcherSpec qualified
 import Shibuya.Runner.PartitionOrderingSpec qualified
@@ -30,6 +31,7 @@
   describe "Shibuya.Core.Ack" Shibuya.Core.AckSpec.spec
   describe "Shibuya.Core.Retry" Shibuya.Core.RetrySpec.spec
   describe "Shibuya.Policy" Shibuya.PolicySpec.spec
+  Shibuya.PublicApiSpec.spec
   describe "Shibuya.Runner" Shibuya.RunnerSpec.spec
   Shibuya.Runner.BatcherSpec.spec
   Shibuya.Runner.BatchProcessorSpec.spec
diff --git a/test/Shibuya/Core/AckSpec.hs b/test/Shibuya/Core/AckSpec.hs
--- a/test/Shibuya/Core/AckSpec.hs
+++ b/test/Shibuya/Core/AckSpec.hs
@@ -2,6 +2,7 @@
 
 module Shibuya.Core.AckSpec (spec) where
 
+import Data.Text qualified as Text
 import Data.Time (secondsToNominalDiffTime)
 import Shibuya.Core.Ack
 import Test.Hspec
@@ -25,9 +26,12 @@
       let r1 = PoisonPill "bad message"
           r2 = InvalidPayload "parse error"
           r3 = MaxRetriesExceeded
+          code = validCode "example.policy.rejected"
+          r4 = ApplicationFailure code "policy rejected"
       r1 `shouldNotBe` r2
       r2 `shouldNotBe` r3
       r1 `shouldNotBe` r3
+      r3 `shouldNotBe` r4
 
     it "PoisonPill carries message" $ do
       let r = PoisonPill "corrupt data"
@@ -41,6 +45,59 @@
         InvalidPayload msg -> msg `shouldBe` "JSON decode failed"
         _ -> expectationFailure "wrong constructor"
 
+    describe "mkDeadLetterCode" $ do
+      it "accepts namespaced lowercase application codes" $ do
+        fmap deadLetterCodeText (mkDeadLetterCode "keiro.router.selection.recipient_overflow")
+          `shouldBe` Right "keiro.router.selection.recipient_overflow"
+
+      it "accepts the 128-character boundary" $ do
+        let code = "a." <> Text.replicate 126 "b"
+        fmap deadLetterCodeText (mkDeadLetterCode code) `shouldBe` Right code
+
+      it "rejects each invalid grammar boundary" $ do
+        let invalidCodes =
+              [ "",
+                "unqualified",
+                "keiro.Router",
+                "keiro.router-selection",
+                "keiro..selection",
+                "1keiro.router",
+                "keiro.1router",
+                "keiro.routér",
+                "shibuya.router",
+                "a." <> Text.replicate 127 "b"
+              ]
+        mapM_ (\code -> mkDeadLetterCode code `shouldSatisfy` isLeft) invalidCodes
+
+      it "identifies the rejected code and failed rule" $ do
+        mkDeadLetterCode "Keiro.router"
+          `shouldBe` Left "invalid dead-letter code \"Keiro.router\": segment \"Keiro\" must match [a-z][a-z0-9_]*"
+
+    describe "dead-letter projections and rendering" $ do
+      it "preserves the built-in contracts exactly" $ do
+        let cases =
+              [ (PoisonPill "x", "poison_pill", Just "x", "poison_pill: x"),
+                (InvalidPayload "x", "invalid_payload", Just "x", "invalid_payload: x"),
+                (MaxRetriesExceeded, "max_retries_exceeded", Nothing, "max_retries_exceeded")
+              ]
+        mapM_
+          ( \(reason, code, detail, rendered) -> do
+              deadLetterCodeText (deadLetterReasonCode reason) `shouldBe` code
+              deadLetterReasonDetail reason `shouldBe` detail
+              renderDeadLetterReason reason `shouldBe` rendered
+          )
+          cases
+
+      it "preserves an application code and detail" $ do
+        let code = validCode "keiro.router.selection.recipient_overflow"
+            reason = ApplicationFailure code "selected 101 recipients; configured limit is 100"
+        deadLetterCodeText (deadLetterReasonCode reason)
+          `shouldBe` "keiro.router.selection.recipient_overflow"
+        deadLetterReasonDetail reason
+          `shouldBe` Just "selected 101 recipients; configured limit is 100"
+        renderDeadLetterReason reason
+          `shouldBe` "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"
+
   describe "HaltReason" $ do
     it "HaltOrderedStream carries message" $ do
       let r = HaltOrderedStream "ordering violation"
@@ -88,3 +145,13 @@
       case decision of
         AckHalt r -> r `shouldBe` reason
         _ -> expectationFailure "wrong constructor"
+
+isLeft :: Either a b -> Bool
+isLeft (Left _) = True
+isLeft (Right _) = False
+
+validCode :: Text.Text -> DeadLetterCode
+validCode code =
+  case mkDeadLetterCode code of
+    Left err -> error $ "invalid test fixture: " <> show err
+    Right valid -> valid
diff --git a/test/Shibuya/PublicApiSpec.hs b/test/Shibuya/PublicApiSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Shibuya/PublicApiSpec.hs
@@ -0,0 +1,31 @@
+module Shibuya.PublicApiSpec (spec) where
+
+import Shibuya
+import Test.Hspec
+
+spec :: Spec
+spec =
+  describe "Shibuya.PublicApi" $ do
+    it "constructs and projects an application-defined dead-letter reason" $ do
+      case mkDeadLetterCode "keiro.router.selection.recipient_overflow" of
+        Left err -> expectationFailure $ "valid public code was rejected: " <> show err
+        Right code -> do
+          let _handler = mkRouterHandler code
+              reason = routerReason code
+          deadLetterCodeText (deadLetterReasonCode reason)
+            `shouldBe` "keiro.router.selection.recipient_overflow"
+          deadLetterReasonDetail reason
+            `shouldBe` Just "selected 101 recipients; configured limit is 100"
+          renderDeadLetterReason reason
+            `shouldBe` "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"
+
+mkRouterHandler :: DeadLetterCode -> Handler es RouterMessage
+mkRouterHandler code _message = pure $ AckDeadLetter $ routerReason code
+
+routerReason :: DeadLetterCode -> DeadLetterReason
+routerReason code =
+  ApplicationFailure
+    code
+    "selected 101 recipients; configured limit is 100"
+
+type RouterMessage = ()
diff --git a/test/Shibuya/Runner/BatchProcessorSpec.hs b/test/Shibuya/Runner/BatchProcessorSpec.hs
--- a/test/Shibuya/Runner/BatchProcessorSpec.hs
+++ b/test/Shibuya/Runner/BatchProcessorSpec.hs
@@ -35,9 +35,11 @@
   )
 import Shibuya.Core.Ack
   ( AckDecision (..),
+    DeadLetterCode,
     DeadLetterReason (..),
     HaltReason (..),
     RetryDelay (..),
+    mkDeadLetterCode,
   )
 import Shibuya.Core.AckHandle (AckHandle (..))
 import Shibuya.Core.Ingested (Ingested, Message (..), mkIngested)
@@ -109,6 +111,34 @@
       -- Exactly one successful finalization despite two prior throws.
       tracked `shouldBe` [(MessageId "flaky-1", AckOk)]
 
+    it "preserves application failures in explicit and fallback decisions" $ do
+      let code = validDeadLetterCode "keiro.router.selection.recipient_overflow"
+          fallbackReason = ApplicationFailure code "fallback policy rejection"
+          explicitReason = ApplicationFailure code "explicit policy rejection"
+      (tracked, metrics) <- runEff $ runTracingNoop $ do
+        tracking <- newTrackingAck
+        batch <- buildBatch tracking 5 TriggerSize
+        let handler _info _msgs =
+              pure $
+                withFallback
+                  (AckDeadLetter fallbackReason)
+                  [ (MessageId "msg-2", AckDeadLetter explicitReason),
+                    (MessageId "msg-4", AckOk)
+                  ]
+        m <- runBatchesWithMetrics (ProcessorId "m1-application-failure") Serial handler [batch]
+        t <- getTrackedDecisions tracking
+        pure (t, m)
+
+      sort (map fst tracked) `shouldBe` expectedIds
+      lookup (MessageId "msg-1") tracked `shouldBe` Just (AckDeadLetter fallbackReason)
+      lookup (MessageId "msg-2") tracked `shouldBe` Just (AckDeadLetter explicitReason)
+      lookup (MessageId "msg-3") tracked `shouldBe` Just (AckDeadLetter fallbackReason)
+      lookup (MessageId "msg-4") tracked `shouldBe` Just AckOk
+      lookup (MessageId "msg-5") tracked `shouldBe` Just (AckDeadLetter fallbackReason)
+      metrics.batch.partialFailures `shouldBe` 1
+      metrics.stats.processed `shouldBe` 1
+      metrics.stats.failed `shouldBe` 4
+
   describe "exception fallback (M2)" $ do
     it "finalizes all 5 with AckRetry when the handler throws" $ do
       (tracked, metrics) <- runEff $ runTracingNoop $ do
@@ -321,3 +351,9 @@
 
 tshow :: (Show a) => a -> Text
 tshow = Text.pack . show
+
+validDeadLetterCode :: Text -> DeadLetterCode
+validDeadLetterCode code =
+  case mkDeadLetterCode code of
+    Left err -> error $ "invalid test fixture: " <> show err
+    Right valid -> valid
diff --git a/test/Shibuya/Runner/SupervisedSpec.hs b/test/Shibuya/Runner/SupervisedSpec.hs
--- a/test/Shibuya/Runner/SupervisedSpec.hs
+++ b/test/Shibuya/Runner/SupervisedSpec.hs
@@ -27,7 +27,14 @@
     stopAppGracefully,
   )
 import Shibuya.Batch.TestHarness (finalizedExactlyOnce)
-import Shibuya.Core.Ack (AckDecision (..), HaltReason (..), RetryDelay (..))
+import Shibuya.Core.Ack
+  ( AckDecision (..),
+    DeadLetterCode,
+    DeadLetterReason (..),
+    HaltReason (..),
+    RetryDelay (..),
+    mkDeadLetterCode,
+  )
 import Shibuya.Core.AckHandle (AckHandle (..))
 import Shibuya.Core.Ingested (Ingested, Message (..), mkIngested)
 import Shibuya.Core.Metrics
@@ -500,6 +507,29 @@
           metrics.stats.processed `shouldBe` 3
           metrics.stats.failed `shouldBe` 2
 
+        it "preserves an application failure through finalization" $ do
+          let code = validDeadLetterCode "keiro.router.selection.recipient_overflow"
+              reason =
+                ApplicationFailure
+                  code
+                  "selected 101 recipients; configured limit is 100"
+              decision = AckDeadLetter reason
+          (metrics, tracked) <- runEff $ runTracingNoop $ do
+            tracking <- newTrackingAck
+            let adapter = trackedListAdapter tracking [createTestEnvelope 1]
+                handler _ = pure decision
+            sp <- runWithMetrics 1 (ProcessorId "application-failure-finalize") adapter handler
+            decisions <- getTrackedDecisions tracking
+            finalMetrics <- getMetrics sp
+            pure (finalMetrics, decisions)
+
+          finalizedExactlyOnce
+            tracked
+            (Map.singleton (MessageId "msg-1") decision)
+            `shouldBe` Right ()
+          metrics.stats.processed `shouldBe` 0
+          metrics.stats.failed `shouldBe` 1
+
         it "retries transient finalizer failures on the single-message path" $ do
           (attempts, tracked) <- runEff $ runTracingNoop $ do
             attemptsRef <- liftIO $ newIORef (0 :: Int)
@@ -1123,6 +1153,12 @@
       source = Stream.fromList messages,
       shutdown = pure ()
     }
+
+validDeadLetterCode :: Text.Text -> DeadLetterCode
+validDeadLetterCode code =
+  case mkDeadLetterCode code of
+    Left err -> error $ "invalid test fixture: " <> show err
+    Right valid -> valid
 
 testHandler :: (IOE :> es) => IORef [String] -> Handler es String
 testHandler ref ingested = do
diff --git a/test/Shibuya/Telemetry/SemanticSpec.hs b/test/Shibuya/Telemetry/SemanticSpec.hs
--- a/test/Shibuya/Telemetry/SemanticSpec.hs
+++ b/test/Shibuya/Telemetry/SemanticSpec.hs
@@ -29,28 +29,40 @@
   ( Event (..),
     ImmutableSpan (..),
     InstrumentationLibrary (..),
+    SpanStatus (..),
     createTracerProvider,
     emptyTracerProviderOptions,
     hotAttributes,
     hotEvents,
     hotName,
+    hotStatus,
     makeTracer,
     shutdownTracerProvider,
     tracerOptions,
   )
 import OpenTelemetry.Util (appendOnlyBoundedCollectionValues)
 import Shibuya.Adapter.Mock (listAdapter)
-import Shibuya.Core.Ack (AckDecision (..))
+import Shibuya.Core.Ack
+  ( AckDecision (..),
+    DeadLetterCode,
+    DeadLetterReason (..),
+    mkDeadLetterCode,
+  )
 import Shibuya.Core.AckHandle (AckHandle (..))
 import Shibuya.Core.Ingested (mkIngested)
 import Shibuya.Core.Metrics (ProcessorId (..))
 import Shibuya.Core.Types (Envelope (..), MessageId (..), mkEnvelope)
 import Shibuya.Internal.Runner.Supervised (runWithMetrics)
 import Shibuya.Telemetry.Effect (runTracing)
+import Shibuya.Telemetry.Semantic (attrShibuyaDeadLetterReasonCode)
 import Test.Hspec
 
 spec :: Spec
 spec = describe "Shibuya.Telemetry.Semantic (wire-format)" $ do
+  it "keeps the application dead-letter reason code wire key stable" $ do
+    attrShibuyaDeadLetterReasonCode
+      `shouldBe` "shibuya.dead_letter.reason.code"
+
   it "emits a process span with conventions-aligned attributes and events" $ do
     (processor, spansRef) <- inMemoryListExporter
     provider <- createTracerProvider [processor] emptyTracerProviderOptions
@@ -135,6 +147,39 @@
       _ ->
         expectationFailure $
           "expected exactly one span, got " <> show (length spans)
+
+  it "emits an application dead-letter code and canonical error status" $ do
+    (processor, spansRef) <- inMemoryListExporter
+    provider <- createTracerProvider [processor] emptyTracerProviderOptions
+    let tracer = mkTestTracer provider
+        code = validDeadLetterCode "keiro.router.selection.recipient_overflow"
+        detail = "selected 101 recipients; configured limit is 100"
+
+    runEff $ runTracing tracer $ do
+      let envelope = mkEnvelope (MessageId "router-1") ("hello" :: Text)
+          ingested = mkIngested envelope (AckHandle (\_ -> pure ()))
+          adapter = listAdapter [ingested]
+          handler _ = pure $ AckDeadLetter $ ApplicationFailure code detail
+          procId = ProcessorId "router-consumer"
+      _ <- runWithMetrics 1 procId adapter handler
+      pure ()
+
+    _ <- shutdownTracerProvider provider (Just 5_000_000)
+    spans <- readIORef spansRef
+    case spans of
+      [s] -> do
+        hot <- readIORef (spanHot s)
+        let attrs = getAttributeMap (hotAttributes hot)
+        attrs `shouldHaveTextAttribute` ("shibuya.ack.decision", "ack_dead_letter")
+        attrs
+          `shouldHaveTextAttribute` ( "shibuya.dead_letter.reason.code",
+                                      "keiro.router.selection.recipient_overflow"
+                                    )
+        hotStatus hot
+          `shouldBe` Error "keiro.router.selection.recipient_overflow: selected 101 recipients; configured limit is 100"
+      _ ->
+        expectationFailure $
+          "expected exactly one span, got " <> show (length spans)
   where
     mkTestTracer p =
       makeTracer
@@ -167,3 +212,9 @@
         Nothing ->
           expectationFailure $
             "attribute " <> show k <> " missing; have keys " <> show (HashMap.keys attrs)
+
+validDeadLetterCode :: Text -> DeadLetterCode
+validDeadLetterCode code =
+  case mkDeadLetterCode code of
+    Left err -> error $ "invalid test fixture: " <> show err
+    Right valid -> valid
