packages feed

keiro 0.5.0.0 → 0.6.0.0

raw patch · 49 files changed

+24634/−24925 lines, 49 filesdep ~keikidep ~keiki-codec-jsondep ~keiro-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: keiki, keiki-codec-json, keiro-core

API changes (from Hackage documentation)

Files

CHANGELOG.md view
@@ -6,6 +6,20 @@  ## [Unreleased] +## 0.6.0.0 — 2026-07-31++### Breaking Changes++- Requires the exact-`Integer` / total-`Natural` Keiki releases+  (`keiki >=0.6 && <0.7`, `keiki-codec-json >=0.6 && <0.7`), replacing the+  previous `>=0.4 && <0.5` bounds.++### New Features++- Re-exports `Keiro.Codec.Nominal` from `keiro-core`, so generated nominal+  consumer bindings keep a single direct `keiro` dependency alongside the+  existing structural binding re-exports.+ ## 0.5.0.0 — 2026-07-31  No changes this release. Released with the package set for the `keiro-dsl`
bench/Main.hs view
@@ -1,9 +1,9 @@ {-# LANGUAGE BlockArguments #-} {-# LANGUAGE OverloadedRecordDot #-} -module Main (-    main,-)+module Main+  ( main,+  ) where  import Control.Concurrent (threadDelay)@@ -14,24 +14,24 @@ import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)-import Keiro.Inbox (-    InboxDedupePolicy (..),+import Keiro.Inbox+  ( InboxDedupePolicy (..),     InboxPersistence (..),     InboxResult (..),     KafkaDeliveryRef (..),     runInboxTransactionBatch,     runInboxTransactionWith,- )+  ) import Keiro.Integration.Event (IntegrationContentType (..), IntegrationEvent (..))-import Keiro.Outbox (-    OutboxId (..),+import Keiro.Outbox+  ( OutboxId (..),     OutboxRow,     PublishOutcome (..),     countOutboxBacklog,     defaultPublishOptions,     enqueueIntegrationEventTx,     publishClaimedOutbox,- )+  ) import Keiro.Prelude import Keiro.Telemetry qualified as Telemetry import Keiro.Test.Postgres (withFreshStore, withMigratedSuite)@@ -59,247 +59,247 @@ fixedOccurredAt = UTCTime (ModifiedJulianDay 61000) (secondsToDiffTime 0)  data BrokerModel = BrokerModel-    { invocationMicros :: !Int-    , perRecordMicros :: !Int-    }-    deriving stock (Eq, Show)+  { invocationMicros :: !Int,+    perRecordMicros :: !Int+  }+  deriving stock (Eq, Show)  data OutboxScenario = OutboxScenario-    { scenarioName :: !Text-    , brokerModel :: !BrokerModel-    , messages :: ![(OutboxId, IntegrationEvent)]-    }+  { scenarioName :: !Text,+    brokerModel :: !BrokerModel,+    messages :: ![(OutboxId, IntegrationEvent)]+  }  data InboxScenario = InboxScenario-    { inboxScenarioName :: !Text-    , inboxMetrics :: !(Maybe Telemetry.KeiroMetrics)-    , inboxPersistence :: !InboxPersistence-    , inboxBatchSize :: !(Maybe Int)-    , inboxMessages :: ![(IntegrationEvent, KafkaDeliveryRef)]-    }+  { inboxScenarioName :: !Text,+    inboxMetrics :: !(Maybe Telemetry.KeiroMetrics),+    inboxPersistence :: !InboxPersistence,+    inboxBatchSize :: !(Maybe Int),+    inboxMessages :: ![(IntegrationEvent, KafkaDeliveryRef)]+  }  main :: IO () main =-    withMigratedSuite \fixture ->-        withFreshStore fixture \store -> do-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            metrics <- Telemetry.newKeiroMetrics meter-            defaultMain (benchmarks store metrics)+  withMigratedSuite \fixture ->+    withFreshStore fixture \store -> do+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      metrics <- Telemetry.newKeiroMetrics meter+      defaultMain (benchmarks store metrics)  benchmarks :: Store.KirokuStore -> Telemetry.KeiroMetrics -> [Benchmark] benchmarks store metrics =-    [ bgroup-        "outbox"-        [ scenarioBench store hotKey-        , scenarioBench store hotKeyNoLatency-        , scenarioBench store multiKey-        ]-    , bgroup-        "inbox"-        [ inboxScenarioBench store (singleFull metrics)-        , inboxScenarioBench store singleNoMetrics-        , inboxScenarioBench store batch100-        , inboxScenarioBench store singleSlim-        ]-    ]+  [ bgroup+      "outbox"+      [ scenarioBench store hotKey,+        scenarioBench store hotKeyNoLatency,+        scenarioBench store multiKey+      ],+    bgroup+      "inbox"+      [ inboxScenarioBench store (singleFull metrics),+        inboxScenarioBench store singleNoMetrics,+        inboxScenarioBench store batch100,+        inboxScenarioBench store singleSlim+      ]+  ]   where     hotKey =-        OutboxScenario-            { scenarioName = "hot-key"-            , brokerModel = BrokerModel{invocationMicros = 1000, perRecordMicros = 10}-            , messages = scenarioMessages \_ -> Just "aggregate-hot"-            }+      OutboxScenario+        { scenarioName = "hot-key",+          brokerModel = BrokerModel {invocationMicros = 1000, perRecordMicros = 10},+          messages = scenarioMessages \_ -> Just "aggregate-hot"+        }     hotKeyNoLatency =-        OutboxScenario-            { scenarioName = "hot-key-nolatency"-            , brokerModel = BrokerModel{invocationMicros = 0, perRecordMicros = 0}-            , messages = scenarioMessages \_ -> Just "aggregate-hot"-            }+      OutboxScenario+        { scenarioName = "hot-key-nolatency",+          brokerModel = BrokerModel {invocationMicros = 0, perRecordMicros = 0},+          messages = scenarioMessages \_ -> Just "aggregate-hot"+        }     multiKey =-        OutboxScenario-            { scenarioName = "multi-key"-            , brokerModel = BrokerModel{invocationMicros = 1000, perRecordMicros = 10}-            , messages = scenarioMessages \i -> Just ("aggregate-" <> Text.pack (show (i `mod` 200)))-            }+      OutboxScenario+        { scenarioName = "multi-key",+          brokerModel = BrokerModel {invocationMicros = 1000, perRecordMicros = 10},+          messages = scenarioMessages \i -> Just ("aggregate-" <> Text.pack (show (i `mod` 200)))+        }     singleFull metrics' =-        InboxScenario-            { inboxScenarioName = "single-full"-            , inboxMetrics = Just metrics'-            , inboxPersistence = PersistFullEnvelope-            , inboxBatchSize = Nothing-            , inboxMessages = inboxScenarioMessages-            }+      InboxScenario+        { inboxScenarioName = "single-full",+          inboxMetrics = Just metrics',+          inboxPersistence = PersistFullEnvelope,+          inboxBatchSize = Nothing,+          inboxMessages = inboxScenarioMessages+        }     singleNoMetrics =-        InboxScenario-            { inboxScenarioName = "single-nometrics"-            , inboxMetrics = Nothing-            , inboxPersistence = PersistFullEnvelope-            , inboxBatchSize = Nothing-            , inboxMessages = inboxScenarioMessages-            }+      InboxScenario+        { inboxScenarioName = "single-nometrics",+          inboxMetrics = Nothing,+          inboxPersistence = PersistFullEnvelope,+          inboxBatchSize = Nothing,+          inboxMessages = inboxScenarioMessages+        }     batch100 =-        InboxScenario-            { inboxScenarioName = "batch-100"-            , inboxMetrics = Nothing-            , inboxPersistence = PersistFullEnvelope-            , inboxBatchSize = Just 100-            , inboxMessages = inboxScenarioMessages-            }+      InboxScenario+        { inboxScenarioName = "batch-100",+          inboxMetrics = Nothing,+          inboxPersistence = PersistFullEnvelope,+          inboxBatchSize = Just 100,+          inboxMessages = inboxScenarioMessages+        }     singleSlim =-        InboxScenario-            { inboxScenarioName = "single-slim"-            , inboxMetrics = Nothing-            , inboxPersistence = PersistDedupeOnly-            , inboxBatchSize = Nothing-            , inboxMessages = inboxScenarioMessages-            }+      InboxScenario+        { inboxScenarioName = "single-slim",+          inboxMetrics = Nothing,+          inboxPersistence = PersistDedupeOnly,+          inboxBatchSize = Nothing,+          inboxMessages = inboxScenarioMessages+        }  scenarioBench :: Store.KirokuStore -> OutboxScenario -> Benchmark scenarioBench store scenario =-    bench (Text.unpack scenario.scenarioName) $-        nfIO (runScenario store scenario)+  bench (Text.unpack scenario.scenarioName) $+    nfIO (runScenario store scenario)  runScenario :: Store.KirokuStore -> OutboxScenario -> IO () runScenario store scenario = do-    runStoreChecked store do-        Store.runTransaction (Tx.sql "TRUNCATE keiro_outbox")-    seedOutbox store scenario.messages-    runStoreChecked store (drainOutbox scenario.brokerModel 0)+  runStoreChecked store do+    Store.runTransaction (Tx.sql "TRUNCATE keiro_outbox")+  seedOutbox store scenario.messages+  runStoreChecked store (drainOutbox scenario.brokerModel 0)  inboxScenarioBench :: Store.KirokuStore -> InboxScenario -> Benchmark inboxScenarioBench store scenario =-    bench (Text.unpack scenario.inboxScenarioName) $-        nfIO (runInboxScenario store scenario)+  bench (Text.unpack scenario.inboxScenarioName) $+    nfIO (runInboxScenario store scenario)  runInboxScenario :: Store.KirokuStore -> InboxScenario -> IO () runInboxScenario store scenario = do-    runStoreChecked store do-        Store.runTransaction (Tx.sql "TRUNCATE keiro_inbox")-    runStoreChecked store $-        case scenario.inboxBatchSize of-            Nothing ->-                traverse_-                    (processInboxDelivery scenario.inboxMetrics scenario.inboxPersistence)-                    scenario.inboxMessages-            Just batchSize ->-                traverse_-                    (processInboxBatch scenario.inboxMetrics scenario.inboxPersistence)-                    (chunksOf batchSize scenario.inboxMessages)+  runStoreChecked store do+    Store.runTransaction (Tx.sql "TRUNCATE keiro_inbox")+  runStoreChecked store $+    case scenario.inboxBatchSize of+      Nothing ->+        traverse_+          (processInboxDelivery scenario.inboxMetrics scenario.inboxPersistence)+          scenario.inboxMessages+      Just batchSize ->+        traverse_+          (processInboxBatch scenario.inboxMetrics scenario.inboxPersistence)+          (chunksOf batchSize scenario.inboxMessages)  processInboxDelivery ::-    (IOE :> es, Store :> es) =>-    Maybe Telemetry.KeiroMetrics ->-    InboxPersistence ->-    (IntegrationEvent, KafkaDeliveryRef) ->-    Eff es ()+  (IOE :> es, Store :> es) =>+  Maybe Telemetry.KeiroMetrics ->+  InboxPersistence ->+  (IntegrationEvent, KafkaDeliveryRef) ->+  Eff es () processInboxDelivery mMetrics persistence (event, kafkaRef) = do-    result <- runInboxTransactionWith mMetrics persistence PreferIntegrationMessageId event (Just kafkaRef) (\_ -> pure ())-    case result of-        Right (InboxProcessed ()) -> pure ()-        other -> liftIO (fail ("unexpected inbox benchmark result: " <> show other))+  result <- runInboxTransactionWith mMetrics persistence PreferIntegrationMessageId event (Just kafkaRef) (\_ -> pure ())+  case result of+    Right (InboxProcessed ()) -> pure ()+    other -> liftIO (fail ("unexpected inbox benchmark result: " <> show other))  processInboxBatch ::-    (IOE :> es, Store :> es) =>-    Maybe Telemetry.KeiroMetrics ->-    InboxPersistence ->-    [(IntegrationEvent, KafkaDeliveryRef)] ->-    Eff es ()+  (IOE :> es, Store :> es) =>+  Maybe Telemetry.KeiroMetrics ->+  InboxPersistence ->+  [(IntegrationEvent, KafkaDeliveryRef)] ->+  Eff es () processInboxBatch mMetrics persistence chunk = do-    results <--        runInboxTransactionBatch-            mMetrics-            3-            PreferIntegrationMessageId-            persistence-            [(event, Just kafkaRef) | (event, kafkaRef) <- chunk]-            (\_ -> pure ())-    for_ results \case-        Right (InboxProcessed ()) -> pure ()-        other -> liftIO (fail ("unexpected inbox batch benchmark result: " <> show other))+  results <-+    runInboxTransactionBatch+      mMetrics+      3+      PreferIntegrationMessageId+      persistence+      [(event, Just kafkaRef) | (event, kafkaRef) <- chunk]+      (\_ -> pure ())+  for_ results \case+    Right (InboxProcessed ()) -> pure ()+    other -> liftIO (fail ("unexpected inbox batch benchmark result: " <> show other))  seedOutbox :: Store.KirokuStore -> [(OutboxId, IntegrationEvent)] -> IO () seedOutbox store messages =-    traverse_ seedChunk (chunksOf seedChunkSize messages)+  traverse_ seedChunk (chunksOf seedChunkSize messages)   where     seedChunk chunk =-        runStoreChecked store $-            Store.runTransaction $-                traverse_ (uncurry enqueueIntegrationEventTx) chunk+      runStoreChecked store $+        Store.runTransaction $+          traverse_ (uncurry enqueueIntegrationEventTx) chunk  drainOutbox :: (IOE :> es, Store :> es) => BrokerModel -> Int -> Eff es () drainOutbox broker passes = do-    backlog <- countOutboxBacklog-    if backlog == 0-        then pure ()-        else do-            when (passes >= maxDrainPasses) $-                liftIO (fail ("outbox benchmark exceeded safety cap of " <> show maxDrainPasses <> " passes"))-            void (publishClaimedOutbox (simulatedPublish broker) defaultPublishOptions Nothing)-            drainOutbox broker (passes + 1)+  backlog <- countOutboxBacklog+  if backlog == 0+    then pure ()+    else do+      when (passes >= maxDrainPasses) $+        liftIO (fail ("outbox benchmark exceeded safety cap of " <> show maxDrainPasses <> " passes"))+      void (publishClaimedOutbox (simulatedPublish broker) defaultPublishOptions Nothing)+      drainOutbox broker (passes + 1)  simulatedPublish :: (IOE :> es) => BrokerModel -> [OutboxRow] -> Eff es [(OutboxId, PublishOutcome)] simulatedPublish broker rows = do-    let totalMicros = broker.invocationMicros + broker.perRecordMicros * length rows-    when (totalMicros > 0) $-        liftIO (threadDelay totalMicros)-    pure [(row ^. #outboxId, PublishSucceeded) | row <- rows]+  let totalMicros = broker.invocationMicros + broker.perRecordMicros * length rows+  when (totalMicros > 0) $+    liftIO (threadDelay totalMicros)+  pure [(row ^. #outboxId, PublishSucceeded) | row <- rows]  scenarioMessages :: (Int -> Maybe Text) -> [(OutboxId, IntegrationEvent)] scenarioMessages keyFor =-    [ (OutboxId (UUID.fromWords64 0x018f0f1800007000 (0x8000000000000000 + fromIntegral i)), integrationEvent i (keyFor i))-    | i <- [1 .. workloadSize]-    ]+  [ (OutboxId (UUID.fromWords64 0x018f0f1800007000 (0x8000000000000000 + fromIntegral i)), integrationEvent i (keyFor i))+  | i <- [1 .. workloadSize]+  ]  integrationEvent :: Int -> Maybe Text -> IntegrationEvent integrationEvent i key =-    IntegrationEvent-        { messageId = "bench-msg-" <> Text.pack (show i)-        , source = "bench.outbox"-        , destination = "bench.outbox.events.v1"-        , key-        , eventType = "BenchEvent"-        , schemaVersion = 1-        , contentType = ApplicationJson-        , schemaReference = Nothing-        , sourceEventId = Nothing-        , sourceGlobalPosition = Nothing-        , payloadBytes = BS.replicate payloadSize 65-        , occurredAt = fixedOccurredAt-        , causationId = Nothing-        , correlationId = Nothing-        , traceContext = Nothing-        , attributes = Nothing-        }+  IntegrationEvent+    { messageId = "bench-msg-" <> Text.pack (show i),+      source = "bench.outbox",+      destination = "bench.outbox.events.v1",+      key,+      eventType = "BenchEvent",+      schemaVersion = 1,+      contentType = ApplicationJson,+      schemaReference = Nothing,+      sourceEventId = Nothing,+      sourceGlobalPosition = Nothing,+      payloadBytes = BS.replicate payloadSize 65,+      occurredAt = fixedOccurredAt,+      causationId = Nothing,+      correlationId = Nothing,+      traceContext = Nothing,+      attributes = Nothing+    }  inboxScenarioMessages :: [(IntegrationEvent, KafkaDeliveryRef)] inboxScenarioMessages =-    [ ( integrationEvent i (Just ("inbox-key-" <> Text.pack (show i)))-            & #messageId-            .~ ("bench-inbox-msg-" <> Text.pack (show i))-            & #source-            .~ "bench.inbox"-            & #destination-            .~ "bench.inbox.events.v1"-      , KafkaDeliveryRef "bench.inbox.events.v1" 0 (fromIntegral i)-      )-    | i <- [1 .. workloadSize]-    ]+  [ ( integrationEvent i (Just ("inbox-key-" <> Text.pack (show i)))+        & #messageId+        .~ ("bench-inbox-msg-" <> Text.pack (show i))+        & #source+        .~ "bench.inbox"+        & #destination+        .~ "bench.inbox.events.v1",+      KafkaDeliveryRef "bench.inbox.events.v1" 0 (fromIntegral i)+    )+  | i <- [1 .. workloadSize]+  ]  chunksOf :: Int -> [a] -> [[a]] chunksOf n xs-    | n <= 0 = error "chunksOf: non-positive chunk size"-    | otherwise =-        case splitAt n xs of-            ([], _) -> []-            (chunk, rest) -> chunk : chunksOf n rest+  | n <= 0 = error "chunksOf: non-positive chunk size"+  | otherwise =+      case splitAt n xs of+        ([], _) -> []+        (chunk, rest) -> chunk : chunksOf n rest  runStoreChecked :: Store.KirokuStore -> Eff [Store, Error Store.StoreError, IOE] a -> IO a runStoreChecked store action = do-    result <- Store.runStoreIO store action-    case result of-        Left err -> fail (show err)-        Right value -> pure value+  result <- Store.runStoreIO store action+  case result of+    Left err -> fail (show err)+    Right value -> pure value
keiro.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            keiro-version:         0.5.0.0+version:         0.6.0.0 synopsis:        Event sourcing framework and workflow engine description:   A library that composes kiroku, keiki, and shibuya into an@@ -91,6 +91,7 @@     keiro-core:Keiro.Codec,     keiro-core:Keiro.Codec.Structural,     keiro-core:Keiro.Codec.Structural.Generic,+    keiro-core:Keiro.Codec.Nominal,     keiro-core:Keiro.EventStream,     keiro-core:Keiro.EventStream.Validate,     keiro-core:Keiro.Integration.Event,@@ -118,9 +119,9 @@     , hs-opentelemetry-api                   >=1.0      && <1.1     , hs-opentelemetry-propagator-w3c        >=1.0      && <1.1     , hs-opentelemetry-semantic-conventions  >=1.40     && <2-    , keiki                                  >=0.4      && <0.5-    , keiki-codec-json                       >=0.4      && <0.5-    , keiro-core                             ^>=0.5.0.0+    , keiki                                  >=0.6      && <0.7+    , keiki-codec-json                       >=0.6      && <0.7+    , keiro-core                             ^>=0.6.0.0     , kiroku-store                           >=0.3      && <0.4     , lens                                   >=5.2      && <5.4     , mmzk-typeid                            >=0.7      && <0.8@@ -188,7 +189,7 @@     , hs-opentelemetry-api  >=1.0      && <1.1     , hs-opentelemetry-sdk  >=1.0      && <1.1     , keiro-    , keiro-core            ^>=0.5.0.0+    , keiro-core            ^>=0.6.0.0     , keiro-test-support     , kiroku-store          >=0.3      && <0.4     , tasty-bench           >=0.4
src/Keiro.hs view
@@ -1,28 +1,27 @@-{- | Umbrella entry point for the Keiro event-sourcing framework.--Importing @Keiro@ brings the everyday command-side surface into scope in-one go: the command runner ("Keiro.Command"), event 'Codec's-("Keiro.Codec"), the 'EventStream' definition and its snapshot policy-("Keiro.EventStream"), the content-based 'Router' ("Keiro.Router"),-snapshot helpers ("Keiro.Snapshot"), and typed 'Stream' handles-("Keiro.Stream").--The more specialized subsystems are not re-exported here and are imported-directly when needed: read models ("Keiro.ReadModel"), projections-("Keiro.Projection"), process managers ("Keiro.ProcessManager"), the-integration in/outbox ("Keiro.Inbox", "Keiro.Outbox"), timers-("Keiro.Timer"), and telemetry ("Keiro.Telemetry").--Known limit: Keiro currently uses kiroku's PostgreSQL event store, whose append-path serializes all writers through the @$all@ stream row while assigning the-global event order. That makes the global position simple and deterministic,-but it is a throughput ceiling for very high write rates across many unrelated-streams. This production-readiness hardening pass documents the limit rather-than redesigning it; applications that outgrow the single-row global append-lock need a future event-store migration plan.--}-module Keiro (-    -- * Library version+-- | Umbrella entry point for the Keiro event-sourcing framework.+--+-- Importing @Keiro@ brings the everyday command-side surface into scope in+-- one go: the command runner ("Keiro.Command"), event 'Codec's+-- ("Keiro.Codec"), the 'EventStream' definition and its snapshot policy+-- ("Keiro.EventStream"), the content-based 'Router' ("Keiro.Router"),+-- snapshot helpers ("Keiro.Snapshot"), and typed 'Stream' handles+-- ("Keiro.Stream").+--+-- The more specialized subsystems are not re-exported here and are imported+-- directly when needed: read models ("Keiro.ReadModel"), projections+-- ("Keiro.Projection"), process managers ("Keiro.ProcessManager"), the+-- integration in/outbox ("Keiro.Inbox", "Keiro.Outbox"), timers+-- ("Keiro.Timer"), and telemetry ("Keiro.Telemetry").+--+-- Known limit: Keiro currently uses kiroku's PostgreSQL event store, whose append+-- path serializes all writers through the @$all@ stream row while assigning the+-- global event order. That makes the global position simple and deterministic,+-- but it is a throughput ceiling for very high write rates across many unrelated+-- streams. This production-readiness hardening pass documents the limit rather+-- than redesigning it; applications that outgrow the single-row global append+-- lock need a future event-store migration plan.+module Keiro+  ( -- * Library version     version,      -- * Command side@@ -40,7 +39,7 @@     -- * Routing and snapshots     module Keiro.Router,     module Keiro.Snapshot,-)+  ) where  import Keiro.Codec@@ -52,9 +51,8 @@ import Keiro.Snapshot import Keiro.Stream -{- | The Keiro library version, as a 'Text' for display and telemetry.-Keep in lockstep with the @version:@ field in @keiro/keiro.cabal@ when-cutting a release.--}+-- | The Keiro library version, as a 'Text' for display and telemetry.+-- Keep in lockstep with the @version:@ field in @keiro/keiro.cabal@ when+-- cutting a release. version :: Text version = "0.4.0.0"
src/Keiro/Command.hs view
@@ -1,1077 +1,1057 @@-{- | The command side of the framework: hydrate an aggregate, transduce, append.--Running a command against an 'EventStream' follows one pipeline:--1. /Hydrate/ — replay the stream's stored events (optionally fast-forwarding-   from a snapshot) through the keiki transducer to recover the current-   @(state, registers)@ and stream version.-2. /Transduce/ — step the transducer with the command. A rejected transition-   yields 'CommandRejected', while multiple matching transitions yield-   'CommandAmbiguous'; a transition that emits no events yields a no-op-   'CommandResult'.-3. /Append/ — encode the emitted events with the stream's 'Codec' and append-   them at the expected version. An optimistic-concurrency conflict is-   retried up to 'retryLimit' times by rehydrating and replaying; exhausting-   the budget yields 'RetryExhausted'.--Three runners expose this pipeline at increasing levels of integration:--* 'runCommand' — append only.-* 'runCommandWithSql' — run an extra @afterAppend@ action in the /same/-  transaction as the append (e.g. update an inline read model).-* 'runCommandWithSqlEvents' — same, but the callback also receives the-  emitted events paired with their 'RecordedEvent's. This is the primitive-  the projection, process-manager, and router layers build on.--The transactional runners apply Kiroku's configured @enrichEvent@ hook before-event preparation, exactly like 'runCommand'. They therefore require a-'KirokuStoreResource' in the effect stack; install it with @withKirokuStore@-and interpret 'Store' with @runStoreResource@.--Per-stream hydration honors Kiroku's stream-truncation marker. A retained-snapshot must cover every hidden event (snapshot version at least marker minus-one); otherwise hydration fails with 'HydrationGapDetected'. A marker above-the stream head can instead appear as an empty stream and repeated append-conflicts end in 'ConflictFixpoint'. Keiro never truncates streams itself.-Kiroku's @$all@ and category/subscription reads are unaffected by per-stream-truncation: the marker hides events from stream reads rather than deleting-them from the global log.--Every successful append is replayed immediately from its pre-command state so-an unreplayable batch is witnessed at the moment it poisons the stream. The-post-commit witness is counted and attached to the command span without-changing the successful result. The same replay fold feeds transparent-snapshot writes when the stream's 'Keiro.EventStream.SnapshotPolicy' fires;-post-commit snapshot failures are likewise swallowed and counted. Every runner-accepts a tracer for optional OpenTelemetry spans.--The additive hydration primitives are also consumed by "Keiro.ReplayAudit".-The audit deliberately calls the seeded and full variants separately so the-public command-serving fallback cannot hide a stale or unreplayable seed.--}-module Keiro.Command (-    -- * Results and errors-    CommandResult (..),-    CommandError (..),-    HydrationReplayReason (..),-    commandErrorClass,--    -- * Options-    RunCommandOptions (..),-    defaultRunCommandOptions,--    -- * Running commands-    runCommand,-    runCommandWithSql,-    runCommandWithSqlEvents,--    -- * Hydration primitives (replay audit)-    Hydrated (..),-    hydrate,-    hydrateFull,-    hydrateSeeded,-)-where--import Control.Concurrent (threadDelay)-import Control.Exception (displayException)-import Data.Aeson qualified as Aeson-import Data.ByteString.Lazy.Char8 qualified as LazyByteString-import Data.Functor (($>))-import Data.Int (Int32)-import Data.Text qualified as Text-import Effectful (Eff, IOE, (:>))-import Effectful.Concurrent (runConcurrent)-import Effectful.Concurrent.Async qualified as Async-import Effectful.Error.Static (Error, tryError)-import Effectful.Exception (trySync)-import GHC.Clock (getMonotonicTimeNSec)-import GHC.Stack (HasCallStack)-import Keiki.Core (BoolAlg, RegFile)-import Keiki.Core qualified as Keiki-import Keiro.Codec (Codec, CodecError, decodeRecorded, encodeForAppendWithMetadata)-import Keiro.EventStream (EventStream, StateCodec, Terminality (..))-import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)-import Keiro.Prelude-import Keiro.ReplayDigest (canonicalJsonBytes, replayDigest)-import Keiro.Snapshot (-    SnapshotLookup (..),-    SnapshotMissReason (..),-    SnapshotSeed,-    encodeSnapshotStrict,-    lookupSnapshotSeed,-    writeSnapshotEncoded,- )-import Keiro.Snapshot.Policy (shouldSnapshotSpan)-import Keiro.Stream (Stream)-import Keiro.Telemetry (-    KeiroMetrics,-    keiro_events_appended,-    keiro_replay_divergence,-    keiro_retry_attempt,-    recordCommandConflicts,-    recordCommandDuplicates,-    recordCommandRetries,-    recordSnapshotApplyDivergence,-    recordSnapshotDecodeFailures,-    recordSnapshotEncodeFailures,-    recordSnapshotReadHits,-    recordSnapshotReadMisses,-    recordSnapshotSeedDivergence,-    recordSnapshotWriteFailures,-    withCommandSpan,- )-import Kiroku.Store.Append (appendToStream)-import Kiroku.Store.Effect (Store)-import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)-import Kiroku.Store.Error (StoreError (..))-import Kiroku.Store.Read (readStreamForwardStream)-import Kiroku.Store.Transaction (-    PreparedEvent,-    appendConflictToStoreError,-    appendToStreamTx,-    enrichEventsIO,-    prepareEventsIO,-    runTransaction,- )-import Kiroku.Store.Types (-    AppendResult,-    EventData,-    EventId (..),-    ExpectedVersion (..),-    GlobalPosition (..),-    RecordedEvent (..),-    StreamName (..),-    StreamVersion (..),- )-import OpenTelemetry.Attributes.Key (unkey)-import OpenTelemetry.SemanticConventions (db_system_name, error_type)-import OpenTelemetry.Trace.Core (Span, SpanStatus (..), Tracer, addAttribute, setStatus)-import Streamly.Data.Fold qualified as Fold-import Streamly.Data.Stream qualified as Streamly-import System.IO (stderr)-import System.Random.Stateful (globalStdGen, uniformRM)-import "hasql-transaction" Hasql.Transaction qualified as Tx-import Prelude qualified--{- | The outcome of a successfully handled command.--Reports the target 'Stream', the stream version after the command, the global-log position only when this command appended and the store assigned a real-one, and how many events were appended. A no-op reports @0@ events and-@Nothing@ for its global position because per-stream reads cannot recover a-true global position.--}-data CommandResult target = CommandResult-    { target :: !(Stream target)-    , streamVersion :: !StreamVersion-    , globalPosition :: !(Maybe GlobalPosition)-    -- ^ 'Just' only when this command appended; 'Nothing' for a no-op.-    , eventsAppended :: !Int-    }-    deriving stock (Generic, Eq, Show)---- | Why a command did not complete.-data CommandError-    = -- | A stored event could not be decoded while rehydrating the aggregate.-      HydrationDecodeFailed !CodecError-    | {- | Replay of the stored events through the transducer stalled. The-      version identifies the failing stored event; for-      'HydrationTruncatedChain' it identifies the last stored event, after-      which the expected multi-event chain remained incomplete.-      -}-      HydrationReplayFailed !StreamVersion !HydrationReplayReason-    | {- | Hydration observed a non-contiguous stream version. Carries the-      expected version followed by the observed version. The store writes-      contiguous versions, so this indicates that stream truncation hid-      events not covered by the hydration seed. Restore visibility with-      @clearStreamTruncateBefore@ or provide a covering snapshot before-      retrying the command.-      -}-      HydrationGapDetected !StreamVersion !StreamVersion-    | -- | No transducer edge matched the command in the hydrated state.-      CommandRejected-    | {- | Two or more transducer edges matched the command in the hydrated-      state. This is a deterministic aggregate-definition bug rather than a-      business rejection; the list contains the zero-based matched edge-      indices in declaration order.-      -}-      CommandAmbiguous ![Int]-    | -- | An emitted event could not be encoded for append.-      EncodeFailed !CodecError-    | -- | The underlying store rejected the append.-      StoreFailed !StoreError-    | {- | Optimistic-concurrency retries were exhausted (carries the total-      attempts made and the last store error).-      -}-      RetryExhausted !Int !StoreError-    | {- | Retrying after a 'StreamAlreadyExists' conflict re-observed the same-      stream version: the store says the stream exists but reading it shows no-      progress. The typical cause is a soft-deleted stream, where reads return-      nothing but appends still collide. Carries the observed version and the-      conflict.-      -}-      ConflictFixpoint !StreamVersion !StoreError-    deriving stock (Generic, Eq, Show)--{- | Why replay of stored events stalled, projected from keiki's structured-failure types onto a monomorphic vocabulary suitable for 'CommandError'.--}-data HydrationReplayReason-    = -- | No edge's first output template could have produced the event.-      HydrationNoInvertingEdge-    | -- | More than one edge could have produced the event.-      HydrationAmbiguousInversion-    | -- | An event did not match the next expected event in a chain.-      HydrationQueueMismatch-    | -- | The stream ended in the middle of a multi-event chain.-      HydrationTruncatedChain-    deriving stock (Generic, Eq, Show)--{- | Knobs controlling a single command invocation.--* 'retryLimit' — how many times to rehydrate-and-replay after an-  optimistic-concurrency conflict before giving up with 'RetryExhausted'.-* 'pageSize' — batch size when reading the stream during hydration.-* 'eventIds' — caller-supplied ids assigned to the emitted events in order;-  the basis for deterministic, idempotent appends (see 'Keiro.Router' and-  'Keiro.ProcessManager').-* 'beforeAppend' — a hook run immediately before each append attempt,-  primarily a test seam for injecting concurrent writes.-* 'retryBackoffMicros' — base delay before the k-th OCC retry, capped at-  100 ms and jittered. Set to 0 to disable backoff.-* 'metrics' — optional metrics handle for command and snapshot counters.-* 'verifyReplayOnAppend' — replay every just-appended batch from the-  pre-command state. Divergence is a post-commit advisory: it is counted and-  attached to the command span, but the already-successful command still-  succeeds. Snapshot-enabled streams always run the fold because snapshots-  consume its result.-* 'seedVerifySampleRate' — verify one in N snapshot seeds against a full-  replay through the seed version. The replay runs asynchronously and never-  blocks or fails the command. This detects hand-written fold changes that-  leave the snapshot discriminator unchanged; @0@ disables the witness.--}-data RunCommandOptions = RunCommandOptions-    { retryLimit :: !Int-    , pageSize :: !Int32-    , eventIds :: ![EventId]-    , beforeAppend :: !(IO ())-    , retryBackoffMicros :: !Int-    , metrics :: !(Maybe KeiroMetrics)-    , verifyReplayOnAppend :: !Bool-    , seedVerifySampleRate :: !Int-    , tracer :: !(Maybe Tracer)-    {- ^ Optional OpenTelemetry tracer. When 'Just', the command runner-    opens an 'Internal'-kind span around each invocation, named after-    the resolved stream identifier and decorated with the messaging /-    error semantic-conventions attributes audited in-    'docs/research/opentelemetry-semconv-audit.md'. When 'Nothing',-    the runner emits no spans.-    -}-    , metadata :: !(Maybe Value)-    {- ^ Optional JSON merged into every event's metadata for this command-    invocation. Carries ambient context such as actor type, agent id,-    and session id. The codec always adds a @schemaVersion@ key; the-    keys here are merged on top (see 'Keiro.Codec.metadataFor'). When-    'Nothing', events carry only the schema-version marker, exactly as-    before this field existed.-    -}-    }-    deriving stock (Generic)--{- | Sensible defaults: 3 retries, 256-event read pages, no caller-assigned-event ids, a no-op pre-append hook, 5ms retry backoff, no metrics, post-append-replay verification enabled, one sampled snapshot-seed verification per 1000-snapshot hits, no tracer, and no extra metadata.--}-defaultRunCommandOptions :: RunCommandOptions-defaultRunCommandOptions =-    RunCommandOptions-        { retryLimit = 3-        , pageSize = 256-        , eventIds = []-        , beforeAppend = pure ()-        , retryBackoffMicros = 5000-        , metrics = Nothing-        , verifyReplayOnAppend = True-        , seedVerifySampleRate = 1000-        , tracer = Nothing-        , metadata = Nothing-        }--data Hydrated rs s = Hydrated-    { state :: !s-    , registers :: !(RegFile rs)-    , streamVersion :: !StreamVersion-    }-    deriving stock (Generic)--data CommandPlan target rs s co-    = CommandNoOp !(CommandResult target)-    | CommandAppend !(Hydrated rs s) ![co] ![EventData]-    deriving stock (Generic)--hydrate ::-    forall phi rs s ci co es.-    (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    Eff es (Either CommandError (Hydrated rs s))-hydrate options eventStream targetStream =-    snapshotSeed >>= \case-        Nothing -> hydrateFull options eventStream targetStream-        Just seed -> do-            replayed <--                hydrateSeeded-                    options-                    eventStream-                    targetStream-                    (seed ^. #state)-                    (seed ^. #registers)-                    (seed ^. #streamVersion)-            case replayed of-                Left _ -> hydrateFull options eventStream targetStream-                Right hydrated -> do-                    for_ (eventStream ^. #stateCodec) $ \codec ->-                        scheduleSeedVerification options eventStream targetStream codec seed-                    pure (Right hydrated)-  where-    snapshotSeed =-        case eventStream ^. #stateCodec of-            Nothing -> pure Nothing-            Just codec -> do-                lookupSnapshotSeed ((eventStream ^. #resolveStreamName) targetStream) codec >>= \case-                    SnapshotHit seed -> do-                        recordSnapshotReadHits (options ^. #metrics) 1-                        pure (Just seed)-                    SnapshotUnavailable reason -> do-                        recordSnapshotReadMisses (options ^. #metrics) 1-                        case reason of-                            SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1-                            _ -> pure ()-                        pure Nothing--hydrateFull ::-    forall phi rs s ci co es.-    (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    Eff es (Either CommandError (Hydrated rs s))-hydrateFull options eventStream targetStream =-    hydrateSeeded-        options-        eventStream-        targetStream-        (eventStream ^. #initialState)-        (eventStream ^. #initialRegisters)-        (StreamVersion 0)--{- | Replay a stored stream from an arbitrary snapshot or initial-state seed.--The store stream is grouped into bounded lists and each decoded prefix is-handed to keiki's 'Keiki.replayEvents'. Decoding stops at the first bad event in-a group, but the valid prefix is replayed first so an earlier replay failure-retains precedence over a later codec failure.--}-hydrateSeeded ::-    forall phi rs s ci co es.-    (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    s ->-    RegFile rs ->-    StreamVersion ->-    Eff es (Either CommandError (Hydrated rs s))-hydrateSeeded options eventStream targetStream seedState seedRegisters seedVersion = do-    hydrateSeededThrough-        Nothing-        options-        eventStream-        targetStream-        seedState-        seedRegisters-        seedVersion--hydrateSeededThrough ::-    forall phi rs s ci co es.-    (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    Maybe StreamVersion ->-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    s ->-    RegFile rs ->-    StreamVersion ->-    Eff es (Either CommandError (Hydrated rs s))-hydrateSeededThrough replayThrough options eventStream targetStream seedState seedRegisters seedVersion = do-    replayed <--        Streamly.fold-            (Fold.foldlM' replayPage (pure (Right initialReplay)))-            recordedPages-    pure (finishReplay replayed)-  where-    readPageSize = Prelude.max 1 (options ^. #pageSize)-    groupSize = Prelude.fromIntegral readPageSize-    recordedPages =-        Streamly.foldMany-            (Fold.take groupSize Fold.toList)-            boundedRecorded-    boundedRecorded =-        case replayThrough of-            Nothing -> readStreamForwardStream resolvedName seedVersion readPageSize-            Just endVersion ->-                Streamly.takeWhile-                    (\recorded -> recorded ^. #streamVersion <= endVersion)-                    (readStreamForwardStream resolvedName seedVersion readPageSize)-    resolvedName = (eventStream ^. #resolveStreamName) targetStream-    initialReplay = (Keiki.Settled seedState, seedRegisters, Nothing)--    replayPage ::-        Either CommandError (Keiki.InFlight s co, RegFile rs, Maybe RecordedEvent) ->-        [RecordedEvent] ->-        Eff es (Either CommandError (Keiki.InFlight s co, RegFile rs, Maybe RecordedEvent))-    replayPage (Left err) _ = pure (Left err)-    replayPage (Right (wrapper, registers, previousRecorded)) page =-        pure $ case Keiki.replayEvents (eventStream ^. #transducer) (wrapper, registers) decodedEvents of-            Left replayFailure ->-                Left (hydrationReplayError previousRecorded decodedRecorded replayFailure)-            Right (nextWrapper, nextRegisters) ->-                case pendingInputFailure of-                    Just err -> Left err-                    Nothing ->-                        Right-                            ( nextWrapper-                            , nextRegisters-                            , latestRecorded decodedRecorded previousRecorded-                            )-      where-        (decodedRecorded, decodedEvents, pendingInputFailure) = decodePrefix previousRecorded page--    decodePrefix :: Maybe RecordedEvent -> [RecordedEvent] -> ([RecordedEvent], [co], Maybe CommandError)-    decodePrefix previousRecorded = go [] [] startingVersion-      where-        startingVersion = maybe seedVersion (^. #streamVersion) previousRecorded--        go recordedAcc eventAcc lastSeen = \case-            [] -> (Prelude.reverse recordedAcc, Prelude.reverse eventAcc, Nothing)-            recorded : rest ->-                let observed = recorded ^. #streamVersion-                    expected = nextStreamVersion lastSeen-                 in if observed /= expected-                        then-                            ( Prelude.reverse recordedAcc-                            , Prelude.reverse eventAcc-                            , Just (HydrationGapDetected expected observed)-                            )-                        else case decodeRecorded (eventStream ^. #eventCodec) recorded of-                            Left err ->-                                ( Prelude.reverse recordedAcc-                                , Prelude.reverse eventAcc-                                , Just (HydrationDecodeFailed err)-                                )-                            Right event ->-                                go (recorded : recordedAcc) (event : eventAcc) observed rest--        nextStreamVersion (StreamVersion version) = StreamVersion (version Prelude.+ 1)--    hydrationReplayError ::-        Maybe RecordedEvent ->-        [RecordedEvent] ->-        Keiki.ReplayFailure s co ->-        CommandError-    hydrationReplayError previousRecorded decodedRecorded replayFailure =-        HydrationReplayFailed failureVersion (toHydrationReason (Keiki.replayFailureReason replayFailure))-      where-        failureVersion =-            maybe seedVersion (^. #streamVersion) failureRecorded-        failureRecorded =-            case Keiki.replayFailureReason replayFailure of-                Keiki.ReplayLogTruncated{} -> latestRecorded decodedRecorded previousRecorded-                Keiki.ReplayEventFailed{} ->-                    case recordedAt (Keiki.replayFailedIndex replayFailure) decodedRecorded of-                        Just recorded -> Just recorded-                        Nothing -> latestRecorded decodedRecorded previousRecorded--    finishReplay = \case-        Left err -> Left err-        Right (wrapper, finalRegisters, lastRecorded) ->-            case wrapper of-                Keiki.Settled finalState ->-                    Right-                        Hydrated-                            { state = finalState-                            , registers = finalRegisters-                            , streamVersion = maybe seedVersion (^. #streamVersion) lastRecorded-                            }-                Keiki.InFlight{} ->-                    Left-                        ( HydrationReplayFailed-                            (maybe seedVersion (^. #streamVersion) lastRecorded)-                            HydrationTruncatedChain-                        )--    toHydrationReason = \case-        Keiki.ReplayEventFailed stepFailure -> case stepFailure of-            Keiki.ReplayNoInvertingEdge{} -> HydrationNoInvertingEdge-            Keiki.ReplayAmbiguousInversions{} -> HydrationAmbiguousInversion-            Keiki.ReplayQueueMismatch{} -> HydrationQueueMismatch-        Keiki.ReplayLogTruncated{} -> HydrationTruncatedChain--    latestRecorded recorded fallback =-        case lastMaybe recorded of-            Just latest -> Just latest-            Nothing -> fallback--    lastMaybe = \case-        [] -> Nothing-        first : rest -> Just (Prelude.foldl (\_ current -> current) first rest)--    recordedAt eventIndex recorded =-        case Prelude.drop eventIndex recorded of-            found : _ -> Just found-            [] -> Nothing--scheduleSeedVerification ::-    forall phi rs s ci co es.-    (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    StateCodec (s, RegFile rs) ->-    SnapshotSeed rs s ->-    Eff es ()-scheduleSeedVerification options eventStream targetStream codec seed = do-    void $ trySync $ do-        sampled <--            case options ^. #seedVerifySampleRate of-                rate | rate <= 0 -> pure False-                1 -> pure True-                rate -> liftIO ((== (1 :: Int)) <$> uniformRM (1, rate) globalStdGen)-        when sampled-            $ void-            $ runConcurrent-            $ Async.async-            $ verifySnapshotSeed options eventStream targetStream codec seed--verifySnapshotSeed ::-    forall phi rs s ci co es.-    (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    StateCodec (s, RegFile rs) ->-    SnapshotSeed rs s ->-    Eff es ()-verifySnapshotSeed options eventStream targetStream codec seed = do-    let seedVersion = seed ^. #streamVersion-        streamName = (eventStream ^. #resolveStreamName) targetStream-    full <--        hydrateSeededThrough-            (Just seedVersion)-            options-            eventStream-            targetStream-            (eventStream ^. #initialState)-            (eventStream ^. #initialRegisters)-            (StreamVersion 0)-    seededEncoded <--        liftIO-            $ encodeSnapshotStrict-                codec-                (seed ^. #state, seed ^. #registers)-    case (seededEncoded, full) of-        (Left seedEncodeError, _) ->-            reportSeedDivergence-                options-                streamName-                seedVersion-                ("encode-failed:" <> Text.pack (displayException seedEncodeError))-                (case full of Left replayError -> "replay-failed:" <> Text.pack (show replayError); Right _ -> "not-compared:seed-encode-failed")-        (Right seededValue, Left replayError) ->-            reportSeedDivergence-                options-                streamName-                seedVersion-                (replayDigest seededValue)-                ("replay-failed:" <> Text.pack (show replayError))-        (Right seededValue, Right fullHydrated)-            | fullHydrated ^. #streamVersion /= seedVersion ->-                reportSeedDivergence-                    options-                    streamName-                    seedVersion-                    (replayDigest seededValue)-                    ("version-mismatch:" <> Text.pack (show (fullHydrated ^. #streamVersion)))-            | otherwise -> do-                fullEncoded <--                    liftIO-                        $ encodeSnapshotStrict-                            codec-                            (fullHydrated ^. #state, fullHydrated ^. #registers)-                case fullEncoded of-                    Left fullEncodeError ->-                        reportSeedDivergence-                            options-                            streamName-                            seedVersion-                            (replayDigest seededValue)-                            ("encode-failed:" <> Text.pack (displayException fullEncodeError))-                    Right fullValue ->-                        unless-                            (canonicalJsonBytes seededValue == canonicalJsonBytes fullValue)-                            ( reportSeedDivergence-                                options-                                streamName-                                seedVersion-                                (replayDigest seededValue)-                                (replayDigest fullValue)-                            )--reportSeedDivergence ::-    (IOE :> es) =>-    RunCommandOptions ->-    StreamName ->-    StreamVersion ->-    Text ->-    Text ->-    Eff es ()-reportSeedDivergence options (StreamName streamName) (StreamVersion seedVersion) seededDigest fullDigest = do-    recordSnapshotSeedDivergence (options ^. #metrics) 1-    liftIO-        $ LazyByteString.hPutStrLn stderr-        $ Aeson.encode-        $ Aeson.object-            [ "event" Aeson..= ("keiro.snapshot.seed.divergence" :: Text)-            , "level" Aeson..= ("error" :: Text)-            , "stream" Aeson..= streamName-            , "seedVersion" Aeson..= seedVersion-            , "seededDigest" Aeson..= seededDigest-            , "fullDigest" Aeson..= fullDigest-            ]--{- | Hydrate the target stream, transduce the command, and append any emitted-events. Retries optimistic-concurrency conflicts up to 'retryLimit'. This-is the plain runner with no in-transaction side effects.--}-runCommand ::-    forall phi rs s ci co es.-    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    ValidatedEventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    ci ->-    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co)))-runCommand options validatedEventStream targetStream command =-    withCommandSpan (options ^. #tracer) (resolvedStreamName eventStream targetStream) Nothing $ \mSpan -> do-        (result, attemptNo) <- attempt mSpan 1 Nothing-        recordCommandOutcome mSpan (^. #eventsAppended) attemptNo result-        pure result-  where-    eventStream = unvalidated validatedEventStream--    attempt mSpan attemptNo lastConflict = do-        hydrated <- hydrate options eventStream targetStream-        either (\err -> pure (Left err, attemptNo)) (runPlan mSpan attemptNo lastConflict) hydrated--    runPlan mSpan attemptNo lastConflict current =-        case conflictFixpoint lastConflict (current ^. #streamVersion) of-            Just err -> pure (Left err, attemptNo)-            Nothing ->-                case prepareCommandPlan options eventStream targetStream current command of-                    Left err -> pure (Left err, attemptNo)-                    Right (CommandNoOp result) -> pure (Right result, attemptNo)-                    Right (CommandAppend current' events encoded) ->-                        appendOnce mSpan attemptNo current' events encoded--    appendOnce mSpan attemptNo current events encoded = do-        liftIO (options ^. #beforeAppend)-        appended <--            tryError @StoreError-                $ appendToStream-                    ((eventStream ^. #resolveStreamName) targetStream)-                    (expectedVersion (current ^. #streamVersion))-                    encoded-        case appended of-            Right appendResult -> do-                verifyAndSnapshot options mSpan eventStream current events appendResult-                pure (Right (appendedResult targetStream appendResult (Prelude.length encoded)), attemptNo)-            Left (_, storeError) ->-                retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError--{- | Like 'runCommand', but run @afterAppend@ inside the /same/ transaction-as the append, so a read-model write commits atomically with the events.-The callback's result is returned as @Just@ on append (and 'Nothing' for a-no-op command that appended nothing).--Requires 'KirokuStoreResource' so the transactional append applies the-configured @enrichEvent@ hook. See 'runCommandWithSqlEvents' for the callback's-locking and latency implications.--}-runCommandWithSql ::-    forall phi rs s ci co a es.-    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    ValidatedEventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    ci ->-    (AppendResult -> Tx.Transaction a) ->-    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co), Maybe a))-runCommandWithSql options eventStream targetStream command afterAppend =-    runCommandWithSqlEvents options eventStream targetStream command (\_ appendResult -> afterAppend appendResult)--{- | The most general runner: like 'runCommandWithSql', but the-in-transaction callback also receives every emitted event paired with the-'RecordedEvent' the store persisted for it, in append order. Inline-projections, process managers, and routers are all built on this.--The runner requires 'KirokuStoreResource' and applies the configured-@enrichEvent@ hook before preparing the append. The callback therefore sees-the enriched metadata that was persisted.--The append updates Kiroku's global @$all@ stream and holds its PostgreSQL row-lock until this transaction commits. Every SQL operation in the callback-therefore extends the store-wide append serialization window. Keep the-callback small: precompute outside the transaction where possible, batch-writes, and minimize database round trips.--}-runCommandWithSqlEvents ::-    forall phi rs s ci co a es.-    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    ValidatedEventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    ci ->-    ([(co, RecordedEvent)] -> AppendResult -> Tx.Transaction a) ->-    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co), Maybe a))-runCommandWithSqlEvents options validatedEventStream targetStream command afterAppend =-    withCommandSpan (options ^. #tracer) (resolvedStreamName eventStream targetStream) Nothing $ \mSpan -> do-        (result, attemptNo) <- attempt mSpan 1 Nothing-        recordCommandOutcome mSpan (\(r, _) -> r ^. #eventsAppended) attemptNo result-        pure result-  where-    eventStream = unvalidated validatedEventStream--    attempt mSpan attemptNo lastConflict = do-        hydrated <- hydrate options eventStream targetStream-        either (\err -> pure (Left err, attemptNo)) (runPlan mSpan attemptNo lastConflict) hydrated--    runPlan mSpan attemptNo lastConflict current =-        case conflictFixpoint lastConflict (current ^. #streamVersion) of-            Just err -> pure (Left err, attemptNo)-            Nothing ->-                case prepareCommandPlan options eventStream targetStream current command of-                    Left err -> pure (Left err, attemptNo)-                    Right (CommandNoOp result) -> pure (Right (result, Nothing), attemptNo)-                    Right (CommandAppend current' events encoded) ->-                        appendWithSqlOnce mSpan attemptNo current' events encoded--    appendWithSqlOnce mSpan attemptNo current events encoded = do-        liftIO (options ^. #beforeAppend)-        store <- getKirokuStore-        enriched <- liftIO (enrichEventsIO store encoded)-        prepared <- prepareEventsIO enriched-        now <- liftIO getCurrentTime-        let streamName = (eventStream ^. #resolveStreamName) targetStream-            expected = expectedVersion (current ^. #streamVersion)-            body = do-                appended <- appendToStreamTx streamName expected prepared now-                case appended of-                    Left conflict ->-                        Tx.condemn $> Left (appendConflictToStoreError conflict)-                    Right appendResult -> do-                        let recordeds = reconstructRecorded appendResult now prepared-                        userValue <- afterAppend (Prelude.zip events recordeds) appendResult-                        pure (Right (appendResult, userValue))-        outcome <- tryError @StoreError (runTransaction body)-        case outcome of-            Right (Right (appendResult, userValue)) -> do-                verifyAndSnapshot options mSpan eventStream current events appendResult-                pure (Right (appendedResult targetStream appendResult (Prelude.length encoded), Just userValue), attemptNo)-            Right (Left storeError) ->-                retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError-            Left (_, storeError) ->-                retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError--prepareCommandPlan ::-    (BoolAlg phi (RegFile rs, ci)) =>-    RunCommandOptions ->-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    Hydrated rs s ->-    ci ->-    Either CommandError (CommandPlan (EventStream phi rs s ci co) rs s co)-prepareCommandPlan options eventStream targetStream current command =-    case evaluateCommand eventStream current command of-        Left err -> Left err-        Right events -> toPlan events-  where-    toPlan [] =-        Right (CommandNoOp (noOpResult targetStream current))-    toPlan events =-        CommandAppend current events-            . assignEventIds (options ^. #eventIds)-            <$> encodeEvents (eventStream ^. #eventCodec) (options ^. #metadata) events--{- | Render the stream that the command targets as plain 'Text', for use-as a span name.--}-resolvedStreamName ::-    EventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    Text-resolvedStreamName eventStream targetStream =-    case (eventStream ^. #resolveStreamName) targetStream of-        StreamName n -> n--{- | Attach the command-span outcome attributes after the runner returns.--On success: 'db.system.name' and 'keiro.events.appended'.-On failure: 'error.type' (low-cardinality classifier) and span status-'Error' (carrying the rendered 'CommandError' as the description).--Pure no-op when no span is active ('Nothing' tracer, etc).--}-recordCommandOutcome ::-    (IOE :> es) =>-    Maybe Span ->-    (a -> Int) ->-    Int ->-    Either CommandError a ->-    Eff es ()-recordCommandOutcome Nothing _ _ _ = pure ()-recordCommandOutcome (Just sp) eventsOf attemptNo result = do-    addAttribute sp (unkey db_system_name) ("postgresql" :: Text)-    addAttribute sp (unkey keiro_retry_attempt) (Prelude.fromIntegral attemptNo :: Int64)-    case result of-        Right v ->-            addAttribute sp (unkey keiro_events_appended) (Prelude.fromIntegral (eventsOf v) :: Int64)-        Left err -> do-            addAttribute sp (unkey error_type) (commandErrorClass err)-            setStatus sp (Error (Text.take 256 (Text.pack (show err))))--{- | Low-cardinality classifier for a 'CommandError'. Used as the-@error.type@ attribute value on the command span.--}-commandErrorClass :: CommandError -> Text-commandErrorClass = \case-    HydrationDecodeFailed{} -> "hydration_decode_failed"-    HydrationReplayFailed _ HydrationNoInvertingEdge -> "hydration_replay_no_inverting_edge"-    HydrationReplayFailed _ HydrationAmbiguousInversion -> "hydration_replay_ambiguous_inversion"-    HydrationReplayFailed _ HydrationQueueMismatch -> "hydration_replay_queue_mismatch"-    HydrationReplayFailed _ HydrationTruncatedChain -> "hydration_replay_truncated_chain"-    HydrationGapDetected{} -> "hydration_gap_detected"-    CommandRejected -> "command_rejected"-    CommandAmbiguous{} -> "command_ambiguous"-    EncodeFailed{} -> "encode_failed"-    StoreFailed{} -> "store_failed"-    RetryExhausted{} -> "retry_exhausted"-    ConflictFixpoint{} -> "conflict_fixpoint"--verifyAndSnapshot ::-    forall phi rs s ci co es.-    (BoolAlg phi (RegFile rs, ci), IOE :> es, Store :> es, Error StoreError :> es, Eq co) =>-    RunCommandOptions ->-    Maybe Span ->-    EventStream phi rs s ci co ->-    Hydrated rs s ->-    [co] ->-    AppendResult ->-    Eff es ()-verifyAndSnapshot options mSpan eventStream current events appendResult-    | Prelude.not (options ^. #verifyReplayOnAppend)-    , Nothing <- eventStream ^. #stateCodec =-        pure ()-    | otherwise =-        case Keiki.applyEventsEither (eventStream ^. #transducer) (state current, registers current) events of-            Left failure -> do-                recordSnapshotApplyDivergence (options ^. #metrics) 1-                for_ mSpan $ \sp ->-                    addAttribute-                        sp-                        (unkey keiro_replay_divergence)-                        (Text.take 256 (renderReplayFailure failure))-            Right finalState ->-                case eventStream ^. #stateCodec of-                    Nothing -> pure ()-                    Just codec -> do-                        let finalVersion = appendResult ^. #streamVersion-                            terminality =-                                if Keiki.isFinal (eventStream ^. #transducer) (Prelude.fst finalState)-                                    then Terminal-                                    else NotTerminal-                        when (shouldSnapshotSpan (eventStream ^. #snapshotPolicy) terminality finalState (current ^. #streamVersion) finalVersion)-                            $ do-                                encoded <- liftIO (encodeSnapshotStrict codec finalState)-                                case encoded of-                                    Left _ -> recordSnapshotEncodeFailures (options ^. #metrics) 1-                                    Right value -> do-                                        outcome <- tryError @StoreError (writeSnapshotEncoded (appendResult ^. #streamId) finalVersion codec value)-                                        case outcome of-                                            Right () -> pure ()-                                            Left _ -> recordSnapshotWriteFailures (options ^. #metrics) 1--renderReplayFailure :: Keiki.ReplayFailure s co -> Text-renderReplayFailure failure =-    "event_index="-        <> Text.pack (show (Keiki.replayFailedIndex failure))-        <> ";reason="-        <> case Keiki.replayFailureReason failure of-            Keiki.ReplayEventFailed stepFailure -> case stepFailure of-                Keiki.ReplayNoInvertingEdge{} -> "no_inverting_edge"-                Keiki.ReplayAmbiguousInversions{} -> "ambiguous_inversions"-                Keiki.ReplayQueueMismatch{} -> "queue_mismatch"-            Keiki.ReplayLogTruncated{} -> "log_truncated"--retryOrFail ::-    (IOE :> es) =>-    RunCommandOptions ->-    (Int -> Maybe (StoreError, StreamVersion) -> Eff es (Either CommandError a, Int)) ->-    Int ->-    StreamVersion ->-    StoreError ->-    Eff es (Either CommandError a, Int)-retryOrFail options retry attemptNo observedVersion storeError-    | isRetryableConflict storeError-    , attemptNo <= options ^. #retryLimit = do-        recordCommandConflicts (options ^. #metrics) 1-        backoffDelay options attemptNo-        recordCommandRetries (options ^. #metrics) 1-        retry (attemptNo Prelude.+ 1) (Just (storeError, observedVersion))-    | isRetryableConflict storeError = do-        recordCommandConflicts (options ^. #metrics) 1-        pure (Left (RetryExhausted attemptNo storeError), attemptNo)-    | otherwise = do-        case storeError of-            DuplicateEvent{} -> recordCommandDuplicates (options ^. #metrics) 1-            _ -> pure ()-        pure (Left (StoreFailed storeError), attemptNo)--backoffDelay :: (IOE :> es) => RunCommandOptions -> Int -> Eff es ()-backoffDelay options attemptNo-    | base <= 0 = pure ()-    | otherwise = do-        nanos <- liftIO getMonotonicTimeNSec-        let exponential = min 100000 (base Prelude.* (2 Prelude.^ (attemptNo Prelude.- 1 :: Int)))-            jitter =-                Prelude.fromIntegral (nanos `Prelude.mod` Prelude.fromIntegral exponential)-                    Prelude.- (exponential `Prelude.div` 2)-        liftIO (threadDelay (max 0 (exponential Prelude.+ jitter)))-  where-    base = options ^. #retryBackoffMicros--conflictFixpoint :: Maybe (StoreError, StreamVersion) -> StreamVersion -> Maybe CommandError-conflictFixpoint (Just (previousError@StreamAlreadyExists{}, previousVersion)) currentVersion-    | currentVersion == previousVersion = Just (ConflictFixpoint currentVersion previousError)-conflictFixpoint _ _ = Nothing--evaluateCommand ::-    (BoolAlg phi (RegFile rs, ci)) =>-    EventStream phi rs s ci co ->-    Hydrated rs s ->-    ci ->-    Either CommandError [co]-evaluateCommand eventStream current command =-    case Keiki.stepEither (eventStream ^. #transducer) (state current, registers current) command of-        Left Keiki.NoOutgoingEdges{} -> Left CommandRejected-        Left Keiki.NoMatchingEdge{} -> Left CommandRejected-        Left (Keiki.AmbiguousEdges _ matches) ->-            Left-                ( CommandAmbiguous-                    [ Keiki.edgeIndex (Keiki.matchedEdge matched)-                    | matched <- matches-                    ]-                )-        Right (_, _, events) -> Right events--encodeEvents :: Codec co -> Maybe Value -> [co] -> Either CommandError [EventData]-encodeEvents codec md =-    Prelude.mapM (mapLeft EncodeFailed . encodeForAppendWithMetadata codec md)--assignEventIds :: [EventId] -> [EventData] -> [EventData]-assignEventIds [] events = events-assignEventIds _ [] = []-assignEventIds (supplied : suppliedRest) (event : eventRest) =-    (event & #eventId .~ Just supplied) : assignEventIds suppliedRest eventRest--expectedVersion :: StreamVersion -> ExpectedVersion-expectedVersion (StreamVersion 0) = NoStream-expectedVersion version = ExactVersion version--noOpResult ::-    Stream target ->-    Hydrated rs s ->-    CommandResult target-noOpResult targetStream current =-    CommandResult-        { target = targetStream-        , streamVersion = current ^. #streamVersion-        , globalPosition = Nothing-        , eventsAppended = 0-        }--appendedResult ::-    Stream target ->-    AppendResult ->-    Int ->-    CommandResult target-appendedResult targetStream appendResult count =-    CommandResult-        { target = targetStream-        , streamVersion = appendResult ^. #streamVersion-        , globalPosition = Just (appendResult ^. #globalPosition)-        , eventsAppended = count-        }--{- | Rebuild the per-event 'RecordedEvent' values for a just-appended batch.--The store assigns each event in a batch a contiguous stream version and-global position: event @i@ (1-based) gets @last - count + i@ for both-counters, where @last@ is the position the 'AppendResult' reports for the-final event and @count@ is the batch size. (The kiroku append SQL numbers-events with @WITH ORDINALITY@ and inserts @initial + idx@; see EP-27's-Surprises & Discoveries.) We therefore reconstruct each 'RecordedEvent'-exactly, rather than reading the batch back. The @createdAt@ is the same-timestamp 'prepareEventsIO'/'appendToStreamTx' used for the insert.--This is a source append (events are written to their own stream), so-@streamVersion == originalVersion@ and @originalStreamId@ is the appended-stream's id, per the 'RecordedEvent' contract.--}-reconstructRecorded :: AppendResult -> UTCTime -> [PreparedEvent] -> [RecordedEvent]-reconstructRecorded appendResult now prepared =-    Prelude.zipWith mk [0 ..] prepared-  where-    count = Prelude.length prepared-    StreamVersion lastSv = appendResult ^. #streamVersion-    GlobalPosition lastGp = appendResult ^. #globalPosition-    firstSv = lastSv Prelude.- Prelude.fromIntegral count Prelude.+ 1-    firstGp = lastGp Prelude.- Prelude.fromIntegral count Prelude.+ 1-    mk :: Int64 -> PreparedEvent -> RecordedEvent-    mk i prepared' =-        RecordedEvent-            { eventId = EventId (prepared' ^. #peEventId)-            , eventType = prepared' ^. #peEventType-            , streamVersion = StreamVersion (firstSv Prelude.+ i)-            , globalPosition = GlobalPosition (firstGp Prelude.+ i)-            , originalStreamId = appendResult ^. #streamId-            , originalVersion = StreamVersion (firstSv Prelude.+ i)-            , payload = prepared' ^. #pePayload-            , metadata = prepared' ^. #peMetadata-            , causationId = prepared' ^. #peCausationId-            , correlationId = prepared' ^. #peCorrelationId-            , createdAt = now-            }--isRetryableConflict :: StoreError -> Bool-isRetryableConflict = \case-    WrongExpectedVersion{} -> True-    StreamAlreadyExists{} -> True-    _ -> False--mapLeft :: (e -> e') -> Either e a -> Either e' a-mapLeft f = \case-    Left err -> Left (f err)-    Right value -> Right value+-- | The command side of the framework: hydrate an aggregate, transduce, append.+--+-- Running a command against an 'EventStream' follows one pipeline:+--+-- 1. /Hydrate/ — replay the stream's stored events (optionally fast-forwarding+--    from a snapshot) through the keiki transducer to recover the current+--    @(state, registers)@ and stream version.+-- 2. /Transduce/ — step the transducer with the command. A rejected transition+--    yields 'CommandRejected', while multiple matching transitions yield+--    'CommandAmbiguous'; a transition that emits no events yields a no-op+--    'CommandResult'.+-- 3. /Append/ — encode the emitted events with the stream's 'Codec' and append+--    them at the expected version. An optimistic-concurrency conflict is+--    retried up to 'retryLimit' times by rehydrating and replaying; exhausting+--    the budget yields 'RetryExhausted'.+--+-- Three runners expose this pipeline at increasing levels of integration:+--+-- * 'runCommand' — append only.+-- * 'runCommandWithSql' — run an extra @afterAppend@ action in the /same/+--   transaction as the append (e.g. update an inline read model).+-- * 'runCommandWithSqlEvents' — same, but the callback also receives the+--   emitted events paired with their 'RecordedEvent's. This is the primitive+--   the projection, process-manager, and router layers build on.+--+-- The transactional runners apply Kiroku's configured @enrichEvent@ hook before+-- event preparation, exactly like 'runCommand'. They therefore require a+-- 'KirokuStoreResource' in the effect stack; install it with @withKirokuStore@+-- and interpret 'Store' with @runStoreResource@.+--+-- Per-stream hydration honors Kiroku's stream-truncation marker. A retained+-- snapshot must cover every hidden event (snapshot version at least marker minus+-- one); otherwise hydration fails with 'HydrationGapDetected'. A marker above+-- the stream head can instead appear as an empty stream and repeated append+-- conflicts end in 'ConflictFixpoint'. Keiro never truncates streams itself.+-- Kiroku's @$all@ and category/subscription reads are unaffected by per-stream+-- truncation: the marker hides events from stream reads rather than deleting+-- them from the global log.+--+-- Every successful append is replayed immediately from its pre-command state so+-- an unreplayable batch is witnessed at the moment it poisons the stream. The+-- post-commit witness is counted and attached to the command span without+-- changing the successful result. The same replay fold feeds transparent+-- snapshot writes when the stream's 'Keiro.EventStream.SnapshotPolicy' fires;+-- post-commit snapshot failures are likewise swallowed and counted. Every runner+-- accepts a tracer for optional OpenTelemetry spans.+--+-- The additive hydration primitives are also consumed by "Keiro.ReplayAudit".+-- The audit deliberately calls the seeded and full variants separately so the+-- public command-serving fallback cannot hide a stale or unreplayable seed.+module Keiro.Command+  ( -- * Results and errors+    CommandResult (..),+    CommandError (..),+    HydrationReplayReason (..),+    commandErrorClass,++    -- * Options+    RunCommandOptions (..),+    defaultRunCommandOptions,++    -- * Running commands+    runCommand,+    runCommandWithSql,+    runCommandWithSqlEvents,++    -- * Hydration primitives (replay audit)+    Hydrated (..),+    hydrate,+    hydrateFull,+    hydrateSeeded,+  )+where++import Control.Concurrent (threadDelay)+import Control.Exception (displayException)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy.Char8 qualified as LazyByteString+import Data.Functor (($>))+import Data.Int (Int32)+import Data.Text qualified as Text+import Effectful (Eff, IOE, (:>))+import Effectful.Concurrent (runConcurrent)+import Effectful.Concurrent.Async qualified as Async+import Effectful.Error.Static (Error, tryError)+import Effectful.Exception (trySync)+import GHC.Clock (getMonotonicTimeNSec)+import GHC.Stack (HasCallStack)+import Keiki.Core (BoolAlg, RegFile)+import Keiki.Core qualified as Keiki+import Keiro.Codec (Codec, CodecError, decodeRecorded, encodeForAppendWithMetadata)+import Keiro.EventStream (EventStream, StateCodec, Terminality (..))+import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)+import Keiro.Prelude+import Keiro.ReplayDigest (canonicalJsonBytes, replayDigest)+import Keiro.Snapshot+  ( SnapshotLookup (..),+    SnapshotMissReason (..),+    SnapshotSeed,+    encodeSnapshotStrict,+    lookupSnapshotSeed,+    writeSnapshotEncoded,+  )+import Keiro.Snapshot.Policy (shouldSnapshotSpan)+import Keiro.Stream (Stream)+import Keiro.Telemetry+  ( KeiroMetrics,+    keiro_events_appended,+    keiro_replay_divergence,+    keiro_retry_attempt,+    recordCommandConflicts,+    recordCommandDuplicates,+    recordCommandRetries,+    recordSnapshotApplyDivergence,+    recordSnapshotDecodeFailures,+    recordSnapshotEncodeFailures,+    recordSnapshotReadHits,+    recordSnapshotReadMisses,+    recordSnapshotSeedDivergence,+    recordSnapshotWriteFailures,+    withCommandSpan,+  )+import Kiroku.Store.Append (appendToStream)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)+import Kiroku.Store.Error (StoreError (..))+import Kiroku.Store.Read (readStreamForwardStream)+import Kiroku.Store.Transaction+  ( PreparedEvent,+    appendConflictToStoreError,+    appendToStreamTx,+    enrichEventsIO,+    prepareEventsIO,+    runTransaction,+  )+import Kiroku.Store.Types+  ( AppendResult,+    EventData,+    EventId (..),+    ExpectedVersion (..),+    GlobalPosition (..),+    RecordedEvent (..),+    StreamName (..),+    StreamVersion (..),+  )+import OpenTelemetry.Attributes.Key (unkey)+import OpenTelemetry.SemanticConventions (db_system_name, error_type)+import OpenTelemetry.Trace.Core (Span, SpanStatus (..), Tracer, addAttribute, setStatus)+import Streamly.Data.Fold qualified as Fold+import Streamly.Data.Stream qualified as Streamly+import System.IO (stderr)+import System.Random.Stateful (globalStdGen, uniformRM)+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude qualified++-- | The outcome of a successfully handled command.+--+-- Reports the target 'Stream', the stream version after the command, the global+-- log position only when this command appended and the store assigned a real+-- one, and how many events were appended. A no-op reports @0@ events and+-- @Nothing@ for its global position because per-stream reads cannot recover a+-- true global position.+data CommandResult target = CommandResult+  { target :: !(Stream target),+    streamVersion :: !StreamVersion,+    -- | 'Just' only when this command appended; 'Nothing' for a no-op.+    globalPosition :: !(Maybe GlobalPosition),+    eventsAppended :: !Int+  }+  deriving stock (Generic, Eq, Show)++-- | Why a command did not complete.+data CommandError+  = -- | A stored event could not be decoded while rehydrating the aggregate.+    HydrationDecodeFailed !CodecError+  | -- | Replay of the stored events through the transducer stalled. The+    --       version identifies the failing stored event; for+    --       'HydrationTruncatedChain' it identifies the last stored event, after+    --       which the expected multi-event chain remained incomplete.+    HydrationReplayFailed !StreamVersion !HydrationReplayReason+  | -- | Hydration observed a non-contiguous stream version. Carries the+    --       expected version followed by the observed version. The store writes+    --       contiguous versions, so this indicates that stream truncation hid+    --       events not covered by the hydration seed. Restore visibility with+    --       @clearStreamTruncateBefore@ or provide a covering snapshot before+    --       retrying the command.+    HydrationGapDetected !StreamVersion !StreamVersion+  | -- | No transducer edge matched the command in the hydrated state.+    CommandRejected+  | -- | Two or more transducer edges matched the command in the hydrated+    --       state. This is a deterministic aggregate-definition bug rather than a+    --       business rejection; the list contains the zero-based matched edge+    --       indices in declaration order.+    CommandAmbiguous ![Int]+  | -- | An emitted event could not be encoded for append.+    EncodeFailed !CodecError+  | -- | The underlying store rejected the append.+    StoreFailed !StoreError+  | -- | Optimistic-concurrency retries were exhausted (carries the total+    --       attempts made and the last store error).+    RetryExhausted !Int !StoreError+  | -- | Retrying after a 'StreamAlreadyExists' conflict re-observed the same+    --       stream version: the store says the stream exists but reading it shows no+    --       progress. The typical cause is a soft-deleted stream, where reads return+    --       nothing but appends still collide. Carries the observed version and the+    --       conflict.+    ConflictFixpoint !StreamVersion !StoreError+  deriving stock (Generic, Eq, Show)++-- | Why replay of stored events stalled, projected from keiki's structured+-- failure types onto a monomorphic vocabulary suitable for 'CommandError'.+data HydrationReplayReason+  = -- | No edge's first output template could have produced the event.+    HydrationNoInvertingEdge+  | -- | More than one edge could have produced the event.+    HydrationAmbiguousInversion+  | -- | An event did not match the next expected event in a chain.+    HydrationQueueMismatch+  | -- | The stream ended in the middle of a multi-event chain.+    HydrationTruncatedChain+  deriving stock (Generic, Eq, Show)++-- | Knobs controlling a single command invocation.+--+-- * 'retryLimit' — how many times to rehydrate-and-replay after an+--   optimistic-concurrency conflict before giving up with 'RetryExhausted'.+-- * 'pageSize' — batch size when reading the stream during hydration.+-- * 'eventIds' — caller-supplied ids assigned to the emitted events in order;+--   the basis for deterministic, idempotent appends (see 'Keiro.Router' and+--   'Keiro.ProcessManager').+-- * 'beforeAppend' — a hook run immediately before each append attempt,+--   primarily a test seam for injecting concurrent writes.+-- * 'retryBackoffMicros' — base delay before the k-th OCC retry, capped at+--   100 ms and jittered. Set to 0 to disable backoff.+-- * 'metrics' — optional metrics handle for command and snapshot counters.+-- * 'verifyReplayOnAppend' — replay every just-appended batch from the+--   pre-command state. Divergence is a post-commit advisory: it is counted and+--   attached to the command span, but the already-successful command still+--   succeeds. Snapshot-enabled streams always run the fold because snapshots+--   consume its result.+-- * 'seedVerifySampleRate' — verify one in N snapshot seeds against a full+--   replay through the seed version. The replay runs asynchronously and never+--   blocks or fails the command. This detects hand-written fold changes that+--   leave the snapshot discriminator unchanged; @0@ disables the witness.+data RunCommandOptions = RunCommandOptions+  { retryLimit :: !Int,+    pageSize :: !Int32,+    eventIds :: ![EventId],+    beforeAppend :: !(IO ()),+    retryBackoffMicros :: !Int,+    metrics :: !(Maybe KeiroMetrics),+    verifyReplayOnAppend :: !Bool,+    seedVerifySampleRate :: !Int,+    -- | Optional OpenTelemetry tracer. When 'Just', the command runner+    --     opens an 'Internal'-kind span around each invocation, named after+    --     the resolved stream identifier and decorated with the messaging /+    --     error semantic-conventions attributes audited in+    --     'docs/research/opentelemetry-semconv-audit.md'. When 'Nothing',+    --     the runner emits no spans.+    tracer :: !(Maybe Tracer),+    -- | Optional JSON merged into every event's metadata for this command+    --     invocation. Carries ambient context such as actor type, agent id,+    --     and session id. The codec always adds a @schemaVersion@ key; the+    --     keys here are merged on top (see 'Keiro.Codec.metadataFor'). When+    --     'Nothing', events carry only the schema-version marker, exactly as+    --     before this field existed.+    metadata :: !(Maybe Value)+  }+  deriving stock (Generic)++-- | Sensible defaults: 3 retries, 256-event read pages, no caller-assigned+-- event ids, a no-op pre-append hook, 5ms retry backoff, no metrics, post-append+-- replay verification enabled, one sampled snapshot-seed verification per 1000+-- snapshot hits, no tracer, and no extra metadata.+defaultRunCommandOptions :: RunCommandOptions+defaultRunCommandOptions =+  RunCommandOptions+    { retryLimit = 3,+      pageSize = 256,+      eventIds = [],+      beforeAppend = pure (),+      retryBackoffMicros = 5000,+      metrics = Nothing,+      verifyReplayOnAppend = True,+      seedVerifySampleRate = 1000,+      tracer = Nothing,+      metadata = Nothing+    }++data Hydrated rs s = Hydrated+  { state :: !s,+    registers :: !(RegFile rs),+    streamVersion :: !StreamVersion+  }+  deriving stock (Generic)++data CommandPlan target rs s co+  = CommandNoOp !(CommandResult target)+  | CommandAppend !(Hydrated rs s) ![co] ![EventData]+  deriving stock (Generic)++hydrate ::+  forall phi rs s ci co es.+  (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Eff es (Either CommandError (Hydrated rs s))+hydrate options eventStream targetStream =+  snapshotSeed >>= \case+    Nothing -> hydrateFull options eventStream targetStream+    Just seed -> do+      replayed <-+        hydrateSeeded+          options+          eventStream+          targetStream+          (seed ^. #state)+          (seed ^. #registers)+          (seed ^. #streamVersion)+      case replayed of+        Left _ -> hydrateFull options eventStream targetStream+        Right hydrated -> do+          for_ (eventStream ^. #stateCodec) $ \codec ->+            scheduleSeedVerification options eventStream targetStream codec seed+          pure (Right hydrated)+  where+    snapshotSeed =+      case eventStream ^. #stateCodec of+        Nothing -> pure Nothing+        Just codec -> do+          lookupSnapshotSeed ((eventStream ^. #resolveStreamName) targetStream) codec >>= \case+            SnapshotHit seed -> do+              recordSnapshotReadHits (options ^. #metrics) 1+              pure (Just seed)+            SnapshotUnavailable reason -> do+              recordSnapshotReadMisses (options ^. #metrics) 1+              case reason of+                SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1+                _ -> pure ()+              pure Nothing++hydrateFull ::+  forall phi rs s ci co es.+  (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Eff es (Either CommandError (Hydrated rs s))+hydrateFull options eventStream targetStream =+  hydrateSeeded+    options+    eventStream+    targetStream+    (eventStream ^. #initialState)+    (eventStream ^. #initialRegisters)+    (StreamVersion 0)++-- | Replay a stored stream from an arbitrary snapshot or initial-state seed.+--+-- The store stream is grouped into bounded lists and each decoded prefix is+-- handed to keiki's 'Keiki.replayEvents'. Decoding stops at the first bad event in+-- a group, but the valid prefix is replayed first so an earlier replay failure+-- retains precedence over a later codec failure.+hydrateSeeded ::+  forall phi rs s ci co es.+  (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  s ->+  RegFile rs ->+  StreamVersion ->+  Eff es (Either CommandError (Hydrated rs s))+hydrateSeeded options eventStream targetStream seedState seedRegisters seedVersion = do+  hydrateSeededThrough+    Nothing+    options+    eventStream+    targetStream+    seedState+    seedRegisters+    seedVersion++hydrateSeededThrough ::+  forall phi rs s ci co es.+  (HasCallStack, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  Maybe StreamVersion ->+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  s ->+  RegFile rs ->+  StreamVersion ->+  Eff es (Either CommandError (Hydrated rs s))+hydrateSeededThrough replayThrough options eventStream targetStream seedState seedRegisters seedVersion = do+  replayed <-+    Streamly.fold+      (Fold.foldlM' replayPage (pure (Right initialReplay)))+      recordedPages+  pure (finishReplay replayed)+  where+    readPageSize = Prelude.max 1 (options ^. #pageSize)+    groupSize = Prelude.fromIntegral readPageSize+    recordedPages =+      Streamly.foldMany+        (Fold.take groupSize Fold.toList)+        boundedRecorded+    boundedRecorded =+      case replayThrough of+        Nothing -> readStreamForwardStream resolvedName seedVersion readPageSize+        Just endVersion ->+          Streamly.takeWhile+            (\recorded -> recorded ^. #streamVersion <= endVersion)+            (readStreamForwardStream resolvedName seedVersion readPageSize)+    resolvedName = (eventStream ^. #resolveStreamName) targetStream+    initialReplay = (Keiki.Settled seedState, seedRegisters, Nothing)++    replayPage ::+      Either CommandError (Keiki.InFlight s co, RegFile rs, Maybe RecordedEvent) ->+      [RecordedEvent] ->+      Eff es (Either CommandError (Keiki.InFlight s co, RegFile rs, Maybe RecordedEvent))+    replayPage (Left err) _ = pure (Left err)+    replayPage (Right (wrapper, registers, previousRecorded)) page =+      pure $ case Keiki.replayEvents (eventStream ^. #transducer) (wrapper, registers) decodedEvents of+        Left replayFailure ->+          Left (hydrationReplayError previousRecorded decodedRecorded replayFailure)+        Right (nextWrapper, nextRegisters) ->+          case pendingInputFailure of+            Just err -> Left err+            Nothing ->+              Right+                ( nextWrapper,+                  nextRegisters,+                  latestRecorded decodedRecorded previousRecorded+                )+      where+        (decodedRecorded, decodedEvents, pendingInputFailure) = decodePrefix previousRecorded page++    decodePrefix :: Maybe RecordedEvent -> [RecordedEvent] -> ([RecordedEvent], [co], Maybe CommandError)+    decodePrefix previousRecorded = go [] [] startingVersion+      where+        startingVersion = maybe seedVersion (^. #streamVersion) previousRecorded++        go recordedAcc eventAcc lastSeen = \case+          [] -> (Prelude.reverse recordedAcc, Prelude.reverse eventAcc, Nothing)+          recorded : rest ->+            let observed = recorded ^. #streamVersion+                expected = nextStreamVersion lastSeen+             in if observed /= expected+                  then+                    ( Prelude.reverse recordedAcc,+                      Prelude.reverse eventAcc,+                      Just (HydrationGapDetected expected observed)+                    )+                  else case decodeRecorded (eventStream ^. #eventCodec) recorded of+                    Left err ->+                      ( Prelude.reverse recordedAcc,+                        Prelude.reverse eventAcc,+                        Just (HydrationDecodeFailed err)+                      )+                    Right event ->+                      go (recorded : recordedAcc) (event : eventAcc) observed rest++        nextStreamVersion (StreamVersion version) = StreamVersion (version Prelude.+ 1)++    hydrationReplayError ::+      Maybe RecordedEvent ->+      [RecordedEvent] ->+      Keiki.ReplayFailure s co ->+      CommandError+    hydrationReplayError previousRecorded decodedRecorded replayFailure =+      HydrationReplayFailed failureVersion (toHydrationReason (Keiki.replayFailureReason replayFailure))+      where+        failureVersion =+          maybe seedVersion (^. #streamVersion) failureRecorded+        failureRecorded =+          case Keiki.replayFailureReason replayFailure of+            Keiki.ReplayLogTruncated {} -> latestRecorded decodedRecorded previousRecorded+            Keiki.ReplayEventFailed {} ->+              case recordedAt (Keiki.replayFailedIndex replayFailure) decodedRecorded of+                Just recorded -> Just recorded+                Nothing -> latestRecorded decodedRecorded previousRecorded++    finishReplay = \case+      Left err -> Left err+      Right (wrapper, finalRegisters, lastRecorded) ->+        case wrapper of+          Keiki.Settled finalState ->+            Right+              Hydrated+                { state = finalState,+                  registers = finalRegisters,+                  streamVersion = maybe seedVersion (^. #streamVersion) lastRecorded+                }+          Keiki.InFlight {} ->+            Left+              ( HydrationReplayFailed+                  (maybe seedVersion (^. #streamVersion) lastRecorded)+                  HydrationTruncatedChain+              )++    toHydrationReason = \case+      Keiki.ReplayEventFailed stepFailure -> case stepFailure of+        Keiki.ReplayNoInvertingEdge {} -> HydrationNoInvertingEdge+        Keiki.ReplayAmbiguousInversions {} -> HydrationAmbiguousInversion+        Keiki.ReplayQueueMismatch {} -> HydrationQueueMismatch+      Keiki.ReplayLogTruncated {} -> HydrationTruncatedChain++    latestRecorded recorded fallback =+      case lastMaybe recorded of+        Just latest -> Just latest+        Nothing -> fallback++    lastMaybe = \case+      [] -> Nothing+      first : rest -> Just (Prelude.foldl (\_ current -> current) first rest)++    recordedAt eventIndex recorded =+      case Prelude.drop eventIndex recorded of+        found : _ -> Just found+        [] -> Nothing++scheduleSeedVerification ::+  forall phi rs s ci co es.+  (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  StateCodec (s, RegFile rs) ->+  SnapshotSeed rs s ->+  Eff es ()+scheduleSeedVerification options eventStream targetStream codec seed = do+  void $ trySync $ do+    sampled <-+      case options ^. #seedVerifySampleRate of+        rate | rate <= 0 -> pure False+        1 -> pure True+        rate -> liftIO ((== (1 :: Int)) <$> uniformRM (1, rate) globalStdGen)+    when sampled+      $ void+      $ runConcurrent+      $ Async.async+      $ verifySnapshotSeed options eventStream targetStream codec seed++verifySnapshotSeed ::+  forall phi rs s ci co es.+  (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  StateCodec (s, RegFile rs) ->+  SnapshotSeed rs s ->+  Eff es ()+verifySnapshotSeed options eventStream targetStream codec seed = do+  let seedVersion = seed ^. #streamVersion+      streamName = (eventStream ^. #resolveStreamName) targetStream+  full <-+    hydrateSeededThrough+      (Just seedVersion)+      options+      eventStream+      targetStream+      (eventStream ^. #initialState)+      (eventStream ^. #initialRegisters)+      (StreamVersion 0)+  seededEncoded <-+    liftIO+      $ encodeSnapshotStrict+        codec+        (seed ^. #state, seed ^. #registers)+  case (seededEncoded, full) of+    (Left seedEncodeError, _) ->+      reportSeedDivergence+        options+        streamName+        seedVersion+        ("encode-failed:" <> Text.pack (displayException seedEncodeError))+        (case full of Left replayError -> "replay-failed:" <> Text.pack (show replayError); Right _ -> "not-compared:seed-encode-failed")+    (Right seededValue, Left replayError) ->+      reportSeedDivergence+        options+        streamName+        seedVersion+        (replayDigest seededValue)+        ("replay-failed:" <> Text.pack (show replayError))+    (Right seededValue, Right fullHydrated)+      | fullHydrated ^. #streamVersion /= seedVersion ->+          reportSeedDivergence+            options+            streamName+            seedVersion+            (replayDigest seededValue)+            ("version-mismatch:" <> Text.pack (show (fullHydrated ^. #streamVersion)))+      | otherwise -> do+          fullEncoded <-+            liftIO+              $ encodeSnapshotStrict+                codec+                (fullHydrated ^. #state, fullHydrated ^. #registers)+          case fullEncoded of+            Left fullEncodeError ->+              reportSeedDivergence+                options+                streamName+                seedVersion+                (replayDigest seededValue)+                ("encode-failed:" <> Text.pack (displayException fullEncodeError))+            Right fullValue ->+              unless+                (canonicalJsonBytes seededValue == canonicalJsonBytes fullValue)+                ( reportSeedDivergence+                    options+                    streamName+                    seedVersion+                    (replayDigest seededValue)+                    (replayDigest fullValue)+                )++reportSeedDivergence ::+  (IOE :> es) =>+  RunCommandOptions ->+  StreamName ->+  StreamVersion ->+  Text ->+  Text ->+  Eff es ()+reportSeedDivergence options (StreamName streamName) (StreamVersion seedVersion) seededDigest fullDigest = do+  recordSnapshotSeedDivergence (options ^. #metrics) 1+  liftIO+    $ LazyByteString.hPutStrLn stderr+    $ Aeson.encode+    $ Aeson.object+      [ "event" Aeson..= ("keiro.snapshot.seed.divergence" :: Text),+        "level" Aeson..= ("error" :: Text),+        "stream" Aeson..= streamName,+        "seedVersion" Aeson..= seedVersion,+        "seededDigest" Aeson..= seededDigest,+        "fullDigest" Aeson..= fullDigest+      ]++-- | Hydrate the target stream, transduce the command, and append any emitted+-- events. Retries optimistic-concurrency conflicts up to 'retryLimit'. This+-- is the plain runner with no in-transaction side effects.+runCommand ::+  forall phi rs s ci co es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  ValidatedEventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co)))+runCommand options validatedEventStream targetStream command =+  withCommandSpan (options ^. #tracer) (resolvedStreamName eventStream targetStream) Nothing $ \mSpan -> do+    (result, attemptNo) <- attempt mSpan 1 Nothing+    recordCommandOutcome mSpan (^. #eventsAppended) attemptNo result+    pure result+  where+    eventStream = unvalidated validatedEventStream++    attempt mSpan attemptNo lastConflict = do+      hydrated <- hydrate options eventStream targetStream+      either (\err -> pure (Left err, attemptNo)) (runPlan mSpan attemptNo lastConflict) hydrated++    runPlan mSpan attemptNo lastConflict current =+      case conflictFixpoint lastConflict (current ^. #streamVersion) of+        Just err -> pure (Left err, attemptNo)+        Nothing ->+          case prepareCommandPlan options eventStream targetStream current command of+            Left err -> pure (Left err, attemptNo)+            Right (CommandNoOp result) -> pure (Right result, attemptNo)+            Right (CommandAppend current' events encoded) ->+              appendOnce mSpan attemptNo current' events encoded++    appendOnce mSpan attemptNo current events encoded = do+      liftIO (options ^. #beforeAppend)+      appended <-+        tryError @StoreError+          $ appendToStream+            ((eventStream ^. #resolveStreamName) targetStream)+            (expectedVersion (current ^. #streamVersion))+            encoded+      case appended of+        Right appendResult -> do+          verifyAndSnapshot options mSpan eventStream current events appendResult+          pure (Right (appendedResult targetStream appendResult (Prelude.length encoded)), attemptNo)+        Left (_, storeError) ->+          retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError++-- | Like 'runCommand', but run @afterAppend@ inside the /same/ transaction+-- as the append, so a read-model write commits atomically with the events.+-- The callback's result is returned as @Just@ on append (and 'Nothing' for a+-- no-op command that appended nothing).+--+-- Requires 'KirokuStoreResource' so the transactional append applies the+-- configured @enrichEvent@ hook. See 'runCommandWithSqlEvents' for the callback's+-- locking and latency implications.+runCommandWithSql ::+  forall phi rs s ci co a es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  ValidatedEventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co), Maybe a))+runCommandWithSql options eventStream targetStream command afterAppend =+  runCommandWithSqlEvents options eventStream targetStream command (\_ appendResult -> afterAppend appendResult)++-- | The most general runner: like 'runCommandWithSql', but the+-- in-transaction callback also receives every emitted event paired with the+-- 'RecordedEvent' the store persisted for it, in append order. Inline+-- projections, process managers, and routers are all built on this.+--+-- The runner requires 'KirokuStoreResource' and applies the configured+-- @enrichEvent@ hook before preparing the append. The callback therefore sees+-- the enriched metadata that was persisted.+--+-- The append updates Kiroku's global @$all@ stream and holds its PostgreSQL row+-- lock until this transaction commits. Every SQL operation in the callback+-- therefore extends the store-wide append serialization window. Keep the+-- callback small: precompute outside the transaction where possible, batch+-- writes, and minimize database round trips.+runCommandWithSqlEvents ::+  forall phi rs s ci co a es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  ValidatedEventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  ([(co, RecordedEvent)] -> AppendResult -> Tx.Transaction a) ->+  Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co), Maybe a))+runCommandWithSqlEvents options validatedEventStream targetStream command afterAppend =+  withCommandSpan (options ^. #tracer) (resolvedStreamName eventStream targetStream) Nothing $ \mSpan -> do+    (result, attemptNo) <- attempt mSpan 1 Nothing+    recordCommandOutcome mSpan (\(r, _) -> r ^. #eventsAppended) attemptNo result+    pure result+  where+    eventStream = unvalidated validatedEventStream++    attempt mSpan attemptNo lastConflict = do+      hydrated <- hydrate options eventStream targetStream+      either (\err -> pure (Left err, attemptNo)) (runPlan mSpan attemptNo lastConflict) hydrated++    runPlan mSpan attemptNo lastConflict current =+      case conflictFixpoint lastConflict (current ^. #streamVersion) of+        Just err -> pure (Left err, attemptNo)+        Nothing ->+          case prepareCommandPlan options eventStream targetStream current command of+            Left err -> pure (Left err, attemptNo)+            Right (CommandNoOp result) -> pure (Right (result, Nothing), attemptNo)+            Right (CommandAppend current' events encoded) ->+              appendWithSqlOnce mSpan attemptNo current' events encoded++    appendWithSqlOnce mSpan attemptNo current events encoded = do+      liftIO (options ^. #beforeAppend)+      store <- getKirokuStore+      enriched <- liftIO (enrichEventsIO store encoded)+      prepared <- prepareEventsIO enriched+      now <- liftIO getCurrentTime+      let streamName = (eventStream ^. #resolveStreamName) targetStream+          expected = expectedVersion (current ^. #streamVersion)+          body = do+            appended <- appendToStreamTx streamName expected prepared now+            case appended of+              Left conflict ->+                Tx.condemn $> Left (appendConflictToStoreError conflict)+              Right appendResult -> do+                let recordeds = reconstructRecorded appendResult now prepared+                userValue <- afterAppend (Prelude.zip events recordeds) appendResult+                pure (Right (appendResult, userValue))+      outcome <- tryError @StoreError (runTransaction body)+      case outcome of+        Right (Right (appendResult, userValue)) -> do+          verifyAndSnapshot options mSpan eventStream current events appendResult+          pure (Right (appendedResult targetStream appendResult (Prelude.length encoded), Just userValue), attemptNo)+        Right (Left storeError) ->+          retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError+        Left (_, storeError) ->+          retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError++prepareCommandPlan ::+  (BoolAlg phi (RegFile rs, ci)) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Hydrated rs s ->+  ci ->+  Either CommandError (CommandPlan (EventStream phi rs s ci co) rs s co)+prepareCommandPlan options eventStream targetStream current command =+  case evaluateCommand eventStream current command of+    Left err -> Left err+    Right events -> toPlan events+  where+    toPlan [] =+      Right (CommandNoOp (noOpResult targetStream current))+    toPlan events =+      CommandAppend current events+        . assignEventIds (options ^. #eventIds)+        <$> encodeEvents (eventStream ^. #eventCodec) (options ^. #metadata) events++-- | Render the stream that the command targets as plain 'Text', for use+-- as a span name.+resolvedStreamName ::+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Text+resolvedStreamName eventStream targetStream =+  case (eventStream ^. #resolveStreamName) targetStream of+    StreamName n -> n++-- | Attach the command-span outcome attributes after the runner returns.+--+-- On success: 'db.system.name' and 'keiro.events.appended'.+-- On failure: 'error.type' (low-cardinality classifier) and span status+-- 'Error' (carrying the rendered 'CommandError' as the description).+--+-- Pure no-op when no span is active ('Nothing' tracer, etc).+recordCommandOutcome ::+  (IOE :> es) =>+  Maybe Span ->+  (a -> Int) ->+  Int ->+  Either CommandError a ->+  Eff es ()+recordCommandOutcome Nothing _ _ _ = pure ()+recordCommandOutcome (Just sp) eventsOf attemptNo result = do+  addAttribute sp (unkey db_system_name) ("postgresql" :: Text)+  addAttribute sp (unkey keiro_retry_attempt) (Prelude.fromIntegral attemptNo :: Int64)+  case result of+    Right v ->+      addAttribute sp (unkey keiro_events_appended) (Prelude.fromIntegral (eventsOf v) :: Int64)+    Left err -> do+      addAttribute sp (unkey error_type) (commandErrorClass err)+      setStatus sp (Error (Text.take 256 (Text.pack (show err))))++-- | Low-cardinality classifier for a 'CommandError'. Used as the+-- @error.type@ attribute value on the command span.+commandErrorClass :: CommandError -> Text+commandErrorClass = \case+  HydrationDecodeFailed {} -> "hydration_decode_failed"+  HydrationReplayFailed _ HydrationNoInvertingEdge -> "hydration_replay_no_inverting_edge"+  HydrationReplayFailed _ HydrationAmbiguousInversion -> "hydration_replay_ambiguous_inversion"+  HydrationReplayFailed _ HydrationQueueMismatch -> "hydration_replay_queue_mismatch"+  HydrationReplayFailed _ HydrationTruncatedChain -> "hydration_replay_truncated_chain"+  HydrationGapDetected {} -> "hydration_gap_detected"+  CommandRejected -> "command_rejected"+  CommandAmbiguous {} -> "command_ambiguous"+  EncodeFailed {} -> "encode_failed"+  StoreFailed {} -> "store_failed"+  RetryExhausted {} -> "retry_exhausted"+  ConflictFixpoint {} -> "conflict_fixpoint"++verifyAndSnapshot ::+  forall phi rs s ci co es.+  (BoolAlg phi (RegFile rs, ci), IOE :> es, Store :> es, Error StoreError :> es, Eq co) =>+  RunCommandOptions ->+  Maybe Span ->+  EventStream phi rs s ci co ->+  Hydrated rs s ->+  [co] ->+  AppendResult ->+  Eff es ()+verifyAndSnapshot options mSpan eventStream current events appendResult+  | Prelude.not (options ^. #verifyReplayOnAppend),+    Nothing <- eventStream ^. #stateCodec =+      pure ()+  | otherwise =+      case Keiki.applyEventsEither (eventStream ^. #transducer) (state current, registers current) events of+        Left failure -> do+          recordSnapshotApplyDivergence (options ^. #metrics) 1+          for_ mSpan $ \sp ->+            addAttribute+              sp+              (unkey keiro_replay_divergence)+              (Text.take 256 (renderReplayFailure failure))+        Right finalState ->+          case eventStream ^. #stateCodec of+            Nothing -> pure ()+            Just codec -> do+              let finalVersion = appendResult ^. #streamVersion+                  terminality =+                    if Keiki.isFinal (eventStream ^. #transducer) (Prelude.fst finalState)+                      then Terminal+                      else NotTerminal+              when (shouldSnapshotSpan (eventStream ^. #snapshotPolicy) terminality finalState (current ^. #streamVersion) finalVersion)+                $ do+                  encoded <- liftIO (encodeSnapshotStrict codec finalState)+                  case encoded of+                    Left _ -> recordSnapshotEncodeFailures (options ^. #metrics) 1+                    Right value -> do+                      outcome <- tryError @StoreError (writeSnapshotEncoded (appendResult ^. #streamId) finalVersion codec value)+                      case outcome of+                        Right () -> pure ()+                        Left _ -> recordSnapshotWriteFailures (options ^. #metrics) 1++renderReplayFailure :: Keiki.ReplayFailure s co -> Text+renderReplayFailure failure =+  "event_index="+    <> Text.pack (show (Keiki.replayFailedIndex failure))+    <> ";reason="+    <> case Keiki.replayFailureReason failure of+      Keiki.ReplayEventFailed stepFailure -> case stepFailure of+        Keiki.ReplayNoInvertingEdge {} -> "no_inverting_edge"+        Keiki.ReplayAmbiguousInversions {} -> "ambiguous_inversions"+        Keiki.ReplayQueueMismatch {} -> "queue_mismatch"+      Keiki.ReplayLogTruncated {} -> "log_truncated"++retryOrFail ::+  (IOE :> es) =>+  RunCommandOptions ->+  (Int -> Maybe (StoreError, StreamVersion) -> Eff es (Either CommandError a, Int)) ->+  Int ->+  StreamVersion ->+  StoreError ->+  Eff es (Either CommandError a, Int)+retryOrFail options retry attemptNo observedVersion storeError+  | isRetryableConflict storeError,+    attemptNo <= options ^. #retryLimit = do+      recordCommandConflicts (options ^. #metrics) 1+      backoffDelay options attemptNo+      recordCommandRetries (options ^. #metrics) 1+      retry (attemptNo Prelude.+ 1) (Just (storeError, observedVersion))+  | isRetryableConflict storeError = do+      recordCommandConflicts (options ^. #metrics) 1+      pure (Left (RetryExhausted attemptNo storeError), attemptNo)+  | otherwise = do+      case storeError of+        DuplicateEvent {} -> recordCommandDuplicates (options ^. #metrics) 1+        _ -> pure ()+      pure (Left (StoreFailed storeError), attemptNo)++backoffDelay :: (IOE :> es) => RunCommandOptions -> Int -> Eff es ()+backoffDelay options attemptNo+  | base <= 0 = pure ()+  | otherwise = do+      nanos <- liftIO getMonotonicTimeNSec+      let exponential = min 100000 (base Prelude.* (2 Prelude.^ (attemptNo Prelude.- 1 :: Int)))+          jitter =+            Prelude.fromIntegral (nanos `Prelude.mod` Prelude.fromIntegral exponential)+              Prelude.- (exponential `Prelude.div` 2)+      liftIO (threadDelay (max 0 (exponential Prelude.+ jitter)))+  where+    base = options ^. #retryBackoffMicros++conflictFixpoint :: Maybe (StoreError, StreamVersion) -> StreamVersion -> Maybe CommandError+conflictFixpoint (Just (previousError@StreamAlreadyExists {}, previousVersion)) currentVersion+  | currentVersion == previousVersion = Just (ConflictFixpoint currentVersion previousError)+conflictFixpoint _ _ = Nothing++evaluateCommand ::+  (BoolAlg phi (RegFile rs, ci)) =>+  EventStream phi rs s ci co ->+  Hydrated rs s ->+  ci ->+  Either CommandError [co]+evaluateCommand eventStream current command =+  case Keiki.stepEither (eventStream ^. #transducer) (state current, registers current) command of+    Left Keiki.NoOutgoingEdges {} -> Left CommandRejected+    Left Keiki.NoMatchingEdge {} -> Left CommandRejected+    Left (Keiki.AmbiguousEdges _ matches) ->+      Left+        ( CommandAmbiguous+            [ Keiki.edgeIndex (Keiki.matchedEdge matched)+            | matched <- matches+            ]+        )+    Right (_, _, events) -> Right events++encodeEvents :: Codec co -> Maybe Value -> [co] -> Either CommandError [EventData]+encodeEvents codec md =+  Prelude.mapM (mapLeft EncodeFailed . encodeForAppendWithMetadata codec md)++assignEventIds :: [EventId] -> [EventData] -> [EventData]+assignEventIds [] events = events+assignEventIds _ [] = []+assignEventIds (supplied : suppliedRest) (event : eventRest) =+  (event & #eventId .~ Just supplied) : assignEventIds suppliedRest eventRest++expectedVersion :: StreamVersion -> ExpectedVersion+expectedVersion (StreamVersion 0) = NoStream+expectedVersion version = ExactVersion version++noOpResult ::+  Stream target ->+  Hydrated rs s ->+  CommandResult target+noOpResult targetStream current =+  CommandResult+    { target = targetStream,+      streamVersion = current ^. #streamVersion,+      globalPosition = Nothing,+      eventsAppended = 0+    }++appendedResult ::+  Stream target ->+  AppendResult ->+  Int ->+  CommandResult target+appendedResult targetStream appendResult count =+  CommandResult+    { target = targetStream,+      streamVersion = appendResult ^. #streamVersion,+      globalPosition = Just (appendResult ^. #globalPosition),+      eventsAppended = count+    }++-- | Rebuild the per-event 'RecordedEvent' values for a just-appended batch.+--+-- The store assigns each event in a batch a contiguous stream version and+-- global position: event @i@ (1-based) gets @last - count + i@ for both+-- counters, where @last@ is the position the 'AppendResult' reports for the+-- final event and @count@ is the batch size. (The kiroku append SQL numbers+-- events with @WITH ORDINALITY@ and inserts @initial + idx@; see EP-27's+-- Surprises & Discoveries.) We therefore reconstruct each 'RecordedEvent'+-- exactly, rather than reading the batch back. The @createdAt@ is the same+-- timestamp 'prepareEventsIO'/'appendToStreamTx' used for the insert.+--+-- This is a source append (events are written to their own stream), so+-- @streamVersion == originalVersion@ and @originalStreamId@ is the appended+-- stream's id, per the 'RecordedEvent' contract.+reconstructRecorded :: AppendResult -> UTCTime -> [PreparedEvent] -> [RecordedEvent]+reconstructRecorded appendResult now prepared =+  Prelude.zipWith mk [0 ..] prepared+  where+    count = Prelude.length prepared+    StreamVersion lastSv = appendResult ^. #streamVersion+    GlobalPosition lastGp = appendResult ^. #globalPosition+    firstSv = lastSv Prelude.- Prelude.fromIntegral count Prelude.+ 1+    firstGp = lastGp Prelude.- Prelude.fromIntegral count Prelude.+ 1+    mk :: Int64 -> PreparedEvent -> RecordedEvent+    mk i prepared' =+      RecordedEvent+        { eventId = EventId (prepared' ^. #peEventId),+          eventType = prepared' ^. #peEventType,+          streamVersion = StreamVersion (firstSv Prelude.+ i),+          globalPosition = GlobalPosition (firstGp Prelude.+ i),+          originalStreamId = appendResult ^. #streamId,+          originalVersion = StreamVersion (firstSv Prelude.+ i),+          payload = prepared' ^. #pePayload,+          metadata = prepared' ^. #peMetadata,+          causationId = prepared' ^. #peCausationId,+          correlationId = prepared' ^. #peCorrelationId,+          createdAt = now+        }++isRetryableConflict :: StoreError -> Bool+isRetryableConflict = \case+  WrongExpectedVersion {} -> True+  StreamAlreadyExists {} -> True+  _ -> False++mapLeft :: (e -> e') -> Either e a -> Either e' a+mapLeft f = \case+  Left err -> Left (f err)+  Right value -> Right value
src/Keiro/Connection.hs view
@@ -1,41 +1,40 @@-{- | Schema-resolution helpers for application read-model and projection tables.--Keiro's own framework tables live in the dedicated @keiro@ schema and its-runtime queries are fully qualified there. This module is about the /third/-layer: where an application's read-model /data/ tables live, and how the-application declares and reaches that location.--A PostgreSQL /schema/ is a namespace of tables inside one database. Because-Keiro opens its database pool through kiroku's connection settings — whose-@search_path@ starts with the event store's private @kiroku@ schema — an-unqualified @CREATE TABLE my_read_model (...)@ would land the table inside the-@kiroku@ event-store schema. To place it elsewhere, the application must-qualify at least its @CREATE TABLE@ as @schema.table@ (an unqualified create-always lands in the first @search_path@ entry). Qualifying the read/write SQL-too makes everything correct regardless of @search_path@ — the robust default.--This module gives applications exactly one convention:--* 'qualifyTable' builds a double-quoted, schema-qualified table reference to-  interpolate into projection SQL.-* 'withProjectionSchema' / 'keiroConnectionSettings' wire the store connection-  so a chosen projection schema also /resolves/ on the pool (via kiroku's-  @extraSearchPath@), for applications that also want unqualified SQL to work.-* 'ensureProjectionSchema' is an opt-in @CREATE SCHEMA IF NOT EXISTS@ helper for-  development, tests, and worked examples; Keiro never calls it automatically.--The kiroku store connection's @schema@ field stays @kiroku@ and is never-repointed: it also drives the @<schema>.events@ @LISTEN@/@NOTIFY@ channel, so-changing it would break subscription wake-ups. The projection schema is reached-only by qualification and\/or @extraSearchPath@.--}-module Keiro.Connection (-    qualifyTable,+-- | Schema-resolution helpers for application read-model and projection tables.+--+-- Keiro's own framework tables live in the dedicated @keiro@ schema and its+-- runtime queries are fully qualified there. This module is about the /third/+-- layer: where an application's read-model /data/ tables live, and how the+-- application declares and reaches that location.+--+-- A PostgreSQL /schema/ is a namespace of tables inside one database. Because+-- Keiro opens its database pool through kiroku's connection settings — whose+-- @search_path@ starts with the event store's private @kiroku@ schema — an+-- unqualified @CREATE TABLE my_read_model (...)@ would land the table inside the+-- @kiroku@ event-store schema. To place it elsewhere, the application must+-- qualify at least its @CREATE TABLE@ as @schema.table@ (an unqualified create+-- always lands in the first @search_path@ entry). Qualifying the read/write SQL+-- too makes everything correct regardless of @search_path@ — the robust default.+--+-- This module gives applications exactly one convention:+--+-- * 'qualifyTable' builds a double-quoted, schema-qualified table reference to+--   interpolate into projection SQL.+-- * 'withProjectionSchema' / 'keiroConnectionSettings' wire the store connection+--   so a chosen projection schema also /resolves/ on the pool (via kiroku's+--   @extraSearchPath@), for applications that also want unqualified SQL to work.+-- * 'ensureProjectionSchema' is an opt-in @CREATE SCHEMA IF NOT EXISTS@ helper for+--   development, tests, and worked examples; Keiro never calls it automatically.+--+-- The kiroku store connection's @schema@ field stays @kiroku@ and is never+-- repointed: it also drives the @<schema>.events@ @LISTEN@/@NOTIFY@ channel, so+-- changing it would break subscription wake-ups. The projection schema is reached+-- only by qualification and\/or @extraSearchPath@.+module Keiro.Connection+  ( qualifyTable,     quoteIdentifier,     withProjectionSchema,     keiroConnectionSettings,     ensureProjectionSchema,-)+  ) where  import Data.Text qualified as T@@ -47,54 +46,49 @@ import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | Build a double-quoted, schema-qualified table reference @"schema"."table"@,-doubling any embedded double quotes (the same identifier quoting kiroku uses).-Interpolate the result directly into projection SQL, e.g.--@-"SELECT ... FROM " <> qualifyTable "app" "orders" <> " WHERE ..."-@--}+-- | Build a double-quoted, schema-qualified table reference @"schema"."table"@,+-- doubling any embedded double quotes (the same identifier quoting kiroku uses).+-- Interpolate the result directly into projection SQL, e.g.+--+-- @+-- "SELECT ... FROM " <> qualifyTable "app" "orders" <> " WHERE ..."+-- @ qualifyTable :: Text -> Text -> Text qualifyTable schema table = quoteIdentifier schema <> "." <> quoteIdentifier table -{- | Double-quote a single SQL identifier, doubling any embedded double quotes-(the same identifier quoting kiroku uses). Useful for building qualified names-or a @CREATE SCHEMA "<name>"@ statement.--}+-- | Double-quote a single SQL identifier, doubling any embedded double quotes+-- (the same identifier quoting kiroku uses). Useful for building qualified names+-- or a @CREATE SCHEMA "<name>"@ statement. quoteIdentifier :: Text -> Text quoteIdentifier ident = "\"" <> T.replace "\"" "\"\"" ident <> "\"" -{- | Append @projectionSchema@ to a settings value's @extraSearchPath@ so a-pooled connection can /resolve/ (read\/write) application tables in that schema.-Idempotent: the schema is appended only if not already present. The store's-@schema@ field is left untouched (stays @kiroku@).--}+-- | Append @projectionSchema@ to a settings value's @extraSearchPath@ so a+-- pooled connection can /resolve/ (read\/write) application tables in that schema.+-- Idempotent: the schema is appended only if not already present. The store's+-- @schema@ field is left untouched (stays @kiroku@). withProjectionSchema :: Text -> ConnectionSettings -> ConnectionSettings withProjectionSchema projectionSchema settings =-    settings & #extraSearchPath %~ appendUnique projectionSchema+  settings & #extraSearchPath %~ appendUnique projectionSchema   where     appendUnique s xs-        | s `elem` xs = xs-        | otherwise = xs <> [s]+      | s `elem` xs = xs+      | otherwise = xs <> [s] -{- | kiroku's default connection settings (@schema = "kiroku"@) with-@projectionSchema@ added to @extraSearchPath@, so unqualified application-data-manipulation SQL resolves on the store pool while the store @schema@ stays-@kiroku@ (honoring the NOTIFY-channel constraint). This does /not/ bake Keiro's-own @keiro@ schema into @extraSearchPath@: Keiro's runtime queries are already-fully qualified and must not depend on @search_path@.--}+-- | kiroku's default connection settings (@schema = "kiroku"@) with+-- @projectionSchema@ added to @extraSearchPath@, so unqualified application+-- data-manipulation SQL resolves on the store pool while the store @schema@ stays+-- @kiroku@ (honoring the NOTIFY-channel constraint). This does /not/ bake Keiro's+-- own @keiro@ schema into @extraSearchPath@: Keiro's runtime queries are already+-- fully qualified and must not depend on @search_path@. keiroConnectionSettings :: Text -> Text -> ConnectionSettings keiroConnectionSettings connString projectionSchema =-    withProjectionSchema projectionSchema (defaultConnectionSettings connString)+  withProjectionSchema projectionSchema (defaultConnectionSettings connString) -{- | Run @CREATE SCHEMA IF NOT EXISTS "<schema>"@ in a transaction. This is-opt-in: Keiro never calls it automatically. Use it in development, tests, and-worked examples where the application (not a production migration tool) owns-schema creation.--}+-- | Run @CREATE SCHEMA IF NOT EXISTS "<schema>"@ in a transaction. This is+-- opt-in: Keiro never calls it automatically. Use it in development, tests, and+-- worked examples where the application (not a production migration tool) owns+-- schema creation. ensureProjectionSchema :: (Store :> es) => Text -> Eff es () ensureProjectionSchema projectionSchema =-    runTransaction $-        Tx.sql (TE.encodeUtf8 ("CREATE SCHEMA IF NOT EXISTS " <> quoteIdentifier projectionSchema))+  runTransaction $+    Tx.sql (TE.encodeUtf8 ("CREATE SCHEMA IF NOT EXISTS " <> quoteIdentifier projectionSchema))
src/Keiro/DeadLetter.hs view
@@ -1,27 +1,26 @@-{- | Durable records for process-manager and router dispatches rejected by a-target state machine.--Use 'recordDispatchDeadLetter' from a worker before acknowledging its source-event, and 'listDispatchDeadLetters' to inspect the durable witnesses for one-dispatcher. Inserts are idempotent under source-event redelivery.--}-module Keiro.DeadLetter (-    DispatcherKind (..),+-- | Durable records for process-manager and router dispatches rejected by a+-- target state machine.+--+-- Use 'recordDispatchDeadLetter' from a worker before acknowledging its source+-- event, and 'listDispatchDeadLetters' to inspect the durable witnesses for one+-- dispatcher. Inserts are idempotent under source-event redelivery.+module Keiro.DeadLetter+  ( DispatcherKind (..),     DispatchDeadLetter (..),     DispatchDeadLetterRecord (..),     recordDispatchDeadLetter,     listDispatchDeadLetters,-)+  ) where  import Effectful (Eff, (:>))-import Keiro.DeadLetter.Schema (-    DispatchDeadLetter (..),+import Keiro.DeadLetter.Schema+  ( DispatchDeadLetter (..),     DispatchDeadLetterRecord (..),     DispatcherKind (..),     listDispatchDeadLettersTx,     recordDispatchDeadLetterTx,- )+  ) import Keiro.Prelude import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction)
src/Keiro/DeadLetter/Replay.hs view
@@ -1,42 +1,41 @@-{- | Operator replay for source events parked in Kiroku's-@kiroku.dead_letters@ table.--The rows remain Kiroku-owned: replay neither deletes nor marks them. Instead it-re-runs a caller-supplied handler and relies on that handler's idempotency. This-is safe for "Keiro.ProcessManager" and "Keiro.Router" handlers because their-writes use deterministic event identifiers derived from the source event;-already-applied writes collapse to duplicate outcomes on every later replay.--Kiroku exposes dead-letter rows by event id and global position, but does not-currently expose an exact point-read by either value. Global positions are-opaque cursors and must never be decremented. 'replaySubscriptionDeadLetters'-therefore scans the global stream backward using only @0@ and cursors returned-by Kiroku, matches both event id and position, and scans once for the whole-batch. A source event removed by hard deletion is reported as-'ReplaySourceMissing' and is not handed to the caller.--A process-manager handler normally decodes the recorded event, calls-@runProcessManagerOnce@, and classifies a result whose manager state and every-target command are duplicates as 'ReplayedDuplicate'; any append makes it-'ReplayedFresh'. Return @Left detail@ for a decode or command failure. For-example, the classification has this shape:--> case result of->   Left err -> Left (showText err)->   Right pmResult->     | managerAndEveryCommandAreDuplicates pmResult -> Right ReplayedDuplicate->     | otherwise -> Right ReplayedFresh--Because rows are retained, an operator may safely run the same replay command-again after a partial failure or uncertain client disconnect.--}-module Keiro.DeadLetter.Replay (-    ReplayOutcome (..),+-- | Operator replay for source events parked in Kiroku's+-- @kiroku.dead_letters@ table.+--+-- The rows remain Kiroku-owned: replay neither deletes nor marks them. Instead it+-- re-runs a caller-supplied handler and relies on that handler's idempotency. This+-- is safe for "Keiro.ProcessManager" and "Keiro.Router" handlers because their+-- writes use deterministic event identifiers derived from the source event;+-- already-applied writes collapse to duplicate outcomes on every later replay.+--+-- Kiroku exposes dead-letter rows by event id and global position, but does not+-- currently expose an exact point-read by either value. Global positions are+-- opaque cursors and must never be decremented. 'replaySubscriptionDeadLetters'+-- therefore scans the global stream backward using only @0@ and cursors returned+-- by Kiroku, matches both event id and position, and scans once for the whole+-- batch. A source event removed by hard deletion is reported as+-- 'ReplaySourceMissing' and is not handed to the caller.+--+-- A process-manager handler normally decodes the recorded event, calls+-- @runProcessManagerOnce@, and classifies a result whose manager state and every+-- target command are duplicates as 'ReplayedDuplicate'; any append makes it+-- 'ReplayedFresh'. Return @Left detail@ for a decode or command failure. For+-- example, the classification has this shape:+--+-- > case result of+-- >   Left err -> Left (showText err)+-- >   Right pmResult+-- >     | managerAndEveryCommandAreDuplicates pmResult -> Right ReplayedDuplicate+-- >     | otherwise -> Right ReplayedFresh+--+-- Because rows are retained, an operator may safely run the same replay command+-- again after a partial failure or uncertain client disconnect.+module Keiro.DeadLetter.Replay+  ( ReplayOutcome (..),     ReplayResult (..),     DeadLetterRecord (..),     listSubscriptionDeadLetters,     replaySubscriptionDeadLetters,-)+  ) where  import Data.Int (Int32)@@ -55,88 +54,87 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx  data ReplayOutcome = ReplayOutcome-    { replayGlobalPosition :: !GlobalPosition-    , replayEventId :: !EventId-    , replayResult :: !ReplayResult-    }-    deriving stock (Generic, Eq, Show)+  { replayGlobalPosition :: !GlobalPosition,+    replayEventId :: !EventId,+    replayResult :: !ReplayResult+  }+  deriving stock (Generic, Eq, Show)  data ReplayResult-    = ReplayedFresh-    | ReplayedDuplicate-    | ReplayFailed !Text-    | ReplaySourceMissing-    deriving stock (Generic, Eq, Show)+  = ReplayedFresh+  | ReplayedDuplicate+  | ReplayFailed !Text+  | ReplaySourceMissing+  deriving stock (Generic, Eq, Show)  -- | List one Kiroku subscription member's dead letters, newest first. listSubscriptionDeadLetters ::-    (Store :> es) => SubscriptionName -> Int32 -> Eff es (Vector DeadLetterRecord)+  (Store :> es) => SubscriptionName -> Int32 -> Eff es (Vector DeadLetterRecord) listSubscriptionDeadLetters (SubscriptionName name) member =-    runTransaction (Tx.statement (name, member) readDeadLettersStmt)--{- | Replay every dead letter currently listed for one subscription member.+  runTransaction (Tx.statement (name, member) readDeadLettersStmt) -The handler controls domain-specific decoding and decides whether its writes-were fresh or duplicates. A @Left detail@ is recorded in the corresponding-'ReplayOutcome' as 'ReplayFailed'; replay continues with later rows. Store-errors still surface through the surrounding Kiroku 'Store' interpreter.--}+-- | Replay every dead letter currently listed for one subscription member.+--+-- The handler controls domain-specific decoding and decides whether its writes+-- were fresh or duplicates. A @Left detail@ is recorded in the corresponding+-- 'ReplayOutcome' as 'ReplayFailed'; replay continues with later rows. Store+-- errors still surface through the surrounding Kiroku 'Store' interpreter. replaySubscriptionDeadLetters ::-    (Store :> es) =>-    SubscriptionName ->-    Int32 ->-    (RecordedEvent -> Eff es (Either Text ReplayResult)) ->-    Eff es [ReplayOutcome]+  (Store :> es) =>+  SubscriptionName ->+  Int32 ->+  (RecordedEvent -> Eff es (Either Text ReplayResult)) ->+  Eff es [ReplayOutcome] replaySubscriptionDeadLetters subscriptionName member handler = do-    rows <- listSubscriptionDeadLetters subscriptionName member-    sources <- findSources rows-    traverse (replayOne sources) (Vector.toList rows)+  rows <- listSubscriptionDeadLetters subscriptionName member+  sources <- findSources rows+  traverse (replayOne sources) (Vector.toList rows)   where     replayOne sources row = do-        let position = deadLetterPosition row-            eventId = deadLetterEventIdValue row-        result <- case Map.lookup eventId sources of-            Just event-                | event ^. #globalPosition == position -> do-                    handled <- handler event-                    pure (either ReplayFailed id handled)-            _ -> pure ReplaySourceMissing-        pure-            ReplayOutcome-                { replayGlobalPosition = position-                , replayEventId = eventId-                , replayResult = result-                }+      let position = deadLetterPosition row+          eventId = deadLetterEventIdValue row+      result <- case Map.lookup eventId sources of+        Just event+          | event ^. #globalPosition == position -> do+              handled <- handler event+              pure (either ReplayFailed id handled)+        _ -> pure ReplaySourceMissing+      pure+        ReplayOutcome+          { replayGlobalPosition = position,+            replayEventId = eventId,+            replayResult = result+          }  -- Scan once for the complete replay batch. Pages are descending, and the next -- cursor is always a position Kiroku returned; no global-position arithmetic. findSources ::-    (Store :> es) => Vector DeadLetterRecord -> Eff es (Map EventId RecordedEvent)+  (Store :> es) => Vector DeadLetterRecord -> Eff es (Map EventId RecordedEvent) findSources rows = go (GlobalPosition 0) Map.empty   where     wanted =-        Map.fromList-            [ (deadLetterEventIdValue row, deadLetterPosition row)-            | row <- Vector.toList rows-            ]+      Map.fromList+        [ (deadLetterEventIdValue row, deadLetterPosition row)+        | row <- Vector.toList rows+        ]      go cursor found-        | Map.size found == Map.size wanted = pure found-        | otherwise = do-            page <- readAllBackward cursor replayReadPageSize-            if Vector.null page-                then pure found-                else do-                    let found' = Vector.foldl' remember found page-                        nextCursor = Vector.last page ^. #globalPosition-                    go nextCursor found'+      | Map.size found == Map.size wanted = pure found+      | otherwise = do+          page <- readAllBackward cursor replayReadPageSize+          if Vector.null page+            then pure found+            else do+              let found' = Vector.foldl' remember found page+                  nextCursor = Vector.last page ^. #globalPosition+              go nextCursor found'      remember found event =-        case Map.lookup (event ^. #eventId) wanted of-            Just expectedPosition-                | event ^. #globalPosition == expectedPosition ->-                    Map.insert (event ^. #eventId) event found-            _ -> found+      case Map.lookup (event ^. #eventId) wanted of+        Just expectedPosition+          | event ^. #globalPosition == expectedPosition ->+              Map.insert (event ^. #eventId) event found+        _ -> found  replayReadPageSize :: Int32 replayReadPageSize = 256
src/Keiro/DeadLetter/Schema.hs view
@@ -1,18 +1,17 @@-{- | SQL storage for rejected process-manager and router dispatches.--The @keiro.keiro_dead_letters@ table records a dispatched command that a-target state machine rejected. It is deliberately separate from Kiroku's-subscription dead-letter table: a row here describes one failed dispatch,-while the source subscription event is considered handled and may advance its-checkpoint.--}-module Keiro.DeadLetter.Schema (-    DispatcherKind (..),+-- | SQL storage for rejected process-manager and router dispatches.+--+-- The @keiro.keiro_dead_letters@ table records a dispatched command that a+-- target state machine rejected. It is deliberately separate from Kiroku's+-- subscription dead-letter table: a row here describes one failed dispatch,+-- while the source subscription event is considered handled and may advance its+-- checkpoint.+module Keiro.DeadLetter.Schema+  ( DispatcherKind (..),     DispatchDeadLetter (..),     DispatchDeadLetterRecord (..),     recordDispatchDeadLetterTx,     listDispatchDeadLettersTx,-)+  ) where  import Contravariant.Extras (contrazip10)@@ -29,137 +28,136 @@  -- | Which coordination primitive attempted the rejected dispatch. data DispatcherKind-    = DispatcherProcessManager-    | DispatcherRouter-    deriving stock (Generic, Eq, Ord, Show)+  = DispatcherProcessManager+  | DispatcherRouter+  deriving stock (Generic, Eq, Ord, Show)  -- | Fields persisted for one rejected dispatch. data DispatchDeadLetter = DispatchDeadLetter-    { dispatcherKind :: !DispatcherKind-    , dispatcherName :: !Text-    , correlationId :: !Text-    , sourceEventId :: !EventId-    , sourceGlobalPosition :: !GlobalPosition-    , emitIndex :: !Int-    , targetStreamName :: !StreamName-    , errorClass :: !Text-    , errorDetail :: !Text-    , attemptCount :: !Int-    }-    deriving stock (Generic, Eq, Show)+  { dispatcherKind :: !DispatcherKind,+    dispatcherName :: !Text,+    correlationId :: !Text,+    sourceEventId :: !EventId,+    sourceGlobalPosition :: !GlobalPosition,+    emitIndex :: !Int,+    targetStreamName :: !StreamName,+    errorClass :: !Text,+    errorDetail :: !Text,+    attemptCount :: !Int+  }+  deriving stock (Generic, Eq, Show)  -- | A persisted rejected dispatch, including database-managed identity and time. data DispatchDeadLetterRecord = DispatchDeadLetterRecord-    { deadLetterId :: !Int64-    , dispatcherKind :: !DispatcherKind-    , dispatcherName :: !Text-    , correlationId :: !Text-    , sourceEventId :: !EventId-    , sourceGlobalPosition :: !GlobalPosition-    , emitIndex :: !Int-    , targetStreamName :: !StreamName-    , errorClass :: !Text-    , errorDetail :: !Text-    , attemptCount :: !Int-    , createdAt :: !UTCTime-    }-    deriving stock (Generic, Eq, Show)+  { deadLetterId :: !Int64,+    dispatcherKind :: !DispatcherKind,+    dispatcherName :: !Text,+    correlationId :: !Text,+    sourceEventId :: !EventId,+    sourceGlobalPosition :: !GlobalPosition,+    emitIndex :: !Int,+    targetStreamName :: !StreamName,+    errorClass :: !Text,+    errorDetail :: !Text,+    attemptCount :: !Int,+    createdAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show) -{- | Insert a rejected dispatch inside the caller's transaction. Redelivery is-idempotent: a duplicate @(dispatcher_name, source_event_id, emit_index)@ is a-no-op. Error detail is bounded here so every caller gets the same 1024-character-storage contract.--}+-- | Insert a rejected dispatch inside the caller's transaction. Redelivery is+-- idempotent: a duplicate @(dispatcher_name, source_event_id, emit_index)@ is a+-- no-op. Error detail is bounded here so every caller gets the same 1024-character+-- storage contract. recordDispatchDeadLetterTx :: DispatchDeadLetter -> Tx.Transaction () recordDispatchDeadLetterTx deadLetter =-    Tx.statement (dispatchDeadLetterParams deadLetter) insertDispatchDeadLetterStmt+  Tx.statement (dispatchDeadLetterParams deadLetter) insertDispatchDeadLetterStmt  -- | List one dispatcher's records, newest first. listDispatchDeadLettersTx :: Text -> Tx.Transaction [DispatchDeadLetterRecord] listDispatchDeadLettersTx dispatcher =-    Tx.statement dispatcher listDispatchDeadLettersStmt+  Tx.statement dispatcher listDispatchDeadLettersStmt  insertDispatchDeadLetterStmt :: Statement (Text, Text, Text, UUID, Int64, Int32, Text, Text, Text, Int32) () insertDispatchDeadLetterStmt =-    preparable-        """-        INSERT INTO keiro.keiro_dead_letters-          (dispatcher_kind, dispatcher_name, correlation_id, source_event_id,-           source_global_position, emit_index, target_stream_name, error_class,-           error_detail, attempt_count)-        VALUES-          ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)-        ON CONFLICT (dispatcher_name, source_event_id, emit_index) DO NOTHING-        """-        ( contrazip10-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_dead_letters+      (dispatcher_kind, dispatcher_name, correlation_id, source_event_id,+       source_global_position, emit_index, target_stream_name, error_class,+       error_detail, attempt_count)+    VALUES+      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)+    ON CONFLICT (dispatcher_name, source_event_id, emit_index) DO NOTHING+    """+    ( contrazip10+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    D.noResult  listDispatchDeadLettersStmt :: Statement Text [DispatchDeadLetterRecord] listDispatchDeadLettersStmt =-    preparable-        """-        SELECT dead_letter_id, dispatcher_kind, dispatcher_name, correlation_id,-               source_event_id, source_global_position, emit_index,-               target_stream_name, error_class, error_detail, attempt_count,-               created_at-        FROM keiro.keiro_dead_letters-        WHERE dispatcher_name = $1-        ORDER BY created_at DESC, dead_letter_id DESC-        """-        (E.param (E.nonNullable E.text))-        (D.rowList dispatchDeadLetterRecordDecoder)+  preparable+    """+    SELECT dead_letter_id, dispatcher_kind, dispatcher_name, correlation_id,+           source_event_id, source_global_position, emit_index,+           target_stream_name, error_class, error_detail, attempt_count,+           created_at+    FROM keiro.keiro_dead_letters+    WHERE dispatcher_name = $1+    ORDER BY created_at DESC, dead_letter_id DESC+    """+    (E.param (E.nonNullable E.text))+    (D.rowList dispatchDeadLetterRecordDecoder)  dispatchDeadLetterRecordDecoder :: D.Row DispatchDeadLetterRecord dispatchDeadLetterRecordDecoder =-    DispatchDeadLetterRecord-        <$> D.column (D.nonNullable D.int8)-        <*> D.column (D.nonNullable (D.refine dispatcherKindFromText D.text))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> (EventId <$> D.column (D.nonNullable D.uuid))-        <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))-        <*> (fromIntegral <$> D.column (D.nonNullable D.int4))-        <*> (StreamName <$> D.column (D.nonNullable D.text))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> (fromIntegral <$> D.column (D.nonNullable D.int4))-        <*> D.column (D.nonNullable D.timestamptz)+  DispatchDeadLetterRecord+    <$> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable (D.refine dispatcherKindFromText D.text))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> (EventId <$> D.column (D.nonNullable D.uuid))+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+    <*> (StreamName <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+    <*> D.column (D.nonNullable D.timestamptz)  dispatchDeadLetterParams :: DispatchDeadLetter -> (Text, Text, Text, UUID, Int64, Int32, Text, Text, Text, Int32) dispatchDeadLetterParams deadLetter =-    ( dispatcherKindToText (deadLetter ^. #dispatcherKind)-    , deadLetter ^. #dispatcherName-    , deadLetter ^. #correlationId-    , eventIdToUuid (deadLetter ^. #sourceEventId)-    , globalPositionToInt64 (deadLetter ^. #sourceGlobalPosition)-    , fromIntegral (deadLetter ^. #emitIndex)-    , streamNameToText (deadLetter ^. #targetStreamName)-    , deadLetter ^. #errorClass-    , Text.take 1024 (deadLetter ^. #errorDetail)-    , fromIntegral (deadLetter ^. #attemptCount)-    )+  ( dispatcherKindToText (deadLetter ^. #dispatcherKind),+    deadLetter ^. #dispatcherName,+    deadLetter ^. #correlationId,+    eventIdToUuid (deadLetter ^. #sourceEventId),+    globalPositionToInt64 (deadLetter ^. #sourceGlobalPosition),+    fromIntegral (deadLetter ^. #emitIndex),+    streamNameToText (deadLetter ^. #targetStreamName),+    deadLetter ^. #errorClass,+    Text.take 1024 (deadLetter ^. #errorDetail),+    fromIntegral (deadLetter ^. #attemptCount)+  )  dispatcherKindToText :: DispatcherKind -> Text dispatcherKindToText = \case-    DispatcherProcessManager -> "process-manager"-    DispatcherRouter -> "router"+  DispatcherProcessManager -> "process-manager"+  DispatcherRouter -> "router"  dispatcherKindFromText :: Text -> Either Text DispatcherKind dispatcherKindFromText = \case-    "process-manager" -> Right DispatcherProcessManager-    "router" -> Right DispatcherRouter-    other -> Left ("unknown keiro_dead_letters.dispatcher_kind: " <> other)+  "process-manager" -> Right DispatcherProcessManager+  "router" -> Right DispatcherRouter+  other -> Left ("unknown keiro_dead_letters.dispatcher_kind: " <> other)  eventIdToUuid :: EventId -> UUID eventIdToUuid (EventId uuid) = uuid
src/Keiro/Inbox.hs view
@@ -1,28 +1,27 @@-{- | Idempotent inbox for cross-bounded-context integration events.--The inbox lives in the consuming bounded context. When a Kafka consumer-receives an integration event, the inbox records the event's stable-external identity and runs the local handler in the same Postgres-transaction. Duplicate redeliveries (Kafka offset retry, rebalance,-producer republish) become observable as duplicates instead of-re-running the handler.--The wrapper is a single-transaction primitive: the completed inbox row-and the handler's local writes commit atomically. If the handler raises-or condemns the transaction, the inbox row never appears and the next-delivery starts fresh.--Completed-row retention defines the deduplication window. After-'garbageCollectCompleted' removes a row, a later delivery of the same key is-processed again. A concurrent GC can also delete a conflicting completed row-between the insert attempt and its lookup; the handler then commits without a-replacement deduplication row, so a later redelivery can run it again. These-cases preserve at-least-once delivery, not permanent exactly-once processing;-size retention beyond the maximum redelivery delay and keep handlers-idempotent.--}-module Keiro.Inbox (-    -- * Re-exports+-- | Idempotent inbox for cross-bounded-context integration events.+--+-- The inbox lives in the consuming bounded context. When a Kafka consumer+-- receives an integration event, the inbox records the event's stable+-- external identity and runs the local handler in the same Postgres+-- transaction. Duplicate redeliveries (Kafka offset retry, rebalance,+-- producer republish) become observable as duplicates instead of+-- re-running the handler.+--+-- The wrapper is a single-transaction primitive: the completed inbox row+-- and the handler's local writes commit atomically. If the handler raises+-- or condemns the transaction, the inbox row never appears and the next+-- delivery starts fresh.+--+-- Completed-row retention defines the deduplication window. After+-- 'garbageCollectCompleted' removes a row, a later delivery of the same key is+-- processed again. A concurrent GC can also delete a conflicting completed row+-- between the insert attempt and its lookup; the handler then commits without a+-- replacement deduplication row, so a later redelivery can run it again. These+-- cases preserve at-least-once delivery, not permanent exactly-once processing;+-- size retention beyond the maximum redelivery delay and keep handlers+-- idempotent.+module Keiro.Inbox+  ( -- * Re-exports     module Keiro.Inbox.Types,      -- * Storage primitives@@ -41,7 +40,7 @@     runInboxTransactionWithRetriesKey,     runInboxTransactionBatch,     sampleInboxBacklog,-)+  ) where  import Data.Map.Strict qualified as Map@@ -53,374 +52,368 @@ import Keiro.Inbox.Types import Keiro.Integration.Event (IntegrationEvent) import Keiro.Prelude-import Keiro.Telemetry (-    KeiroMetrics,+import Keiro.Telemetry+  ( KeiroMetrics,     recordInboxBacklog,     recordInboxDuplicates,     recordInboxFailed,     recordInboxPoisoned,     recordInboxProcessed,- )+  ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx  data BatchPlan-    = BatchKeyError !InboxError-    | BatchDuplicate-    | BatchWork !Text !Text !IntegrationEvent !(Maybe KafkaDeliveryRef)--{- | Run @handler@ at most once for each @(source, dedupe_key)@.--Computes the dedupe key from @policy@ and @kafka@, then in one-transaction:--* Inserts the inbox row with status @completed@.-* If the row already exists, branches on its status: 'InboxCompleted'-  → 'InboxDuplicate'; 'InboxProcessing' → 'InboxInProgress';-  'InboxFailed' → 'InboxPreviouslyFailed'.-* Otherwise runs @handler@; the row commits only if the handler succeeds.+  = BatchKeyError !InboxError+  | BatchDuplicate+  | BatchWork !Text !Text !IntegrationEvent !(Maybe KafkaDeliveryRef) -The handler is invoked with the decoded 'IntegrationEvent' so it does-not need to redecode bytes. On exception or 'Tx.condemn' the whole-transaction rolls back, including the inbox row insert — the next-delivery sees no row and can retry.--}+-- | Run @handler@ at most once for each @(source, dedupe_key)@.+--+-- Computes the dedupe key from @policy@ and @kafka@, then in one+-- transaction:+--+-- * Inserts the inbox row with status @completed@.+-- * If the row already exists, branches on its status: 'InboxCompleted'+--   → 'InboxDuplicate'; 'InboxProcessing' → 'InboxInProgress';+--   'InboxFailed' → 'InboxPreviouslyFailed'.+-- * Otherwise runs @handler@; the row commits only if the handler succeeds.+--+-- The handler is invoked with the decoded 'IntegrationEvent' so it does+-- not need to redecode bytes. On exception or 'Tx.condemn' the whole+-- transaction rolls back, including the inbox row insert — the next+-- delivery sees no row and can retry. runInboxTransaction ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    InboxDedupePolicy ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (Either InboxError (InboxResult a))+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  InboxDedupePolicy ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (Either InboxError (InboxResult a)) runInboxTransaction mMetrics policy event kafka handler =-    runInboxTransactionWith mMetrics PersistFullEnvelope policy event kafka handler--{- | Variant of 'runInboxTransaction' that controls success-path-envelope persistence.+  runInboxTransactionWith mMetrics PersistFullEnvelope policy event kafka handler -'PersistDedupeOnly' keeps enough columns for dedupe and operator-correlation but stores an empty payload and omits schema, trace, and-attribute columns for successfully processed rows.--}+-- | Variant of 'runInboxTransaction' that controls success-path+-- envelope persistence.+--+-- 'PersistDedupeOnly' keeps enough columns for dedupe and operator+-- correlation but stores an empty payload and omits schema, trace, and+-- attribute columns for successfully processed rows. runInboxTransactionWith ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    InboxPersistence ->-    InboxDedupePolicy ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (Either InboxError (InboxResult a))+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  InboxPersistence ->+  InboxDedupePolicy ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (Either InboxError (InboxResult a)) runInboxTransactionWith mMetrics persistence policy event kafka handler =-    case dedupeKeyFor policy event kafka of-        Left err -> pure (Left err)-        Right dedupe ->-            Right-                <$> runInboxTransactionWithKeyPersist-                    mMetrics-                    persistence-                    (event ^. #source)-                    dedupe-                    event-                    kafka-                    handler--{- | Lower-level variant that takes the dedupe key directly.+  case dedupeKeyFor policy event kafka of+    Left err -> pure (Left err)+    Right dedupe ->+      Right+        <$> runInboxTransactionWithKeyPersist+          mMetrics+          persistence+          (event ^. #source)+          dedupe+          event+          kafka+          handler -Use when the policy is not enough to express the identity scheme — for-example, when the consumer joins fields from multiple headers or-derives the key from the payload itself.--}+-- | Lower-level variant that takes the dedupe key directly.+--+-- Use when the policy is not enough to express the identity scheme — for+-- example, when the consumer joins fields from multiple headers or+-- derives the key from the payload itself. runInboxTransactionWithKey ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (InboxResult a)+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (InboxResult a) runInboxTransactionWithKey mMetrics src dedupe event kafka handler =-    runInboxTransactionWithKeyPersist mMetrics PersistFullEnvelope src dedupe event kafka handler+  runInboxTransactionWithKeyPersist mMetrics PersistFullEnvelope src dedupe event kafka handler  runInboxTransactionWithKeyPersist ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    InboxPersistence ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (InboxResult a)+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  InboxPersistence ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (InboxResult a) runInboxTransactionWithKeyPersist mMetrics persistence src dedupe event kafka handler = do-    now <- liftIO getCurrentTime-    result <--        runTransaction $-            attemptOneTx persistence Nothing src dedupe event kafka now handler-    -- Record the classification counter outside the handler transaction.-    -- Backlog gauge sampling is intentionally scheduled separately via-    -- 'sampleInboxBacklog'.-    recordInboxResult mMetrics Nothing result-    pure result--{- | Run @handler@ with opt-in poison-message accounting.--This wrapper behaves like 'runInboxTransaction' for fresh messages,-duplicates, and in-flight rows, but changes the behavior for handler-exceptions and previously failed rows:--* A synchronous exception from @handler@ rolls back the handler-  transaction, then records a failed attempt in a second transaction and-  returns 'InboxHandlerFailed' with the new attempt count.-* A previously failed row with @attempt_count < ceiling@ is retried.-* A previously failed row with @attempt_count >= ceiling@ returns-  'InboxPreviouslyFailed' without running the handler. The consumer can-  commit its offset and move on; the failed inbox row is the dead-letter-  record for operator review.+  now <- liftIO getCurrentTime+  result <-+    runTransaction $+      attemptOneTx persistence Nothing src dedupe event kafka now handler+  -- Record the classification counter outside the handler transaction.+  -- Backlog gauge sampling is intentionally scheduled separately via+  -- 'sampleInboxBacklog'.+  recordInboxResult mMetrics Nothing result+  pure result -'Tx.condemn' is not treated as a handler failure by this wrapper. It-keeps the original rollback semantics from 'runInboxTransaction'.--}+-- | Run @handler@ with opt-in poison-message accounting.+--+-- This wrapper behaves like 'runInboxTransaction' for fresh messages,+-- duplicates, and in-flight rows, but changes the behavior for handler+-- exceptions and previously failed rows:+--+-- * A synchronous exception from @handler@ rolls back the handler+--   transaction, then records a failed attempt in a second transaction and+--   returns 'InboxHandlerFailed' with the new attempt count.+-- * A previously failed row with @attempt_count < ceiling@ is retried.+-- * A previously failed row with @attempt_count >= ceiling@ returns+--   'InboxPreviouslyFailed' without running the handler. The consumer can+--   commit its offset and move on; the failed inbox row is the dead-letter+--   record for operator review.+--+-- 'Tx.condemn' is not treated as a handler failure by this wrapper. It+-- keeps the original rollback semantics from 'runInboxTransaction'. runInboxTransactionWithRetries ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    Int ->-    InboxDedupePolicy ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (Either InboxError (InboxResult a))+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  Int ->+  InboxDedupePolicy ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (Either InboxError (InboxResult a)) runInboxTransactionWithRetries mMetrics attemptCeiling policy event kafka handler =-    runInboxTransactionWithRetriesWith mMetrics attemptCeiling PersistFullEnvelope policy event kafka handler+  runInboxTransactionWithRetriesWith mMetrics attemptCeiling PersistFullEnvelope policy event kafka handler  -- | Variant of 'runInboxTransactionWithRetries' that controls success-path persistence. runInboxTransactionWithRetriesWith ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    Int ->-    InboxPersistence ->-    InboxDedupePolicy ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (Either InboxError (InboxResult a))+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  Int ->+  InboxPersistence ->+  InboxDedupePolicy ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (Either InboxError (InboxResult a)) runInboxTransactionWithRetriesWith mMetrics attemptCeiling persistence policy event kafka handler =-    case dedupeKeyFor policy event kafka of-        Left err -> pure (Left err)-        Right dedupe ->-            Right-                <$> runInboxTransactionWithRetriesKeyPersist-                    mMetrics-                    attemptCeiling-                    persistence-                    (event ^. #source)-                    dedupe-                    event-                    kafka-                    handler+  case dedupeKeyFor policy event kafka of+    Left err -> pure (Left err)+    Right dedupe ->+      Right+        <$> runInboxTransactionWithRetriesKeyPersist+          mMetrics+          attemptCeiling+          persistence+          (event ^. #source)+          dedupe+          event+          kafka+          handler  -- | Lower-level retrying variant that takes the dedupe key directly. runInboxTransactionWithRetriesKey ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    Int ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (InboxResult a)+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  Int ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (InboxResult a) runInboxTransactionWithRetriesKey mMetrics attemptCeiling src dedupe event kafka handler =-    runInboxTransactionWithRetriesKeyPersist mMetrics attemptCeiling PersistFullEnvelope src dedupe event kafka handler+  runInboxTransactionWithRetriesKeyPersist mMetrics attemptCeiling PersistFullEnvelope src dedupe event kafka handler  runInboxTransactionWithRetriesKeyPersist ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    Int ->-    InboxPersistence ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es (InboxResult a)+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  Int ->+  InboxPersistence ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es (InboxResult a) runInboxTransactionWithRetriesKeyPersist mMetrics attemptCeiling persistence src dedupe event kafka handler = do-    now <- liftIO getCurrentTime-    attempted <--        trySync $-            runTransaction $-                attemptOneTx persistence (Just attemptCeiling) src dedupe event kafka now handler-    result <- case attempted of-        Right ok -> pure ok-        Left err -> do-            failedAt <- liftIO getCurrentTime-            let errMsg = Text.pack (displayException err)-            attempts <--                runTransaction $-                    recordFailedAttemptTx src dedupe event kafka errMsg failedAt-            pure (InboxHandlerFailed errMsg attempts)-    recordInboxResult mMetrics (Just attemptCeiling) result-    pure result--{- | Process a batch of inbox deliveries with a single transactional fast path.--The fast path computes each @(source, dedupe_key)@, suppresses repeated-keys within the batch as duplicates, then runs all remaining deliveries-in one Postgres transaction. If any handler throws or condemns that-transaction, the whole batch rolls back and every original delivery is-retried through 'runInboxTransactionWithRetries'. That fallback preserves-per-message failure accounting and prevents one poison message from-discarding unrelated batch mates.+  now <- liftIO getCurrentTime+  attempted <-+    trySync $+      runTransaction $+        attemptOneTx persistence (Just attemptCeiling) src dedupe event kafka now handler+  result <- case attempted of+    Right ok -> pure ok+    Left err -> do+      failedAt <- liftIO getCurrentTime+      let errMsg = Text.pack (displayException err)+      attempts <-+        runTransaction $+          recordFailedAttemptTx src dedupe event kafka errMsg failedAt+      pure (InboxHandlerFailed errMsg attempts)+  recordInboxResult mMetrics (Just attemptCeiling) result+  pure result -'Tx.condemn' rolls the transaction back at commit but returns normally,-so it cannot be observed from the transaction's return value alone.-The batch detects it by re-reading one row it should have committed:-every write in the fast path belongs to a delivery classified-'InboxProcessed' (fresh insert as @completed@ or retry promotion to-@completed@), so if the first such row is not @completed@ after the-transaction returns, the whole batch was condemned and the per-message-fallback runs. A batch with no 'InboxProcessed' rows performed no writes,-so a condemned transaction loses nothing.--}+-- | Process a batch of inbox deliveries with a single transactional fast path.+--+-- The fast path computes each @(source, dedupe_key)@, suppresses repeated+-- keys within the batch as duplicates, then runs all remaining deliveries+-- in one Postgres transaction. If any handler throws or condemns that+-- transaction, the whole batch rolls back and every original delivery is+-- retried through 'runInboxTransactionWithRetries'. That fallback preserves+-- per-message failure accounting and prevents one poison message from+-- discarding unrelated batch mates.+--+-- 'Tx.condemn' rolls the transaction back at commit but returns normally,+-- so it cannot be observed from the transaction's return value alone.+-- The batch detects it by re-reading one row it should have committed:+-- every write in the fast path belongs to a delivery classified+-- 'InboxProcessed' (fresh insert as @completed@ or retry promotion to+-- @completed@), so if the first such row is not @completed@ after the+-- transaction returns, the whole batch was condemned and the per-message+-- fallback runs. A batch with no 'InboxProcessed' rows performed no writes,+-- so a condemned transaction loses nothing. runInboxTransactionBatch ::-    forall a es.-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    Int ->-    InboxDedupePolicy ->-    InboxPersistence ->-    [(IntegrationEvent, Maybe KafkaDeliveryRef)] ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Eff es [Either InboxError (InboxResult a)]+  forall a es.+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  Int ->+  InboxDedupePolicy ->+  InboxPersistence ->+  [(IntegrationEvent, Maybe KafkaDeliveryRef)] ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Eff es [Either InboxError (InboxResult a)] runInboxTransactionBatch mMetrics attemptCeiling policy persistence deliveries handler = do-    now <- liftIO getCurrentTime-    let plan = planInboxBatch policy deliveries-    attempted <--        trySync $-            runTransaction $-                traverse-                    ( \case-                        BatchKeyError err -> pure (Left err)-                        BatchDuplicate -> pure (Right InboxDuplicate)-                        BatchWork src dedupe event kafka ->-                            Right <$> attemptOneTx persistence (Just attemptCeiling) src dedupe event kafka now handler-                    )-                    plan-    case attempted of-        Right results -> do-            committed <- verifyBatchCommitted plan results-            if committed-                then do-                    for_ results $ \case-                        Right result -> recordInboxResult mMetrics (Just attemptCeiling) result-                        Left _ -> pure ()-                    pure results-                else perMessageFallback-        Left _ -> perMessageFallback+  now <- liftIO getCurrentTime+  let plan = planInboxBatch policy deliveries+  attempted <-+    trySync $+      runTransaction $+        traverse+          ( \case+              BatchKeyError err -> pure (Left err)+              BatchDuplicate -> pure (Right InboxDuplicate)+              BatchWork src dedupe event kafka ->+                Right <$> attemptOneTx persistence (Just attemptCeiling) src dedupe event kafka now handler+          )+          plan+  case attempted of+    Right results -> do+      committed <- verifyBatchCommitted plan results+      if committed+        then do+          for_ results $ \case+            Right result -> recordInboxResult mMetrics (Just attemptCeiling) result+            Left _ -> pure ()+          pure results+        else perMessageFallback+    Left _ -> perMessageFallback   where     perMessageFallback :: Eff es [Either InboxError (InboxResult a)]     perMessageFallback =-        traverse-            ( \(event, kafka) ->-                runInboxTransactionWithRetriesWith mMetrics attemptCeiling persistence policy event kafka handler-            )-            deliveries+      traverse+        ( \(event, kafka) ->+            runInboxTransactionWithRetriesWith mMetrics attemptCeiling persistence policy event kafka handler+        )+        deliveries      -- A condemned transaction returns its results normally but commits     -- nothing. Re-read the first row the batch claims to have completed;     -- if it is not @completed@, the transaction rolled back at commit.     verifyBatchCommitted ::-        [BatchPlan] ->-        [Either InboxError (InboxResult a)] ->-        Eff es Bool+      [BatchPlan] ->+      [Either InboxError (InboxResult a)] ->+      Eff es Bool     verifyBatchCommitted plan results =-        case listToMaybe (mapMaybe processedKey (zip plan results)) of-            Nothing -> pure True-            Just (src, dedupe) -> do-                row <- lookupInbox src dedupe-                pure (fmap (^. #status) row == Just InboxCompleted)+      case listToMaybe (mapMaybe processedKey (zip plan results)) of+        Nothing -> pure True+        Just (src, dedupe) -> do+          row <- lookupInbox src dedupe+          pure (fmap (^. #status) row == Just InboxCompleted)      processedKey :: (BatchPlan, Either InboxError (InboxResult a)) -> Maybe (Text, Text)     processedKey = \case-        (BatchWork src dedupe _ _, Right (InboxProcessed _)) -> Just (src, dedupe)-        _ -> Nothing+      (BatchWork src dedupe _ _, Right (InboxProcessed _)) -> Just (src, dedupe)+      _ -> Nothing  attemptOneTx ::-    InboxPersistence ->-    Maybe Int ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    UTCTime ->-    (IntegrationEvent -> Tx.Transaction a) ->-    Tx.Transaction (InboxResult a)+  InboxPersistence ->+  Maybe Int ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  UTCTime ->+  (IntegrationEvent -> Tx.Transaction a) ->+  Tx.Transaction (InboxResult a) attemptOneTx persistence attemptCeiling src dedupe event kafka now handler = do-    inserted <- tryInsertCompletedTx persistence src dedupe event kafka now-    case inserted of-        Right () -> do-            handled <- handler event-            pure (InboxProcessed handled)-        Left row -> case row ^. #status of-            InboxCompleted -> pure InboxDuplicate-            InboxProcessing -> pure InboxInProgress-            InboxFailed -> case attemptCeiling of-                Nothing -> pure (InboxPreviouslyFailed (row ^. #lastError))-                Just attemptLimit-                    | row ^. #attemptCount >= attemptLimit ->-                        pure (InboxPreviouslyFailed (row ^. #lastError))-                    | otherwise -> do-                        handled <- handler event-                        markCompletedTx src dedupe now-                        pure (InboxProcessed handled)+  inserted <- tryInsertCompletedTx persistence src dedupe event kafka now+  case inserted of+    Right () -> do+      handled <- handler event+      pure (InboxProcessed handled)+    Left row -> case row ^. #status of+      InboxCompleted -> pure InboxDuplicate+      InboxProcessing -> pure InboxInProgress+      InboxFailed -> case attemptCeiling of+        Nothing -> pure (InboxPreviouslyFailed (row ^. #lastError))+        Just attemptLimit+          | row ^. #attemptCount >= attemptLimit ->+              pure (InboxPreviouslyFailed (row ^. #lastError))+          | otherwise -> do+              handled <- handler event+              markCompletedTx src dedupe now+              pure (InboxProcessed handled)  recordInboxResult :: (IOE :> es) => Maybe KeiroMetrics -> Maybe Int -> InboxResult a -> Eff es () recordInboxResult mMetrics attemptCeiling = \case-    InboxProcessed _ -> recordInboxProcessed mMetrics 1-    InboxDuplicate -> recordInboxDuplicates mMetrics 1-    InboxPreviouslyFailed _ -> recordInboxFailed mMetrics 1-    InboxHandlerFailed _ attempts -> do-        recordInboxFailed mMetrics 1-        case attemptCeiling of-            Just attemptLimit | attempts >= attemptLimit -> recordInboxPoisoned mMetrics 1-            _ -> pure ()-    InboxInProgress -> pure ()+  InboxProcessed _ -> recordInboxProcessed mMetrics 1+  InboxDuplicate -> recordInboxDuplicates mMetrics 1+  InboxPreviouslyFailed _ -> recordInboxFailed mMetrics 1+  InboxHandlerFailed _ attempts -> do+    recordInboxFailed mMetrics 1+    case attemptCeiling of+      Just attemptLimit | attempts >= attemptLimit -> recordInboxPoisoned mMetrics 1+      _ -> pure ()+  InboxInProgress -> pure ()  planInboxBatch ::-    InboxDedupePolicy ->-    [(IntegrationEvent, Maybe KafkaDeliveryRef)] ->-    [BatchPlan]+  InboxDedupePolicy ->+  [(IntegrationEvent, Maybe KafkaDeliveryRef)] ->+  [BatchPlan] planInboxBatch policy = go Map.empty   where     go _ [] = []     go seen ((event, kafka) : rest) =-        case dedupeKeyFor policy event kafka of-            Left err -> BatchKeyError err : go seen rest-            Right dedupe ->-                let key = (event ^. #source, dedupe)-                 in if Map.member key seen-                        then BatchDuplicate : go seen rest-                        else BatchWork (event ^. #source) dedupe event kafka : go (Map.insert key () seen) rest--{- | Count the inbox backlog and record the gauge when metrics are enabled.+      case dedupeKeyFor policy event kafka of+        Left err -> BatchKeyError err : go seen rest+        Right dedupe ->+          let key = (event ^. #source, dedupe)+           in if Map.member key seen+                then BatchDuplicate : go seen rest+                else BatchWork (event ^. #source) dedupe event kafka : go (Map.insert key () seen) rest -The backlog is non-terminal rows: legacy @processing@ rows plus failed rows.-Schedule this on its own interval; it is intentionally not part of the-per-message intake path.--}+-- | Count the inbox backlog and record the gauge when metrics are enabled.+--+-- The backlog is non-terminal rows: legacy @processing@ rows plus failed rows.+-- Schedule this on its own interval; it is intentionally not part of the+-- per-message intake path. sampleInboxBacklog :: (IOE :> es, Store :> es) => Maybe KeiroMetrics -> Eff es () sampleInboxBacklog Nothing = pure () sampleInboxBacklog (Just metrics) = do-    backlog <- countInboxBacklog-    recordInboxBacklog (Just metrics) (fromIntegral backlog)+  backlog <- countInboxBacklog+  recordInboxBacklog (Just metrics) (fromIntegral backlog)
src/Keiro/Inbox/Kafka.hs view
@@ -1,21 +1,20 @@-{- | Reconstruct an 'IntegrationEvent' from Kafka payload bytes plus-headers.--This module is the receiving-side counterpart of-'Keiro.Outbox.Kafka.integrationEventToKafkaRecord'. It is pure: the-caller supplies the bytes and the @Text@-keyed header map produced by-its Kafka adapter, and gets back a decoded envelope or a typed error.--@keiro@ itself does not depend on @hw-kafka-client@ or-@shibuya-kafka-adapter@; the consumer adapter in EP-22 bridges the-broker library's header type to @[(Text, Text)]@ before calling-'integrationEventFromKafka'.--}-module Keiro.Inbox.Kafka (-    KafkaInboundRecord (..),+-- | Reconstruct an 'IntegrationEvent' from Kafka payload bytes plus+-- headers.+--+-- This module is the receiving-side counterpart of+-- 'Keiro.Outbox.Kafka.integrationEventToKafkaRecord'. It is pure: the+-- caller supplies the bytes and the @Text@-keyed header map produced by+-- its Kafka adapter, and gets back a decoded envelope or a typed error.+--+-- @keiro@ itself does not depend on @hw-kafka-client@ or+-- @shibuya-kafka-adapter@; the consumer adapter in EP-22 bridges the+-- broker library's header type to @[(Text, Text)]@ before calling+-- 'integrationEventFromKafka'.+module Keiro.Inbox.Kafka+  ( KafkaInboundRecord (..),     KafkaDecodeError (..),     integrationEventFromKafka,-)+  ) where  import Data.Aeson qualified as Aeson@@ -27,8 +26,8 @@ import Data.Time.Format.ISO8601 (iso8601ParseM) import Data.UUID qualified as UUID import Keiro.Inbox.Types (KafkaDeliveryRef (..))-import Keiro.Integration.Event (-    IntegrationContentType (..),+import Keiro.Integration.Event+  ( IntegrationContentType (..),     IntegrationEvent (..),     SchemaReference (..),     TraceContext (..),@@ -52,93 +51,91 @@     headerTraceParent,     headerTraceState,     parseContentType,- )+  ) import Keiro.Prelude import Kiroku.Store.Types (EventId (..), GlobalPosition (..)) -{- | A Kafka record as seen by the consumer-side adapter, decoupled from-the broker library's record type.--}+-- | A Kafka record as seen by the consumer-side adapter, decoupled from+-- the broker library's record type. data KafkaInboundRecord = KafkaInboundRecord-    { topic :: !Text-    , partition :: !Int64-    , offset :: !Int64-    , key :: !(Maybe Text)-    , payload :: !ByteString-    , headers :: ![(Text, Text)]-    , receivedAt :: !UTCTime-    }-    deriving stock (Generic, Eq, Show)+  { topic :: !Text,+    partition :: !Int64,+    offset :: !Int64,+    key :: !(Maybe Text),+    payload :: !ByteString,+    headers :: ![(Text, Text)],+    receivedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show)  -- | Typed failures from 'integrationEventFromKafka'. data KafkaDecodeError-    = MissingHeader !Text-    | InvalidIntHeader !Text !Text-    | InvalidUuidHeader !Text !Text-    | InvalidTimeHeader !Text !Text-    | InvalidJsonHeader !Text !Text-    deriving stock (Generic, Eq, Show)--{- | Reconstruct a full 'IntegrationEvent' plus the 'KafkaDeliveryRef'-recorded for diagnostics.+  = MissingHeader !Text+  | InvalidIntHeader !Text !Text+  | InvalidUuidHeader !Text !Text+  | InvalidTimeHeader !Text !Text+  | InvalidJsonHeader !Text !Text+  deriving stock (Generic, Eq, Show) -The reconstruction is faithful to the canonical header names defined-by 'Keiro.Integration.Event' (e.g. @keiro-message-id@, @keiro-source@,-@traceparent@). Missing required headers (@keiro-source@,-@keiro-destination@, @keiro-event-type@, @keiro-schema-version@,-@content-type@, @keiro-message-id@) produce 'MissingHeader'; malformed-numeric or UUID headers produce 'InvalidIntHeader' / 'InvalidUuidHeader'.-Optional headers are silently absent in the resulting envelope.--}+-- | Reconstruct a full 'IntegrationEvent' plus the 'KafkaDeliveryRef'+-- recorded for diagnostics.+--+-- The reconstruction is faithful to the canonical header names defined+-- by 'Keiro.Integration.Event' (e.g. @keiro-message-id@, @keiro-source@,+-- @traceparent@). Missing required headers (@keiro-source@,+-- @keiro-destination@, @keiro-event-type@, @keiro-schema-version@,+-- @content-type@, @keiro-message-id@) produce 'MissingHeader'; malformed+-- numeric or UUID headers produce 'InvalidIntHeader' / 'InvalidUuidHeader'.+-- Optional headers are silently absent in the resulting envelope. integrationEventFromKafka ::-    KafkaInboundRecord ->-    Either KafkaDecodeError (IntegrationEvent, KafkaDeliveryRef)+  KafkaInboundRecord ->+  Either KafkaDecodeError (IntegrationEvent, KafkaDeliveryRef) integrationEventFromKafka record = do-    let hs = record ^. #headers-    source <- requireHeader hs headerSource-    destination <- requireHeader hs headerDestination-    eventType <- requireHeader hs headerEventType-    schemaVersionText <- requireHeader hs headerSchemaVersion-    schemaVersion <- parseInt headerSchemaVersion schemaVersionText-    contentTypeRaw <- requireHeader hs headerContentType-    messageId <- requireHeader hs headerMessageId-    schemaReference <- buildSchemaReference hs-    sourceEventId <- traverseLookup hs headerSourceEventId (fmap EventId . parseUuid headerSourceEventId)-    sourceGlobalPosition <--        traverseLookup hs headerSourceGlobalPosition (fmap GlobalPosition . parseInt headerSourceGlobalPosition)-    causationId <- traverseLookup hs headerCausationId (fmap EventId . parseUuid headerCausationId)-    correlationId <- traverseLookup hs headerCorrelationId (fmap EventId . parseUuid headerCorrelationId)-    occurredAt <- fromMaybe (record ^. #receivedAt) <$> traverseLookup hs headerOccurredAt (parseTimeHeader headerOccurredAt)-    attributes <- traverseLookup hs headerAttributes (parseJsonHeader headerAttributes)-    let traceContext = case Prelude.lookup headerTraceParent hs of-            Nothing -> Nothing-            Just tp -> Just (TraceContext tp (Prelude.lookup headerTraceState hs))-        event =-            IntegrationEvent-                { messageId-                , source-                , destination-                , key = record ^. #key-                , eventType-                , schemaVersion-                , contentType = parseContentType contentTypeRaw-                , schemaReference-                , sourceEventId-                , sourceGlobalPosition-                , payloadBytes = record ^. #payload-                , occurredAt-                , causationId-                , correlationId-                , traceContext-                , attributes-                }-        kafka =-            KafkaDeliveryRef-                { topic = record ^. #topic-                , partition = record ^. #partition-                , offset = record ^. #offset-                }-    pure (event, kafka)+  let hs = record ^. #headers+  source <- requireHeader hs headerSource+  destination <- requireHeader hs headerDestination+  eventType <- requireHeader hs headerEventType+  schemaVersionText <- requireHeader hs headerSchemaVersion+  schemaVersion <- parseInt headerSchemaVersion schemaVersionText+  contentTypeRaw <- requireHeader hs headerContentType+  messageId <- requireHeader hs headerMessageId+  schemaReference <- buildSchemaReference hs+  sourceEventId <- traverseLookup hs headerSourceEventId (fmap EventId . parseUuid headerSourceEventId)+  sourceGlobalPosition <-+    traverseLookup hs headerSourceGlobalPosition (fmap GlobalPosition . parseInt headerSourceGlobalPosition)+  causationId <- traverseLookup hs headerCausationId (fmap EventId . parseUuid headerCausationId)+  correlationId <- traverseLookup hs headerCorrelationId (fmap EventId . parseUuid headerCorrelationId)+  occurredAt <- fromMaybe (record ^. #receivedAt) <$> traverseLookup hs headerOccurredAt (parseTimeHeader headerOccurredAt)+  attributes <- traverseLookup hs headerAttributes (parseJsonHeader headerAttributes)+  let traceContext = case Prelude.lookup headerTraceParent hs of+        Nothing -> Nothing+        Just tp -> Just (TraceContext tp (Prelude.lookup headerTraceState hs))+      event =+        IntegrationEvent+          { messageId,+            source,+            destination,+            key = record ^. #key,+            eventType,+            schemaVersion,+            contentType = parseContentType contentTypeRaw,+            schemaReference,+            sourceEventId,+            sourceGlobalPosition,+            payloadBytes = record ^. #payload,+            occurredAt,+            causationId,+            correlationId,+            traceContext,+            attributes+          }+      kafka =+        KafkaDeliveryRef+          { topic = record ^. #topic,+            partition = record ^. #partition,+            offset = record ^. #offset+          }+  pure (event, kafka)  -- --------------------------------------------------------------------------- -- Helpers@@ -146,67 +143,66 @@  requireHeader :: [(Text, Text)] -> Text -> Either KafkaDecodeError Text requireHeader hs name = case Prelude.lookup name hs of-    Just v -> Right v-    Nothing -> Left (MissingHeader name)+  Just v -> Right v+  Nothing -> Left (MissingHeader name)  traverseLookup ::-    [(Text, Text)] ->-    Text ->-    (Text -> Either KafkaDecodeError a) ->-    Either KafkaDecodeError (Maybe a)+  [(Text, Text)] ->+  Text ->+  (Text -> Either KafkaDecodeError a) ->+  Either KafkaDecodeError (Maybe a) traverseLookup hs name parser = case Prelude.lookup name hs of-    Nothing -> Right Nothing-    Just raw -> fmap Just (parser raw)+  Nothing -> Right Nothing+  Just raw -> fmap Just (parser raw)  parseInt :: (Integral a) => Text -> Text -> Either KafkaDecodeError a parseInt name raw = case TextRead.signed TextRead.decimal raw of-    Right (n, rest) | Text.null rest -> Right n-    _ -> Left (InvalidIntHeader name raw)+  Right (n, rest) | Text.null rest -> Right n+  _ -> Left (InvalidIntHeader name raw)  parseUuid :: Text -> Text -> Either KafkaDecodeError UUID.UUID parseUuid name raw = case UUID.fromText raw of-    Just u -> Right u-    Nothing -> Left (InvalidUuidHeader name raw)+  Just u -> Right u+  Nothing -> Left (InvalidUuidHeader name raw)  parseTimeHeader :: Text -> Text -> Either KafkaDecodeError UTCTime parseTimeHeader name raw =-    maybe (Left (InvalidTimeHeader name raw)) Right (iso8601ParseM (Text.unpack raw))+  maybe (Left (InvalidTimeHeader name raw)) Right (iso8601ParseM (Text.unpack raw))  parseJsonHeader :: Text -> Text -> Either KafkaDecodeError Value parseJsonHeader name raw =-    case Aeson.eitherDecodeStrict (TextEncoding.encodeUtf8 raw) of-        Right value -> Right value-        Left _ -> Left (InvalidJsonHeader name raw)+  case Aeson.eitherDecodeStrict (TextEncoding.encodeUtf8 raw) of+    Right value -> Right value+    Left _ -> Left (InvalidJsonHeader name raw)  buildSchemaReference ::-    [(Text, Text)] ->-    Either KafkaDecodeError (Maybe SchemaReference)+  [(Text, Text)] ->+  Either KafkaDecodeError (Maybe SchemaReference) buildSchemaReference hs = do-    let registry = Prelude.lookup headerSchemaRegistry hs-        subject = Prelude.lookup headerSchemaSubject hs-        fingerprint = Prelude.lookup headerSchemaFingerprint hs-    versionRef <- traverseLookup hs headerSchemaVersionRef (parseInt headerSchemaVersionRef)-    schemaId <- traverseLookup hs headerSchemaId (parseInt headerSchemaId)-    let presentFields =-            mapMaybe id [registry, subject, fmap (Text.pack . show) versionRef, fmap (Text.pack . show) schemaId, fingerprint]-    if null presentFields-        then pure Nothing-        else-            pure-                ( Just-                    ( SchemaReference-                        { registry-                        , subject-                        , version = versionRef-                        , schemaId-                        , fingerprint-                        }-                    )-                )+  let registry = Prelude.lookup headerSchemaRegistry hs+      subject = Prelude.lookup headerSchemaSubject hs+      fingerprint = Prelude.lookup headerSchemaFingerprint hs+  versionRef <- traverseLookup hs headerSchemaVersionRef (parseInt headerSchemaVersionRef)+  schemaId <- traverseLookup hs headerSchemaId (parseInt headerSchemaId)+  let presentFields =+        mapMaybe id [registry, subject, fmap (Text.pack . show) versionRef, fmap (Text.pack . show) schemaId, fingerprint]+  if null presentFields+    then pure Nothing+    else+      pure+        ( Just+            ( SchemaReference+                { registry,+                  subject,+                  version = versionRef,+                  schemaId,+                  fingerprint+                }+            )+        ) -{- | Silence unused-import warning when 'IntegrationContentType' is-imported only via the open re-export but referenced through-'parseContentType'.--}+-- | Silence unused-import warning when 'IntegrationContentType' is+-- imported only via the open re-export but referenced through+-- 'parseContentType'. _unusedKeepContentType :: IntegrationContentType -> () _unusedKeepContentType _ = ()
src/Keiro/Inbox/Schema.hs view
@@ -1,13 +1,12 @@ {-# LANGUAGE ApplicativeDo #-} -{- | Hasql-level storage for the idempotent integration-event inbox.--This module owns the SQL surface that the inbox wrapper-('Keiro.Inbox.runInboxTransaction') and the optional Kafka decoder-('Keiro.Inbox.Kafka') consume.--}-module Keiro.Inbox.Schema (-    tryInsertCompletedTx,+-- | Hasql-level storage for the idempotent integration-event inbox.+--+-- This module owns the SQL surface that the inbox wrapper+-- ('Keiro.Inbox.runInboxTransaction') and the optional Kafka decoder+-- ('Keiro.Inbox.Kafka') consume.+module Keiro.Inbox.Schema+  ( tryInsertCompletedTx,     markCompletedTx,     markFailedTx,     recordFailedAttemptTx,@@ -15,7 +14,7 @@     listInbox,     garbageCollectCompleted,     countInboxBacklog,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4)@@ -29,226 +28,222 @@ import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Keiro.Inbox.Types-import Keiro.Integration.Event (-    IntegrationEvent (..),+import Keiro.Integration.Event+  ( IntegrationEvent (..),     SchemaReference (..),     TraceContext (..),     contentTypeText,     parseContentType,- )+  ) import Keiro.Prelude import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (EventId (..), GlobalPosition (..)) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | Attempt to insert a new completed row.--Returns 'Left' carrying the existing row if @(source, dedupe_key)@-already exists; returns 'Right ()' if the insert created a new row. The-caller — typically 'Keiro.Inbox.runInboxTransaction' — then either runs-the handler (new) or branches on the existing row's status (duplicate).--The row is inserted as @completed@ before the handler runs, but remains-uncommitted until the handler transaction succeeds. On handler failure the-whole transaction rolls back, so the completed row never becomes visible.--If a concurrent retention job deletes the conflicting row between the-insert attempt and the lookup, this returns 'Right ()'. The handler's effects-then commit without a replacement deduplication row, and a later redelivery-can run the handler again. This preserves at-least-once delivery but not-permanent exactly-once processing. Retention must exceed the maximum tolerated-redelivery delay, and handlers must remain idempotent.--}+-- | Attempt to insert a new completed row.+--+-- Returns 'Left' carrying the existing row if @(source, dedupe_key)@+-- already exists; returns 'Right ()' if the insert created a new row. The+-- caller — typically 'Keiro.Inbox.runInboxTransaction' — then either runs+-- the handler (new) or branches on the existing row's status (duplicate).+--+-- The row is inserted as @completed@ before the handler runs, but remains+-- uncommitted until the handler transaction succeeds. On handler failure the+-- whole transaction rolls back, so the completed row never becomes visible.+--+-- If a concurrent retention job deletes the conflicting row between the+-- insert attempt and the lookup, this returns 'Right ()'. The handler's effects+-- then commit without a replacement deduplication row, and a later redelivery+-- can run the handler again. This preserves at-least-once delivery but not+-- permanent exactly-once processing. Retention must exceed the maximum tolerated+-- redelivery delay, and handlers must remain idempotent. tryInsertCompletedTx ::-    InboxPersistence ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    UTCTime ->-    Tx.Transaction (Either InboxRow ())+  InboxPersistence ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  UTCTime ->+  Tx.Transaction (Either InboxRow ()) tryInsertCompletedTx persistence src dedupe event kafka now = do-    inserted <--        Tx.statement (toEncodedInsert persistence src dedupe event kafka now) tryInsertStmt-    if inserted-        then pure (Right ())-        else do-            existing <- Tx.statement (src, dedupe) selectByKeyStmt-            case existing of-                Just row -> pure (Left row)-                Nothing -> pure (Right ())+  inserted <-+    Tx.statement (toEncodedInsert persistence src dedupe event kafka now) tryInsertStmt+  if inserted+    then pure (Right ())+    else do+      existing <- Tx.statement (src, dedupe) selectByKeyStmt+      case existing of+        Just row -> pure (Left row)+        Nothing -> pure (Right ())  -- | Mark an inbox row completed inside the same transaction as the handler. markCompletedTx :: Text -> Text -> UTCTime -> Tx.Transaction () markCompletedTx src dedupe now =-    Tx.statement (src, dedupe, now) markCompletedStmt+  Tx.statement (src, dedupe, now) markCompletedStmt  -- | Mark an inbox row failed inside the same transaction as the handler. markFailedTx :: Text -> Text -> Text -> UTCTime -> Tx.Transaction () markFailedTx src dedupe errMsg now =-    Tx.statement (src, dedupe, errMsg, now) markFailedStmt--{- | Record one failed handler attempt for @(source, dedupe_key)@.+  Tx.statement (src, dedupe, errMsg, now) markFailedStmt -Creates a failed row when the handler transaction rolled back the initial-processing insert, or increments the existing failed row's attempt count.-Returns the new attempt count.--}+-- | Record one failed handler attempt for @(source, dedupe_key)@.+--+-- Creates a failed row when the handler transaction rolled back the initial+-- processing insert, or increments the existing failed row's attempt count.+-- Returns the new attempt count. recordFailedAttemptTx ::-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    Text ->-    UTCTime ->-    Tx.Transaction Int+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  Text ->+  UTCTime ->+  Tx.Transaction Int recordFailedAttemptTx src dedupe event kafka errMsg now =-    Tx.statement (toEncodedFailedInsert src dedupe event kafka errMsg now) recordFailedAttemptStmt+  Tx.statement (toEncodedFailedInsert src dedupe event kafka errMsg now) recordFailedAttemptStmt  -- | Read one inbox row. lookupInbox :: (Store :> es) => Text -> Text -> Eff es (Maybe InboxRow) lookupInbox src dedupe =-    runTransaction $-        Tx.statement (src, dedupe) selectByKeyStmt+  runTransaction $+    Tx.statement (src, dedupe) selectByKeyStmt  -- | List inbox rows for a source, ordered by @received_at@. Test helper. listInbox :: (Store :> es) => Text -> Eff es [InboxRow] listInbox src =-    runTransaction $-        Tx.statement src listBySourceStmt--{- | Count inbox rows in a non-terminal state (backlog gauge source).+  runTransaction $+    Tx.statement src listBySourceStmt -Backlog = rows still @processing@ (in flight) or @failed@ (awaiting a-retry decision). Completed rows are terminal and excluded.--}+-- | Count inbox rows in a non-terminal state (backlog gauge source).+--+-- Backlog = rows still @processing@ (in flight) or @failed@ (awaiting a+-- retry decision). Completed rows are terminal and excluded. countInboxBacklog :: (Store :> es) => Eff es Int countInboxBacklog =-    runTransaction (Tx.statement () countInboxBacklogStmt)--{- | Delete completed inbox rows older than @keepFor@ from @now@.+  runTransaction (Tx.statement () countInboxBacklogStmt) -Returns the number of rows deleted. The retention window defines the-duplicate-detection window: a redelivery that arrives after retention-GC has run will be processed again, so the window must exceed the-maximum delivery delay tolerated by the operator. The user guide-recommends 30 days as a default. See 'tryInsertCompletedTx' for the related-concurrent-GC race and its at-least-once consequence.--}+-- | Delete completed inbox rows older than @keepFor@ from @now@.+--+-- Returns the number of rows deleted. The retention window defines the+-- duplicate-detection window: a redelivery that arrives after retention+-- GC has run will be processed again, so the window must exceed the+-- maximum delivery delay tolerated by the operator. The user guide+-- recommends 30 days as a default. See 'tryInsertCompletedTx' for the related+-- concurrent-GC race and its at-least-once consequence. garbageCollectCompleted ::-    (Store :> es) =>-    NominalDiffTime ->-    UTCTime ->-    Eff es Int+  (Store :> es) =>+  NominalDiffTime ->+  UTCTime ->+  Eff es Int garbageCollectCompleted keepFor now = do-    let cutoff = addUTCTime (negate keepFor) now-    result <--        runTransaction $-            Tx.statement cutoff gcStmt-    pure (fromIntegral result)+  let cutoff = addUTCTime (negate keepFor) now+  result <-+    runTransaction $+      Tx.statement cutoff gcStmt+  pure (fromIntegral result)  -- --------------------------------------------------------------------------- -- Encoder support -- ---------------------------------------------------------------------------  data EncodedInsert = EncodedInsert-    { source :: !Text-    , dedupeKey :: !Text-    , messageId :: !(Maybe Text)-    , sourceEventId :: !(Maybe UUID)-    , sourceGlobalPosition :: !(Maybe Int64)-    , destination :: !(Maybe Text)-    , eventType :: !(Maybe Text)-    , schemaVersion :: !(Maybe Int64)-    , contentType :: !Text-    , schemaRegistry :: !(Maybe Text)-    , schemaSubject :: !(Maybe Text)-    , schemaVersionRef :: !(Maybe Int64)-    , schemaId :: !(Maybe Int64)-    , schemaFingerprint :: !(Maybe Text)-    , causationId :: !(Maybe UUID)-    , correlationId :: !(Maybe UUID)-    , traceparent :: !(Maybe Text)-    , tracestate :: !(Maybe Text)-    , kafkaTopic :: !(Maybe Text)-    , kafkaPartition :: !(Maybe Int64)-    , kafkaOffset :: !(Maybe Int64)-    , payloadBytes :: !ByteString-    , attributes :: !(Maybe Value)-    , occurredAt :: !(Maybe UTCTime)-    , receivedAt :: !UTCTime-    }-    deriving stock (Generic)+  { source :: !Text,+    dedupeKey :: !Text,+    messageId :: !(Maybe Text),+    sourceEventId :: !(Maybe UUID),+    sourceGlobalPosition :: !(Maybe Int64),+    destination :: !(Maybe Text),+    eventType :: !(Maybe Text),+    schemaVersion :: !(Maybe Int64),+    contentType :: !Text,+    schemaRegistry :: !(Maybe Text),+    schemaSubject :: !(Maybe Text),+    schemaVersionRef :: !(Maybe Int64),+    schemaId :: !(Maybe Int64),+    schemaFingerprint :: !(Maybe Text),+    causationId :: !(Maybe UUID),+    correlationId :: !(Maybe UUID),+    traceparent :: !(Maybe Text),+    tracestate :: !(Maybe Text),+    kafkaTopic :: !(Maybe Text),+    kafkaPartition :: !(Maybe Int64),+    kafkaOffset :: !(Maybe Int64),+    payloadBytes :: !ByteString,+    attributes :: !(Maybe Value),+    occurredAt :: !(Maybe UTCTime),+    receivedAt :: !UTCTime+  }+  deriving stock (Generic)  data EncodedFailedInsert = EncodedFailedInsert-    { insert :: !EncodedInsert-    , lastError :: !Text-    }-    deriving stock (Generic)+  { insert :: !EncodedInsert,+    lastError :: !Text+  }+  deriving stock (Generic)  toEncodedInsert ::-    InboxPersistence ->-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    UTCTime ->-    EncodedInsert+  InboxPersistence ->+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  UTCTime ->+  EncodedInsert toEncodedInsert persistence src dedupe event kafka now =-    let mref = event ^. #schemaReference-        mtrace = event ^. #traceContext-     in EncodedInsert-            { source = src-            , dedupeKey = dedupe-            , messageId = nullIfEmpty (event ^. #messageId)-            , sourceEventId = fmap unEventId (event ^. #sourceEventId)-            , sourceGlobalPosition = fmap unGlobalPosition (event ^. #sourceGlobalPosition)-            , destination = nullIfEmpty (event ^. #destination)-            , eventType = nullIfEmpty (event ^. #eventType)-            , schemaVersion = persistedSchema (Just (fromIntegral (event ^. #schemaVersion)))-            , contentType = contentTypeText (event ^. #contentType)-            , schemaRegistry = persistedSchema (mref >>= (^. #registry))-            , schemaSubject = persistedSchema (mref >>= (^. #subject))-            , schemaVersionRef = persistedSchema (fmap fromIntegral (mref >>= (^. #version)))-            , schemaId = persistedSchema (mref >>= (^. #schemaId))-            , schemaFingerprint = persistedSchema (mref >>= (^. #fingerprint))-            , causationId = fmap unEventId (event ^. #causationId)-            , correlationId = fmap unEventId (event ^. #correlationId)-            , traceparent = persistedEnvelope (fmap (^. #traceparent) mtrace)-            , tracestate = persistedEnvelope (mtrace >>= (^. #tracestate))-            , kafkaTopic = fmap (^. #topic) kafka-            , kafkaPartition = fmap (^. #partition) kafka-            , kafkaOffset = fmap (^. #offset) kafka-            , payloadBytes = case persistence of-                PersistFullEnvelope -> event ^. #payloadBytes-                PersistDedupeOnly -> mempty-            , attributes = persistedEnvelope (event ^. #attributes)-            , occurredAt = Just (event ^. #occurredAt)-            , receivedAt = now-            }+  let mref = event ^. #schemaReference+      mtrace = event ^. #traceContext+   in EncodedInsert+        { source = src,+          dedupeKey = dedupe,+          messageId = nullIfEmpty (event ^. #messageId),+          sourceEventId = fmap unEventId (event ^. #sourceEventId),+          sourceGlobalPosition = fmap unGlobalPosition (event ^. #sourceGlobalPosition),+          destination = nullIfEmpty (event ^. #destination),+          eventType = nullIfEmpty (event ^. #eventType),+          schemaVersion = persistedSchema (Just (fromIntegral (event ^. #schemaVersion))),+          contentType = contentTypeText (event ^. #contentType),+          schemaRegistry = persistedSchema (mref >>= (^. #registry)),+          schemaSubject = persistedSchema (mref >>= (^. #subject)),+          schemaVersionRef = persistedSchema (fmap fromIntegral (mref >>= (^. #version))),+          schemaId = persistedSchema (mref >>= (^. #schemaId)),+          schemaFingerprint = persistedSchema (mref >>= (^. #fingerprint)),+          causationId = fmap unEventId (event ^. #causationId),+          correlationId = fmap unEventId (event ^. #correlationId),+          traceparent = persistedEnvelope (fmap (^. #traceparent) mtrace),+          tracestate = persistedEnvelope (mtrace >>= (^. #tracestate)),+          kafkaTopic = fmap (^. #topic) kafka,+          kafkaPartition = fmap (^. #partition) kafka,+          kafkaOffset = fmap (^. #offset) kafka,+          payloadBytes = case persistence of+            PersistFullEnvelope -> event ^. #payloadBytes+            PersistDedupeOnly -> mempty,+          attributes = persistedEnvelope (event ^. #attributes),+          occurredAt = Just (event ^. #occurredAt),+          receivedAt = now+        }   where     persistedEnvelope :: Maybe x -> Maybe x     persistedEnvelope value = case persistence of-        PersistFullEnvelope -> value-        PersistDedupeOnly -> Nothing+      PersistFullEnvelope -> value+      PersistDedupeOnly -> Nothing      persistedSchema :: Maybe x -> Maybe x     persistedSchema = persistedEnvelope  toEncodedFailedInsert ::-    Text ->-    Text ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    Text ->-    UTCTime ->-    EncodedFailedInsert+  Text ->+  Text ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  Text ->+  UTCTime ->+  EncodedFailedInsert toEncodedFailedInsert src dedupe event kafka errMsg now =-    EncodedFailedInsert-        { insert = toEncodedInsert PersistFullEnvelope src dedupe event kafka now-        , lastError = errMsg-        }+  EncodedFailedInsert+    { insert = toEncodedInsert PersistFullEnvelope src dedupe event kafka now,+      lastError = errMsg+    }  nullIfEmpty :: Text -> Maybe Text nullIfEmpty t = if t == mempty then Nothing else Just t@@ -261,38 +256,38 @@  encodedInsertEncoder :: E.Params EncodedInsert encodedInsertEncoder =-    mconcat-        [ view #source >$< E.param (E.nonNullable E.text)-        , view #dedupeKey >$< E.param (E.nonNullable E.text)-        , view #messageId >$< E.param (E.nullable E.text)-        , view #sourceEventId >$< E.param (E.nullable E.uuid)-        , view #sourceGlobalPosition >$< E.param (E.nullable E.int8)-        , view #destination >$< E.param (E.nullable E.text)-        , view #eventType >$< E.param (E.nullable E.text)-        , view #schemaVersion >$< E.param (E.nullable E.int8)-        , view #contentType >$< E.param (E.nonNullable E.text)-        , view #schemaRegistry >$< E.param (E.nullable E.text)-        , view #schemaSubject >$< E.param (E.nullable E.text)-        , view #schemaVersionRef >$< E.param (E.nullable E.int8)-        , view #schemaId >$< E.param (E.nullable E.int8)-        , view #schemaFingerprint >$< E.param (E.nullable E.text)-        , view #causationId >$< E.param (E.nullable E.uuid)-        , view #correlationId >$< E.param (E.nullable E.uuid)-        , view #traceparent >$< E.param (E.nullable E.text)-        , view #tracestate >$< E.param (E.nullable E.text)-        , view #kafkaTopic >$< E.param (E.nullable E.text)-        , view #kafkaPartition >$< E.param (E.nullable E.int8)-        , view #kafkaOffset >$< E.param (E.nullable E.int8)-        , view #payloadBytes >$< E.param (E.nonNullable E.bytea)-        , view #attributes >$< E.param (E.nullable E.jsonb)-        , view #occurredAt >$< E.param (E.nullable E.timestamptz)-        , view #receivedAt >$< E.param (E.nonNullable E.timestamptz)-        ]+  mconcat+    [ view #source >$< E.param (E.nonNullable E.text),+      view #dedupeKey >$< E.param (E.nonNullable E.text),+      view #messageId >$< E.param (E.nullable E.text),+      view #sourceEventId >$< E.param (E.nullable E.uuid),+      view #sourceGlobalPosition >$< E.param (E.nullable E.int8),+      view #destination >$< E.param (E.nullable E.text),+      view #eventType >$< E.param (E.nullable E.text),+      view #schemaVersion >$< E.param (E.nullable E.int8),+      view #contentType >$< E.param (E.nonNullable E.text),+      view #schemaRegistry >$< E.param (E.nullable E.text),+      view #schemaSubject >$< E.param (E.nullable E.text),+      view #schemaVersionRef >$< E.param (E.nullable E.int8),+      view #schemaId >$< E.param (E.nullable E.int8),+      view #schemaFingerprint >$< E.param (E.nullable E.text),+      view #causationId >$< E.param (E.nullable E.uuid),+      view #correlationId >$< E.param (E.nullable E.uuid),+      view #traceparent >$< E.param (E.nullable E.text),+      view #tracestate >$< E.param (E.nullable E.text),+      view #kafkaTopic >$< E.param (E.nullable E.text),+      view #kafkaPartition >$< E.param (E.nullable E.int8),+      view #kafkaOffset >$< E.param (E.nullable E.int8),+      view #payloadBytes >$< E.param (E.nonNullable E.bytea),+      view #attributes >$< E.param (E.nullable E.jsonb),+      view #occurredAt >$< E.param (E.nullable E.timestamptz),+      view #receivedAt >$< E.param (E.nonNullable E.timestamptz)+    ]  encodedFailedInsertEncoder :: E.Params EncodedFailedInsert encodedFailedInsertEncoder =-    (view #insert >$< encodedInsertEncoder)-        <> (view #lastError >$< E.param (E.nonNullable E.text))+  (view #insert >$< encodedInsertEncoder)+    <> (view #lastError >$< E.param (E.nonNullable E.text))  -- --------------------------------------------------------------------------- -- Statements@@ -300,314 +295,313 @@  tryInsertStmt :: Statement EncodedInsert Bool tryInsertStmt =-    preparable-        """-        INSERT INTO keiro.keiro_inbox-          ( source-          , dedupe_key-          , message_id-          , source_event_id-          , source_global_position-          , destination-          , event_type-          , schema_version-          , content_type-          , schema_registry-          , schema_subject-          , schema_version_ref-          , schema_id-          , schema_fingerprint-          , causation_id-          , correlation_id-          , traceparent-          , tracestate-          , kafka_topic-          , kafka_partition-          , kafka_offset-          , payload_bytes-          , attributes-          , occurred_at-          , received_at-          , status-          , completed_at-          )-        VALUES-          ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'completed', $25)-        ON CONFLICT (source, dedupe_key) DO NOTHING-        RETURNING TRUE-        """-        encodedInsertEncoder-        (fmap (fromMaybe False) (D.rowMaybe (D.column (D.nonNullable D.bool))))+  preparable+    """+    INSERT INTO keiro.keiro_inbox+      ( source+      , dedupe_key+      , message_id+      , source_event_id+      , source_global_position+      , destination+      , event_type+      , schema_version+      , content_type+      , schema_registry+      , schema_subject+      , schema_version_ref+      , schema_id+      , schema_fingerprint+      , causation_id+      , correlation_id+      , traceparent+      , tracestate+      , kafka_topic+      , kafka_partition+      , kafka_offset+      , payload_bytes+      , attributes+      , occurred_at+      , received_at+      , status+      , completed_at+      )+    VALUES+      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'completed', $25)+    ON CONFLICT (source, dedupe_key) DO NOTHING+    RETURNING TRUE+    """+    encodedInsertEncoder+    (fmap (fromMaybe False) (D.rowMaybe (D.column (D.nonNullable D.bool))))  markCompletedStmt :: Statement (Text, Text, UTCTime) () markCompletedStmt =-    preparable-        """-        UPDATE keiro.keiro_inbox-        SET status = 'completed',-            completed_at = $3,-            last_error = NULL-        WHERE source = $1 AND dedupe_key = $2-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_inbox+    SET status = 'completed',+        completed_at = $3,+        last_error = NULL+    WHERE source = $1 AND dedupe_key = $2+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult  markFailedStmt :: Statement (Text, Text, Text, UTCTime) () markFailedStmt =-    preparable-        """-        UPDATE keiro.keiro_inbox-        SET status = 'failed',-            failed_at = $4,-            last_error = $3-        WHERE source = $1 AND dedupe_key = $2-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_inbox+    SET status = 'failed',+        failed_at = $4,+        last_error = $3+    WHERE source = $1 AND dedupe_key = $2+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult  recordFailedAttemptStmt :: Statement EncodedFailedInsert Int recordFailedAttemptStmt =-    preparable-        """-        INSERT INTO keiro.keiro_inbox-          ( source-          , dedupe_key-          , message_id-          , source_event_id-          , source_global_position-          , destination-          , event_type-          , schema_version-          , content_type-          , schema_registry-          , schema_subject-          , schema_version_ref-          , schema_id-          , schema_fingerprint-          , causation_id-          , correlation_id-          , traceparent-          , tracestate-          , kafka_topic-          , kafka_partition-          , kafka_offset-          , payload_bytes-          , attributes-          , occurred_at-          , received_at-          , status-          , attempt_count-          , failed_at-          , last_error-          )-        VALUES-          ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'failed', 1, $25, $26)-        ON CONFLICT (source, dedupe_key) DO UPDATE-        SET status = 'failed',-            attempt_count = keiro_inbox.attempt_count + 1,-            last_error = EXCLUDED.last_error,-            failed_at = EXCLUDED.failed_at-        RETURNING attempt_count-        """-        encodedFailedInsertEncoder-        (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))+  preparable+    """+    INSERT INTO keiro.keiro_inbox+      ( source+      , dedupe_key+      , message_id+      , source_event_id+      , source_global_position+      , destination+      , event_type+      , schema_version+      , content_type+      , schema_registry+      , schema_subject+      , schema_version_ref+      , schema_id+      , schema_fingerprint+      , causation_id+      , correlation_id+      , traceparent+      , tracestate+      , kafka_topic+      , kafka_partition+      , kafka_offset+      , payload_bytes+      , attributes+      , occurred_at+      , received_at+      , status+      , attempt_count+      , failed_at+      , last_error+      )+    VALUES+      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'failed', 1, $25, $26)+    ON CONFLICT (source, dedupe_key) DO UPDATE+    SET status = 'failed',+        attempt_count = keiro_inbox.attempt_count + 1,+        last_error = EXCLUDED.last_error,+        failed_at = EXCLUDED.failed_at+    RETURNING attempt_count+    """+    encodedFailedInsertEncoder+    (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))  selectByKeyStmt :: Statement (Text, Text) (Maybe InboxRow) selectByKeyStmt =-    preparable-        (selectAllSql <> " WHERE source = $1 AND dedupe_key = $2")-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.rowMaybe inboxRowDecoder)+  preparable+    (selectAllSql <> " WHERE source = $1 AND dedupe_key = $2")+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe inboxRowDecoder)  listBySourceStmt :: Statement Text [InboxRow] listBySourceStmt =-    preparable-        (selectAllSql <> " WHERE source = $1 ORDER BY received_at, dedupe_key")-        (E.param (E.nonNullable E.text))-        (D.rowList inboxRowDecoder)+  preparable+    (selectAllSql <> " WHERE source = $1 ORDER BY received_at, dedupe_key")+    (E.param (E.nonNullable E.text))+    (D.rowList inboxRowDecoder)  countInboxBacklogStmt :: Statement () Int countInboxBacklogStmt =-    preparable-        "SELECT COUNT(*)::bigint FROM keiro.keiro_inbox WHERE status IN ('processing', 'failed')"-        E.noParams-        (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))+  preparable+    "SELECT COUNT(*)::bigint FROM keiro.keiro_inbox WHERE status IN ('processing', 'failed')"+    E.noParams+    (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))  gcStmt :: Statement UTCTime Int64 gcStmt =-    preparable-        """-        WITH deleted AS (-          DELETE FROM keiro.keiro_inbox-          WHERE status = 'completed' AND completed_at < $1-          RETURNING 1-        )-        SELECT COALESCE(COUNT(*), 0)::bigint FROM deleted-        """-        (E.param (E.nonNullable E.timestamptz))-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    """+    WITH deleted AS (+      DELETE FROM keiro.keiro_inbox+      WHERE status = 'completed' AND completed_at < $1+      RETURNING 1+    )+    SELECT COALESCE(COUNT(*), 0)::bigint FROM deleted+    """+    (E.param (E.nonNullable E.timestamptz))+    (D.singleRow (D.column (D.nonNullable D.int8)))  selectAllSql :: Text selectAllSql =-    """-    SELECT source, dedupe_key, message_id, source_event_id, source_global_position,-           destination, event_type, schema_version, content_type, schema_registry,-           schema_subject, schema_version_ref, schema_id, schema_fingerprint,-           causation_id, correlation_id, traceparent, tracestate, kafka_topic,-           kafka_partition, kafka_offset, payload_bytes, attributes, occurred_at,-           status, attempt_count, received_at, completed_at, failed_at, last_error-    FROM keiro.keiro_inbox-    """+  """+  SELECT source, dedupe_key, message_id, source_event_id, source_global_position,+         destination, event_type, schema_version, content_type, schema_registry,+         schema_subject, schema_version_ref, schema_id, schema_fingerprint,+         causation_id, correlation_id, traceparent, tracestate, kafka_topic,+         kafka_partition, kafka_offset, payload_bytes, attributes, occurred_at,+         status, attempt_count, received_at, completed_at, failed_at, last_error+  FROM keiro.keiro_inbox+  """  inboxRowDecoder :: D.Row InboxRow inboxRowDecoder = fmap assembleInboxRow rawDecoder  data RawInbox = RawInbox-    { source :: !Text-    , dedupeKey :: !Text-    , messageId :: !(Maybe Text)-    , sourceEventId :: !(Maybe EventId)-    , sourceGlobalPosition :: !(Maybe GlobalPosition)-    , destination :: !(Maybe Text)-    , eventType :: !(Maybe Text)-    , schemaVersion :: !(Maybe Int)-    , contentType :: !Text-    , schemaRegistry :: !(Maybe Text)-    , schemaSubject :: !(Maybe Text)-    , schemaVersionRef :: !(Maybe Int)-    , schemaId :: !(Maybe Int64)-    , schemaFingerprint :: !(Maybe Text)-    , causationId :: !(Maybe EventId)-    , correlationId :: !(Maybe EventId)-    , traceparent :: !(Maybe Text)-    , tracestate :: !(Maybe Text)-    , kafkaTopic :: !(Maybe Text)-    , kafkaPartition :: !(Maybe Int64)-    , kafkaOffset :: !(Maybe Int64)-    , payloadBytes :: !ByteString-    , attributes :: !(Maybe Value)-    , occurredAt :: !(Maybe UTCTime)-    , status :: !InboxStatus-    , attemptCount :: !Int-    , receivedAt :: !UTCTime-    , completedAt :: !(Maybe UTCTime)-    , failedAt :: !(Maybe UTCTime)-    , lastError :: !(Maybe Text)-    }-    deriving stock (Generic)+  { source :: !Text,+    dedupeKey :: !Text,+    messageId :: !(Maybe Text),+    sourceEventId :: !(Maybe EventId),+    sourceGlobalPosition :: !(Maybe GlobalPosition),+    destination :: !(Maybe Text),+    eventType :: !(Maybe Text),+    schemaVersion :: !(Maybe Int),+    contentType :: !Text,+    schemaRegistry :: !(Maybe Text),+    schemaSubject :: !(Maybe Text),+    schemaVersionRef :: !(Maybe Int),+    schemaId :: !(Maybe Int64),+    schemaFingerprint :: !(Maybe Text),+    causationId :: !(Maybe EventId),+    correlationId :: !(Maybe EventId),+    traceparent :: !(Maybe Text),+    tracestate :: !(Maybe Text),+    kafkaTopic :: !(Maybe Text),+    kafkaPartition :: !(Maybe Int64),+    kafkaOffset :: !(Maybe Int64),+    payloadBytes :: !ByteString,+    attributes :: !(Maybe Value),+    occurredAt :: !(Maybe UTCTime),+    status :: !InboxStatus,+    attemptCount :: !Int,+    receivedAt :: !UTCTime,+    completedAt :: !(Maybe UTCTime),+    failedAt :: !(Maybe UTCTime),+    lastError :: !(Maybe Text)+  }+  deriving stock (Generic)  rawDecoder :: D.Row RawInbox rawDecoder =-    RawInbox-        <$> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nullable D.text)-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))-        <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.text)-        <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.text)-        <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))-        <*> D.column (D.nullable D.int8)-        <*> D.column (D.nullable D.text)-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.int8)-        <*> D.column (D.nullable D.int8)-        <*> D.column (D.nonNullable D.bytea)-        <*> D.column (D.nullable D.jsonb)-        <*> D.column (D.nullable D.timestamptz)-        <*> D.column (D.nonNullable (D.refine parseInboxStatus D.text))-        <*> (fromIntegral <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)-        <*> D.column (D.nullable D.text)+  RawInbox+    <$> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.text)+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))+    <*> D.column (D.nullable D.int8)+    <*> D.column (D.nullable D.text)+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.int8)+    <*> D.column (D.nullable D.int8)+    <*> D.column (D.nonNullable D.bytea)+    <*> D.column (D.nullable D.jsonb)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nonNullable (D.refine parseInboxStatus D.text))+    <*> (fromIntegral <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.text)  assembleInboxRow :: RawInbox -> InboxRow assembleInboxRow raw =-    let traceContext = case raw ^. #traceparent of-            Nothing -> Nothing-            Just tp -> Just (TraceContext tp (raw ^. #tracestate))-        schemaReference =-            case ( raw ^. #schemaRegistry-                 , raw ^. #schemaSubject-                 , raw ^. #schemaVersionRef-                 , raw ^. #schemaId-                 , raw ^. #schemaFingerprint-                 ) of-                (Nothing, Nothing, Nothing, Nothing, Nothing) -> Nothing-                _ ->-                    Just-                        ( SchemaReference-                            (raw ^. #schemaRegistry)-                            (raw ^. #schemaSubject)-                            (raw ^. #schemaVersionRef)-                            (raw ^. #schemaId)-                            (raw ^. #schemaFingerprint)-                        )-        kafka =-            case (raw ^. #kafkaTopic, raw ^. #kafkaPartition, raw ^. #kafkaOffset) of-                (Just t, Just p, Just o) -> Just (KafkaDeliveryRef t p o)-                _ -> Nothing-        event =-            IntegrationEvent-                { messageId = fromMaybe mempty (raw ^. #messageId)-                , source = raw ^. #source-                , destination = fromMaybe mempty (raw ^. #destination)-                , key = Nothing-                , -- @key@ is not part of the inbox primary key and is not-                  -- carried separately on the row — the same partition info-                  -- lives in @kafka@. Receivers that need it can re-derive-                  -- from the payload.-                  eventType = fromMaybe mempty (raw ^. #eventType)-                , schemaVersion = fromMaybe 0 (raw ^. #schemaVersion)-                , contentType = parseContentType (raw ^. #contentType)-                , schemaReference-                , sourceEventId = raw ^. #sourceEventId-                , sourceGlobalPosition = raw ^. #sourceGlobalPosition-                , payloadBytes = raw ^. #payloadBytes-                , occurredAt = fromMaybe defaultEpoch (raw ^. #occurredAt)-                , causationId = raw ^. #causationId-                , correlationId = raw ^. #correlationId-                , traceContext-                , attributes = raw ^. #attributes-                }-     in InboxRow-            { source = raw ^. #source-            , dedupeKey = raw ^. #dedupeKey-            , event-            , kafka-            , status = raw ^. #status-            , attemptCount = raw ^. #attemptCount-            , receivedAt = raw ^. #receivedAt-            , completedAt = raw ^. #completedAt-            , failedAt = raw ^. #failedAt-            , lastError = raw ^. #lastError-            }--{- | Sentinel used when an inbox row was written without an @occurred_at@.+  let traceContext = case raw ^. #traceparent of+        Nothing -> Nothing+        Just tp -> Just (TraceContext tp (raw ^. #tracestate))+      schemaReference =+        case ( raw ^. #schemaRegistry,+               raw ^. #schemaSubject,+               raw ^. #schemaVersionRef,+               raw ^. #schemaId,+               raw ^. #schemaFingerprint+             ) of+          (Nothing, Nothing, Nothing, Nothing, Nothing) -> Nothing+          _ ->+            Just+              ( SchemaReference+                  (raw ^. #schemaRegistry)+                  (raw ^. #schemaSubject)+                  (raw ^. #schemaVersionRef)+                  (raw ^. #schemaId)+                  (raw ^. #schemaFingerprint)+              )+      kafka =+        case (raw ^. #kafkaTopic, raw ^. #kafkaPartition, raw ^. #kafkaOffset) of+          (Just t, Just p, Just o) -> Just (KafkaDeliveryRef t p o)+          _ -> Nothing+      event =+        IntegrationEvent+          { messageId = fromMaybe mempty (raw ^. #messageId),+            source = raw ^. #source,+            destination = fromMaybe mempty (raw ^. #destination),+            key = Nothing,+            -- @key@ is not part of the inbox primary key and is not+            -- carried separately on the row — the same partition info+            -- lives in @kafka@. Receivers that need it can re-derive+            -- from the payload.+            eventType = fromMaybe mempty (raw ^. #eventType),+            schemaVersion = fromMaybe 0 (raw ^. #schemaVersion),+            contentType = parseContentType (raw ^. #contentType),+            schemaReference,+            sourceEventId = raw ^. #sourceEventId,+            sourceGlobalPosition = raw ^. #sourceGlobalPosition,+            payloadBytes = raw ^. #payloadBytes,+            occurredAt = fromMaybe defaultEpoch (raw ^. #occurredAt),+            causationId = raw ^. #causationId,+            correlationId = raw ^. #correlationId,+            traceContext,+            attributes = raw ^. #attributes+          }+   in InboxRow+        { source = raw ^. #source,+          dedupeKey = raw ^. #dedupeKey,+          event,+          kafka,+          status = raw ^. #status,+          attemptCount = raw ^. #attemptCount,+          receivedAt = raw ^. #receivedAt,+          completedAt = raw ^. #completedAt,+          failedAt = raw ^. #failedAt,+          lastError = raw ^. #lastError+        } -Inbox rows are observability-shaped; consumers that care should-re-read the timestamp from the envelope payload directly.--}+-- | Sentinel used when an inbox row was written without an @occurred_at@.+--+-- Inbox rows are observability-shaped; consumers that care should+-- re-read the timestamp from the envelope payload directly. defaultEpoch :: UTCTime defaultEpoch = posixSecondsToUTCTime 0
src/Keiro/Inbox/Types.hs view
@@ -1,14 +1,13 @@-{- | Shared types for the idempotent integration-event inbox.--The inbox lives in the consuming bounded context. When a Kafka consumer-receives an integration event, the inbox records a stable external-identity for that message and runs the local handler in the same-transaction. Duplicate redeliveries (Kafka offset retry, rebalance,-producer republish) become observable as duplicates instead of-re-running the handler.--}-module Keiro.Inbox.Types (-    InboxDedupePolicy (..),+-- | Shared types for the idempotent integration-event inbox.+--+-- The inbox lives in the consuming bounded context. When a Kafka consumer+-- receives an integration event, the inbox records a stable external+-- identity for that message and runs the local handler in the same+-- transaction. Duplicate redeliveries (Kafka offset retry, rebalance,+-- producer republish) become observable as duplicates instead of+-- re-running the handler.+module Keiro.Inbox.Types+  ( InboxDedupePolicy (..),     InboxPersistence (..),     InboxStatus (..),     InboxResult (..),@@ -18,7 +17,7 @@     inboxStatusText,     parseInboxStatus,     dedupeKeyFor,-)+  ) where  import Data.Text qualified as Text@@ -27,172 +26,164 @@ import Keiro.Prelude import Kiroku.Store.Types (EventId (..), GlobalPosition (..)) -{- | Which identity is used as the inbox primary key for an-'IntegrationEvent'.--* 'PreferIntegrationMessageId' (default) — use the application-level-  @messageId@ minted at the producer's outbox enqueue. EP-19 / EP-20-  keep this id stable across publish retries, so it is the natural-  primary dedupe key for Kafka-delivered events.-* 'PreferSourceEventIdentity' — use the @sourceEventId@ of the private-  event that produced this integration event. Useful when a producer-  may emit the same logical fact under different @messageId@s (e.g.-  schema-upgrade republish), and the consumer wants those republishes-  collapsed to a single handler run.-* 'KafkaDeliveryIdentity' — use the Kafka topic-partition-offset triple-  as the dedupe key. Fallback only when neither @messageId@ nor source-  identity is available. This identifies one broker delivery, not one-  logical producer message: if a producer republishes the same logical-  message, Kafka assigns a new offset and this policy will not collapse-  the republish.-* 'CustomDedupeKey' — caller supplies the key. Use only when the other-  policies cannot represent the identity scheme; the consuming service-  owns key collision resistance.--}+-- | Which identity is used as the inbox primary key for an+-- 'IntegrationEvent'.+--+-- * 'PreferIntegrationMessageId' (default) — use the application-level+--   @messageId@ minted at the producer's outbox enqueue. EP-19 / EP-20+--   keep this id stable across publish retries, so it is the natural+--   primary dedupe key for Kafka-delivered events.+-- * 'PreferSourceEventIdentity' — use the @sourceEventId@ of the private+--   event that produced this integration event. Useful when a producer+--   may emit the same logical fact under different @messageId@s (e.g.+--   schema-upgrade republish), and the consumer wants those republishes+--   collapsed to a single handler run.+-- * 'KafkaDeliveryIdentity' — use the Kafka topic-partition-offset triple+--   as the dedupe key. Fallback only when neither @messageId@ nor source+--   identity is available. This identifies one broker delivery, not one+--   logical producer message: if a producer republishes the same logical+--   message, Kafka assigns a new offset and this policy will not collapse+--   the republish.+-- * 'CustomDedupeKey' — caller supplies the key. Use only when the other+--   policies cannot represent the identity scheme; the consuming service+--   owns key collision resistance. data InboxDedupePolicy-    = PreferIntegrationMessageId-    | PreferSourceEventIdentity-    | KafkaDeliveryIdentity-    | CustomDedupeKey !Text-    deriving stock (Generic, Eq, Show)--{- | How much of the integration-event envelope the inbox persists on the-success path.+  = PreferIntegrationMessageId+  | PreferSourceEventIdentity+  | KafkaDeliveryIdentity+  | CustomDedupeKey !Text+  deriving stock (Generic, Eq, Show) -The failure path always persists the full envelope because a failed-inbox row is the operator's dead-letter record.--}+-- | How much of the integration-event envelope the inbox persists on the+-- success path.+--+-- The failure path always persists the full envelope because a failed+-- inbox row is the operator's dead-letter record. data InboxPersistence-    = PersistFullEnvelope-    | PersistDedupeOnly-    deriving stock (Generic, Eq, Show)--{- | Lifecycle state of an inbox row.+  = PersistFullEnvelope+  | PersistDedupeOnly+  deriving stock (Generic, Eq, Show) -* 'InboxProcessing' — legacy on-disk state from older wrappers and-  reserved for future async paths. Current single-transaction intake-  inserts fresh successful rows directly as 'InboxCompleted'.-* 'InboxCompleted' — handler ran to completion; terminal.-* 'InboxFailed' — handler signaled a permanent failure; terminal. The-  caller is responsible for operator action (dead-letter, manual-  retry).--}+-- | Lifecycle state of an inbox row.+--+-- * 'InboxProcessing' — legacy on-disk state from older wrappers and+--   reserved for future async paths. Current single-transaction intake+--   inserts fresh successful rows directly as 'InboxCompleted'.+-- * 'InboxCompleted' — handler ran to completion; terminal.+-- * 'InboxFailed' — handler signaled a permanent failure; terminal. The+--   caller is responsible for operator action (dead-letter, manual+--   retry). data InboxStatus-    = InboxProcessing-    | InboxCompleted-    | InboxFailed-    deriving stock (Generic, Eq, Show)--{- | The classified outcome of 'Keiro.Inbox.runInboxTransaction'.+  = InboxProcessing+  | InboxCompleted+  | InboxFailed+  deriving stock (Generic, Eq, Show) -* 'InboxProcessed a' — first delivery; handler ran and returned @a@.-* 'InboxDuplicate' — a previous delivery already completed; handler not-  run.-* 'InboxInProgress' — a previous attempt is currently in-flight, or a-  legacy @processing@ row was read. Current single-transaction intake-  does not commit @processing@ rows. Treat as transient.-* 'InboxPreviouslyFailed' — a previous attempt recorded a permanent-  failure. Operator should review before reprocessing.--}+-- | The classified outcome of 'Keiro.Inbox.runInboxTransaction'.+--+-- * 'InboxProcessed a' — first delivery; handler ran and returned @a@.+-- * 'InboxDuplicate' — a previous delivery already completed; handler not+--   run.+-- * 'InboxInProgress' — a previous attempt is currently in-flight, or a+--   legacy @processing@ row was read. Current single-transaction intake+--   does not commit @processing@ rows. Treat as transient.+-- * 'InboxPreviouslyFailed' — a previous attempt recorded a permanent+--   failure. Operator should review before reprocessing. data InboxResult a-    = InboxProcessed !a-    | InboxDuplicate-    | InboxInProgress-    | InboxPreviouslyFailed !(Maybe Text)-    | InboxHandlerFailed !Text !Int-    deriving stock (Generic, Eq, Show)+  = InboxProcessed !a+  | InboxDuplicate+  | InboxInProgress+  | InboxPreviouslyFailed !(Maybe Text)+  | InboxHandlerFailed !Text !Int+  deriving stock (Generic, Eq, Show) -{- | Errors surfaced by the inbox wrapper that originate from the inbox-itself rather than from the supplied handler.--}+-- | Errors surfaced by the inbox wrapper that originate from the inbox+-- itself rather than from the supplied handler. data InboxError-    = -- | The integration event lacked the field required by the chosen policy.-      DedupePolicyUnsatisfied !InboxDedupePolicy-    deriving stock (Generic, Eq, Show)--{- | Optional Kafka-delivery metadata recorded alongside an inbox row.+  = -- | The integration event lacked the field required by the chosen policy.+    DedupePolicyUnsatisfied !InboxDedupePolicy+  deriving stock (Generic, Eq, Show) -Used by 'KafkaDeliveryIdentity' to compute the dedupe key, and stored on-the row regardless of policy so operators can correlate the inbox-record with Kafka logs. Not part of the EP-19 envelope.--}+-- | Optional Kafka-delivery metadata recorded alongside an inbox row.+--+-- Used by 'KafkaDeliveryIdentity' to compute the dedupe key, and stored on+-- the row regardless of policy so operators can correlate the inbox+-- record with Kafka logs. Not part of the EP-19 envelope. data KafkaDeliveryRef = KafkaDeliveryRef-    { topic :: !Text-    , partition :: !Int64-    , offset :: !Int64-    }-    deriving stock (Generic, Eq, Show)--{- | One row read back from @keiro_inbox@.+  { topic :: !Text,+    partition :: !Int64,+    offset :: !Int64+  }+  deriving stock (Generic, Eq, Show) -Rows written with 'PersistDedupeOnly' decode with an empty-'IntegrationEvent' @payloadBytes@, no attributes, no trace context, and-no schema reference. Identity, routing, source-event ids, occurrence-time, and Kafka delivery metadata are still preserved.--}+-- | One row read back from @keiro_inbox@.+--+-- Rows written with 'PersistDedupeOnly' decode with an empty+-- 'IntegrationEvent' @payloadBytes@, no attributes, no trace context, and+-- no schema reference. Identity, routing, source-event ids, occurrence+-- time, and Kafka delivery metadata are still preserved. data InboxRow = InboxRow-    { source :: !Text-    , dedupeKey :: !Text-    , event :: !IntegrationEvent-    , kafka :: !(Maybe KafkaDeliveryRef)-    , status :: !InboxStatus-    , attemptCount :: !Int-    , receivedAt :: !UTCTime-    , completedAt :: !(Maybe UTCTime)-    , failedAt :: !(Maybe UTCTime)-    , lastError :: !(Maybe Text)-    }-    deriving stock (Generic, Eq, Show)+  { source :: !Text,+    dedupeKey :: !Text,+    event :: !IntegrationEvent,+    kafka :: !(Maybe KafkaDeliveryRef),+    status :: !InboxStatus,+    attemptCount :: !Int,+    receivedAt :: !UTCTime,+    completedAt :: !(Maybe UTCTime),+    failedAt :: !(Maybe UTCTime),+    lastError :: !(Maybe Text)+  }+  deriving stock (Generic, Eq, Show)  inboxStatusText :: InboxStatus -> Text inboxStatusText = \case-    InboxProcessing -> "processing"-    InboxCompleted -> "completed"-    InboxFailed -> "failed"+  InboxProcessing -> "processing"+  InboxCompleted -> "completed"+  InboxFailed -> "failed"  parseInboxStatus :: Text -> Either Text InboxStatus parseInboxStatus = \case-    "processing" -> Right InboxProcessing-    "completed" -> Right InboxCompleted-    "failed" -> Right InboxFailed-    other -> Left ("unknown keiro_inbox.status: " <> other)+  "processing" -> Right InboxProcessing+  "completed" -> Right InboxCompleted+  "failed" -> Right InboxFailed+  other -> Left ("unknown keiro_inbox.status: " <> other) -{- | Compute the inbox dedupe key for an integration event under the-given policy plus optional Kafka delivery context. Returns 'Left' when-the policy demands a field the envelope does not carry (for example,-'PreferSourceEventIdentity' on an envelope with no-@sourceEventId@).--}+-- | Compute the inbox dedupe key for an integration event under the+-- given policy plus optional Kafka delivery context. Returns 'Left' when+-- the policy demands a field the envelope does not carry (for example,+-- 'PreferSourceEventIdentity' on an envelope with no+-- @sourceEventId@). dedupeKeyFor ::-    InboxDedupePolicy ->-    IntegrationEvent ->-    Maybe KafkaDeliveryRef ->-    Either InboxError Text+  InboxDedupePolicy ->+  IntegrationEvent ->+  Maybe KafkaDeliveryRef ->+  Either InboxError Text dedupeKeyFor policy event kafka = case policy of-    PreferIntegrationMessageId ->-        let mid = event ^. #messageId-         in if Text.null mid-                then Left (DedupePolicyUnsatisfied policy)-                else Right mid-    PreferSourceEventIdentity ->-        case event ^. #sourceEventId of-            Just (EventId u) -> Right (UUID.toText u)-            Nothing ->-                case event ^. #sourceGlobalPosition of-                    Just (GlobalPosition p) -> Right (Text.pack (show p))-                    Nothing -> Left (DedupePolicyUnsatisfied policy)-    KafkaDeliveryIdentity ->-        case kafka of-            Just ref ->-                Right-                    ( (ref ^. #topic)-                        <> ":"-                        <> Text.pack (show (ref ^. #partition))-                        <> ":"-                        <> Text.pack (show (ref ^. #offset))-                    )-            Nothing -> Left (DedupePolicyUnsatisfied policy)-    CustomDedupeKey k ->-        if Text.null k-            then Left (DedupePolicyUnsatisfied policy)-            else Right k+  PreferIntegrationMessageId ->+    let mid = event ^. #messageId+     in if Text.null mid+          then Left (DedupePolicyUnsatisfied policy)+          else Right mid+  PreferSourceEventIdentity ->+    case event ^. #sourceEventId of+      Just (EventId u) -> Right (UUID.toText u)+      Nothing ->+        case event ^. #sourceGlobalPosition of+          Just (GlobalPosition p) -> Right (Text.pack (show p))+          Nothing -> Left (DedupePolicyUnsatisfied policy)+  KafkaDeliveryIdentity ->+    case kafka of+      Just ref ->+        Right+          ( (ref ^. #topic)+              <> ":"+              <> Text.pack (show (ref ^. #partition))+              <> ":"+              <> Text.pack (show (ref ^. #offset))+          )+      Nothing -> Left (DedupePolicyUnsatisfied policy)+  CustomDedupeKey k ->+    if Text.null k+      then Left (DedupePolicyUnsatisfied policy)+      else Right k
src/Keiro/Outbox.hs view
@@ -1,37 +1,36 @@-{- | Durable integration-event outbox.--The outbox decouples "this service has decided to publish an integration-event" from "this service has actually published it". Two surfaces use-it:--* The canonical 'IntegrationProducer' helper maps durable private events-  to public 'Keiro.Integration.Event.IntegrationEvent' values and enqueues-  one outbox row per mapped event. It mints @messageId@ as a prefixed-  UUIDv7 (TypeID) so the id is time-ordered, human-readable, and stable-  across publish retries.-* 'enqueueOutboxTx' is the inline escape hatch for sagas and process-  managers that need to emit an integration event without an intermediate-  private domain event. It runs inside the caller's-  'Hasql.Transaction.Transaction'.--The 'publishClaimedOutbox' worker is transport-neutral. It claims rows-with @FOR UPDATE SKIP LOCKED@ plus the configured 'OrderingPolicy',-hands claimed batches to a caller-supplied publish function, and marks rows-sent, retryable, or dead. The Kafka adapter lives in-'Keiro.Outbox.Kafka'.--Run 'outboxMaintenancePass' on a separate, slower schedule to reclaim rows-left in @publishing@ by crashed workers and to sample the backlog gauge.--The per-key and per-source ordering policies sort by @created_at@, which-PostgreSQL fills at transaction start. The canonical 'IntegrationProducer'-subscription serializes same-key enqueues, so its ordering is stable. Callers-using the inline 'enqueueIntegrationEventTx' escape hatch concurrently for the-same key must serialize those enqueues themselves or accept best-effort order:-two transactions can commit in the opposite order of their @created_at@ values.--}-module Keiro.Outbox (-    -- * Re-exports+-- | Durable integration-event outbox.+--+-- The outbox decouples "this service has decided to publish an integration+-- event" from "this service has actually published it". Two surfaces use+-- it:+--+-- * The canonical 'IntegrationProducer' helper maps durable private events+--   to public 'Keiro.Integration.Event.IntegrationEvent' values and enqueues+--   one outbox row per mapped event. It mints @messageId@ as a prefixed+--   UUIDv7 (TypeID) so the id is time-ordered, human-readable, and stable+--   across publish retries.+-- * 'enqueueOutboxTx' is the inline escape hatch for sagas and process+--   managers that need to emit an integration event without an intermediate+--   private domain event. It runs inside the caller's+--   'Hasql.Transaction.Transaction'.+--+-- The 'publishClaimedOutbox' worker is transport-neutral. It claims rows+-- with @FOR UPDATE SKIP LOCKED@ plus the configured 'OrderingPolicy',+-- hands claimed batches to a caller-supplied publish function, and marks rows+-- sent, retryable, or dead. The Kafka adapter lives in+-- 'Keiro.Outbox.Kafka'.+--+-- Run 'outboxMaintenancePass' on a separate, slower schedule to reclaim rows+-- left in @publishing@ by crashed workers and to sample the backlog gauge.+--+-- The per-key and per-source ordering policies sort by @created_at@, which+-- PostgreSQL fills at transaction start. The canonical 'IntegrationProducer'+-- subscription serializes same-key enqueues, so its ordering is stable. Callers+-- using the inline 'enqueueIntegrationEventTx' escape hatch concurrently for the+-- same key must serialize those enqueues themselves or accept best-effort order:+-- two transactions can commit in the opposite order of their @created_at@ values.+module Keiro.Outbox+  ( -- * Re-exports     module Keiro.Outbox.Types,      -- * Storage primitives (transport-neutral)@@ -62,7 +61,7 @@     publishClaimedOutbox,     outboxMaintenancePass,     sampleOutboxBacklog,-)+  ) where  import Data.ByteString (ByteString)@@ -72,25 +71,25 @@ import Data.UUID.V7 qualified as V7 import Effectful (Eff, IOE, (:>)) import Effectful.Exception (displayException, trySync)-import Keiro.Integration.Event (-    IntegrationContentType,+import Keiro.Integration.Event+  ( IntegrationContentType,     IntegrationEvent (..),     SchemaReference,     TraceContext,- )+  ) import Keiro.Outbox.Kafka (outboxRowToKafkaRecord) import Keiro.Outbox.Schema import Keiro.Outbox.Types import Keiro.Prelude-import Keiro.Telemetry (-    KeiroMetrics,+import Keiro.Telemetry+  ( KeiroMetrics,     recordOutboxBacklog,     recordOutboxDeadlettered,     recordOutboxPublished,     recordOutboxReclaimed,     recordOutboxRetried,     withProducerSpan,- )+  ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (EventId, GlobalPosition, RecordedEvent)@@ -106,161 +105,156 @@ freshOutboxId :: (IOE :> es) => Eff es OutboxId freshOutboxId = fmap OutboxId (liftIO V7.genUUID) -{- | Enqueue an 'IntegrationEvent' from a saga or process manager that is-already running inside a 'runCommandWithSqlEvents' transaction. The-caller supplies a stable 'OutboxId' so retried command attempts coalesce-on the @(source, message_id)@ unique constraint.--Ordering caveat: under 'PerKeyHeadOfLine' and 'PerSourceStream', the publisher-orders rows by @created_at@, which PostgreSQL sets to transaction-start time.-If two concurrent transactions enqueue the same key/source and commit in the-opposite order, a publisher can observe that order. Serialize same-key enqueues-when strict order matters.--}+-- | Enqueue an 'IntegrationEvent' from a saga or process manager that is+-- already running inside a 'runCommandWithSqlEvents' transaction. The+-- caller supplies a stable 'OutboxId' so retried command attempts coalesce+-- on the @(source, message_id)@ unique constraint.+--+-- Ordering caveat: under 'PerKeyHeadOfLine' and 'PerSourceStream', the publisher+-- orders rows by @created_at@, which PostgreSQL sets to transaction-start time.+-- If two concurrent transactions enqueue the same key/source and commit in the+-- opposite order, a publisher can observe that order. Serialize same-key enqueues+-- when strict order matters. enqueueIntegrationEventTx ::-    OutboxId ->-    IntegrationEvent ->-    Tx.Transaction ()+  OutboxId ->+  IntegrationEvent ->+  Tx.Transaction () enqueueIntegrationEventTx outboxId event =-    enqueueOutboxTx (OutboxMessage{outboxId, event})+  enqueueOutboxTx (OutboxMessage {outboxId, event})  -- --------------------------------------------------------------------------- -- Producer-subscription helper -- --------------------------------------------------------------------------- -{- | Configuration for the canonical producer subscription.--A service running 'IntegrationProducer' reads its private event stream,-decodes each event with a 'Keiro.Codec.Codec', calls 'mapEvent', and for-each 'Just' result writes one 'keiro_outbox' row. The helper mints-@messageId@ on each insert so the id is stable across publish retries.--* 'name' — subscription name used to checkpoint the producer's cursor-  in the @subscriptions@ table.-* 'source' — value written into @keiro_outbox.source@; identifies the-  producing bounded context.-* 'messageIdPrefix' — TypeID prefix used when minting @messageId@.-  Must be 1-63 lowercase Latin letters (e.g. @\"msg\"@, @\"order\"@).-  Prefer constructing producers with 'mkIntegrationProducer'; an invalid-  prefix passed directly to 'IntegrationProducer' raises when the first-  message id is minted.-* 'mapEvent' — pure mapper from a private 'RecordedEvent' and its-  decoded payload to an 'IntegrationEventDraft'. Returning 'Nothing'-  skips the event without enqueuing a row.--}+-- | Configuration for the canonical producer subscription.+--+-- A service running 'IntegrationProducer' reads its private event stream,+-- decodes each event with a 'Keiro.Codec.Codec', calls 'mapEvent', and for+-- each 'Just' result writes one 'keiro_outbox' row. The helper mints+-- @messageId@ on each insert so the id is stable across publish retries.+--+-- * 'name' — subscription name used to checkpoint the producer's cursor+--   in the @subscriptions@ table.+-- * 'source' — value written into @keiro_outbox.source@; identifies the+--   producing bounded context.+-- * 'messageIdPrefix' — TypeID prefix used when minting @messageId@.+--   Must be 1-63 lowercase Latin letters (e.g. @\"msg\"@, @\"order\"@).+--   Prefer constructing producers with 'mkIntegrationProducer'; an invalid+--   prefix passed directly to 'IntegrationProducer' raises when the first+--   message id is minted.+-- * 'mapEvent' — pure mapper from a private 'RecordedEvent' and its+--   decoded payload to an 'IntegrationEventDraft'. Returning 'Nothing'+--   skips the event without enqueuing a row. data IntegrationProducer e = IntegrationProducer-    { name :: !Text-    , source :: !Text-    , messageIdPrefix :: !Text-    , mapEvent :: !(RecordedEvent -> e -> Maybe IntegrationEventDraft)-    }-    deriving stock (Generic)+  { name :: !Text,+    source :: !Text,+    messageIdPrefix :: !Text,+    mapEvent :: !(RecordedEvent -> e -> Maybe IntegrationEventDraft)+  }+  deriving stock (Generic)  data IntegrationProducerConfigError-    = InvalidMessageIdPrefix !Text !Text-    deriving stock (Generic, Eq, Show)+  = InvalidMessageIdPrefix !Text !Text+  deriving stock (Generic, Eq, Show)  -- | Validate an integration producer before starting its subscription. mkIntegrationProducer :: IntegrationProducer e -> Either IntegrationProducerConfigError (IntegrationProducer e) mkIntegrationProducer producer =-    case TypeID.checkPrefix (producer ^. #messageIdPrefix) of-        Nothing -> Right producer-        Just err ->-            Left-                ( InvalidMessageIdPrefix-                    (producer ^. #messageIdPrefix)-                    (Text.pack (show err))-                )--{- | Everything in 'IntegrationEvent' except 'messageId' and 'source' —-those are filled in by 'mintIntegrationEvent' from the producer-configuration and the freshly minted TypeID.+  case TypeID.checkPrefix (producer ^. #messageIdPrefix) of+    Nothing -> Right producer+    Just err ->+      Left+        ( InvalidMessageIdPrefix+            (producer ^. #messageIdPrefix)+            (Text.pack (show err))+        ) -@sourceEventId@ and @sourceGlobalPosition@ default to the values on the-underlying 'RecordedEvent' (see 'mintIntegrationEvent'); a mapper that-needs to override them can replace the draft fields directly.--}+-- | Everything in 'IntegrationEvent' except 'messageId' and 'source' —+-- those are filled in by 'mintIntegrationEvent' from the producer+-- configuration and the freshly minted TypeID.+--+-- @sourceEventId@ and @sourceGlobalPosition@ default to the values on the+-- underlying 'RecordedEvent' (see 'mintIntegrationEvent'); a mapper that+-- needs to override them can replace the draft fields directly. data IntegrationEventDraft = IntegrationEventDraft-    { destination :: !Text-    , key :: !(Maybe Text)-    , eventType :: !Text-    , schemaVersion :: !Int-    , contentType :: !IntegrationContentType-    , schemaReference :: !(Maybe SchemaReference)-    , sourceEventId :: !(Maybe EventId)-    , sourceGlobalPosition :: !(Maybe GlobalPosition)-    , payloadBytes :: !ByteString-    , occurredAt :: !UTCTime-    , causationId :: !(Maybe EventId)-    , correlationId :: !(Maybe EventId)-    , traceContext :: !(Maybe TraceContext)-    , attributes :: !(Maybe Value)-    }-    deriving stock (Generic, Eq, Show)+  { destination :: !Text,+    key :: !(Maybe Text),+    eventType :: !Text,+    schemaVersion :: !Int,+    contentType :: !IntegrationContentType,+    schemaReference :: !(Maybe SchemaReference),+    sourceEventId :: !(Maybe EventId),+    sourceGlobalPosition :: !(Maybe GlobalPosition),+    payloadBytes :: !ByteString,+    occurredAt :: !UTCTime,+    causationId :: !(Maybe EventId),+    correlationId :: !(Maybe EventId),+    traceContext :: !(Maybe TraceContext),+    attributes :: !(Maybe Value)+  }+  deriving stock (Generic, Eq, Show) -{- | Mint a fresh @messageId@ (TypeID with the producer's prefix) and build-the full 'IntegrationEvent' from the draft. Lives in 'IO' because TypeID-generation reads the global UUIDv7 sequence counter.--}+-- | Mint a fresh @messageId@ (TypeID with the producer's prefix) and build+-- the full 'IntegrationEvent' from the draft. Lives in 'IO' because TypeID+-- generation reads the global UUIDv7 sequence counter. mintIntegrationEvent ::-    (IOE :> es) =>-    IntegrationProducer e ->-    IntegrationEventDraft ->-    Eff es IntegrationEvent+  (IOE :> es) =>+  IntegrationProducer e ->+  IntegrationEventDraft ->+  Eff es IntegrationEvent mintIntegrationEvent producer draft = do-    typeId <- liftIO (TypeID.genTypeID (producer ^. #messageIdPrefix))-    pure (draftToEvent (producer ^. #source) (TypeID.toText typeId) draft)+  typeId <- liftIO (TypeID.genTypeID (producer ^. #messageIdPrefix))+  pure (draftToEvent (producer ^. #source) (TypeID.toText typeId) draft)  -- | Build an 'IntegrationEvent' from a source, a minted message id, and a draft. draftToEvent :: Text -> Text -> IntegrationEventDraft -> IntegrationEvent draftToEvent source minted draft =-    IntegrationEvent-        { messageId = minted-        , source-        , destination = draft ^. #destination-        , key = draft ^. #key-        , eventType = draft ^. #eventType-        , schemaVersion = draft ^. #schemaVersion-        , contentType = draft ^. #contentType-        , schemaReference = draft ^. #schemaReference-        , sourceEventId = draft ^. #sourceEventId-        , sourceGlobalPosition = draft ^. #sourceGlobalPosition-        , payloadBytes = draft ^. #payloadBytes-        , occurredAt = draft ^. #occurredAt-        , causationId = draft ^. #causationId-        , correlationId = draft ^. #correlationId-        , traceContext = draft ^. #traceContext-        , attributes = draft ^. #attributes-        }--{- | Enqueue one drafted producer event inside an existing transaction.--This is the primitive a subscription worker calls per event. It mints a-fresh @messageId@ (TypeID), constructs the full envelope, and inserts-the row. The caller supplies the 'OutboxId' so retries from a known-subscription cursor coalesce on @(source, message_id)@.--The TypeID is minted before the insert; if the transaction rolls back-the message id is discarded (no observable effect) and the next attempt-mints a different id. Idempotency at the row level relies on a stable-'OutboxId', not the minted message id.+  IntegrationEvent+    { messageId = minted,+      source,+      destination = draft ^. #destination,+      key = draft ^. #key,+      eventType = draft ^. #eventType,+      schemaVersion = draft ^. #schemaVersion,+      contentType = draft ^. #contentType,+      schemaReference = draft ^. #schemaReference,+      sourceEventId = draft ^. #sourceEventId,+      sourceGlobalPosition = draft ^. #sourceGlobalPosition,+      payloadBytes = draft ^. #payloadBytes,+      occurredAt = draft ^. #occurredAt,+      causationId = draft ^. #causationId,+      correlationId = draft ^. #correlationId,+      traceContext = draft ^. #traceContext,+      attributes = draft ^. #attributes+    } -Ordering caveat: @created_at@ records transaction-start time. Under-'PerKeyHeadOfLine' or 'PerSourceStream', concurrent transactions for the same-key/source can commit in the opposite order and are therefore best-effort-unless the caller serializes them. The canonical producer subscription does-serialize same-key enqueues.--}+-- | Enqueue one drafted producer event inside an existing transaction.+--+-- This is the primitive a subscription worker calls per event. It mints a+-- fresh @messageId@ (TypeID), constructs the full envelope, and inserts+-- the row. The caller supplies the 'OutboxId' so retries from a known+-- subscription cursor coalesce on @(source, message_id)@.+--+-- The TypeID is minted before the insert; if the transaction rolls back+-- the message id is discarded (no observable effect) and the next attempt+-- mints a different id. Idempotency at the row level relies on a stable+-- 'OutboxId', not the minted message id.+--+-- Ordering caveat: @created_at@ records transaction-start time. Under+-- 'PerKeyHeadOfLine' or 'PerSourceStream', concurrent transactions for the same+-- key/source can commit in the opposite order and are therefore best-effort+-- unless the caller serializes them. The canonical producer subscription does+-- serialize same-key enqueues. enqueueProducerEventTx ::-    forall e es.-    (IOE :> es) =>-    IntegrationProducer e ->-    OutboxId ->-    IntegrationEventDraft ->-    Eff es (Tx.Transaction ())+  forall e es.+  (IOE :> es) =>+  IntegrationProducer e ->+  OutboxId ->+  IntegrationEventDraft ->+  Eff es (Tx.Transaction ()) enqueueProducerEventTx producer outboxId draft = do-    event <- mintIntegrationEvent producer draft-    pure (enqueueOutboxTx (OutboxMessage{outboxId, event}))+  event <- mintIntegrationEvent producer draft+  pure (enqueueOutboxTx (OutboxMessage {outboxId, event}))  -- --------------------------------------------------------------------------- -- Publisher worker@@ -268,133 +262,132 @@  -- | Result of one publish attempt as reported by the transport-specific publisher. data PublishOutcome-    = -- | Kafka acknowledged the publish.-      PublishSucceeded-    | -- | Publish failed; will be retried after the configured backoff.-      PublishFailed !Text-    deriving stock (Generic, Eq, Show)--{- | Drain claimed outbox rows by handing the claimed batch to @publish@ and-reflecting the outcomes back into row statuses.--Claims rows in batches of @batchSize@ under the active 'OrderingPolicy',-calls @publish@ with the claimed rows in claim order, and marks every row-sent or — using 'markOutboxFailedTx' — failed/dead. The publish result must-contain one outcome per input row; a missing outcome is treated as-@PublishFailed "publisher returned no outcome"@. If the publisher throws,-every row in that call is treated as failed with the exception text.--For ordered policies, if a row fails then later rows in the same ordered group-are skipped and returned to @failed@ without consuming an attempt; these-skipped rows count as 'OutboxPublishSummary.retried'. A real Kafka transport-must not successfully deliver a later same-key record after reporting an-earlier same-key failure from the same call. On 'StopTheLine', the worker calls-@publish@ with singleton batches and halts after the first failed row, recording-the offending 'OutboxId' in 'haltedOn'.--Returns when one of:--* No rows are claimable.-* The active policy is 'StopTheLine' and a publish failed.+  = -- | Kafka acknowledged the publish.+    PublishSucceeded+  | -- | Publish failed; will be retried after the configured backoff.+    PublishFailed !Text+  deriving stock (Generic, Eq, Show) -The worker does not loop indefinitely; the application is expected to-schedule it repeatedly (e.g. once per process-compose tick).--}+-- | Drain claimed outbox rows by handing the claimed batch to @publish@ and+-- reflecting the outcomes back into row statuses.+--+-- Claims rows in batches of @batchSize@ under the active 'OrderingPolicy',+-- calls @publish@ with the claimed rows in claim order, and marks every row+-- sent or — using 'markOutboxFailedTx' — failed/dead. The publish result must+-- contain one outcome per input row; a missing outcome is treated as+-- @PublishFailed "publisher returned no outcome"@. If the publisher throws,+-- every row in that call is treated as failed with the exception text.+--+-- For ordered policies, if a row fails then later rows in the same ordered group+-- are skipped and returned to @failed@ without consuming an attempt; these+-- skipped rows count as 'OutboxPublishSummary.retried'. A real Kafka transport+-- must not successfully deliver a later same-key record after reporting an+-- earlier same-key failure from the same call. On 'StopTheLine', the worker calls+-- @publish@ with singleton batches and halts after the first failed row, recording+-- the offending 'OutboxId' in 'haltedOn'.+--+-- Returns when one of:+--+-- * No rows are claimable.+-- * The active policy is 'StopTheLine' and a publish failed.+--+-- The worker does not loop indefinitely; the application is expected to+-- schedule it repeatedly (e.g. once per process-compose tick). publishClaimedOutbox ::-    forall es.-    (IOE :> es, Store :> es) =>-    ([OutboxRow] -> Eff es [(OutboxId, PublishOutcome)]) ->-    OutboxPublishOptions ->-    Maybe KeiroMetrics ->-    Eff es OutboxPublishSummary+  forall es.+  (IOE :> es, Store :> es) =>+  ([OutboxRow] -> Eff es [(OutboxId, PublishOutcome)]) ->+  OutboxPublishOptions ->+  Maybe KeiroMetrics ->+  Eff es OutboxPublishSummary publishClaimedOutbox publish options mMetrics = do-    now <- liftIO getCurrentTime-    rows <- claimOutboxBatch (options ^. #orderingPolicy) (options ^. #batchSize) now-    summary <- publishBatch rows-    -- Counters from the aggregated pass summary (each a no-op under 'Nothing';-    -- a zero delta is harmless).-    recordOutboxPublished mMetrics (fromIntegral (summary ^. #published))-    recordOutboxRetried mMetrics (fromIntegral (summary ^. #retried))-    recordOutboxDeadlettered mMetrics (fromIntegral (summary ^. #dead))-    pure summary+  now <- liftIO getCurrentTime+  rows <- claimOutboxBatch (options ^. #orderingPolicy) (options ^. #batchSize) now+  summary <- publishBatch rows+  -- Counters from the aggregated pass summary (each a no-op under 'Nothing';+  -- a zero delta is harmless).+  recordOutboxPublished mMetrics (fromIntegral (summary ^. #published))+  recordOutboxRetried mMetrics (fromIntegral (summary ^. #retried))+  recordOutboxDeadlettered mMetrics (fromIntegral (summary ^. #dead))+  pure summary   where     publishBatch :: [OutboxRow] -> Eff es OutboxPublishSummary     publishBatch [] =-        pure OutboxPublishSummary{claimed = 0, published = 0, retried = 0, dead = 0, haltedOn = Nothing}+      pure OutboxPublishSummary {claimed = 0, published = 0, retried = 0, dead = 0, haltedOn = Nothing}     publishBatch batch =-        case options ^. #orderingPolicy of-            StopTheLine -> publishStopTheLine batch batch [] Nothing-            policy -> do-                outcomes <- publishRows batch-                markProcessedOutcomes policy batch outcomes Nothing+      case options ^. #orderingPolicy of+        StopTheLine -> publishStopTheLine batch batch [] Nothing+        policy -> do+          outcomes <- publishRows batch+          markProcessedOutcomes policy batch outcomes Nothing      publishStopTheLine ::-        [OutboxRow] ->-        [OutboxRow] ->-        [(OutboxId, PublishOutcome)] ->-        Maybe OutboxId ->-        Eff es OutboxPublishSummary+      [OutboxRow] ->+      [OutboxRow] ->+      [(OutboxId, PublishOutcome)] ->+      Maybe OutboxId ->+      Eff es OutboxPublishSummary     publishStopTheLine original [] outcomes halted =-        markProcessedOutcomes StopTheLine original (Map.fromList outcomes) halted+      markProcessedOutcomes StopTheLine original (Map.fromList outcomes) halted     publishStopTheLine original (row : rest) outcomes _ = do-        result <- publishRows [row]-        let outcome = outcomeFor result row-            outcomes' = outcomes <> [(row ^. #outboxId, outcome)]-        case outcome of-            PublishSucceeded -> publishStopTheLine original rest outcomes' Nothing-            PublishFailed _ -> markProcessedOutcomes StopTheLine original (Map.fromList outcomes') (Just (row ^. #outboxId))+      result <- publishRows [row]+      let outcome = outcomeFor result row+          outcomes' = outcomes <> [(row ^. #outboxId, outcome)]+      case outcome of+        PublishSucceeded -> publishStopTheLine original rest outcomes' Nothing+        PublishFailed _ -> markProcessedOutcomes StopTheLine original (Map.fromList outcomes') (Just (row ^. #outboxId))      publishRows :: [OutboxRow] -> Eff es (Map.Map OutboxId PublishOutcome)     publishRows [] = pure Map.empty     publishRows batch@(firstRow : _) =-        Map.fromList-            <$> withBatchSpan-                batch-                firstRow-                ( do-                    attempted <- trySync (publish batch)-                    let normalized =-                            case attempted of-                                Left err ->-                                    let errMsg = Text.pack (displayException err)-                                     in [(row ^. #outboxId, PublishFailed errMsg) | row <- batch]-                                Right reported ->-                                    normalizeOutcomes batch reported-                    pure normalized-                )+      Map.fromList+        <$> withBatchSpan+          batch+          firstRow+          ( do+              attempted <- trySync (publish batch)+              let normalized =+                    case attempted of+                      Left err ->+                        let errMsg = Text.pack (displayException err)+                         in [(row ^. #outboxId, PublishFailed errMsg) | row <- batch]+                      Right reported ->+                        normalizeOutcomes batch reported+              pure normalized+          )      withBatchSpan ::-        [OutboxRow] ->-        OutboxRow ->-        Eff es [(OutboxId, PublishOutcome)] ->-        Eff es [(OutboxId, PublishOutcome)]+      [OutboxRow] ->+      OutboxRow ->+      Eff es [(OutboxId, PublishOutcome)] ->+      Eff es [(OutboxId, PublishOutcome)]     withBatchSpan batch firstRow action =-        withProducerSpan-            (options ^. #tracer)-            (firstRow ^. #event)-            (outboxRowToKafkaRecord firstRow)-            $ \mSpan -> do-                for_ mSpan $ \sp ->-                    addAttribute sp (unkey keiro_outbox_batch_size) (fromIntegral (length batch) :: Int64)-                outcomes <- action-                case (mSpan, firstFailure outcomes) of-                    (Just sp, Just errMsg) -> do-                        addAttribute sp (unkey error_type) ("publish_failed" :: Text)-                        setStatus sp (Error errMsg)-                    _ -> pure ()-                pure outcomes+      withProducerSpan+        (options ^. #tracer)+        (firstRow ^. #event)+        (outboxRowToKafkaRecord firstRow)+        $ \mSpan -> do+          for_ mSpan $ \sp ->+            addAttribute sp (unkey keiro_outbox_batch_size) (fromIntegral (length batch) :: Int64)+          outcomes <- action+          case (mSpan, firstFailure outcomes) of+            (Just sp, Just errMsg) -> do+              addAttribute sp (unkey error_type) ("publish_failed" :: Text)+              setStatus sp (Error errMsg)+            _ -> pure ()+          pure outcomes      normalizeOutcomes :: [OutboxRow] -> [(OutboxId, PublishOutcome)] -> [(OutboxId, PublishOutcome)]     normalizeOutcomes batch reported =-        let reportedMap = Map.fromList reported-         in [ (row ^. #outboxId, outcomeFor reportedMap row)-            | row <- batch-            ]+      let reportedMap = Map.fromList reported+       in [ (row ^. #outboxId, outcomeFor reportedMap row)+          | row <- batch+          ]      outcomeFor :: Map.Map OutboxId PublishOutcome -> OutboxRow -> PublishOutcome     outcomeFor outcomes row =-        fromMaybe (PublishFailed "publisher returned no outcome") $-            Map.lookup (row ^. #outboxId) outcomes+      fromMaybe (PublishFailed "publisher returned no outcome") $+        Map.lookup (row ^. #outboxId) outcomes      firstFailure :: [(OutboxId, PublishOutcome)] -> Maybe Text     firstFailure [] = Nothing@@ -402,152 +395,151 @@     firstFailure ((_, PublishFailed errMsg) : _) = Just errMsg      markProcessedOutcomes ::-        OrderingPolicy ->-        [OutboxRow] ->-        Map.Map OutboxId PublishOutcome ->-        Maybe OutboxId ->-        Eff es OutboxPublishSummary+      OrderingPolicy ->+      [OutboxRow] ->+      Map.Map OutboxId PublishOutcome ->+      Maybe OutboxId ->+      Eff es OutboxPublishSummary     markProcessedOutcomes policy batch outcomes halted = do-        now <- liftIO getCurrentTime-        let marks = foldMap (groupMarks outcomes) (outcomeGroups policy batch)-            sentIds = marks ^. #sentIds-            failedRows = marks ^. #failedRows-            skippedRows = marks ^. #skippedRows-        failedStatuses <--            if null failedRows && null skippedRows-                then pure []-                else runTransaction $ do-                    statuses <- traverse (markFailed now) failedRows-                    traverse_ (markSkipped now) skippedRows-                    pure statuses-        _ <- markOutboxSentBatch sentIds now-        let deadCount = length [() | OutboxDead <- failedStatuses]-            retriedFailures = length failedStatuses - deadCount-        pure-            OutboxPublishSummary-                { claimed = length batch-                , published = length sentIds-                , retried = retriedFailures + length skippedRows-                , dead = deadCount-                , haltedOn = halted-                }+      now <- liftIO getCurrentTime+      let marks = foldMap (groupMarks outcomes) (outcomeGroups policy batch)+          sentIds = marks ^. #sentIds+          failedRows = marks ^. #failedRows+          skippedRows = marks ^. #skippedRows+      failedStatuses <-+        if null failedRows && null skippedRows+          then pure []+          else runTransaction $ do+            statuses <- traverse (markFailed now) failedRows+            traverse_ (markSkipped now) skippedRows+            pure statuses+      _ <- markOutboxSentBatch sentIds now+      let deadCount = length [() | OutboxDead <- failedStatuses]+          retriedFailures = length failedStatuses - deadCount+      pure+        OutboxPublishSummary+          { claimed = length batch,+            published = length sentIds,+            retried = retriedFailures + length skippedRows,+            dead = deadCount,+            haltedOn = halted+          }      markFailed :: UTCTime -> (OutboxRow, Text) -> Tx.Transaction OutboxStatus     markFailed now (row, errMsg) =-        markOutboxFailedTx-            (row ^. #outboxId)-            errMsg-            (options ^. #maxAttempts)-            (nextDelay (options ^. #backoff) (row ^. #attemptCount))-            now+      markOutboxFailedTx+        (row ^. #outboxId)+        errMsg+        (options ^. #maxAttempts)+        (nextDelay (options ^. #backoff) (row ^. #attemptCount))+        now      markSkipped :: UTCTime -> OutboxRow -> Tx.Transaction ()     markSkipped now row =-        markOutboxSkippedTx-            (row ^. #outboxId)-            "skipped: earlier record for the same key failed"-            now--{- | Reclaim crashed publisher rows and record the outbox backlog gauge.+      markOutboxSkippedTx+        (row ^. #outboxId)+        "skipped: earlier record for the same key failed"+        now -Schedule this pass independently from 'publishClaimedOutbox', typically on a-slower timer. It is the only library worker path that reclaims rows stranded in-@publishing@.--}+-- | Reclaim crashed publisher rows and record the outbox backlog gauge.+--+-- Schedule this pass independently from 'publishClaimedOutbox', typically on a+-- slower timer. It is the only library worker path that reclaims rows stranded in+-- @publishing@. outboxMaintenancePass ::-    (IOE :> es, Store :> es) =>-    OutboxMaintenanceOptions ->-    Maybe KeiroMetrics ->-    Eff es OutboxMaintenanceSummary+  (IOE :> es, Store :> es) =>+  OutboxMaintenanceOptions ->+  Maybe KeiroMetrics ->+  Eff es OutboxMaintenanceSummary outboxMaintenancePass options mMetrics = do-    now <- liftIO getCurrentTime-    (requeued, deadLettered) <--        requeueStuckOutbox-            (options ^. #maxAttempts)-            (options ^. #publishingTimeout)-            now-    recordOutboxReclaimed mMetrics (fromIntegral requeued)-    recordOutboxDeadlettered mMetrics (fromIntegral deadLettered)-    backlog <- countOutboxBacklog-    recordOutboxBacklog mMetrics (fromIntegral backlog)-    pure OutboxMaintenanceSummary{requeued, deadLettered, backlog}+  now <- liftIO getCurrentTime+  (requeued, deadLettered) <-+    requeueStuckOutbox+      (options ^. #maxAttempts)+      (options ^. #publishingTimeout)+      now+  recordOutboxReclaimed mMetrics (fromIntegral requeued)+  recordOutboxDeadlettered mMetrics (fromIntegral deadLettered)+  backlog <- countOutboxBacklog+  recordOutboxBacklog mMetrics (fromIntegral backlog)+  pure OutboxMaintenanceSummary {requeued, deadLettered, backlog}  -- | Count publishable rows and record the outbox backlog gauge when metrics are enabled. sampleOutboxBacklog :: (IOE :> es, Store :> es) => Maybe KeiroMetrics -> Eff es () sampleOutboxBacklog Nothing = pure () sampleOutboxBacklog (Just metrics) = do-    backlog <- countOutboxBacklog-    recordOutboxBacklog (Just metrics) (fromIntegral backlog)+  backlog <- countOutboxBacklog+  recordOutboxBacklog (Just metrics) (fromIntegral backlog)  data OutcomeMarks = OutcomeMarks-    { sentIds :: ![OutboxId]-    , failedRows :: ![(OutboxRow, Text)]-    , skippedRows :: ![OutboxRow]-    }-    deriving stock (Generic)+  { sentIds :: ![OutboxId],+    failedRows :: ![(OutboxRow, Text)],+    skippedRows :: ![OutboxRow]+  }+  deriving stock (Generic)  instance Semigroup OutcomeMarks where-    left <> right =-        OutcomeMarks-            { sentIds = (left ^. #sentIds) <> (right ^. #sentIds)-            , failedRows = (left ^. #failedRows) <> (right ^. #failedRows)-            , skippedRows = (left ^. #skippedRows) <> (right ^. #skippedRows)-            }+  left <> right =+    OutcomeMarks+      { sentIds = (left ^. #sentIds) <> (right ^. #sentIds),+        failedRows = (left ^. #failedRows) <> (right ^. #failedRows),+        skippedRows = (left ^. #skippedRows) <> (right ^. #skippedRows)+      }  instance Monoid OutcomeMarks where-    mempty = OutcomeMarks{sentIds = [], failedRows = [], skippedRows = []}+  mempty = OutcomeMarks {sentIds = [], failedRows = [], skippedRows = []}  groupMarks :: Map.Map OutboxId PublishOutcome -> [OutboxRow] -> OutcomeMarks groupMarks outcomes = go []   where-    go sent [] = mempty{sentIds = sent}+    go sent [] = mempty {sentIds = sent}     go sent (row : rest) =-        case fromMaybe (PublishFailed "publisher returned no outcome") (Map.lookup (row ^. #outboxId) outcomes) of-            PublishSucceeded -> go (sent <> [row ^. #outboxId]) rest-            PublishFailed errMsg ->-                OutcomeMarks-                    { sentIds = sent-                    , failedRows = [(row, errMsg)]-                    , skippedRows = rest-                    }+      case fromMaybe (PublishFailed "publisher returned no outcome") (Map.lookup (row ^. #outboxId) outcomes) of+        PublishSucceeded -> go (sent <> [row ^. #outboxId]) rest+        PublishFailed errMsg ->+          OutcomeMarks+            { sentIds = sent,+              failedRows = [(row, errMsg)],+              skippedRows = rest+            }  data OutcomeGroupKey-    = BatchGroup-    | SourceGroup !Text-    | RowGroup !OutboxId-    | KeyGroup !Text !Text-    deriving stock (Generic, Eq)+  = BatchGroup+  | SourceGroup !Text+  | RowGroup !OutboxId+  | KeyGroup !Text !Text+  deriving stock (Generic, Eq)  data OutcomeGroup = OutcomeGroup-    { groupKey :: !OutcomeGroupKey-    , groupRows :: ![OutboxRow]-    }-    deriving stock (Generic)+  { groupKey :: !OutcomeGroupKey,+    groupRows :: ![OutboxRow]+  }+  deriving stock (Generic)  outcomeGroups :: OrderingPolicy -> [OutboxRow] -> [[OutboxRow]] outcomeGroups policy =-    fmap (^. #groupRows) . foldl' addGroup []+  fmap (^. #groupRows) . foldl' addGroup []   where     addGroup groups row =-        appendGroup (keyFor row) row groups+      appendGroup (keyFor row) row groups     keyFor row =-        case policy of-            -- A claimed batch can hold runs from several independent sources;-            -- a failure in one source's run must not skip another source's rows.-            PerSourceStream -> SourceGroup (row ^. #event . #source)-            -- Deliberately one group: any failure halts the worker, and the-            -- entire remaining batch is skipped without consuming attempts.-            StopTheLine -> BatchGroup-            BestEffort -> RowGroup (row ^. #outboxId)-            _ ->-                case row ^. #event . #key of-                    Nothing -> RowGroup (row ^. #outboxId)-                    Just key -> KeyGroup (row ^. #event . #source) key+      case policy of+        -- A claimed batch can hold runs from several independent sources;+        -- a failure in one source's run must not skip another source's rows.+        PerSourceStream -> SourceGroup (row ^. #event . #source)+        -- Deliberately one group: any failure halts the worker, and the+        -- entire remaining batch is skipped without consuming attempts.+        StopTheLine -> BatchGroup+        BestEffort -> RowGroup (row ^. #outboxId)+        _ ->+          case row ^. #event . #key of+            Nothing -> RowGroup (row ^. #outboxId)+            Just key -> KeyGroup (row ^. #event . #source) key  appendGroup :: OutcomeGroupKey -> OutboxRow -> [OutcomeGroup] -> [OutcomeGroup]-appendGroup key row [] = [OutcomeGroup{groupKey = key, groupRows = [row]}]+appendGroup key row [] = [OutcomeGroup {groupKey = key, groupRows = [row]}] appendGroup key row (group : rest)-    | group ^. #groupKey == key =-        (group & #groupRows %~ (<> [row])) : rest-    | otherwise =-        group : appendGroup key row rest+  | group ^. #groupKey == key =+      (group & #groupRows %~ (<> [row])) : rest+  | otherwise =+      group : appendGroup key row rest
src/Keiro/Outbox/Kafka.hs view
@@ -1,27 +1,26 @@-{- | Transport-neutral Kafka representation for outbox rows.--This module owns the conversion from 'Keiro.Outbox.Types.OutboxRow' to a-neutral 'KafkaProducerRecord' value. It deliberately does not import-@hw-kafka-client@ or @kafka-effectful@: keiro itself remains free of-librdkafka system-library requirements. The integration test package-(EP-22) bridges 'KafkaProducerRecord' to-@Kafka.Producer.Types.ProducerRecord@ from @hw-kafka-client@ inside its-own dependency scope.--A 'KafkaProducerRecord' carries everything the broker layer needs:-topic, optional partition key, the raw payload bytes from the EP-19-envelope, and the canonical header set. Building the record is pure;-publishing is the caller's responsibility.--The outbox worker opens one producer span around each claimed publish batch.-Adapters that need per-record broker visibility should add their own spans-around the actual Kafka produce calls.--}-module Keiro.Outbox.Kafka (-    KafkaProducerRecord (..),+-- | Transport-neutral Kafka representation for outbox rows.+--+-- This module owns the conversion from 'Keiro.Outbox.Types.OutboxRow' to a+-- neutral 'KafkaProducerRecord' value. It deliberately does not import+-- @hw-kafka-client@ or @kafka-effectful@: keiro itself remains free of+-- librdkafka system-library requirements. The integration test package+-- (EP-22) bridges 'KafkaProducerRecord' to+-- @Kafka.Producer.Types.ProducerRecord@ from @hw-kafka-client@ inside its+-- own dependency scope.+--+-- A 'KafkaProducerRecord' carries everything the broker layer needs:+-- topic, optional partition key, the raw payload bytes from the EP-19+-- envelope, and the canonical header set. Building the record is pure;+-- publishing is the caller's responsibility.+--+-- The outbox worker opens one producer span around each claimed publish batch.+-- Adapters that need per-record broker visibility should add their own spans+-- around the actual Kafka produce calls.+module Keiro.Outbox.Kafka+  ( KafkaProducerRecord (..),     outboxRowToKafkaRecord,     integrationEventToKafkaRecord,-)+  ) where  import Data.ByteString (ByteString)@@ -30,30 +29,29 @@ import Keiro.Outbox.Types (OutboxRow (..)) import Keiro.Prelude -{- | A neutral Kafka producer record.--Fields:--* 'topic' — Kafka topic, taken from 'IntegrationEvent.destination'.-* 'key' — partition key bytes (UTF-8 encoded). 'Nothing' means-  Kafka round-robins the record across partitions and skips per-key-  ordering.-* 'payload' — exactly the bytes from-  'Keiro.Integration.Event.integrationPayload'.-* 'headers' — UTF-8 encoded view of-  'Keiro.Integration.Event.integrationHeaders'.--The byte encoding for keys and headers is UTF-8 by convention; Kafka-treats both as opaque bytes, so a future binary-key transport can drop-the encoding by populating 'key' directly.--}+-- | A neutral Kafka producer record.+--+-- Fields:+--+-- * 'topic' — Kafka topic, taken from 'IntegrationEvent.destination'.+-- * 'key' — partition key bytes (UTF-8 encoded). 'Nothing' means+--   Kafka round-robins the record across partitions and skips per-key+--   ordering.+-- * 'payload' — exactly the bytes from+--   'Keiro.Integration.Event.integrationPayload'.+-- * 'headers' — UTF-8 encoded view of+--   'Keiro.Integration.Event.integrationHeaders'.+--+-- The byte encoding for keys and headers is UTF-8 by convention; Kafka+-- treats both as opaque bytes, so a future binary-key transport can drop+-- the encoding by populating 'key' directly. data KafkaProducerRecord = KafkaProducerRecord-    { topic :: !Text-    , key :: !(Maybe ByteString)-    , payload :: !ByteString-    , headers :: ![(ByteString, ByteString)]-    }-    deriving stock (Generic, Eq, Show)+  { topic :: !Text,+    key :: !(Maybe ByteString),+    payload :: !ByteString,+    headers :: ![(ByteString, ByteString)]+  }+  deriving stock (Generic, Eq, Show)  -- | Build a 'KafkaProducerRecord' from a published outbox row. outboxRowToKafkaRecord :: OutboxRow -> KafkaProducerRecord@@ -62,9 +60,9 @@ -- | Build a 'KafkaProducerRecord' directly from an 'IntegrationEvent'. integrationEventToKafkaRecord :: IntegrationEvent -> KafkaProducerRecord integrationEventToKafkaRecord event =-    KafkaProducerRecord-        { topic = event ^. #destination-        , key = fmap TE.encodeUtf8 (event ^. #key)-        , payload = integrationPayload event-        , headers = [(TE.encodeUtf8 n, TE.encodeUtf8 v) | (n, v) <- integrationHeaders event]-        }+  KafkaProducerRecord+    { topic = event ^. #destination,+      key = fmap TE.encodeUtf8 (event ^. #key),+      payload = integrationPayload event,+      headers = [(TE.encodeUtf8 n, TE.encodeUtf8 v) | (n, v) <- integrationHeaders event]+    }
src/Keiro/Outbox/Schema.hs view
@@ -1,811 +1,799 @@ {-# LANGUAGE ApplicativeDo #-} -{- | Hasql-level storage for the durable integration-event outbox.--This module owns the SQL surface that publishers call into. Higher-level-helpers ('Keiro.Outbox') and transport adapters-('Keiro.Outbox.Kafka') consume these primitives.--}-module Keiro.Outbox.Schema (-    enqueueOutboxTx,-    claimOutboxBatch,-    requeueStuckOutbox,-    markOutboxSent,-    markOutboxSentBatch,-    markOutboxFailedTx,-    markOutboxSkippedTx,-    lookupOutbox,-    listOutbox,-    countOutboxBacklog,-    garbageCollectSent,-)-where--import Contravariant.Extras (contrazip2, contrazip3, contrazip5)-import Data.ByteString (ByteString)-import Data.Functor.Contravariant ((>$<))-import Data.Time.Clock (NominalDiffTime, addUTCTime)-import Data.UUID (UUID)-import Effectful (Eff, (:>))-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Statement (Statement, preparable)-import Keiro.Integration.Event (-    IntegrationEvent (..),-    SchemaReference (..),-    TraceContext (..),-    contentTypeText,-    parseContentType,- )-import Keiro.Outbox.Types-import Keiro.Prelude-import Kiroku.Store.Effect (Store)-import Kiroku.Store.Transaction (runTransaction)-import Kiroku.Store.Types (EventId (..), GlobalPosition (..))-import "hasql-transaction" Hasql.Transaction qualified as Tx--{- | Enqueue one integration event inside an existing transaction.--The @(source, message_id)@ unique constraint catches duplicate retries-from a saga/process-manager. Callers that mint a fresh @messageId@ per-attempt should also mint a fresh @outboxId@; callers that want-idempotent retries should reuse both.--The row's @created_at@ value is the PostgreSQL transaction-start time. The-per-key and per-source publisher policies therefore provide only best-effort-ordering when concurrent transactions enqueue the same key/source and commit-in the opposite order. Serialize those transactions when strict order matters;-the canonical producer subscription already does so.--}-enqueueOutboxTx :: OutboxMessage -> Tx.Transaction ()-enqueueOutboxTx message =-    Tx.statement (toEncodedRow message) enqueueOutboxStmt---- | Read a single outbox row by id. Used by tests and inspection tooling.-lookupOutbox :: (Store :> es) => OutboxId -> Eff es (Maybe OutboxRow)-lookupOutbox outboxId =-    runTransaction $-        Tx.statement (unOutboxId outboxId) lookupOutboxStmt--{- | List outbox rows for a source, ordered by @created_at@. Used by tests;-not intended for application traffic.--}-listOutbox :: (Store :> es) => Text -> Eff es [OutboxRow]-listOutbox source =-    runTransaction $-        Tx.statement source listOutboxStmt--{- | Count outbox rows awaiting publish (backlog gauge source).--Backlog = rows in a claimable, non-terminal state. Mirrors the claim-query's @status IN ('pending','failed')@ predicate so the gauge measures-exactly the rows a publisher still has to drain (rows held mid-pass in-@publishing@, and the terminal @sent@/@dead@ rows, are excluded).--}-countOutboxBacklog :: (Store :> es) => Eff es Int-countOutboxBacklog =-    runTransaction (Tx.statement () countBacklogStmt)--{- | Delete @sent@ rows whose @published_at@ is older than @keepFor@ before-@now@.--Returns the number of rows deleted. @dead@ rows are never deleted: they are-operator action items proving an event was not published. The retention window-only bounds how long successful publish history remains queryable; consumer-dedupe lives in the inbox, not here.--}-garbageCollectSent ::-    (Store :> es) =>-    NominalDiffTime ->-    UTCTime ->-    Eff es Int-garbageCollectSent keepFor now = do-    let cutoff = addUTCTime (negate keepFor) now-    result <--        runTransaction $-            Tx.statement cutoff gcSentStmt-    pure (fromIntegral result)--{- | Claim up to @limit@ rows ready for publish.--Rows in @pending@ or @failed@ status whose @next_attempt_at@ has passed-become candidates. The selection is filtered by 'OrderingPolicy':--* 'PerKeyHeadOfLine' — a row is claimed only if every earlier-  non-terminal row with the same @(source, message_key)@ is also claimed-  by the same statement. Rows with @message_key IS NULL@ bypass the-  per-key check.-* 'PerSourceStream' — a row is claimed only if every earlier-  non-terminal row in the same @source@ is also claimed by the same-  statement, regardless of key.-* 'StopTheLine' — same as 'PerKeyHeadOfLine' at claim time; the worker-  halts on the first failure (decided at the worker level).-* 'BestEffort' — no head-of-line predicate.--The returned list preserves @(created_at, outbox_id)@ order. Per-key and-per-source subsequences are therefore gapless ordered runs. The @LIMIT@-applies to the locked candidate set before the post-filter; under-concurrent claimers or a limit cut through the middle of a run, a pass-can return fewer than @limit@ rows even when more rows are ready.--Claimed rows are transitioned to @publishing@ and have their-@attempt_count@ incremented atomically.--}-claimOutboxBatch ::-    (Store :> es) =>-    OrderingPolicy ->-    Int ->-    UTCTime ->-    Eff es [OutboxRow]-claimOutboxBatch policy limit now =-    runTransaction $-        Tx.statement (fromIntegral limit, now) (claimStmt policy)--{- | Reclaim rows stranded in @publishing@ longer than @olderThan@.--Rows whose claim already consumed the attempt budget are dead-lettered; the-rest return to @failed@ so the regular claim query can retry them. Returns-@(requeued, deadLettered)@.--}-requeueStuckOutbox ::-    (Store :> es) =>-    Int ->-    NominalDiffTime ->-    UTCTime ->-    Eff es (Int, Int)-requeueStuckOutbox maxAttempts olderThan now =-    runTransaction $ do-        let cutoff = addUTCTime (negate olderThan) now-        dead <- Tx.statement (cutoff, fromIntegral maxAttempts, now) deadLetterStuckStmt-        requeued <- Tx.statement (cutoff, fromIntegral maxAttempts, now) requeueStuckStmt-        pure (fromIntegral requeued, fromIntegral dead)--{- | Mark a row as successfully published. Sets @published_at@ and clears-@last_error@. Returns 'False' if the row left @publishing@ before the mark,-for example because a stale-row sweeper or operator changed it while the-transport publish was in flight. The publish may still have happened; callers-must treat this as at-least-once delivery.--}-markOutboxSent :: (Store :> es) => OutboxId -> UTCTime -> Eff es Bool-markOutboxSent outboxId now =-    runTransaction $-        Tx.statement (unOutboxId outboxId, now) markSentStmt--{- | Mark many rows as successfully published in one statement.--Only rows still in @publishing@ transition. Returns how many rows changed;-callers treat a shortfall as benign because delivery is already at-least-once.--}-markOutboxSentBatch :: (Store :> es) => [OutboxId] -> UTCTime -> Eff es Int-markOutboxSentBatch [] _ = pure 0-markOutboxSentBatch outboxIds now =-    fromIntegral-        <$> runTransaction-            ( Tx.statement-                (fmap unOutboxId outboxIds, now)-                markSentBatchStmt-            )--{- | Mark a row as failed and decide whether it is retryable or dead.--Reads the current @attempt_count@; if it is greater than or equal to-@maxAttempts@, transitions to 'OutboxDead'. Otherwise transitions to-'OutboxFailed' and sets @next_attempt_at = now + delay@. Returns the-resulting status so the worker can update its summary counters.--Runs inside the caller's transaction to keep "read attempt count → write-status" atomic with respect to other workers.--Only rows still in @publishing@ are updated (matching 'markSentBatchStmt'-and 'markSkippedStmt'): if the row outlived 'publishingTimeout' and-'outboxMaintenancePass' already requeued it — possibly handing it to-another worker — a late failure mark from the original worker must not-flip the re-claimed row mid-publish.--}-markOutboxFailedTx ::-    OutboxId ->-    Text ->-    Int ->-    NominalDiffTime ->-    UTCTime ->-    Tx.Transaction OutboxStatus-markOutboxFailedTx outboxId errMsg maxAttempts delay now = do-    currentAttempt <- Tx.statement (unOutboxId outboxId) readAttemptCountStmt-    let attempt = fromMaybe 0 currentAttempt-        shouldDie = attempt >= maxAttempts-        nextStatus = if shouldDie then OutboxDead else OutboxFailed-        nextAttempt = addUTCTime delay now-    Tx.statement-        (unOutboxId outboxId, statusText nextStatus, errMsg, nextAttempt, now)-        markFailedStmt-    pure nextStatus--{- | Return a claimed row to @failed@ without consuming an attempt.--Used for rows skipped because an earlier row in the same ordered group failed-inside the same publish batch.--}-markOutboxSkippedTx :: OutboxId -> Text -> UTCTime -> Tx.Transaction ()-markOutboxSkippedTx outboxId errMsg now =-    Tx.statement (unOutboxId outboxId, errMsg, now) markSkippedStmt---- ------------------------------------------------------------------------------ Encoder support--- -----------------------------------------------------------------------------{- | Flattened row used as the encoder input. Field order is locked to-the INSERT statement; the encoder threads each field through a separate-'E.Params' fragment joined by 'mconcat'.--}-data EncodedRow = EncodedRow-    { outboxId :: !UUID-    , messageId :: !Text-    , source :: !Text-    , destination :: !Text-    , messageKey :: !(Maybe Text)-    , eventType :: !Text-    , schemaVersion :: !Int64-    , contentType :: !Text-    , schemaRegistry :: !(Maybe Text)-    , schemaSubject :: !(Maybe Text)-    , schemaVersionRef :: !(Maybe Int64)-    , schemaId :: !(Maybe Int64)-    , schemaFingerprint :: !(Maybe Text)-    , sourceEventId :: !(Maybe UUID)-    , sourceGlobalPosition :: !(Maybe Int64)-    , causationId :: !(Maybe UUID)-    , correlationId :: !(Maybe UUID)-    , traceparent :: !(Maybe Text)-    , tracestate :: !(Maybe Text)-    , payloadBytes :: !ByteString-    , attributes :: !(Maybe Value)-    , occurredAt :: !UTCTime-    }-    deriving stock (Generic)--toEncodedRow :: OutboxMessage -> EncodedRow-toEncodedRow message =-    let event = message ^. #event-        mref = event ^. #schemaReference-        mtrace = event ^. #traceContext-     in EncodedRow-            { outboxId = unOutboxId (message ^. #outboxId)-            , messageId = event ^. #messageId-            , source = event ^. #source-            , destination = event ^. #destination-            , messageKey = event ^. #key-            , eventType = event ^. #eventType-            , schemaVersion = fromIntegral (event ^. #schemaVersion)-            , contentType = contentTypeText (event ^. #contentType)-            , schemaRegistry = mref >>= (^. #registry)-            , schemaSubject = mref >>= (^. #subject)-            , schemaVersionRef = fmap fromIntegral (mref >>= (^. #version))-            , schemaId = mref >>= (^. #schemaId)-            , schemaFingerprint = mref >>= (^. #fingerprint)-            , sourceEventId = fmap unEventId (event ^. #sourceEventId)-            , sourceGlobalPosition = fmap unGlobalPosition (event ^. #sourceGlobalPosition)-            , causationId = fmap unEventId (event ^. #causationId)-            , correlationId = fmap unEventId (event ^. #correlationId)-            , traceparent = fmap (^. #traceparent) mtrace-            , tracestate = mtrace >>= (^. #tracestate)-            , payloadBytes = event ^. #payloadBytes-            , attributes = event ^. #attributes-            , occurredAt = event ^. #occurredAt-            }--unEventId :: EventId -> UUID-unEventId (EventId u) = u--unGlobalPosition :: GlobalPosition -> Int64-unGlobalPosition (GlobalPosition i) = i--encodedRowEncoder :: E.Params EncodedRow-encodedRowEncoder =-    mconcat-        [ view #outboxId >$< E.param (E.nonNullable E.uuid)-        , view #messageId >$< E.param (E.nonNullable E.text)-        , view #source >$< E.param (E.nonNullable E.text)-        , view #destination >$< E.param (E.nonNullable E.text)-        , view #messageKey >$< E.param (E.nullable E.text)-        , view #eventType >$< E.param (E.nonNullable E.text)-        , view #schemaVersion >$< E.param (E.nonNullable E.int8)-        , view #contentType >$< E.param (E.nonNullable E.text)-        , view #schemaRegistry >$< E.param (E.nullable E.text)-        , view #schemaSubject >$< E.param (E.nullable E.text)-        , view #schemaVersionRef >$< E.param (E.nullable E.int8)-        , view #schemaId >$< E.param (E.nullable E.int8)-        , view #schemaFingerprint >$< E.param (E.nullable E.text)-        , view #sourceEventId >$< E.param (E.nullable E.uuid)-        , view #sourceGlobalPosition >$< E.param (E.nullable E.int8)-        , view #causationId >$< E.param (E.nullable E.uuid)-        , view #correlationId >$< E.param (E.nullable E.uuid)-        , view #traceparent >$< E.param (E.nullable E.text)-        , view #tracestate >$< E.param (E.nullable E.text)-        , view #payloadBytes >$< E.param (E.nonNullable E.bytea)-        , view #attributes >$< E.param (E.nullable E.jsonb)-        , view #occurredAt >$< E.param (E.nonNullable E.timestamptz)-        ]---- ------------------------------------------------------------------------------ Statements--- -----------------------------------------------------------------------------enqueueOutboxStmt :: Statement EncodedRow ()-enqueueOutboxStmt =-    preparable-        """-        INSERT INTO keiro.keiro_outbox-          ( outbox_id-          , message_id-          , source-          , destination-          , message_key-          , event_type-          , schema_version-          , content_type-          , schema_registry-          , schema_subject-          , schema_version_ref-          , schema_id-          , schema_fingerprint-          , source_event_id-          , source_global_position-          , causation_id-          , correlation_id-          , traceparent-          , tracestate-          , payload_bytes-          , attributes-          , occurred_at-          )-        VALUES-          ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)-        ON CONFLICT (source, message_id) DO NOTHING-        """-        encodedRowEncoder-        D.noResult--claimStmt :: OrderingPolicy -> Statement (Int64, UTCTime) [OutboxRow]-claimStmt policy =-    preparable-        (claimSql (policyPredicates policy))-        ( contrazip2-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.timestamptz))-        )-        (D.rowList claimResultDecoder)--data PolicyPredicates = PolicyPredicates-    { preFilter :: !Text-    , postFilter :: !Text-    }-    deriving stock (Generic)--policyPredicates :: OrderingPolicy -> PolicyPredicates-policyPredicates = \case-    PerKeyHeadOfLine -> perKeyPredicates-    PerSourceStream -> perSourcePredicates-    StopTheLine -> perKeyPredicates-    BestEffort -> PolicyPredicates{preFilter = "TRUE", postFilter = "TRUE"}--perKeyPredicates :: PolicyPredicates-perKeyPredicates =-    PolicyPredicates-        { preFilter =-            """-            ( r.message_key IS NULL OR NOT EXISTS (-                SELECT 1 FROM keiro.keiro_outbox earlier-                WHERE earlier.source = r.source-                  AND earlier.message_key = r.message_key-                  AND (earlier.created_at, earlier.outbox_id) < (r.created_at, r.outbox_id)-                  AND earlier.status NOT IN ('sent', 'dead')-                  AND NOT (earlier.status IN ('pending', 'failed') AND earlier.next_attempt_at <= $2) ) )-            """-        , postFilter =-            """-            ( c.message_key IS NULL OR NOT EXISTS (-                SELECT 1 FROM keiro.keiro_outbox earlier-                WHERE earlier.source = c.source-                  AND earlier.message_key = c.message_key-                  AND (earlier.created_at, earlier.outbox_id) < (c.created_at, c.outbox_id)-                  AND earlier.status NOT IN ('sent', 'dead')-                  AND NOT EXISTS (-                    SELECT 1 FROM candidate c2-                    WHERE c2.outbox_id = earlier.outbox_id ) ) )-            """-        }--perSourcePredicates :: PolicyPredicates-perSourcePredicates =-    PolicyPredicates-        { preFilter =-            """-            NOT EXISTS (-              SELECT 1 FROM keiro.keiro_outbox earlier-              WHERE earlier.source = r.source-                AND (earlier.created_at, earlier.outbox_id) < (r.created_at, r.outbox_id)-                AND earlier.status NOT IN ('sent', 'dead')-                AND NOT (earlier.status IN ('pending', 'failed') AND earlier.next_attempt_at <= $2) )-            """-        , postFilter =-            """-            NOT EXISTS (-              SELECT 1 FROM keiro.keiro_outbox earlier-              WHERE earlier.source = c.source-                AND (earlier.created_at, earlier.outbox_id) < (c.created_at, c.outbox_id)-                AND earlier.status NOT IN ('sent', 'dead')-                AND NOT EXISTS (-                  SELECT 1 FROM candidate c2-                  WHERE c2.outbox_id = earlier.outbox_id ) )-            """-        }--claimSql :: PolicyPredicates -> Text-claimSql PolicyPredicates{preFilter, postFilter} =-    """-    WITH candidate AS (-      SELECT r.outbox_id, r.source, r.message_key, r.created_at-      FROM keiro.keiro_outbox r-      WHERE r.status IN ('pending', 'failed')-        AND r.next_attempt_at <= $2-        AND (-    """-        <> preFilter-        <> """--           )-             ORDER BY r.created_at, r.outbox_id-             LIMIT $1-             FOR UPDATE SKIP LOCKED-           ),-           ready AS (-           SELECT c.outbox_id, c.created_at AS claim_created_at-           FROM candidate c-           WHERE (-           """-        <> postFilter-        <> """--           )-           ),-           updated AS (-           UPDATE keiro.keiro_outbox kt-           SET status = 'publishing', attempt_count = kt.attempt_count + 1, updated_at = $2-           FROM ready-           WHERE kt.outbox_id = ready.outbox_id-           RETURNING ready.claim_created_at,--           """-        <> rowColumns-        <> """--           )-           SELECT--           """-        <> unqualifiedRowColumns-        <> """--           FROM updated-           ORDER BY claim_created_at, outbox_id-           """--rowColumns :: Text-rowColumns =-    """-    kt.outbox_id, kt.message_id, kt.source, kt.destination, kt.message_key,-    kt.event_type, kt.schema_version, kt.content_type, kt.schema_registry,-    kt.schema_subject, kt.schema_version_ref, kt.schema_id, kt.schema_fingerprint,-    kt.source_event_id, kt.source_global_position, kt.causation_id,-    kt.correlation_id, kt.traceparent, kt.tracestate, kt.payload_bytes,-    kt.attributes, kt.occurred_at, kt.status, kt.attempt_count,-    kt.next_attempt_at, kt.last_error, kt.published_at, kt.created_at,-    kt.updated_at-    """--unqualifiedRowColumns :: Text-unqualifiedRowColumns =-    """-    outbox_id, message_id, source, destination, message_key,-    event_type, schema_version, content_type, schema_registry,-    schema_subject, schema_version_ref, schema_id, schema_fingerprint,-    source_event_id, source_global_position, causation_id,-    correlation_id, traceparent, tracestate, payload_bytes,-    attributes, occurred_at, status, attempt_count,-    next_attempt_at, last_error, published_at, created_at,-    updated_at-    """--claimResultDecoder :: D.Row OutboxRow-claimResultDecoder = outboxRowDecoder--countBacklogStmt :: Statement () Int-countBacklogStmt =-    preparable-        "SELECT COUNT(*)::bigint FROM keiro.keiro_outbox WHERE status IN ('pending', 'failed')"-        E.noParams-        (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))--gcSentStmt :: Statement UTCTime Int64-gcSentStmt =-    preparable-        """-        WITH deleted AS (-          DELETE FROM keiro.keiro_outbox-          WHERE status = 'sent' AND published_at < $1-          RETURNING 1-        )-        SELECT COALESCE(COUNT(*), 0)::bigint FROM deleted-        """-        (E.param (E.nonNullable E.timestamptz))-        (D.singleRow (D.column (D.nonNullable D.int8)))--readAttemptCountStmt :: Statement UUID (Maybe Int)-readAttemptCountStmt =-    preparable-        "SELECT attempt_count FROM keiro.keiro_outbox WHERE outbox_id = $1"-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe (fromIntegral <$> D.column (D.nonNullable D.int8)))--deadLetterStuckStmt :: Statement (UTCTime, Int64, UTCTime) Int64-deadLetterStuckStmt =-    preparable-        """-        UPDATE keiro.keiro_outbox-        SET status = 'dead',-            last_error = COALESCE(last_error, 'reclaimed: publisher crashed mid-publish'),-            updated_at = $3-        WHERE status = 'publishing'-          AND updated_at <= $1-          AND attempt_count >= $2-        """-        ( contrazip3-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.rowsAffected--requeueStuckStmt :: Statement (UTCTime, Int64, UTCTime) Int64-requeueStuckStmt =-    preparable-        """-        UPDATE keiro.keiro_outbox-        SET status = 'failed',-            updated_at = $3-        WHERE status = 'publishing'-          AND updated_at <= $1-          AND attempt_count < $2-        """-        ( contrazip3-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.rowsAffected--markSentStmt :: Statement (UUID, UTCTime) Bool-markSentStmt =-    preparable-        """-        UPDATE keiro.keiro_outbox-        SET status = 'sent',-            published_at = $2,-            last_error = NULL,-            updated_at = $2-        WHERE outbox_id = $1-          AND status = 'publishing'-        """-        ( contrazip2-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.timestamptz))-        )-        ((> 0) <$> D.rowsAffected)--markSentBatchStmt :: Statement ([UUID], UTCTime) Int64-markSentBatchStmt =-    preparable-        """-        UPDATE keiro.keiro_outbox-        SET status = 'sent',-            published_at = $2,-            last_error = NULL,-            updated_at = $2-        WHERE outbox_id = ANY($1)-          AND status = 'publishing'-        """-        ( contrazip2-            (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.rowsAffected--markFailedStmt :: Statement (UUID, Text, Text, UTCTime, UTCTime) ()-markFailedStmt =-    preparable-        """-        UPDATE keiro.keiro_outbox-        SET status = $2,-            last_error = $3,-            next_attempt_at = $4,-            updated_at = $5-        WHERE outbox_id = $1-          AND status = 'publishing'-        """-        ( contrazip5-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult--markSkippedStmt :: Statement (UUID, Text, UTCTime) ()-markSkippedStmt =-    preparable-        """-        UPDATE keiro.keiro_outbox-        SET status = 'failed',-            attempt_count = GREATEST(attempt_count - 1, 0),-            last_error = $2,-            next_attempt_at = $3,-            updated_at = $3-        WHERE outbox_id = $1-          AND status = 'publishing'-        """-        ( contrazip3-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult--lookupOutboxStmt :: Statement UUID (Maybe OutboxRow)-lookupOutboxStmt =-    preparable-        (selectAllSql <> " WHERE outbox_id = $1")-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe outboxRowDecoder)--listOutboxStmt :: Statement Text [OutboxRow]-listOutboxStmt =-    preparable-        (selectAllSql <> " WHERE source = $1 ORDER BY created_at, outbox_id")-        (E.param (E.nonNullable E.text))-        (D.rowList outboxRowDecoder)--selectAllSql :: Text-selectAllSql =-    """-    SELECT outbox_id, message_id, source, destination, message_key, event_type,-           schema_version, content_type, schema_registry, schema_subject,-           schema_version_ref, schema_id, schema_fingerprint, source_event_id,-           source_global_position, causation_id, correlation_id, traceparent,-           tracestate, payload_bytes, attributes, occurred_at, status,-           attempt_count, next_attempt_at, last_error, published_at, created_at,-           updated_at-    FROM keiro.keiro_outbox-    """--outboxRowDecoder :: D.Row OutboxRow-outboxRowDecoder = fmap assembleRow rawRowDecoder--data RawRow = RawRow-    { outboxId :: !OutboxId-    , messageId :: !Text-    , source :: !Text-    , destination :: !Text-    , key :: !(Maybe Text)-    , eventType :: !Text-    , schemaVersion :: !Int-    , contentType :: !Text-    , schemaRegistry :: !(Maybe Text)-    , schemaSubject :: !(Maybe Text)-    , schemaVersionRef :: !(Maybe Int)-    , schemaId :: !(Maybe Int64)-    , schemaFingerprint :: !(Maybe Text)-    , sourceEventId :: !(Maybe EventId)-    , sourceGlobalPosition :: !(Maybe GlobalPosition)-    , causationId :: !(Maybe EventId)-    , correlationId :: !(Maybe EventId)-    , traceparent :: !(Maybe Text)-    , tracestate :: !(Maybe Text)-    , payloadBytes :: !ByteString-    , attributes :: !(Maybe Value)-    , occurredAt :: !UTCTime-    , status :: !OutboxStatus-    , attemptCount :: !Int-    , nextAttemptAt :: !UTCTime-    , lastError :: !(Maybe Text)-    , publishedAt :: !(Maybe UTCTime)-    , createdAt :: !UTCTime-    , updatedAt :: !UTCTime-    }-    deriving stock (Generic)--rawRowDecoder :: D.Row RawRow-rawRowDecoder =-    RawRow-        <$> (OutboxId <$> D.column (D.nonNullable D.uuid))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> (fromIntegral <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.text)-        <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))-        <*> D.column (D.nullable D.int8)-        <*> D.column (D.nullable D.text)-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))-        <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nonNullable D.bytea)-        <*> D.column (D.nullable D.jsonb)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable (D.refine parseStatus D.text))-        <*> (fromIntegral <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)--assembleRow :: RawRow -> OutboxRow-assembleRow raw =-    let traceContext = case raw ^. #traceparent of-            Nothing -> Nothing-            Just tp -> Just (TraceContext tp (raw ^. #tracestate))-        schemaReference =-            case ( raw ^. #schemaRegistry-                 , raw ^. #schemaSubject-                 , raw ^. #schemaVersionRef-                 , raw ^. #schemaId-                 , raw ^. #schemaFingerprint-                 ) of-                (Nothing, Nothing, Nothing, Nothing, Nothing) -> Nothing-                _ ->-                    Just-                        ( SchemaReference-                            (raw ^. #schemaRegistry)-                            (raw ^. #schemaSubject)-                            (raw ^. #schemaVersionRef)-                            (raw ^. #schemaId)-                            (raw ^. #schemaFingerprint)-                        )-        event =-            IntegrationEvent-                { messageId = raw ^. #messageId-                , source = raw ^. #source-                , destination = raw ^. #destination-                , key = raw ^. #key-                , eventType = raw ^. #eventType-                , schemaVersion = raw ^. #schemaVersion-                , contentType = parseContentType (raw ^. #contentType)-                , schemaReference-                , sourceEventId = raw ^. #sourceEventId-                , sourceGlobalPosition = raw ^. #sourceGlobalPosition-                , payloadBytes = raw ^. #payloadBytes-                , occurredAt = raw ^. #occurredAt-                , causationId = raw ^. #causationId-                , correlationId = raw ^. #correlationId-                , traceContext-                , attributes = raw ^. #attributes-                }-     in OutboxRow-            { outboxId = raw ^. #outboxId-            , event-            , status = raw ^. #status-            , attemptCount = raw ^. #attemptCount-            , nextAttemptAt = raw ^. #nextAttemptAt-            , lastError = raw ^. #lastError-            , publishedAt = raw ^. #publishedAt-            , createdAt = raw ^. #createdAt-            , updatedAt = raw ^. #updatedAt-            }+-- | Hasql-level storage for the durable integration-event outbox.+--+-- This module owns the SQL surface that publishers call into. Higher-level+-- helpers ('Keiro.Outbox') and transport adapters+-- ('Keiro.Outbox.Kafka') consume these primitives.+module Keiro.Outbox.Schema+  ( enqueueOutboxTx,+    claimOutboxBatch,+    requeueStuckOutbox,+    markOutboxSent,+    markOutboxSentBatch,+    markOutboxFailedTx,+    markOutboxSkippedTx,+    lookupOutbox,+    listOutbox,+    countOutboxBacklog,+    garbageCollectSent,+  )+where++import Contravariant.Extras (contrazip2, contrazip3, contrazip5)+import Data.ByteString (ByteString)+import Data.Functor.Contravariant ((>$<))+import Data.Time.Clock (NominalDiffTime, addUTCTime)+import Data.UUID (UUID)+import Effectful (Eff, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Integration.Event+  ( IntegrationEvent (..),+    SchemaReference (..),+    TraceContext (..),+    contentTypeText,+    parseContentType,+  )+import Keiro.Outbox.Types+import Keiro.Prelude+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (EventId (..), GlobalPosition (..))+import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | Enqueue one integration event inside an existing transaction.+--+-- The @(source, message_id)@ unique constraint catches duplicate retries+-- from a saga/process-manager. Callers that mint a fresh @messageId@ per+-- attempt should also mint a fresh @outboxId@; callers that want+-- idempotent retries should reuse both.+--+-- The row's @created_at@ value is the PostgreSQL transaction-start time. The+-- per-key and per-source publisher policies therefore provide only best-effort+-- ordering when concurrent transactions enqueue the same key/source and commit+-- in the opposite order. Serialize those transactions when strict order matters;+-- the canonical producer subscription already does so.+enqueueOutboxTx :: OutboxMessage -> Tx.Transaction ()+enqueueOutboxTx message =+  Tx.statement (toEncodedRow message) enqueueOutboxStmt++-- | Read a single outbox row by id. Used by tests and inspection tooling.+lookupOutbox :: (Store :> es) => OutboxId -> Eff es (Maybe OutboxRow)+lookupOutbox outboxId =+  runTransaction $+    Tx.statement (unOutboxId outboxId) lookupOutboxStmt++-- | List outbox rows for a source, ordered by @created_at@. Used by tests;+-- not intended for application traffic.+listOutbox :: (Store :> es) => Text -> Eff es [OutboxRow]+listOutbox source =+  runTransaction $+    Tx.statement source listOutboxStmt++-- | Count outbox rows awaiting publish (backlog gauge source).+--+-- Backlog = rows in a claimable, non-terminal state. Mirrors the claim+-- query's @status IN ('pending','failed')@ predicate so the gauge measures+-- exactly the rows a publisher still has to drain (rows held mid-pass in+-- @publishing@, and the terminal @sent@/@dead@ rows, are excluded).+countOutboxBacklog :: (Store :> es) => Eff es Int+countOutboxBacklog =+  runTransaction (Tx.statement () countBacklogStmt)++-- | Delete @sent@ rows whose @published_at@ is older than @keepFor@ before+-- @now@.+--+-- Returns the number of rows deleted. @dead@ rows are never deleted: they are+-- operator action items proving an event was not published. The retention window+-- only bounds how long successful publish history remains queryable; consumer+-- dedupe lives in the inbox, not here.+garbageCollectSent ::+  (Store :> es) =>+  NominalDiffTime ->+  UTCTime ->+  Eff es Int+garbageCollectSent keepFor now = do+  let cutoff = addUTCTime (negate keepFor) now+  result <-+    runTransaction $+      Tx.statement cutoff gcSentStmt+  pure (fromIntegral result)++-- | Claim up to @limit@ rows ready for publish.+--+-- Rows in @pending@ or @failed@ status whose @next_attempt_at@ has passed+-- become candidates. The selection is filtered by 'OrderingPolicy':+--+-- * 'PerKeyHeadOfLine' — a row is claimed only if every earlier+--   non-terminal row with the same @(source, message_key)@ is also claimed+--   by the same statement. Rows with @message_key IS NULL@ bypass the+--   per-key check.+-- * 'PerSourceStream' — a row is claimed only if every earlier+--   non-terminal row in the same @source@ is also claimed by the same+--   statement, regardless of key.+-- * 'StopTheLine' — same as 'PerKeyHeadOfLine' at claim time; the worker+--   halts on the first failure (decided at the worker level).+-- * 'BestEffort' — no head-of-line predicate.+--+-- The returned list preserves @(created_at, outbox_id)@ order. Per-key and+-- per-source subsequences are therefore gapless ordered runs. The @LIMIT@+-- applies to the locked candidate set before the post-filter; under+-- concurrent claimers or a limit cut through the middle of a run, a pass+-- can return fewer than @limit@ rows even when more rows are ready.+--+-- Claimed rows are transitioned to @publishing@ and have their+-- @attempt_count@ incremented atomically.+claimOutboxBatch ::+  (Store :> es) =>+  OrderingPolicy ->+  Int ->+  UTCTime ->+  Eff es [OutboxRow]+claimOutboxBatch policy limit now =+  runTransaction $+    Tx.statement (fromIntegral limit, now) (claimStmt policy)++-- | Reclaim rows stranded in @publishing@ longer than @olderThan@.+--+-- Rows whose claim already consumed the attempt budget are dead-lettered; the+-- rest return to @failed@ so the regular claim query can retry them. Returns+-- @(requeued, deadLettered)@.+requeueStuckOutbox ::+  (Store :> es) =>+  Int ->+  NominalDiffTime ->+  UTCTime ->+  Eff es (Int, Int)+requeueStuckOutbox maxAttempts olderThan now =+  runTransaction $ do+    let cutoff = addUTCTime (negate olderThan) now+    dead <- Tx.statement (cutoff, fromIntegral maxAttempts, now) deadLetterStuckStmt+    requeued <- Tx.statement (cutoff, fromIntegral maxAttempts, now) requeueStuckStmt+    pure (fromIntegral requeued, fromIntegral dead)++-- | Mark a row as successfully published. Sets @published_at@ and clears+-- @last_error@. Returns 'False' if the row left @publishing@ before the mark,+-- for example because a stale-row sweeper or operator changed it while the+-- transport publish was in flight. The publish may still have happened; callers+-- must treat this as at-least-once delivery.+markOutboxSent :: (Store :> es) => OutboxId -> UTCTime -> Eff es Bool+markOutboxSent outboxId now =+  runTransaction $+    Tx.statement (unOutboxId outboxId, now) markSentStmt++-- | Mark many rows as successfully published in one statement.+--+-- Only rows still in @publishing@ transition. Returns how many rows changed;+-- callers treat a shortfall as benign because delivery is already at-least-once.+markOutboxSentBatch :: (Store :> es) => [OutboxId] -> UTCTime -> Eff es Int+markOutboxSentBatch [] _ = pure 0+markOutboxSentBatch outboxIds now =+  fromIntegral+    <$> runTransaction+      ( Tx.statement+          (fmap unOutboxId outboxIds, now)+          markSentBatchStmt+      )++-- | Mark a row as failed and decide whether it is retryable or dead.+--+-- Reads the current @attempt_count@; if it is greater than or equal to+-- @maxAttempts@, transitions to 'OutboxDead'. Otherwise transitions to+-- 'OutboxFailed' and sets @next_attempt_at = now + delay@. Returns the+-- resulting status so the worker can update its summary counters.+--+-- Runs inside the caller's transaction to keep "read attempt count → write+-- status" atomic with respect to other workers.+--+-- Only rows still in @publishing@ are updated (matching 'markSentBatchStmt'+-- and 'markSkippedStmt'): if the row outlived 'publishingTimeout' and+-- 'outboxMaintenancePass' already requeued it — possibly handing it to+-- another worker — a late failure mark from the original worker must not+-- flip the re-claimed row mid-publish.+markOutboxFailedTx ::+  OutboxId ->+  Text ->+  Int ->+  NominalDiffTime ->+  UTCTime ->+  Tx.Transaction OutboxStatus+markOutboxFailedTx outboxId errMsg maxAttempts delay now = do+  currentAttempt <- Tx.statement (unOutboxId outboxId) readAttemptCountStmt+  let attempt = fromMaybe 0 currentAttempt+      shouldDie = attempt >= maxAttempts+      nextStatus = if shouldDie then OutboxDead else OutboxFailed+      nextAttempt = addUTCTime delay now+  Tx.statement+    (unOutboxId outboxId, statusText nextStatus, errMsg, nextAttempt, now)+    markFailedStmt+  pure nextStatus++-- | Return a claimed row to @failed@ without consuming an attempt.+--+-- Used for rows skipped because an earlier row in the same ordered group failed+-- inside the same publish batch.+markOutboxSkippedTx :: OutboxId -> Text -> UTCTime -> Tx.Transaction ()+markOutboxSkippedTx outboxId errMsg now =+  Tx.statement (unOutboxId outboxId, errMsg, now) markSkippedStmt++-- ---------------------------------------------------------------------------+-- Encoder support+-- ---------------------------------------------------------------------------++-- | Flattened row used as the encoder input. Field order is locked to+-- the INSERT statement; the encoder threads each field through a separate+-- 'E.Params' fragment joined by 'mconcat'.+data EncodedRow = EncodedRow+  { outboxId :: !UUID,+    messageId :: !Text,+    source :: !Text,+    destination :: !Text,+    messageKey :: !(Maybe Text),+    eventType :: !Text,+    schemaVersion :: !Int64,+    contentType :: !Text,+    schemaRegistry :: !(Maybe Text),+    schemaSubject :: !(Maybe Text),+    schemaVersionRef :: !(Maybe Int64),+    schemaId :: !(Maybe Int64),+    schemaFingerprint :: !(Maybe Text),+    sourceEventId :: !(Maybe UUID),+    sourceGlobalPosition :: !(Maybe Int64),+    causationId :: !(Maybe UUID),+    correlationId :: !(Maybe UUID),+    traceparent :: !(Maybe Text),+    tracestate :: !(Maybe Text),+    payloadBytes :: !ByteString,+    attributes :: !(Maybe Value),+    occurredAt :: !UTCTime+  }+  deriving stock (Generic)++toEncodedRow :: OutboxMessage -> EncodedRow+toEncodedRow message =+  let event = message ^. #event+      mref = event ^. #schemaReference+      mtrace = event ^. #traceContext+   in EncodedRow+        { outboxId = unOutboxId (message ^. #outboxId),+          messageId = event ^. #messageId,+          source = event ^. #source,+          destination = event ^. #destination,+          messageKey = event ^. #key,+          eventType = event ^. #eventType,+          schemaVersion = fromIntegral (event ^. #schemaVersion),+          contentType = contentTypeText (event ^. #contentType),+          schemaRegistry = mref >>= (^. #registry),+          schemaSubject = mref >>= (^. #subject),+          schemaVersionRef = fmap fromIntegral (mref >>= (^. #version)),+          schemaId = mref >>= (^. #schemaId),+          schemaFingerprint = mref >>= (^. #fingerprint),+          sourceEventId = fmap unEventId (event ^. #sourceEventId),+          sourceGlobalPosition = fmap unGlobalPosition (event ^. #sourceGlobalPosition),+          causationId = fmap unEventId (event ^. #causationId),+          correlationId = fmap unEventId (event ^. #correlationId),+          traceparent = fmap (^. #traceparent) mtrace,+          tracestate = mtrace >>= (^. #tracestate),+          payloadBytes = event ^. #payloadBytes,+          attributes = event ^. #attributes,+          occurredAt = event ^. #occurredAt+        }++unEventId :: EventId -> UUID+unEventId (EventId u) = u++unGlobalPosition :: GlobalPosition -> Int64+unGlobalPosition (GlobalPosition i) = i++encodedRowEncoder :: E.Params EncodedRow+encodedRowEncoder =+  mconcat+    [ view #outboxId >$< E.param (E.nonNullable E.uuid),+      view #messageId >$< E.param (E.nonNullable E.text),+      view #source >$< E.param (E.nonNullable E.text),+      view #destination >$< E.param (E.nonNullable E.text),+      view #messageKey >$< E.param (E.nullable E.text),+      view #eventType >$< E.param (E.nonNullable E.text),+      view #schemaVersion >$< E.param (E.nonNullable E.int8),+      view #contentType >$< E.param (E.nonNullable E.text),+      view #schemaRegistry >$< E.param (E.nullable E.text),+      view #schemaSubject >$< E.param (E.nullable E.text),+      view #schemaVersionRef >$< E.param (E.nullable E.int8),+      view #schemaId >$< E.param (E.nullable E.int8),+      view #schemaFingerprint >$< E.param (E.nullable E.text),+      view #sourceEventId >$< E.param (E.nullable E.uuid),+      view #sourceGlobalPosition >$< E.param (E.nullable E.int8),+      view #causationId >$< E.param (E.nullable E.uuid),+      view #correlationId >$< E.param (E.nullable E.uuid),+      view #traceparent >$< E.param (E.nullable E.text),+      view #tracestate >$< E.param (E.nullable E.text),+      view #payloadBytes >$< E.param (E.nonNullable E.bytea),+      view #attributes >$< E.param (E.nullable E.jsonb),+      view #occurredAt >$< E.param (E.nonNullable E.timestamptz)+    ]++-- ---------------------------------------------------------------------------+-- Statements+-- ---------------------------------------------------------------------------++enqueueOutboxStmt :: Statement EncodedRow ()+enqueueOutboxStmt =+  preparable+    """+    INSERT INTO keiro.keiro_outbox+      ( outbox_id+      , message_id+      , source+      , destination+      , message_key+      , event_type+      , schema_version+      , content_type+      , schema_registry+      , schema_subject+      , schema_version_ref+      , schema_id+      , schema_fingerprint+      , source_event_id+      , source_global_position+      , causation_id+      , correlation_id+      , traceparent+      , tracestate+      , payload_bytes+      , attributes+      , occurred_at+      )+    VALUES+      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)+    ON CONFLICT (source, message_id) DO NOTHING+    """+    encodedRowEncoder+    D.noResult++claimStmt :: OrderingPolicy -> Statement (Int64, UTCTime) [OutboxRow]+claimStmt policy =+  preparable+    (claimSql (policyPredicates policy))+    ( contrazip2+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.timestamptz))+    )+    (D.rowList claimResultDecoder)++data PolicyPredicates = PolicyPredicates+  { preFilter :: !Text,+    postFilter :: !Text+  }+  deriving stock (Generic)++policyPredicates :: OrderingPolicy -> PolicyPredicates+policyPredicates = \case+  PerKeyHeadOfLine -> perKeyPredicates+  PerSourceStream -> perSourcePredicates+  StopTheLine -> perKeyPredicates+  BestEffort -> PolicyPredicates {preFilter = "TRUE", postFilter = "TRUE"}++perKeyPredicates :: PolicyPredicates+perKeyPredicates =+  PolicyPredicates+    { preFilter =+        """+        ( r.message_key IS NULL OR NOT EXISTS (+            SELECT 1 FROM keiro.keiro_outbox earlier+            WHERE earlier.source = r.source+              AND earlier.message_key = r.message_key+              AND (earlier.created_at, earlier.outbox_id) < (r.created_at, r.outbox_id)+              AND earlier.status NOT IN ('sent', 'dead')+              AND NOT (earlier.status IN ('pending', 'failed') AND earlier.next_attempt_at <= $2) ) )+        """,+      postFilter =+        """+        ( c.message_key IS NULL OR NOT EXISTS (+            SELECT 1 FROM keiro.keiro_outbox earlier+            WHERE earlier.source = c.source+              AND earlier.message_key = c.message_key+              AND (earlier.created_at, earlier.outbox_id) < (c.created_at, c.outbox_id)+              AND earlier.status NOT IN ('sent', 'dead')+              AND NOT EXISTS (+                SELECT 1 FROM candidate c2+                WHERE c2.outbox_id = earlier.outbox_id ) ) )+        """+    }++perSourcePredicates :: PolicyPredicates+perSourcePredicates =+  PolicyPredicates+    { preFilter =+        """+        NOT EXISTS (+          SELECT 1 FROM keiro.keiro_outbox earlier+          WHERE earlier.source = r.source+            AND (earlier.created_at, earlier.outbox_id) < (r.created_at, r.outbox_id)+            AND earlier.status NOT IN ('sent', 'dead')+            AND NOT (earlier.status IN ('pending', 'failed') AND earlier.next_attempt_at <= $2) )+        """,+      postFilter =+        """+        NOT EXISTS (+          SELECT 1 FROM keiro.keiro_outbox earlier+          WHERE earlier.source = c.source+            AND (earlier.created_at, earlier.outbox_id) < (c.created_at, c.outbox_id)+            AND earlier.status NOT IN ('sent', 'dead')+            AND NOT EXISTS (+              SELECT 1 FROM candidate c2+              WHERE c2.outbox_id = earlier.outbox_id ) )+        """+    }++claimSql :: PolicyPredicates -> Text+claimSql PolicyPredicates {preFilter, postFilter} =+  """+  WITH candidate AS (+    SELECT r.outbox_id, r.source, r.message_key, r.created_at+    FROM keiro.keiro_outbox r+    WHERE r.status IN ('pending', 'failed')+      AND r.next_attempt_at <= $2+      AND (+  """+    <> preFilter+    <> """++       )+         ORDER BY r.created_at, r.outbox_id+         LIMIT $1+         FOR UPDATE SKIP LOCKED+       ),+       ready AS (+       SELECT c.outbox_id, c.created_at AS claim_created_at+       FROM candidate c+       WHERE (+       """+    <> postFilter+    <> """++       )+       ),+       updated AS (+       UPDATE keiro.keiro_outbox kt+       SET status = 'publishing', attempt_count = kt.attempt_count + 1, updated_at = $2+       FROM ready+       WHERE kt.outbox_id = ready.outbox_id+       RETURNING ready.claim_created_at,++       """+    <> rowColumns+    <> """++       )+       SELECT++       """+    <> unqualifiedRowColumns+    <> """++       FROM updated+       ORDER BY claim_created_at, outbox_id+       """++rowColumns :: Text+rowColumns =+  """+  kt.outbox_id, kt.message_id, kt.source, kt.destination, kt.message_key,+  kt.event_type, kt.schema_version, kt.content_type, kt.schema_registry,+  kt.schema_subject, kt.schema_version_ref, kt.schema_id, kt.schema_fingerprint,+  kt.source_event_id, kt.source_global_position, kt.causation_id,+  kt.correlation_id, kt.traceparent, kt.tracestate, kt.payload_bytes,+  kt.attributes, kt.occurred_at, kt.status, kt.attempt_count,+  kt.next_attempt_at, kt.last_error, kt.published_at, kt.created_at,+  kt.updated_at+  """++unqualifiedRowColumns :: Text+unqualifiedRowColumns =+  """+  outbox_id, message_id, source, destination, message_key,+  event_type, schema_version, content_type, schema_registry,+  schema_subject, schema_version_ref, schema_id, schema_fingerprint,+  source_event_id, source_global_position, causation_id,+  correlation_id, traceparent, tracestate, payload_bytes,+  attributes, occurred_at, status, attempt_count,+  next_attempt_at, last_error, published_at, created_at,+  updated_at+  """++claimResultDecoder :: D.Row OutboxRow+claimResultDecoder = outboxRowDecoder++countBacklogStmt :: Statement () Int+countBacklogStmt =+  preparable+    "SELECT COUNT(*)::bigint FROM keiro.keiro_outbox WHERE status IN ('pending', 'failed')"+    E.noParams+    (fmap fromIntegral (D.singleRow (D.column (D.nonNullable D.int8))))++gcSentStmt :: Statement UTCTime Int64+gcSentStmt =+  preparable+    """+    WITH deleted AS (+      DELETE FROM keiro.keiro_outbox+      WHERE status = 'sent' AND published_at < $1+      RETURNING 1+    )+    SELECT COALESCE(COUNT(*), 0)::bigint FROM deleted+    """+    (E.param (E.nonNullable E.timestamptz))+    (D.singleRow (D.column (D.nonNullable D.int8)))++readAttemptCountStmt :: Statement UUID (Maybe Int)+readAttemptCountStmt =+  preparable+    "SELECT attempt_count FROM keiro.keiro_outbox WHERE outbox_id = $1"+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe (fromIntegral <$> D.column (D.nonNullable D.int8)))++deadLetterStuckStmt :: Statement (UTCTime, Int64, UTCTime) Int64+deadLetterStuckStmt =+  preparable+    """+    UPDATE keiro.keiro_outbox+    SET status = 'dead',+        last_error = COALESCE(last_error, 'reclaimed: publisher crashed mid-publish'),+        updated_at = $3+    WHERE status = 'publishing'+      AND updated_at <= $1+      AND attempt_count >= $2+    """+    ( contrazip3+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.rowsAffected++requeueStuckStmt :: Statement (UTCTime, Int64, UTCTime) Int64+requeueStuckStmt =+  preparable+    """+    UPDATE keiro.keiro_outbox+    SET status = 'failed',+        updated_at = $3+    WHERE status = 'publishing'+      AND updated_at <= $1+      AND attempt_count < $2+    """+    ( contrazip3+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.rowsAffected++markSentStmt :: Statement (UUID, UTCTime) Bool+markSentStmt =+  preparable+    """+    UPDATE keiro.keiro_outbox+    SET status = 'sent',+        published_at = $2,+        last_error = NULL,+        updated_at = $2+    WHERE outbox_id = $1+      AND status = 'publishing'+    """+    ( contrazip2+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.timestamptz))+    )+    ((> 0) <$> D.rowsAffected)++markSentBatchStmt :: Statement ([UUID], UTCTime) Int64+markSentBatchStmt =+  preparable+    """+    UPDATE keiro.keiro_outbox+    SET status = 'sent',+        published_at = $2,+        last_error = NULL,+        updated_at = $2+    WHERE outbox_id = ANY($1)+      AND status = 'publishing'+    """+    ( contrazip2+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.rowsAffected++markFailedStmt :: Statement (UUID, Text, Text, UTCTime, UTCTime) ()+markFailedStmt =+  preparable+    """+    UPDATE keiro.keiro_outbox+    SET status = $2,+        last_error = $3,+        next_attempt_at = $4,+        updated_at = $5+    WHERE outbox_id = $1+      AND status = 'publishing'+    """+    ( contrazip5+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult++markSkippedStmt :: Statement (UUID, Text, UTCTime) ()+markSkippedStmt =+  preparable+    """+    UPDATE keiro.keiro_outbox+    SET status = 'failed',+        attempt_count = GREATEST(attempt_count - 1, 0),+        last_error = $2,+        next_attempt_at = $3,+        updated_at = $3+    WHERE outbox_id = $1+      AND status = 'publishing'+    """+    ( contrazip3+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult++lookupOutboxStmt :: Statement UUID (Maybe OutboxRow)+lookupOutboxStmt =+  preparable+    (selectAllSql <> " WHERE outbox_id = $1")+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe outboxRowDecoder)++listOutboxStmt :: Statement Text [OutboxRow]+listOutboxStmt =+  preparable+    (selectAllSql <> " WHERE source = $1 ORDER BY created_at, outbox_id")+    (E.param (E.nonNullable E.text))+    (D.rowList outboxRowDecoder)++selectAllSql :: Text+selectAllSql =+  """+  SELECT outbox_id, message_id, source, destination, message_key, event_type,+         schema_version, content_type, schema_registry, schema_subject,+         schema_version_ref, schema_id, schema_fingerprint, source_event_id,+         source_global_position, causation_id, correlation_id, traceparent,+         tracestate, payload_bytes, attributes, occurred_at, status,+         attempt_count, next_attempt_at, last_error, published_at, created_at,+         updated_at+  FROM keiro.keiro_outbox+  """++outboxRowDecoder :: D.Row OutboxRow+outboxRowDecoder = fmap assembleRow rawRowDecoder++data RawRow = RawRow+  { outboxId :: !OutboxId,+    messageId :: !Text,+    source :: !Text,+    destination :: !Text,+    key :: !(Maybe Text),+    eventType :: !Text,+    schemaVersion :: !Int,+    contentType :: !Text,+    schemaRegistry :: !(Maybe Text),+    schemaSubject :: !(Maybe Text),+    schemaVersionRef :: !(Maybe Int),+    schemaId :: !(Maybe Int64),+    schemaFingerprint :: !(Maybe Text),+    sourceEventId :: !(Maybe EventId),+    sourceGlobalPosition :: !(Maybe GlobalPosition),+    causationId :: !(Maybe EventId),+    correlationId :: !(Maybe EventId),+    traceparent :: !(Maybe Text),+    tracestate :: !(Maybe Text),+    payloadBytes :: !ByteString,+    attributes :: !(Maybe Value),+    occurredAt :: !UTCTime,+    status :: !OutboxStatus,+    attemptCount :: !Int,+    nextAttemptAt :: !UTCTime,+    lastError :: !(Maybe Text),+    publishedAt :: !(Maybe UTCTime),+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime+  }+  deriving stock (Generic)++rawRowDecoder :: D.Row RawRow+rawRowDecoder =+  RawRow+    <$> (OutboxId <$> D.column (D.nonNullable D.uuid))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> (fromIntegral <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> (fmap fromIntegral <$> D.column (D.nullable D.int8))+    <*> D.column (D.nullable D.int8)+    <*> D.column (D.nullable D.text)+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nonNullable D.bytea)+    <*> D.column (D.nullable D.jsonb)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable (D.refine parseStatus D.text))+    <*> (fromIntegral <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)++assembleRow :: RawRow -> OutboxRow+assembleRow raw =+  let traceContext = case raw ^. #traceparent of+        Nothing -> Nothing+        Just tp -> Just (TraceContext tp (raw ^. #tracestate))+      schemaReference =+        case ( raw ^. #schemaRegistry,+               raw ^. #schemaSubject,+               raw ^. #schemaVersionRef,+               raw ^. #schemaId,+               raw ^. #schemaFingerprint+             ) of+          (Nothing, Nothing, Nothing, Nothing, Nothing) -> Nothing+          _ ->+            Just+              ( SchemaReference+                  (raw ^. #schemaRegistry)+                  (raw ^. #schemaSubject)+                  (raw ^. #schemaVersionRef)+                  (raw ^. #schemaId)+                  (raw ^. #schemaFingerprint)+              )+      event =+        IntegrationEvent+          { messageId = raw ^. #messageId,+            source = raw ^. #source,+            destination = raw ^. #destination,+            key = raw ^. #key,+            eventType = raw ^. #eventType,+            schemaVersion = raw ^. #schemaVersion,+            contentType = parseContentType (raw ^. #contentType),+            schemaReference,+            sourceEventId = raw ^. #sourceEventId,+            sourceGlobalPosition = raw ^. #sourceGlobalPosition,+            payloadBytes = raw ^. #payloadBytes,+            occurredAt = raw ^. #occurredAt,+            causationId = raw ^. #causationId,+            correlationId = raw ^. #correlationId,+            traceContext,+            attributes = raw ^. #attributes+          }+   in OutboxRow+        { outboxId = raw ^. #outboxId,+          event,+          status = raw ^. #status,+          attemptCount = raw ^. #attemptCount,+          nextAttemptAt = raw ^. #nextAttemptAt,+          lastError = raw ^. #lastError,+          publishedAt = raw ^. #publishedAt,+          createdAt = raw ^. #createdAt,+          updatedAt = raw ^. #updatedAt+        }
src/Keiro/Outbox/Types.hs view
@@ -1,13 +1,12 @@-{- | Shared types for the durable integration-event outbox.--The outbox is the durable handoff between "this service has decided to-publish this integration event" and "this service has actually published-it". The producer subscription writes one outbox row per mapped private-event; the publisher worker drains rows into Kafka and marks each one-sent, retryable, or dead.--}-module Keiro.Outbox.Types (-    OutboxId (..),+-- | Shared types for the durable integration-event outbox.+--+-- The outbox is the durable handoff between "this service has decided to+-- publish this integration event" and "this service has actually published+-- it". The producer subscription writes one outbox row per mapped private+-- event; the publisher worker drains rows into Kafka and marks each one+-- sent, retryable, or dead.+module Keiro.Outbox.Types+  ( OutboxId (..),     OutboxStatus (..),     OrderingPolicy (..),     BackoffSchedule (..),@@ -25,7 +24,7 @@     statusText,     parseStatus,     nextDelay,-)+  ) where  import Data.Time.Clock (NominalDiffTime)@@ -37,241 +36,231 @@  -- | Primary key of a 'keiro_outbox' row. Stable across publish retries. newtype OutboxId = OutboxId {unOutboxId :: UUID}-    deriving stock (Generic, Eq, Ord, Show)+  deriving stock (Generic, Eq, Ord, Show)  instance ToJSON OutboxId where-    toJSON = toJSON . UUID.toText . unOutboxId+  toJSON = toJSON . UUID.toText . unOutboxId  instance FromJSON OutboxId where-    parseJSON v = do-        text <- parseJSON v-        case UUID.fromText text of-            Nothing -> fail ("OutboxId: not a UUID: " <> show text)-            Just uuid -> pure (OutboxId uuid)--{- | Lifecycle state of an outbox row.+  parseJSON v = do+    text <- parseJSON v+    case UUID.fromText text of+      Nothing -> fail ("OutboxId: not a UUID: " <> show text)+      Just uuid -> pure (OutboxId uuid) -* 'OutboxPending' — never attempted.-* 'OutboxPublishing' — currently held by a publisher worker (between-  claim and the call to mark sent/failed/dead). Rows left in this state-  after a worker crash are reclaimed by 'Keiro.Outbox.outboxMaintenancePass'-  after 'publishingTimeout'.-* 'OutboxSent' — Kafka acknowledged the publish; terminal.-* 'OutboxFailed' — last attempt failed; will be retried after-  'next_attempt_at'.-* 'OutboxDead' — terminal failure after 'maxAttempts' consecutive-  failures. Stays in the table for operator inspection.--}+-- | Lifecycle state of an outbox row.+--+-- * 'OutboxPending' — never attempted.+-- * 'OutboxPublishing' — currently held by a publisher worker (between+--   claim and the call to mark sent/failed/dead). Rows left in this state+--   after a worker crash are reclaimed by 'Keiro.Outbox.outboxMaintenancePass'+--   after 'publishingTimeout'.+-- * 'OutboxSent' — Kafka acknowledged the publish; terminal.+-- * 'OutboxFailed' — last attempt failed; will be retried after+--   'next_attempt_at'.+-- * 'OutboxDead' — terminal failure after 'maxAttempts' consecutive+--   failures. Stays in the table for operator inspection. data OutboxStatus-    = OutboxPending-    | OutboxPublishing-    | OutboxSent-    | OutboxFailed-    | OutboxDead-    deriving stock (Generic, Eq, Show)--{- | Ordering policy enforced by the publisher worker's claim query.+  = OutboxPending+  | OutboxPublishing+  | OutboxSent+  | OutboxFailed+  | OutboxDead+  deriving stock (Generic, Eq, Show) -* 'PerKeyHeadOfLine' (default) — within a @source@, a non-terminal row-  with key @k@ blocks every later row with the same key. Rows with-  'Nothing' key bypass the block (Kafka does not promise cross-key order-  for null-keyed records). One stuck aggregate cannot stall traffic on-  other aggregates. Ordering is based on @created_at@, which PostgreSQL-  sets to transaction-start time; callers that concurrently enqueue the-  same key through escape hatches must serialize those enqueues themselves-  if commit order matters.-* 'PerSourceStream' — within a @source@, any non-terminal row blocks-  every later row. Use when ordering matters across keys (rare). This has-  the same @created_at@ concurrency caveat as 'PerKeyHeadOfLine'.-* 'StopTheLine' — any failure halts the worker until operator-  intervention. Use when correctness requires manual review on every-  failure.-* 'BestEffort' — failed rows do not block; explicit opt-in only. Safe-  only when published events have no per-key/causal relationship.--}+-- | Ordering policy enforced by the publisher worker's claim query.+--+-- * 'PerKeyHeadOfLine' (default) — within a @source@, a non-terminal row+--   with key @k@ blocks every later row with the same key. Rows with+--   'Nothing' key bypass the block (Kafka does not promise cross-key order+--   for null-keyed records). One stuck aggregate cannot stall traffic on+--   other aggregates. Ordering is based on @created_at@, which PostgreSQL+--   sets to transaction-start time; callers that concurrently enqueue the+--   same key through escape hatches must serialize those enqueues themselves+--   if commit order matters.+-- * 'PerSourceStream' — within a @source@, any non-terminal row blocks+--   every later row. Use when ordering matters across keys (rare). This has+--   the same @created_at@ concurrency caveat as 'PerKeyHeadOfLine'.+-- * 'StopTheLine' — any failure halts the worker until operator+--   intervention. Use when correctness requires manual review on every+--   failure.+-- * 'BestEffort' — failed rows do not block; explicit opt-in only. Safe+--   only when published events have no per-key/causal relationship. data OrderingPolicy-    = PerKeyHeadOfLine-    | PerSourceStream-    | StopTheLine-    | BestEffort-    deriving stock (Generic, Eq, Show)+  = PerKeyHeadOfLine+  | PerSourceStream+  | StopTheLine+  | BestEffort+  deriving stock (Generic, Eq, Show)  -- | Knobs for 'ExponentialBackoff'. @delay = min maxDelay (initial * multiplier ^ (attempt - 1))@. data ExponentialBackoffOptions = ExponentialBackoffOptions-    { initial :: !NominalDiffTime-    , maxDelay :: !NominalDiffTime-    , multiplier :: !Double-    }-    deriving stock (Generic, Eq, Show)--{- | Backoff curve used to compute 'next_attempt_at' after a failure.+  { initial :: !NominalDiffTime,+    maxDelay :: !NominalDiffTime,+    multiplier :: !Double+  }+  deriving stock (Generic, Eq, Show) -* 'ConstantBackoff' — fixed delay between retries.-* 'ExponentialBackoff' — exponential growth capped at @maxDelay@.--}+-- | Backoff curve used to compute 'next_attempt_at' after a failure.+--+-- * 'ConstantBackoff' — fixed delay between retries.+-- * 'ExponentialBackoff' — exponential growth capped at @maxDelay@. data BackoffSchedule-    = ConstantBackoff !NominalDiffTime-    | ExponentialBackoff !ExponentialBackoffOptions-    deriving stock (Generic, Eq, Show)+  = ConstantBackoff !NominalDiffTime+  | ExponentialBackoff !ExponentialBackoffOptions+  deriving stock (Generic, Eq, Show) -{- | Compute the retry delay for an attempt number (1-based: 1 = first-failure, 2 = second failure, …). Used by 'Keiro.Outbox.Schema.markOutboxFailedTx'-to derive @next_attempt_at@.--}+-- | Compute the retry delay for an attempt number (1-based: 1 = first+-- failure, 2 = second failure, …). Used by 'Keiro.Outbox.Schema.markOutboxFailedTx'+-- to derive @next_attempt_at@. nextDelay :: BackoffSchedule -> Int -> NominalDiffTime nextDelay (ConstantBackoff delay) _ = delay nextDelay (ExponentialBackoff opts) attempt =-    let raw = (opts ^. #initial) * realToFrac ((opts ^. #multiplier) ** fromIntegral (max 0 (attempt - 1)))-     in min (opts ^. #maxDelay) raw+  let raw = (opts ^. #initial) * realToFrac ((opts ^. #multiplier) ** fromIntegral (max 0 (attempt - 1)))+   in min (opts ^. #maxDelay) raw -{- | A request to enqueue one integration event into the outbox. Callers-generate 'outboxId' (use a random UUID for ad-hoc enqueues, a-deterministic UUID for idempotent retries from a saga/process manager).--}+-- | A request to enqueue one integration event into the outbox. Callers+-- generate 'outboxId' (use a random UUID for ad-hoc enqueues, a+-- deterministic UUID for idempotent retries from a saga/process manager). data OutboxMessage = OutboxMessage-    { outboxId :: !OutboxId-    , event :: !IntegrationEvent-    }-    deriving stock (Generic, Eq, Show)+  { outboxId :: !OutboxId,+    event :: !IntegrationEvent+  }+  deriving stock (Generic, Eq, Show) -{- | A row read back from @keiro_outbox@. Worker code consumes these to-publish to Kafka; tests use them to assert state transitions.--}+-- | A row read back from @keiro_outbox@. Worker code consumes these to+-- publish to Kafka; tests use them to assert state transitions. data OutboxRow = OutboxRow-    { outboxId :: !OutboxId-    , event :: !IntegrationEvent-    , status :: !OutboxStatus-    , attemptCount :: !Int-    , nextAttemptAt :: !UTCTime-    , lastError :: !(Maybe Text)-    , publishedAt :: !(Maybe UTCTime)-    , createdAt :: !UTCTime-    , updatedAt :: !UTCTime-    }-    deriving stock (Generic, Eq, Show)--{- | Knobs that govern one invocation of 'Keiro.Outbox.publishClaimedOutbox'.+  { outboxId :: !OutboxId,+    event :: !IntegrationEvent,+    status :: !OutboxStatus,+    attemptCount :: !Int,+    nextAttemptAt :: !UTCTime,+    lastError :: !(Maybe Text),+    publishedAt :: !(Maybe UTCTime),+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show) -The optional 'tracer' field opts the publisher into OpenTelemetry-instrumentation: when present, the publisher opens a @Producer@-kind-span around each publish call, attributing the first row's-'IntegrationEvent.destination' (topic), 'IntegrationEvent.messageId',-and Kafka key per the messaging semantic conventions. When 'tracer' is-'Nothing' (the default) the publisher emits no spans.--}+-- | Knobs that govern one invocation of 'Keiro.Outbox.publishClaimedOutbox'.+--+-- The optional 'tracer' field opts the publisher into OpenTelemetry+-- instrumentation: when present, the publisher opens a @Producer@-kind+-- span around each publish call, attributing the first row's+-- 'IntegrationEvent.destination' (topic), 'IntegrationEvent.messageId',+-- and Kafka key per the messaging semantic conventions. When 'tracer' is+-- 'Nothing' (the default) the publisher emits no spans. data OutboxPublishOptions = OutboxPublishOptions-    { batchSize :: !Int-    , maxAttempts :: !Int-    , backoff :: !BackoffSchedule-    , orderingPolicy :: !OrderingPolicy-    , publishingTimeout :: !NominalDiffTime-    , tracer :: !(Maybe Tracer)-    }-    deriving stock (Generic)+  { batchSize :: !Int,+    maxAttempts :: !Int,+    backoff :: !BackoffSchedule,+    orderingPolicy :: !OrderingPolicy,+    publishingTimeout :: !NominalDiffTime,+    tracer :: !(Maybe Tracer)+  }+  deriving stock (Generic)  data OutboxPublishConfigError-    = InvalidOutboxBatchSize !Int-    | InvalidOutboxMaxAttempts !Int-    | InvalidOutboxPublishingTimeout !NominalDiffTime-    | InvalidConstantBackoff !NominalDiffTime-    | InvalidExponentialBackoffInitial !NominalDiffTime-    | InvalidExponentialBackoffMultiplier !Double-    | InvalidExponentialBackoffMaxDelay !NominalDiffTime !NominalDiffTime-    deriving stock (Generic, Eq, Show)--{- | Aggregate result of one publisher pass.+  = InvalidOutboxBatchSize !Int+  | InvalidOutboxMaxAttempts !Int+  | InvalidOutboxPublishingTimeout !NominalDiffTime+  | InvalidConstantBackoff !NominalDiffTime+  | InvalidExponentialBackoffInitial !NominalDiffTime+  | InvalidExponentialBackoffMultiplier !Double+  | InvalidExponentialBackoffMaxDelay !NominalDiffTime !NominalDiffTime+  deriving stock (Generic, Eq, Show) -@published + retried + dead@ equals the number of rows claimed. 'retried'-includes rows that were skipped because an earlier row in the same ordered-publish group failed; those rows are returned to @failed@ without consuming-an attempt. 'haltedOn' is populated only by 'StopTheLine' policy and names-the failed pivot row, which is already counted in 'retried' or 'dead'.--}+-- | Aggregate result of one publisher pass.+--+-- @published + retried + dead@ equals the number of rows claimed. 'retried'+-- includes rows that were skipped because an earlier row in the same ordered+-- publish group failed; those rows are returned to @failed@ without consuming+-- an attempt. 'haltedOn' is populated only by 'StopTheLine' policy and names+-- the failed pivot row, which is already counted in 'retried' or 'dead'. data OutboxPublishSummary = OutboxPublishSummary-    { claimed :: !Int-    , published :: !Int-    , retried :: !Int-    , dead :: !Int-    , haltedOn :: !(Maybe OutboxId)-    }-    deriving stock (Generic, Eq, Show)--{- | Knobs for 'Keiro.Outbox.outboxMaintenancePass'.+  { claimed :: !Int,+    published :: !Int,+    retried :: !Int,+    dead :: !Int,+    haltedOn :: !(Maybe OutboxId)+  }+  deriving stock (Generic, Eq, Show) -Schedule maintenance less frequently than publish passes; it owns crash-reclamation and backlog gauge sampling.--}+-- | Knobs for 'Keiro.Outbox.outboxMaintenancePass'.+--+-- Schedule maintenance less frequently than publish passes; it owns crash+-- reclamation and backlog gauge sampling. data OutboxMaintenanceOptions = OutboxMaintenanceOptions-    { maxAttempts :: !Int-    , publishingTimeout :: !NominalDiffTime-    }-    deriving stock (Generic, Eq, Show)+  { maxAttempts :: !Int,+    publishingTimeout :: !NominalDiffTime+  }+  deriving stock (Generic, Eq, Show)  -- | Result of one outbox maintenance pass. data OutboxMaintenanceSummary = OutboxMaintenanceSummary-    { requeued :: !Int-    , deadLettered :: !Int-    , backlog :: !Int-    }-    deriving stock (Generic, Eq, Show)+  { requeued :: !Int,+    deadLettered :: !Int,+    backlog :: !Int+  }+  deriving stock (Generic, Eq, Show) -{- | Sensible defaults: batch of 32, ten retry attempts, two-second-constant backoff, per-key head-of-line ordering.--}+-- | Sensible defaults: batch of 32, ten retry attempts, two-second+-- constant backoff, per-key head-of-line ordering. defaultPublishOptions :: OutboxPublishOptions defaultPublishOptions =-    OutboxPublishOptions-        { batchSize = 32-        , maxAttempts = 10-        , backoff = ConstantBackoff 2-        , orderingPolicy = PerKeyHeadOfLine-        , publishingTimeout = 300-        , tracer = Nothing-        }+  OutboxPublishOptions+    { batchSize = 32,+      maxAttempts = 10,+      backoff = ConstantBackoff 2,+      orderingPolicy = PerKeyHeadOfLine,+      publishingTimeout = 300,+      tracer = Nothing+    }  -- | Maintenance defaults match the publisher's retry ceiling and stale-row timeout. defaultMaintenanceOptions :: OutboxMaintenanceOptions defaultMaintenanceOptions =-    OutboxMaintenanceOptions-        { maxAttempts = defaultPublishOptions ^. #maxAttempts-        , publishingTimeout = defaultPublishOptions ^. #publishingTimeout-        }+  OutboxMaintenanceOptions+    { maxAttempts = defaultPublishOptions ^. #maxAttempts,+      publishingTimeout = defaultPublishOptions ^. #publishingTimeout+    }  -- | Validate outbox publisher options before starting a worker. mkOutboxPublishOptions :: OutboxPublishOptions -> Either OutboxPublishConfigError OutboxPublishOptions mkOutboxPublishOptions opts-    | opts ^. #batchSize < 1 = Left (InvalidOutboxBatchSize (opts ^. #batchSize))-    | opts ^. #maxAttempts < 1 = Left (InvalidOutboxMaxAttempts (opts ^. #maxAttempts))-    | opts ^. #publishingTimeout <= 0 = Left (InvalidOutboxPublishingTimeout (opts ^. #publishingTimeout))-    | otherwise = opts <$ validateBackoff (opts ^. #backoff)+  | opts ^. #batchSize < 1 = Left (InvalidOutboxBatchSize (opts ^. #batchSize))+  | opts ^. #maxAttempts < 1 = Left (InvalidOutboxMaxAttempts (opts ^. #maxAttempts))+  | opts ^. #publishingTimeout <= 0 = Left (InvalidOutboxPublishingTimeout (opts ^. #publishingTimeout))+  | otherwise = opts <$ validateBackoff (opts ^. #backoff)  validateBackoff :: BackoffSchedule -> Either OutboxPublishConfigError () validateBackoff = \case-    ConstantBackoff delay-        | delay < 0 -> Left (InvalidConstantBackoff delay)-        | otherwise -> Right ()-    ExponentialBackoff backoff-        | backoff ^. #initial <= 0 -> Left (InvalidExponentialBackoffInitial (backoff ^. #initial))-        | backoff ^. #multiplier < 1 -> Left (InvalidExponentialBackoffMultiplier (backoff ^. #multiplier))-        | backoff ^. #maxDelay < backoff ^. #initial ->-            Left (InvalidExponentialBackoffMaxDelay (backoff ^. #initial) (backoff ^. #maxDelay))-        | otherwise -> Right ()+  ConstantBackoff delay+    | delay < 0 -> Left (InvalidConstantBackoff delay)+    | otherwise -> Right ()+  ExponentialBackoff backoff+    | backoff ^. #initial <= 0 -> Left (InvalidExponentialBackoffInitial (backoff ^. #initial))+    | backoff ^. #multiplier < 1 -> Left (InvalidExponentialBackoffMultiplier (backoff ^. #multiplier))+    | backoff ^. #maxDelay < backoff ^. #initial ->+        Left (InvalidExponentialBackoffMaxDelay (backoff ^. #initial) (backoff ^. #maxDelay))+    | otherwise -> Right ()  -- | Wire representation of 'OutboxStatus' used in the @status@ column. statusText :: OutboxStatus -> Text statusText = \case-    OutboxPending -> "pending"-    OutboxPublishing -> "publishing"-    OutboxSent -> "sent"-    OutboxFailed -> "failed"-    OutboxDead -> "dead"+  OutboxPending -> "pending"+  OutboxPublishing -> "publishing"+  OutboxSent -> "sent"+  OutboxFailed -> "failed"+  OutboxDead -> "dead"  -- | Inverse of 'statusText'. Unknown database values are decode failures. parseStatus :: Text -> Either Text OutboxStatus parseStatus = \case-    "pending" -> Right OutboxPending-    "publishing" -> Right OutboxPublishing-    "sent" -> Right OutboxSent-    "failed" -> Right OutboxFailed-    "dead" -> Right OutboxDead-    bad -> Left ("unknown keiro_outbox.status: " <> bad)+  "pending" -> Right OutboxPending+  "publishing" -> Right OutboxPublishing+  "sent" -> Right OutboxSent+  "failed" -> Right OutboxFailed+  "dead" -> Right OutboxDead+  bad -> Left ("unknown keiro_outbox.status: " <> bad)
src/Keiro/ProcessManager.hs view
@@ -1,731 +1,717 @@-{- | Stateful coordination across aggregates: the process manager (saga).--A 'ProcessManager' reacts to an incoming event by stepping its /own/ state-machine — a private \"manager\" event stream, keyed by a correlation id — and,-in the same turn, dispatching commands to /target/ aggregates and scheduling-timers. It is the stateful counterpart of "Keiro.Router": where a router-resolves targets from a read model and holds no state, a process manager-folds the events it has seen into durable manager state and decides what to-do next from that state.--Every write — the manager-state append, each dispatched command, each timer-— is keyed by a 'deterministicCommandId' derived from-@(name, correlation, source event id, emit index)@. The manager pre-checks-each id with 'eventAlreadyIn' and folds the store's duplicate rejection into-a benign 'PMCommandDuplicate' \/ 'PMStateDuplicate'. Replaying the same-source event therefore appends nothing new, which is what makes the worker-crash-safe under at-least-once delivery.--Use 'runProcessManagerOnce' to react to a single event, or-'runProcessManagerWorker' to run the manager as a live subscription over a-Shibuya adapter. Worker acks are finalized exactly once: successful and duplicate-dispatches ack 'AckOk', transient store failures retry, systemic deterministic-failures halt, rejection-class failures follow 'RejectedCommandPolicy', and-undecodable messages follow the configured 'PoisonPolicy'.--=== Rejected commands and saga history--'RejectedHalt' is the safe default: the subscription stops without advancing,-so an operator cannot miss the failure. 'RejectedDeadLetter' instead writes a-durable "Keiro.DeadLetter.DispatchDeadLetter" and acknowledges the source-event; 'RejectedSkip' acknowledges and records only the metric. Prefer making a-target command total in its Keiki transducer (for example, an explicit no-op-transition for a benign business rejection) so no policy escape hatch is-needed.--For a process manager, dead-lettering a target dispatch creates an important-history split: the manager's own state stream has recorded its reaction, but-the target command never applied. Keiro cannot append a generic correction to-the manager stream because an event unknown to the manager's transducer would-make that stream fail replay. If saga history must reflect the failure, model a-domain-specific @DispatchFailed@-style command and event in the manager and-drive it from 'Keiro.DeadLetter.listDispatchDeadLetters' through an operator-runbook or automation. Otherwise, the dead-letter row is the durable witness.-Manager-state rejection itself has no such split because no manager event was-appended; its record uses emit index @-1@.--=== Bounded retries and source-event dead letters--On a Kiroku-backed Shibuya adapter, a transient failure finalizes 'AckRetry',-but retries are bounded by the Kiroku subscription's @RetryPolicy@. Its-@retryMaxAttempts@ counts total deliveries and defaults to five. When the bound-is exhausted, Kiroku records the /source event/ in @kiroku.dead_letters@ with-structured reason kind @max_attempts_exceeded@ and atomically advances the-checkpoint. The manager will not see that event again unless an operator-replays it through @Keiro.DeadLetter.Replay@. Install-'Keiro.Telemetry.kirokuEventBridge' on Kiroku's @eventHandler@ to observe the-terminal transition.--The adapter's @KirokuAdapterConfig@ does not currently expose @retryPolicy@, so-that path uses Kiroku's default bound. The sharded path is configurable:-'Keiro.Subscription.Shard.Worker.runShardedSubscriptionGroupAck' forwards-'Keiro.Subscription.Shard.Worker.ShardedWorkerOptions.retryPolicy' into the-same Kiroku acknowledgement ladder. A shard handler that dispatches commands-can use 'decideForFailures' and 'isRejectionClass' to classify outcomes, then-map the decision to its existing @ShardAck@ reply.--=== Correlation, ordering, and transaction boundaries--Events from one originating stream retain that stream's append order. Kiroku-consumer groups hash the originating stream id to a stable member, so sharding-does not split one stream across readers. Events from /different/ streams that-'correlate' to the same manager instance have no business-order guarantee,-however. Their append transactions race for global positions and, under-sharding, the streams may be processed concurrently by different members. A-retry on one member does not stop another member from advancing.--For example, both @payment-ORD1@'s @PaymentCaptured@ and @shipment-ORD1@'s-@ShipmentAllocated@ may correlate to order @ORD1@. The manager must accept-@PaymentCaptured@ then @ShipmentAllocated@ /and/ the reverse, normally with-states that record one fact while waiting for the other. An unsharded Kiroku-subscription processes its observed global order serially, including retries,-but that order still reflects append timing rather than a domain sequence.--Rule of thumb: 'correlate' may join streams freely, but every such join must be-order-insensitive. When a strict sequence is required, enforce it in the-manager's own state machine — for example with an explicit no-op/waiting state-and a timer-driven retry, or a modeled rejection handled by the dead-letter-policy — never by assuming delivery order.--The persistence boundary is also intentionally smaller than one whole saga-reaction. A manager event and its timers commit together when the manager-command appends; a timer-only no-op reaction schedules its timers in a separate-transaction. Each target command and its inline projections then commit in-their own transaction. A crash or rejected target can therefore leave durable-manager history without every target write. Deterministic ids make source-event-replay fill the missing writes without duplicating completed ones; the rejected-command section above describes the case that is deliberately acknowledged-instead.--}-module Keiro.ProcessManager (-    -- * Definition-    ProcessManager (..),-    ProcessManagerAction (..),-    PMCommand (..),--    -- * Results-    ProcessManagerResult (..),-    PMCommandResult (..),-    PMStateResult (..),--    -- * Running-    PoisonPolicy (..),-    RejectedCommandPolicy (..),-    DispatchFailure (..),-    WorkerOptions (..),-    defaultWorkerOptions,-    isTransientStoreError,-    isTransientCommandError,-    isRejectionClass,-    decideForFailures,-    ackForCommandError,-    runProcessManagerOnce,-    runProcessManagerWorkerWith,-    runProcessManagerWorker,--    -- * Idempotency primitives-    deterministicCommandId,-    eventAlreadyIn,-    confirmBenignDuplicate,-)-where--import Data.Coerce (coerce)-import Data.Text qualified as Text-import Data.UUID qualified as UUID-import Data.UUID.V5 qualified as UUID.V5-import Effectful (Eff, IOE, (:>))-import Effectful.Error.Static (Error, tryError)-import GHC.Stack (HasCallStack)-import Keiki.Core (BoolAlg, RegFile)-import Keiro.Command (CommandError (..), CommandResult, RunCommandOptions, commandErrorClass, runCommandWithSql)-import Keiro.DeadLetter (DispatchDeadLetter (..), DispatcherKind (..), recordDispatchDeadLetter)-import Keiro.EventStream (EventStream)-import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)-import Keiro.Prelude-import Keiro.Projection (InlineProjection, runCommandWithProjections)-import Keiro.Stream (Stream)-import Keiro.Telemetry (KeiroMetrics, recordDispatchDeadLettered, recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)-import Keiro.Timer (TimerRequest, scheduleTimerTx)-import Kiroku.Store.Effect (Store)-import Kiroku.Store.Effect.Resource (KirokuStoreResource)-import Kiroku.Store.Error (StoreError (..))-import Kiroku.Store.Read (eventExistsInStream)-import Kiroku.Store.Transaction (runTransaction)-import Kiroku.Store.Types (EventId (..), RecordedEvent)-import Kiroku.Store.Types qualified as StoreTypes-import Shibuya.Adapter (Adapter (..))-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))-import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))-import Shibuya.Core.Types (Attempt (..), Envelope (..))-import Streamly.Data.Fold qualified as Fold-import Streamly.Data.Stream qualified as Streamly-import Prelude (any, filter, fromIntegral, length, not, uncurry, zip, (&&), (+))--{- | A process manager wiring together a manager state machine and the target-aggregate it drives.--* 'name' — stable identity; part of every deterministic write id.-* 'correlate' — derives the correlation key for an input event, selecting-  which manager instance handles it.-* 'eventStream' — the manager's own 'EventStream'; its events record the-  saga's progress.-* 'streamFor' — maps a correlation key to the manager's 'Stream' handle.-* 'targetEventStream' — the aggregate that dispatched commands are sent to.-* 'targetProjections' — inline projections for the target aggregate, run in-  the same transaction as each dispatched command's append. Return @[]@ for-  append-only dispatch.-* 'handle' — the pure reaction: given an input event, produce the-  manager-state command, the target commands to dispatch, and any timers to-  schedule.--}-data ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo = ProcessManager-    { name :: !Text-    , correlate :: !(input -> Text)-    , eventStream :: !(ValidatedEventStream phi rs s ci co)-    , streamFor :: !(Text -> Stream (EventStream phi rs s ci co))-    , targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo)-    , targetProjections :: !(Stream targetCi -> [InlineProjection targetCo])-    {- ^ Inline projections for the target aggregate, run in the same transaction-    as each dispatched command's append. Return @[]@ for append-only dispatch.-    -}-    , handle :: !(input -> ProcessManagerAction ci targetCi)-    }-    deriving stock (Generic)--{- | What a process manager decides to do for one input event: advance its own-state with 'command', dispatch zero or more target 'commands', and schedule-zero or more 'timers'. All three are applied atomically with crash-safe-idempotency by 'runProcessManagerOnce'.--}-data ProcessManagerAction ci targetCi = ProcessManagerAction-    { command :: !ci-    , commands :: ![PMCommand targetCi]-    , timers :: ![TimerRequest]-    }-    deriving stock (Generic)---- | A single command addressed to a specific target stream.-data PMCommand targetCi = PMCommand-    { target :: !(Stream targetCi)-    , command :: !targetCi-    }-    deriving stock (Generic, Eq, Show)---- | Outcome of one dispatched target command.-data PMCommandResult target-    = -- | The command appended events (carries the 'CommandResult').-      PMCommandAppended !(CommandResult target)-    | {- | The command was already applied (idempotent replay); carries the-      deterministic id that already existed.-      -}-      PMCommandDuplicate !EventId-    | {- | The command failed in the named target stream; the worker classifies-      the error. Transient failures retry, rejection-class failures follow-      'RejectedCommandPolicy', and systemic deterministic failures halt.-      -}-      PMCommandFailed !StoreTypes.StreamName !CommandError-    deriving stock (Generic, Eq, Show)--{- | Outcome of the manager's own state append. Unlike 'PMCommandResult' there-is no failure case — a manager-state append that genuinely errors aborts the-whole reaction via an outer @Left@ 'CommandError'.--}-data PMStateResult target-    = PMStateAppended !(CommandResult target)-    | PMStateDuplicate !EventId-    deriving stock (Generic, Eq, Show)--{- | The complete result of reacting to one event: how the manager state-advanced, the outcome of each dispatched command in order, and how many-timers were scheduled.--}-data ProcessManagerResult managerTarget commandTarget = ProcessManagerResult-    { managerResult :: !(PMStateResult managerTarget)-    , commandResults :: ![PMCommandResult commandTarget]-    , timersScheduled :: !Int-    }-    deriving stock (Generic, Eq, Show)---- | What a worker does with a message its decoder cannot parse.-data PoisonPolicy es msg-    = PoisonHalt-    | PoisonSkip !(Envelope msg -> Eff es ())-    | PoisonDeadLetter !(Envelope msg -> Eff es ())---- | What a worker does when every failed dispatch is a rejection-class error.-data RejectedCommandPolicy-    = -- | Halt without acknowledging so the source event replays. This is the default.-      RejectedHalt-    | -- | Persist a durable dispatch dead letter and acknowledge the source event.-      RejectedDeadLetter-    | -- | Acknowledge and count the rejection without persisting a record.-      RejectedSkip-    deriving stock (Generic, Eq, Show)---- | One failed dispatch with the target identity needed by worker policy.-data DispatchFailure = DispatchFailure-    { emitIndex :: !Int-    , targetStreamName :: !StoreTypes.StreamName-    , commandError :: !CommandError-    }-    deriving stock (Generic, Eq, Show)---- | Worker-level knobs shared by the process-manager and router workers.-data WorkerOptions es msg = WorkerOptions-    { poisonPolicy :: !(PoisonPolicy es msg)-    , rejectedCommandPolicy :: !RejectedCommandPolicy-    , transientRetryDelay :: !RetryDelay-    , metrics :: !(Maybe KeiroMetrics)-    }-    deriving stock (Generic)--defaultWorkerOptions :: WorkerOptions es msg-defaultWorkerOptions =-    WorkerOptions-        { poisonPolicy = PoisonHalt-        , rejectedCommandPolicy = RejectedHalt-        , transientRetryDelay = RetryDelay 5-        , metrics = Nothing-        }--isTransientStoreError :: StoreError -> Bool-isTransientStoreError = \case-    ConnectionLost{} -> True-    PoolAcquisitionTimeout -> True-    ConnectionError{} -> True-    WrongExpectedVersion{} -> True-    StreamAlreadyExists{} -> True-    EmptyAppendBatch{} -> False-    StreamNotFound{} -> False-    ReservedStreamName{} -> False-    StreamNameTooLong{} -> False-    DuplicateEvent{} -> False-    EventAlreadyLinked{} -> False-    LinkSourceEventMissing{} -> False-    UnexpectedServerError{} -> False--isTransientCommandError :: CommandError -> Bool-isTransientCommandError = \case-    StoreFailed err -> isTransientStoreError err-    RetryExhausted _ err -> isTransientStoreError err-    ConflictFixpoint _ err -> isTransientStoreError err-    HydrationDecodeFailed{} -> False-    HydrationReplayFailed{} -> False-    HydrationGapDetected{} -> False-    CommandRejected -> False-    CommandAmbiguous{} -> False-    EncodeFailed{} -> False---- | Whether a command error is a per-command rejection covered by worker policy.-isRejectionClass :: CommandError -> Bool-isRejectionClass = \case-    CommandRejected -> True-    CommandAmbiguous{} -> True-    _ -> False--{- | Classify a group of failed dispatches and choose one acknowledgement.--Systemic deterministic errors always halt. Any transient error retries the-whole source event. Only an all-rejection group reaches the configured-'RejectedCommandPolicy'. Dead-letter writes are idempotent under redelivery.--}-decideForFailures ::-    (IOE :> es, Store :> es) =>-    WorkerOptions es msg ->-    DispatcherKind ->-    Text ->-    Text ->-    RecordedEvent ->-    Int ->-    [DispatchFailure] ->-    Eff es AckDecision-decideForFailures workerOptions dispatcherKind dispatcherName correlationId sourceEvent attemptCount failures =-    case filter isSystemicDeterministic failures of-        failure : _ -> pure (haltFor failure)-        []-            | any (isTransientCommandError . (^. #commandError)) failures ->-                pure (AckRetry (workerOptions ^. #transientRetryDelay))-            | otherwise ->-                case failures of-                    [] -> pure AckOk-                    _ -> decideRejected-  where-    isSystemicDeterministic failure =-        let err = failure ^. #commandError-         in not (isTransientCommandError err) && not (isRejectionClass err)--    haltFor failure =-        AckHalt (HaltFatal (Text.pack (show (failure ^. #commandError))))--    decideRejected =-        case workerOptions ^. #rejectedCommandPolicy of-            RejectedHalt -> pure (haltFor (headFailure failures))-            RejectedDeadLetter -> do-                traverse_ recordFailure failures-                recordHandled-                pure AckOk-            RejectedSkip -> do-                recordHandled-                pure AckOk--    recordHandled =-        recordDispatchDeadLettered-            (workerOptions ^. #metrics)-            (fromIntegral (length failures))--    recordFailure failure =-        let err = failure ^. #commandError-         in recordDispatchDeadLetter-                DispatchDeadLetter-                    { dispatcherKind = dispatcherKind-                    , dispatcherName = dispatcherName-                    , correlationId = correlationId-                    , sourceEventId = sourceEvent ^. #eventId-                    , sourceGlobalPosition = sourceEvent ^. #globalPosition-                    , emitIndex = failure ^. #emitIndex-                    , targetStreamName = failure ^. #targetStreamName-                    , errorClass = commandErrorClass err-                    , errorDetail = Text.pack (show err)-                    , attemptCount = max 1 attemptCount-                    }--    headFailure = \case-        failure : _ -> failure-        [] -> DispatchFailure (-1) (StoreTypes.StreamName "unknown") CommandRejected--ackForCommandError :: RetryDelay -> CommandError -> AckDecision-ackForCommandError delay err-    | isTransientCommandError err = AckRetry delay-    | otherwise = AckHalt (HaltFatal (Text.pack (show err)))--{- | Derive a stable, collision-resistant 'EventId' for a manager write from-@(manager name, correlation id, source event id, emit index)@ via a v5 UUID.--The same inputs always yield the same id, so a replayed source event-produces the same write ids and the store's uniqueness constraint collapses-the duplicate. The manager-state append uses an emit index of @-1@ to keep-it distinct from the dispatched commands (which start at @0@). This positional-index is sound because 'handle' is pure and therefore returns the same command-order for the same input. The effectful router uses-'Keiro.Router.deterministicRouterCommandId' instead, retaining this positional-id only as a transition probe for pre-upgrade router dispatches.--}-deterministicCommandId :: Text -> Text -> EventId -> Int -> EventId-deterministicCommandId managerName correlationId sourceEventId emitIndex =-    EventId-        $ UUID.V5.generateNamed UUID.V5.namespaceURL-        $ fmap (fromIntegral . fromEnum)-        $ Text.unpack-        $ Text.intercalate-            ":"-            [ "keiro"-            , "process-manager"-            , managerName-            , correlationId-            , UUID.toText (eventIdToUuid sourceEventId)-            , Text.pack (show emitIndex)-            ]--{- | React to a single source event: advance the manager's state, dispatch-its target commands, and schedule its timers — each under a deterministic,-idempotent write id.--The manager-state append and its timers commit in one transaction; each-target command is then dispatched (with its inline projections) in its own.-A duplicate manager append short-circuits to 'PMStateDuplicate' but still-re-runs the dispatch loop, so a crash between the state append and a-command dispatch is recovered on replay. Returns @Left@ only when the-manager-state append fails for a non-duplicate reason; per-command failures-are reported inside 'commandResults'.--}-runProcessManagerOnce ::-    forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.-    ( HasCallStack-    , IOE :> es-    , Store :> es-    , Error StoreError :> es-    , KirokuStoreResource :> es-    , BoolAlg phi (RegFile rs, ci)-    , BoolAlg targetPhi (RegFile targetRs, targetCi)-    , Eq co-    , Eq targetCo-    ) =>-    RunCommandOptions ->-    ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->-    RecordedEvent ->-    input ->-    Eff es (Either CommandError (ProcessManagerResult (EventStream phi rs s ci co) (EventStream targetPhi targetRs targetState targetCi targetCo)))-runProcessManagerOnce options manager sourceEvent input = do-    let correlationId = (manager ^. #correlate) input-        action = (manager ^. #handle) input-        managerStream = (manager ^. #streamFor) correlationId-        managerEventId = deterministicCommandId (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)-        managerOptions = options & #eventIds .~ [managerEventId]-        managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream-    managerAlreadyProcessed <- eventAlreadyIn options managerStreamName managerEventId-    if managerAlreadyProcessed-        then finish correlationId (PMStateDuplicate managerEventId) action-        else do-            managerOutcome <--                runCommandWithSql-                    managerOptions-                    (manager ^. #eventStream)-                    managerStream-                    (action ^. #command)-                    (\_ -> traverse_ scheduleTimerTx (action ^. #timers))-            case managerOutcome of-                Left err -> do-                    benign <- confirmBenignDuplicate managerStreamName managerEventId err-                    if benign-                        then finish correlationId (PMStateDuplicate managerEventId) action-                        else pure (Left err)-                Right (managerResult, scheduledInAppend) -> do-                    -- No-op manager commands do not execute runCommandWithSql's callback,-                    -- so schedule timer-only reactions explicitly.-                    case scheduledInAppend of-                        Nothing -> runTransaction (traverse_ scheduleTimerTx (action ^. #timers))-                        Just () -> pure ()-                    finish correlationId (PMStateAppended managerResult) action-  where-    finish correlationId managerResult action = do-        commandResults <- dispatchCommands correlationId (sourceEvent ^. #eventId) (action ^. #commands)-        pure-            $ Right-                ProcessManagerResult-                    { managerResult = managerResult-                    , commandResults = commandResults-                    , timersScheduled = length (action ^. #timers)-                    }--    dispatchCommands correlationId sourceEventId commands =-        traverse-            (uncurry (dispatchCommand correlationId sourceEventId))-            (zip [0 ..] commands)--    dispatchCommand correlationId sourceEventId emitIndex command = do-        let commandId = deterministicCommandId (manager ^. #name) correlationId sourceEventId emitIndex-            targetOptions = options & #eventIds .~ [commandId]-            targetStream = retarget (command ^. #target)-            targetStreamName = ((unvalidated (manager ^. #targetEventStream)) ^. #resolveStreamName) targetStream-        commandAlreadyProcessed <- eventAlreadyIn options targetStreamName commandId-        if commandAlreadyProcessed-            then pure (PMCommandDuplicate commandId)-            else do-                outcome <--                    runCommandWithProjections-                        targetOptions-                        (manager ^. #targetEventStream)-                        targetStream-                        (command ^. #command)-                        ((manager ^. #targetProjections) (command ^. #target))-                case outcome of-                    Right result -> pure (PMCommandAppended result)-                    Left err -> do-                        benign <- confirmBenignDuplicate targetStreamName commandId err-                        pure $ if benign then PMCommandDuplicate commandId else PMCommandFailed targetStreamName err--    retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)-    retarget = coerce--{- | Run a process manager as a live subscription draining a Shibuya adapter with-'defaultWorkerOptions'.--Use 'runProcessManagerWorkerWith' to override poison-message handling, rejected-command handling, transient retry delay, or dispatch metrics. Every ingested message's ack handle is-finalized exactly once. Successful and duplicate dispatches finalize 'AckOk';-transient store failures finalize 'AckRetry'; rejection-class failures follow-'RejectedCommandPolicy'; other deterministic failures finalize 'AckHalt';-undecodable messages follow the configured 'PoisonPolicy'. Under-'RejectedDeadLetter', see the module-level saga-history contract before opting-in. On a Kiroku-backed adapter, each 'AckRetry' redelivery is bounded by the-subscription @RetryPolicy@ (five total deliveries by default). Exhaustion-dead-letters the source event in @kiroku.dead_letters@ and advances the-checkpoint; @KirokuAdapterConfig@ does not currently expose that bound. Observe-the terminal event with 'Keiro.Telemetry.kirokuEventBridge' and replay it with-@Keiro.DeadLetter.Replay@ when appropriate.--}-runProcessManagerWorker ::-    forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.-    ( HasCallStack-    , IOE :> es-    , Store :> es-    , Error StoreError :> es-    , KirokuStoreResource :> es-    , BoolAlg phi (RegFile rs, ci)-    , BoolAlg targetPhi (RegFile targetRs, targetCi)-    , Eq co-    , Eq targetCo-    ) =>-    RunCommandOptions ->-    ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->-    Adapter es msg ->-    (msg -> Maybe (RecordedEvent, input)) ->-    Eff es ()-runProcessManagerWorker =-    runProcessManagerWorkerWith defaultWorkerOptions--runProcessManagerWorkerWith ::-    forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.-    ( HasCallStack-    , IOE :> es-    , Store :> es-    , Error StoreError :> es-    , KirokuStoreResource :> es-    , BoolAlg phi (RegFile rs, ci)-    , BoolAlg targetPhi (RegFile targetRs, targetCi)-    , Eq co-    , Eq targetCo-    ) =>-    WorkerOptions es msg ->-    RunCommandOptions ->-    ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->-    Adapter es msg ->-    (msg -> Maybe (RecordedEvent, input)) ->-    Eff es ()-runProcessManagerWorkerWith workerOptions options manager Adapter{source = adapterSource} decodeMessage =-    Streamly.fold Fold.drain-        $ Streamly.mapM handleIngested adapterSource-  where-    handleIngested :: Ingested es msg -> Eff es AckDecision-    handleIngested Ingested{envelope = env@Envelope{payload = message}, ack = AckHandle finalizeAck} = do-        decision <- case decodeMessage message of-            Nothing -> decideForPoison workerOptions "process-manager worker could not decode message" env-            Just (recorded, input) -> do-                let correlationId = (manager ^. #correlate) input-                    managerStream = (manager ^. #streamFor) correlationId-                    managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream-                    attemptCount = envelopeAttemptCount env-                outcome <- tryError @StoreError (runProcessManagerOnce options manager recorded input)-                case outcome of-                    Left (_, storeErr) -> do-                        recordDispatchFailed (workerOptions ^. #metrics) 1-                        pure (ackForThrownStoreError (workerOptions ^. #transientRetryDelay) storeErr)-                    Right (Left err) -> do-                        recordDispatchFailed (workerOptions ^. #metrics) 1-                        decideForFailures-                            workerOptions-                            DispatcherProcessManager-                            (manager ^. #name)-                            correlationId-                            recorded-                            attemptCount-                            [DispatchFailure (-1) managerStreamName err]-                    Right (Right result) ->-                        ackForResults-                            workerOptions-                            (manager ^. #name)-                            correlationId-                            recorded-                            attemptCount-                            (result ^. #managerResult)-                            (result ^. #commandResults)-        finalizeAck decision-        pure decision--ackForThrownStoreError :: RetryDelay -> StoreError -> AckDecision-ackForThrownStoreError delay = ackForCommandError delay . StoreFailed--ackForResults ::-    (IOE :> es, Store :> es) =>-    WorkerOptions es msg ->-    Text ->-    Text ->-    RecordedEvent ->-    Int ->-    PMStateResult managerTarget ->-    [PMCommandResult commandTarget] ->-    Eff es AckDecision-ackForResults workerOptions managerName correlationId sourceEvent attemptCount managerResult commandResults = do-    let duplicateCount = stateDuplicateCount managerResult + commandDuplicateCount commandResults-        failures =-            [ DispatchFailure emitIndex targetStreamName err-            | (emitIndex, PMCommandFailed targetStreamName err) <- zip [0 ..] commandResults-            ]-    recordDispatchDuplicate (workerOptions ^. #metrics) duplicateCount-    recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length failures))-    decideForFailures-        workerOptions-        DispatcherProcessManager-        managerName-        correlationId-        sourceEvent-        attemptCount-        failures--stateDuplicateCount :: PMStateResult target -> Int64-stateDuplicateCount = \case-    PMStateDuplicate{} -> 1-    PMStateAppended{} -> 0--commandDuplicateCount :: [PMCommandResult target] -> Int64-commandDuplicateCount =-    fromIntegral . length . filter isDuplicateResult-  where-    isDuplicateResult = \case-        PMCommandDuplicate{} -> True-        _ -> False--envelopeAttemptCount :: Envelope msg -> Int-envelopeAttemptCount env =-    case env ^. #attempt of-        Nothing -> 1-        Just (Attempt attempt) -> fromIntegral attempt + 1--decideForPoison ::-    (IOE :> es) =>-    WorkerOptions es msg ->-    Text ->-    Envelope msg ->-    Eff es AckDecision-decideForPoison workerOptions reason env = do-    recordDispatchPoison (workerOptions ^. #metrics) 1-    case workerOptions ^. #poisonPolicy of-        PoisonHalt -> pure (AckHalt (HaltFatal reason))-        PoisonSkip callback -> do-            callback env-            pure AckOk-        PoisonDeadLetter callback -> do-            callback env-            pure (AckDeadLetter (InvalidPayload reason))--eventIdToUuid :: EventId -> UUID.UUID-eventIdToUuid (EventId uuid) = uuid--{- | Check whether an event with the given id is already present in a live stream.-Used as the pre-dispatch idempotency guard so a-command that was already applied on a prior (possibly crashed) attempt is-recognized as a duplicate before re-running it.--}-eventAlreadyIn ::-    (Store :> es) =>-    RunCommandOptions ->-    StoreTypes.StreamName ->-    EventId ->-    Eff es Bool-eventAlreadyIn _options streamName eventId =-    eventExistsInStream streamName eventId--{- | Decide whether a failed append is a benign duplicate of the write just-attempted: whether @ourId@ is genuinely present in @streamName@.--Kiroku's @DuplicateEvent@ carries 'Just' the colliding id only when-PostgreSQL's detail string parses ('Nothing' otherwise), and because the-store's event-id uniqueness is global, even a matching id does not prove the-event landed in our stream. A mismatched id is never ours; a matching or-missing id is confirmed against the target stream with a point lookup. Callers-fold 'True' into their duplicate result and surface 'False' as the original-failure.--}-confirmBenignDuplicate ::-    (Store :> es) =>-    StoreTypes.StreamName ->-    EventId ->-    CommandError ->-    Eff es Bool-confirmBenignDuplicate streamName ourId = \case-    StoreFailed (DuplicateEvent (Just duplicateId))-        | duplicateId == ourId -> eventExistsInStream streamName ourId-    StoreFailed (DuplicateEvent Nothing) -> eventExistsInStream streamName ourId-    _ -> pure False+-- | Stateful coordination across aggregates: the process manager (saga).+--+-- A 'ProcessManager' reacts to an incoming event by stepping its /own/ state+-- machine — a private \"manager\" event stream, keyed by a correlation id — and,+-- in the same turn, dispatching commands to /target/ aggregates and scheduling+-- timers. It is the stateful counterpart of "Keiro.Router": where a router+-- resolves targets from a read model and holds no state, a process manager+-- folds the events it has seen into durable manager state and decides what to+-- do next from that state.+--+-- Every write — the manager-state append, each dispatched command, each timer+-- — is keyed by a 'deterministicCommandId' derived from+-- @(name, correlation, source event id, emit index)@. The manager pre-checks+-- each id with 'eventAlreadyIn' and folds the store's duplicate rejection into+-- a benign 'PMCommandDuplicate' \/ 'PMStateDuplicate'. Replaying the same+-- source event therefore appends nothing new, which is what makes the worker+-- crash-safe under at-least-once delivery.+--+-- Use 'runProcessManagerOnce' to react to a single event, or+-- 'runProcessManagerWorker' to run the manager as a live subscription over a+-- Shibuya adapter. Worker acks are finalized exactly once: successful and duplicate+-- dispatches ack 'AckOk', transient store failures retry, systemic deterministic+-- failures halt, rejection-class failures follow 'RejectedCommandPolicy', and+-- undecodable messages follow the configured 'PoisonPolicy'.+--+-- === Rejected commands and saga history+--+-- 'RejectedHalt' is the safe default: the subscription stops without advancing,+-- so an operator cannot miss the failure. 'RejectedDeadLetter' instead writes a+-- durable "Keiro.DeadLetter.DispatchDeadLetter" and acknowledges the source+-- event; 'RejectedSkip' acknowledges and records only the metric. Prefer making a+-- target command total in its Keiki transducer (for example, an explicit no-op+-- transition for a benign business rejection) so no policy escape hatch is+-- needed.+--+-- For a process manager, dead-lettering a target dispatch creates an important+-- history split: the manager's own state stream has recorded its reaction, but+-- the target command never applied. Keiro cannot append a generic correction to+-- the manager stream because an event unknown to the manager's transducer would+-- make that stream fail replay. If saga history must reflect the failure, model a+-- domain-specific @DispatchFailed@-style command and event in the manager and+-- drive it from 'Keiro.DeadLetter.listDispatchDeadLetters' through an operator+-- runbook or automation. Otherwise, the dead-letter row is the durable witness.+-- Manager-state rejection itself has no such split because no manager event was+-- appended; its record uses emit index @-1@.+--+-- === Bounded retries and source-event dead letters+--+-- On a Kiroku-backed Shibuya adapter, a transient failure finalizes 'AckRetry',+-- but retries are bounded by the Kiroku subscription's @RetryPolicy@. Its+-- @retryMaxAttempts@ counts total deliveries and defaults to five. When the bound+-- is exhausted, Kiroku records the /source event/ in @kiroku.dead_letters@ with+-- structured reason kind @max_attempts_exceeded@ and atomically advances the+-- checkpoint. The manager will not see that event again unless an operator+-- replays it through @Keiro.DeadLetter.Replay@. Install+-- 'Keiro.Telemetry.kirokuEventBridge' on Kiroku's @eventHandler@ to observe the+-- terminal transition.+--+-- The adapter's @KirokuAdapterConfig@ does not currently expose @retryPolicy@, so+-- that path uses Kiroku's default bound. The sharded path is configurable:+-- 'Keiro.Subscription.Shard.Worker.runShardedSubscriptionGroupAck' forwards+-- 'Keiro.Subscription.Shard.Worker.ShardedWorkerOptions.retryPolicy' into the+-- same Kiroku acknowledgement ladder. A shard handler that dispatches commands+-- can use 'decideForFailures' and 'isRejectionClass' to classify outcomes, then+-- map the decision to its existing @ShardAck@ reply.+--+-- === Correlation, ordering, and transaction boundaries+--+-- Events from one originating stream retain that stream's append order. Kiroku+-- consumer groups hash the originating stream id to a stable member, so sharding+-- does not split one stream across readers. Events from /different/ streams that+-- 'correlate' to the same manager instance have no business-order guarantee,+-- however. Their append transactions race for global positions and, under+-- sharding, the streams may be processed concurrently by different members. A+-- retry on one member does not stop another member from advancing.+--+-- For example, both @payment-ORD1@'s @PaymentCaptured@ and @shipment-ORD1@'s+-- @ShipmentAllocated@ may correlate to order @ORD1@. The manager must accept+-- @PaymentCaptured@ then @ShipmentAllocated@ /and/ the reverse, normally with+-- states that record one fact while waiting for the other. An unsharded Kiroku+-- subscription processes its observed global order serially, including retries,+-- but that order still reflects append timing rather than a domain sequence.+--+-- Rule of thumb: 'correlate' may join streams freely, but every such join must be+-- order-insensitive. When a strict sequence is required, enforce it in the+-- manager's own state machine — for example with an explicit no-op/waiting state+-- and a timer-driven retry, or a modeled rejection handled by the dead-letter+-- policy — never by assuming delivery order.+--+-- The persistence boundary is also intentionally smaller than one whole saga+-- reaction. A manager event and its timers commit together when the manager+-- command appends; a timer-only no-op reaction schedules its timers in a separate+-- transaction. Each target command and its inline projections then commit in+-- their own transaction. A crash or rejected target can therefore leave durable+-- manager history without every target write. Deterministic ids make source-event+-- replay fill the missing writes without duplicating completed ones; the rejected+-- command section above describes the case that is deliberately acknowledged+-- instead.+module Keiro.ProcessManager+  ( -- * Definition+    ProcessManager (..),+    ProcessManagerAction (..),+    PMCommand (..),++    -- * Results+    ProcessManagerResult (..),+    PMCommandResult (..),+    PMStateResult (..),++    -- * Running+    PoisonPolicy (..),+    RejectedCommandPolicy (..),+    DispatchFailure (..),+    WorkerOptions (..),+    defaultWorkerOptions,+    isTransientStoreError,+    isTransientCommandError,+    isRejectionClass,+    decideForFailures,+    ackForCommandError,+    runProcessManagerOnce,+    runProcessManagerWorkerWith,+    runProcessManagerWorker,++    -- * Idempotency primitives+    deterministicCommandId,+    eventAlreadyIn,+    confirmBenignDuplicate,+  )+where++import Data.Coerce (coerce)+import Data.Text qualified as Text+import Data.UUID qualified as UUID+import Data.UUID.V5 qualified as UUID.V5+import Effectful (Eff, IOE, (:>))+import Effectful.Error.Static (Error, tryError)+import GHC.Stack (HasCallStack)+import Keiki.Core (BoolAlg, RegFile)+import Keiro.Command (CommandError (..), CommandResult, RunCommandOptions, commandErrorClass, runCommandWithSql)+import Keiro.DeadLetter (DispatchDeadLetter (..), DispatcherKind (..), recordDispatchDeadLetter)+import Keiro.EventStream (EventStream)+import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)+import Keiro.Prelude+import Keiro.Projection (InlineProjection, runCommandWithProjections)+import Keiro.Stream (Stream)+import Keiro.Telemetry (KeiroMetrics, recordDispatchDeadLettered, recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)+import Keiro.Timer (TimerRequest, scheduleTimerTx)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource)+import Kiroku.Store.Error (StoreError (..))+import Kiroku.Store.Read (eventExistsInStream)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (EventId (..), RecordedEvent)+import Kiroku.Store.Types qualified as StoreTypes+import Shibuya.Adapter (Adapter (..))+import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))+import Shibuya.Core.AckHandle (AckHandle (..))+import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Types (Attempt (..), Envelope (..))+import Streamly.Data.Fold qualified as Fold+import Streamly.Data.Stream qualified as Streamly+import Prelude (any, filter, fromIntegral, length, not, uncurry, zip, (&&), (+))++-- | A process manager wiring together a manager state machine and the target+-- aggregate it drives.+--+-- * 'name' — stable identity; part of every deterministic write id.+-- * 'correlate' — derives the correlation key for an input event, selecting+--   which manager instance handles it.+-- * 'eventStream' — the manager's own 'EventStream'; its events record the+--   saga's progress.+-- * 'streamFor' — maps a correlation key to the manager's 'Stream' handle.+-- * 'targetEventStream' — the aggregate that dispatched commands are sent to.+-- * 'targetProjections' — inline projections for the target aggregate, run in+--   the same transaction as each dispatched command's append. Return @[]@ for+--   append-only dispatch.+-- * 'handle' — the pure reaction: given an input event, produce the+--   manager-state command, the target commands to dispatch, and any timers to+--   schedule.+data ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo = ProcessManager+  { name :: !Text,+    correlate :: !(input -> Text),+    eventStream :: !(ValidatedEventStream phi rs s ci co),+    streamFor :: !(Text -> Stream (EventStream phi rs s ci co)),+    targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo),+    -- | Inline projections for the target aggregate, run in the same transaction+    --     as each dispatched command's append. Return @[]@ for append-only dispatch.+    targetProjections :: !(Stream targetCi -> [InlineProjection targetCo]),+    handle :: !(input -> ProcessManagerAction ci targetCi)+  }+  deriving stock (Generic)++-- | What a process manager decides to do for one input event: advance its own+-- state with 'command', dispatch zero or more target 'commands', and schedule+-- zero or more 'timers'. All three are applied atomically with crash-safe+-- idempotency by 'runProcessManagerOnce'.+data ProcessManagerAction ci targetCi = ProcessManagerAction+  { command :: !ci,+    commands :: ![PMCommand targetCi],+    timers :: ![TimerRequest]+  }+  deriving stock (Generic)++-- | A single command addressed to a specific target stream.+data PMCommand targetCi = PMCommand+  { target :: !(Stream targetCi),+    command :: !targetCi+  }+  deriving stock (Generic, Eq, Show)++-- | Outcome of one dispatched target command.+data PMCommandResult target+  = -- | The command appended events (carries the 'CommandResult').+    PMCommandAppended !(CommandResult target)+  | -- | The command was already applied (idempotent replay); carries the+    --       deterministic id that already existed.+    PMCommandDuplicate !EventId+  | -- | The command failed in the named target stream; the worker classifies+    --       the error. Transient failures retry, rejection-class failures follow+    --       'RejectedCommandPolicy', and systemic deterministic failures halt.+    PMCommandFailed !StoreTypes.StreamName !CommandError+  deriving stock (Generic, Eq, Show)++-- | Outcome of the manager's own state append. Unlike 'PMCommandResult' there+-- is no failure case — a manager-state append that genuinely errors aborts the+-- whole reaction via an outer @Left@ 'CommandError'.+data PMStateResult target+  = PMStateAppended !(CommandResult target)+  | PMStateDuplicate !EventId+  deriving stock (Generic, Eq, Show)++-- | The complete result of reacting to one event: how the manager state+-- advanced, the outcome of each dispatched command in order, and how many+-- timers were scheduled.+data ProcessManagerResult managerTarget commandTarget = ProcessManagerResult+  { managerResult :: !(PMStateResult managerTarget),+    commandResults :: ![PMCommandResult commandTarget],+    timersScheduled :: !Int+  }+  deriving stock (Generic, Eq, Show)++-- | What a worker does with a message its decoder cannot parse.+data PoisonPolicy es msg+  = PoisonHalt+  | PoisonSkip !(Envelope msg -> Eff es ())+  | PoisonDeadLetter !(Envelope msg -> Eff es ())++-- | What a worker does when every failed dispatch is a rejection-class error.+data RejectedCommandPolicy+  = -- | Halt without acknowledging so the source event replays. This is the default.+    RejectedHalt+  | -- | Persist a durable dispatch dead letter and acknowledge the source event.+    RejectedDeadLetter+  | -- | Acknowledge and count the rejection without persisting a record.+    RejectedSkip+  deriving stock (Generic, Eq, Show)++-- | One failed dispatch with the target identity needed by worker policy.+data DispatchFailure = DispatchFailure+  { emitIndex :: !Int,+    targetStreamName :: !StoreTypes.StreamName,+    commandError :: !CommandError+  }+  deriving stock (Generic, Eq, Show)++-- | Worker-level knobs shared by the process-manager and router workers.+data WorkerOptions es msg = WorkerOptions+  { poisonPolicy :: !(PoisonPolicy es msg),+    rejectedCommandPolicy :: !RejectedCommandPolicy,+    transientRetryDelay :: !RetryDelay,+    metrics :: !(Maybe KeiroMetrics)+  }+  deriving stock (Generic)++defaultWorkerOptions :: WorkerOptions es msg+defaultWorkerOptions =+  WorkerOptions+    { poisonPolicy = PoisonHalt,+      rejectedCommandPolicy = RejectedHalt,+      transientRetryDelay = RetryDelay 5,+      metrics = Nothing+    }++isTransientStoreError :: StoreError -> Bool+isTransientStoreError = \case+  ConnectionLost {} -> True+  PoolAcquisitionTimeout -> True+  ConnectionError {} -> True+  WrongExpectedVersion {} -> True+  StreamAlreadyExists {} -> True+  EmptyAppendBatch {} -> False+  StreamNotFound {} -> False+  ReservedStreamName {} -> False+  StreamNameTooLong {} -> False+  DuplicateEvent {} -> False+  EventAlreadyLinked {} -> False+  LinkSourceEventMissing {} -> False+  UnexpectedServerError {} -> False++isTransientCommandError :: CommandError -> Bool+isTransientCommandError = \case+  StoreFailed err -> isTransientStoreError err+  RetryExhausted _ err -> isTransientStoreError err+  ConflictFixpoint _ err -> isTransientStoreError err+  HydrationDecodeFailed {} -> False+  HydrationReplayFailed {} -> False+  HydrationGapDetected {} -> False+  CommandRejected -> False+  CommandAmbiguous {} -> False+  EncodeFailed {} -> False++-- | Whether a command error is a per-command rejection covered by worker policy.+isRejectionClass :: CommandError -> Bool+isRejectionClass = \case+  CommandRejected -> True+  CommandAmbiguous {} -> True+  _ -> False++-- | Classify a group of failed dispatches and choose one acknowledgement.+--+-- Systemic deterministic errors always halt. Any transient error retries the+-- whole source event. Only an all-rejection group reaches the configured+-- 'RejectedCommandPolicy'. Dead-letter writes are idempotent under redelivery.+decideForFailures ::+  (IOE :> es, Store :> es) =>+  WorkerOptions es msg ->+  DispatcherKind ->+  Text ->+  Text ->+  RecordedEvent ->+  Int ->+  [DispatchFailure] ->+  Eff es AckDecision+decideForFailures workerOptions dispatcherKind dispatcherName correlationId sourceEvent attemptCount failures =+  case filter isSystemicDeterministic failures of+    failure : _ -> pure (haltFor failure)+    []+      | any (isTransientCommandError . (^. #commandError)) failures ->+          pure (AckRetry (workerOptions ^. #transientRetryDelay))+      | otherwise ->+          case failures of+            [] -> pure AckOk+            _ -> decideRejected+  where+    isSystemicDeterministic failure =+      let err = failure ^. #commandError+       in not (isTransientCommandError err) && not (isRejectionClass err)++    haltFor failure =+      AckHalt (HaltFatal (Text.pack (show (failure ^. #commandError))))++    decideRejected =+      case workerOptions ^. #rejectedCommandPolicy of+        RejectedHalt -> pure (haltFor (headFailure failures))+        RejectedDeadLetter -> do+          traverse_ recordFailure failures+          recordHandled+          pure AckOk+        RejectedSkip -> do+          recordHandled+          pure AckOk++    recordHandled =+      recordDispatchDeadLettered+        (workerOptions ^. #metrics)+        (fromIntegral (length failures))++    recordFailure failure =+      let err = failure ^. #commandError+       in recordDispatchDeadLetter+            DispatchDeadLetter+              { dispatcherKind = dispatcherKind,+                dispatcherName = dispatcherName,+                correlationId = correlationId,+                sourceEventId = sourceEvent ^. #eventId,+                sourceGlobalPosition = sourceEvent ^. #globalPosition,+                emitIndex = failure ^. #emitIndex,+                targetStreamName = failure ^. #targetStreamName,+                errorClass = commandErrorClass err,+                errorDetail = Text.pack (show err),+                attemptCount = max 1 attemptCount+              }++    headFailure = \case+      failure : _ -> failure+      [] -> DispatchFailure (-1) (StoreTypes.StreamName "unknown") CommandRejected++ackForCommandError :: RetryDelay -> CommandError -> AckDecision+ackForCommandError delay err+  | isTransientCommandError err = AckRetry delay+  | otherwise = AckHalt (HaltFatal (Text.pack (show err)))++-- | Derive a stable, collision-resistant 'EventId' for a manager write from+-- @(manager name, correlation id, source event id, emit index)@ via a v5 UUID.+--+-- The same inputs always yield the same id, so a replayed source event+-- produces the same write ids and the store's uniqueness constraint collapses+-- the duplicate. The manager-state append uses an emit index of @-1@ to keep+-- it distinct from the dispatched commands (which start at @0@). This positional+-- index is sound because 'handle' is pure and therefore returns the same command+-- order for the same input. The effectful router uses+-- 'Keiro.Router.deterministicRouterCommandId' instead, retaining this positional+-- id only as a transition probe for pre-upgrade router dispatches.+deterministicCommandId :: Text -> Text -> EventId -> Int -> EventId+deterministicCommandId managerName correlationId sourceEventId emitIndex =+  EventId+    $ UUID.V5.generateNamed UUID.V5.namespaceURL+    $ fmap (fromIntegral . fromEnum)+    $ Text.unpack+    $ Text.intercalate+      ":"+      [ "keiro",+        "process-manager",+        managerName,+        correlationId,+        UUID.toText (eventIdToUuid sourceEventId),+        Text.pack (show emitIndex)+      ]++-- | React to a single source event: advance the manager's state, dispatch+-- its target commands, and schedule its timers — each under a deterministic,+-- idempotent write id.+--+-- The manager-state append and its timers commit in one transaction; each+-- target command is then dispatched (with its inline projections) in its own.+-- A duplicate manager append short-circuits to 'PMStateDuplicate' but still+-- re-runs the dispatch loop, so a crash between the state append and a+-- command dispatch is recovered on replay. Returns @Left@ only when the+-- manager-state append fails for a non-duplicate reason; per-command failures+-- are reported inside 'commandResults'.+runProcessManagerOnce ::+  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg phi (RegFile rs, ci),+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq co,+    Eq targetCo+  ) =>+  RunCommandOptions ->+  ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->+  RecordedEvent ->+  input ->+  Eff es (Either CommandError (ProcessManagerResult (EventStream phi rs s ci co) (EventStream targetPhi targetRs targetState targetCi targetCo)))+runProcessManagerOnce options manager sourceEvent input = do+  let correlationId = (manager ^. #correlate) input+      action = (manager ^. #handle) input+      managerStream = (manager ^. #streamFor) correlationId+      managerEventId = deterministicCommandId (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)+      managerOptions = options & #eventIds .~ [managerEventId]+      managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream+  managerAlreadyProcessed <- eventAlreadyIn options managerStreamName managerEventId+  if managerAlreadyProcessed+    then finish correlationId (PMStateDuplicate managerEventId) action+    else do+      managerOutcome <-+        runCommandWithSql+          managerOptions+          (manager ^. #eventStream)+          managerStream+          (action ^. #command)+          (\_ -> traverse_ scheduleTimerTx (action ^. #timers))+      case managerOutcome of+        Left err -> do+          benign <- confirmBenignDuplicate managerStreamName managerEventId err+          if benign+            then finish correlationId (PMStateDuplicate managerEventId) action+            else pure (Left err)+        Right (managerResult, scheduledInAppend) -> do+          -- No-op manager commands do not execute runCommandWithSql's callback,+          -- so schedule timer-only reactions explicitly.+          case scheduledInAppend of+            Nothing -> runTransaction (traverse_ scheduleTimerTx (action ^. #timers))+            Just () -> pure ()+          finish correlationId (PMStateAppended managerResult) action+  where+    finish correlationId managerResult action = do+      commandResults <- dispatchCommands correlationId (sourceEvent ^. #eventId) (action ^. #commands)+      pure+        $ Right+          ProcessManagerResult+            { managerResult = managerResult,+              commandResults = commandResults,+              timersScheduled = length (action ^. #timers)+            }++    dispatchCommands correlationId sourceEventId commands =+      traverse+        (uncurry (dispatchCommand correlationId sourceEventId))+        (zip [0 ..] commands)++    dispatchCommand correlationId sourceEventId emitIndex command = do+      let commandId = deterministicCommandId (manager ^. #name) correlationId sourceEventId emitIndex+          targetOptions = options & #eventIds .~ [commandId]+          targetStream = retarget (command ^. #target)+          targetStreamName = ((unvalidated (manager ^. #targetEventStream)) ^. #resolveStreamName) targetStream+      commandAlreadyProcessed <- eventAlreadyIn options targetStreamName commandId+      if commandAlreadyProcessed+        then pure (PMCommandDuplicate commandId)+        else do+          outcome <-+            runCommandWithProjections+              targetOptions+              (manager ^. #targetEventStream)+              targetStream+              (command ^. #command)+              ((manager ^. #targetProjections) (command ^. #target))+          case outcome of+            Right result -> pure (PMCommandAppended result)+            Left err -> do+              benign <- confirmBenignDuplicate targetStreamName commandId err+              pure $ if benign then PMCommandDuplicate commandId else PMCommandFailed targetStreamName err++    retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)+    retarget = coerce++-- | Run a process manager as a live subscription draining a Shibuya adapter with+-- 'defaultWorkerOptions'.+--+-- Use 'runProcessManagerWorkerWith' to override poison-message handling, rejected+-- command handling, transient retry delay, or dispatch metrics. Every ingested message's ack handle is+-- finalized exactly once. Successful and duplicate dispatches finalize 'AckOk';+-- transient store failures finalize 'AckRetry'; rejection-class failures follow+-- 'RejectedCommandPolicy'; other deterministic failures finalize 'AckHalt';+-- undecodable messages follow the configured 'PoisonPolicy'. Under+-- 'RejectedDeadLetter', see the module-level saga-history contract before opting+-- in. On a Kiroku-backed adapter, each 'AckRetry' redelivery is bounded by the+-- subscription @RetryPolicy@ (five total deliveries by default). Exhaustion+-- dead-letters the source event in @kiroku.dead_letters@ and advances the+-- checkpoint; @KirokuAdapterConfig@ does not currently expose that bound. Observe+-- the terminal event with 'Keiro.Telemetry.kirokuEventBridge' and replay it with+-- @Keiro.DeadLetter.Replay@ when appropriate.+runProcessManagerWorker ::+  forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg phi (RegFile rs, ci),+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq co,+    Eq targetCo+  ) =>+  RunCommandOptions ->+  ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runProcessManagerWorker =+  runProcessManagerWorkerWith defaultWorkerOptions++runProcessManagerWorkerWith ::+  forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg phi (RegFile rs, ci),+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq co,+    Eq targetCo+  ) =>+  WorkerOptions es msg ->+  RunCommandOptions ->+  ProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runProcessManagerWorkerWith workerOptions options manager Adapter {source = adapterSource} decodeMessage =+  Streamly.fold Fold.drain+    $ Streamly.mapM handleIngested adapterSource+  where+    handleIngested :: Ingested es msg -> Eff es AckDecision+    handleIngested Ingested {envelope = env@Envelope {payload = message}, ack = AckHandle finalizeAck} = do+      decision <- case decodeMessage message of+        Nothing -> decideForPoison workerOptions "process-manager worker could not decode message" env+        Just (recorded, input) -> do+          let correlationId = (manager ^. #correlate) input+              managerStream = (manager ^. #streamFor) correlationId+              managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream+              attemptCount = envelopeAttemptCount env+          outcome <- tryError @StoreError (runProcessManagerOnce options manager recorded input)+          case outcome of+            Left (_, storeErr) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              pure (ackForThrownStoreError (workerOptions ^. #transientRetryDelay) storeErr)+            Right (Left err) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              decideForFailures+                workerOptions+                DispatcherProcessManager+                (manager ^. #name)+                correlationId+                recorded+                attemptCount+                [DispatchFailure (-1) managerStreamName err]+            Right (Right result) ->+              ackForResults+                workerOptions+                (manager ^. #name)+                correlationId+                recorded+                attemptCount+                (result ^. #managerResult)+                (result ^. #commandResults)+      finalizeAck decision+      pure decision++ackForThrownStoreError :: RetryDelay -> StoreError -> AckDecision+ackForThrownStoreError delay = ackForCommandError delay . StoreFailed++ackForResults ::+  (IOE :> es, Store :> es) =>+  WorkerOptions es msg ->+  Text ->+  Text ->+  RecordedEvent ->+  Int ->+  PMStateResult managerTarget ->+  [PMCommandResult commandTarget] ->+  Eff es AckDecision+ackForResults workerOptions managerName correlationId sourceEvent attemptCount managerResult commandResults = do+  let duplicateCount = stateDuplicateCount managerResult + commandDuplicateCount commandResults+      failures =+        [ DispatchFailure emitIndex targetStreamName err+        | (emitIndex, PMCommandFailed targetStreamName err) <- zip [0 ..] commandResults+        ]+  recordDispatchDuplicate (workerOptions ^. #metrics) duplicateCount+  recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length failures))+  decideForFailures+    workerOptions+    DispatcherProcessManager+    managerName+    correlationId+    sourceEvent+    attemptCount+    failures++stateDuplicateCount :: PMStateResult target -> Int64+stateDuplicateCount = \case+  PMStateDuplicate {} -> 1+  PMStateAppended {} -> 0++commandDuplicateCount :: [PMCommandResult target] -> Int64+commandDuplicateCount =+  fromIntegral . length . filter isDuplicateResult+  where+    isDuplicateResult = \case+      PMCommandDuplicate {} -> True+      _ -> False++envelopeAttemptCount :: Envelope msg -> Int+envelopeAttemptCount env =+  case env ^. #attempt of+    Nothing -> 1+    Just (Attempt attempt) -> fromIntegral attempt + 1++decideForPoison ::+  (IOE :> es) =>+  WorkerOptions es msg ->+  Text ->+  Envelope msg ->+  Eff es AckDecision+decideForPoison workerOptions reason env = do+  recordDispatchPoison (workerOptions ^. #metrics) 1+  case workerOptions ^. #poisonPolicy of+    PoisonHalt -> pure (AckHalt (HaltFatal reason))+    PoisonSkip callback -> do+      callback env+      pure AckOk+    PoisonDeadLetter callback -> do+      callback env+      pure (AckDeadLetter (InvalidPayload reason))++eventIdToUuid :: EventId -> UUID.UUID+eventIdToUuid (EventId uuid) = uuid++-- | Check whether an event with the given id is already present in a live stream.+-- Used as the pre-dispatch idempotency guard so a+-- command that was already applied on a prior (possibly crashed) attempt is+-- recognized as a duplicate before re-running it.+eventAlreadyIn ::+  (Store :> es) =>+  RunCommandOptions ->+  StoreTypes.StreamName ->+  EventId ->+  Eff es Bool+eventAlreadyIn _options streamName eventId =+  eventExistsInStream streamName eventId++-- | Decide whether a failed append is a benign duplicate of the write just+-- attempted: whether @ourId@ is genuinely present in @streamName@.+--+-- Kiroku's @DuplicateEvent@ carries 'Just' the colliding id only when+-- PostgreSQL's detail string parses ('Nothing' otherwise), and because the+-- store's event-id uniqueness is global, even a matching id does not prove the+-- event landed in our stream. A mismatched id is never ours; a matching or+-- missing id is confirmed against the target stream with a point lookup. Callers+-- fold 'True' into their duplicate result and surface 'False' as the original+-- failure.+confirmBenignDuplicate ::+  (Store :> es) =>+  StoreTypes.StreamName ->+  EventId ->+  CommandError ->+  Eff es Bool+confirmBenignDuplicate streamName ourId = \case+  StoreFailed (DuplicateEvent (Just duplicateId))+    | duplicateId == ourId -> eventExistsInStream streamName ourId+  StoreFailed (DuplicateEvent Nothing) -> eventExistsInStream streamName ourId+  _ -> pure False
src/Keiro/Projection.hs view
@@ -1,24 +1,23 @@-{- | Projections: turning a stream's events into read-side state.--Two flavors, trading consistency against coupling:--* An 'InlineProjection' runs in the /same/ transaction as the command that-  produced the events, so the read model is updated atomically with the-  append — never stale, but tied to the writer's transaction and latency.-  'runCommandWithProjections' runs a command and applies a list of inline-  projections to whatever it emits.-* An 'AsyncProjection' runs later from a subscription draining the event-  log. It carries a 'subscriptionName' for checkpointing and an-  'idempotencyKey' so redelivery is safe; 'applyAsyncProjection' performs-  one application. This decouples the read model from the writer at the-  cost of eventual consistency.--Both ultimately fold events into a SQL read model via a-'Hasql.Transaction.Transaction'; the difference is only /when/ that-transaction runs.--}-module Keiro.Projection (-    -- * Inline projections+-- | Projections: turning a stream's events into read-side state.+--+-- Two flavors, trading consistency against coupling:+--+-- * An 'InlineProjection' runs in the /same/ transaction as the command that+--   produced the events, so the read model is updated atomically with the+--   append — never stale, but tied to the writer's transaction and latency.+--   'runCommandWithProjections' runs a command and applies a list of inline+--   projections to whatever it emits.+-- * An 'AsyncProjection' runs later from a subscription draining the event+--   log. It carries a 'subscriptionName' for checkpointing and an+--   'idempotencyKey' so redelivery is safe; 'applyAsyncProjection' performs+--   one application. This decouples the read model from the writer at the+--   cost of eventual consistency.+--+-- Both ultimately fold events into a SQL read model via a+-- 'Hasql.Transaction.Transaction'; the difference is only /when/ that+-- transaction runs.+module Keiro.Projection+  ( -- * Inline projections     InlineProjection (..),     runCommandWithProjections, @@ -29,7 +28,7 @@     applyAsyncProjectionUnfenced,     pruneAsyncProjectionDedupBefore,     recordProjectionLag,-)+  ) where  import Contravariant.Extras (contrazip2)@@ -57,154 +56,147 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude qualified -{- | A read-model update applied synchronously with the command that emits-the event. 'apply' receives both the decoded event @co@ and the-'RecordedEvent' the store persisted, and runs in the append transaction.-'name' identifies the projection for diagnostics.--}+-- | A read-model update applied synchronously with the command that emits+-- the event. 'apply' receives both the decoded event @co@ and the+-- 'RecordedEvent' the store persisted, and runs in the append transaction.+-- 'name' identifies the projection for diagnostics. data InlineProjection co = InlineProjection-    { name :: !Text-    , apply :: !(co -> RecordedEvent -> Tx.Transaction ())-    }-    deriving stock (Generic)--{- | A read-model update applied asynchronously by a subscription worker.+  { name :: !Text,+    apply :: !(co -> RecordedEvent -> Tx.Transaction ())+  }+  deriving stock (Generic) -* 'name' — identifies the projection for diagnostics.-* 'readModelName' — names the registry row for the model this projection writes.-* 'subscriptionName' — the cursor under which the worker checkpoints its-  progress through the event log.-* 'applyRecorded' — folds one 'RecordedEvent' into the read model.-* 'idempotencyKey' — the 'EventId' used to suppress duplicate application on-  redelivery, making the projection safe to retry.--}+-- | A read-model update applied asynchronously by a subscription worker.+--+-- * 'name' — identifies the projection for diagnostics.+-- * 'readModelName' — names the registry row for the model this projection writes.+-- * 'subscriptionName' — the cursor under which the worker checkpoints its+--   progress through the event log.+-- * 'applyRecorded' — folds one 'RecordedEvent' into the read model.+-- * 'idempotencyKey' — the 'EventId' used to suppress duplicate application on+--   redelivery, making the projection safe to retry. data AsyncProjection = AsyncProjection-    { name :: !Text-    , readModelName :: !Text-    , subscriptionName :: !Text-    , applyRecorded :: !(RecordedEvent -> Tx.Transaction ())-    , idempotencyKey :: !(RecordedEvent -> EventId)-    }-    deriving stock (Generic)+  { name :: !Text,+    readModelName :: !Text,+    subscriptionName :: !Text,+    applyRecorded :: !(RecordedEvent -> Tx.Transaction ()),+    idempotencyKey :: !(RecordedEvent -> EventId)+  }+  deriving stock (Generic)  -- | The database-visible result of one asynchronous projection attempt. data AsyncApplyOutcome-    = AsyncApplied-    | AsyncDuplicate-    | AsyncFenced-    deriving stock (Generic, Eq, Show)+  = AsyncApplied+  | AsyncDuplicate+  | AsyncFenced+  deriving stock (Generic, Eq, Show) -{- | Run a command and apply every supplied 'InlineProjection' to the events-it emits, all inside the command's append transaction. A projection failure-aborts the whole transaction, so the events and the read-model update commit-together or not at all.--}+-- | Run a command and apply every supplied 'InlineProjection' to the events+-- it emits, all inside the command's append transaction. A projection failure+-- aborts the whole transaction, so the events and the read-model update commit+-- together or not at all. runCommandWithProjections ::-    forall phi rs s ci co es.-    (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    RunCommandOptions ->-    ValidatedEventStream phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    ci ->-    [InlineProjection co] ->-    Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co)))+  forall phi rs s ci co es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  ValidatedEventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  [InlineProjection co] ->+  Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co))) runCommandWithProjections options eventStream targetStream command projections = do-    result <--        runCommandWithSqlEvents-            options-            eventStream-            targetStream-            command-            ( \pairs _appendResult ->+  result <-+    runCommandWithSqlEvents+      options+      eventStream+      targetStream+      command+      ( \pairs _appendResult ->+          traverse_+            ( \projection ->                 traverse_-                    ( \projection ->-                        traverse_-                            (\(event, recorded) -> (projection ^. #apply) event recorded)-                            pairs-                    )-                    projections+                  (\(event, recorded) -> (projection ^. #apply) event recorded)+                  pairs             )-    pure (fmap Prelude.fst result)--{- | Apply one event to a live 'AsyncProjection', returning a distinct outcome-for a successful application, a retained dedup key, or a rebuild fence.--The registry row is read with @FOR SHARE@ inside the same transaction as the-dedup insert and application. A missing row or any status other than @live@-returns 'AsyncFenced' without touching either table. A worker that receives-'AsyncFenced' must not checkpoint past the event: fail or park the delivery and-retry after promotion. Ack-coupled Kiroku delivery preserves the checkpoint-when its handler does not acknowledge success.+            projections+      )+  pure (fmap Prelude.fst result) -The projection's 'idempotencyKey' is inserted into @keiro_projection_dedup@-inside the same transaction as 'applyRecorded'. When that insert conflicts,-the event was already applied within the retained dedup window and the update-is skipped. Use 'pruneAsyncProjectionDedupBefore' only for events older than-the subscription system can redeliver; pruning intentionally re-opens those-events for application if they are replayed later.--}+-- | Apply one event to a live 'AsyncProjection', returning a distinct outcome+-- for a successful application, a retained dedup key, or a rebuild fence.+--+-- The registry row is read with @FOR SHARE@ inside the same transaction as the+-- dedup insert and application. A missing row or any status other than @live@+-- returns 'AsyncFenced' without touching either table. A worker that receives+-- 'AsyncFenced' must not checkpoint past the event: fail or park the delivery and+-- retry after promotion. Ack-coupled Kiroku delivery preserves the checkpoint+-- when its handler does not acknowledge success.+--+-- The projection's 'idempotencyKey' is inserted into @keiro_projection_dedup@+-- inside the same transaction as 'applyRecorded'. When that insert conflicts,+-- the event was already applied within the retained dedup window and the update+-- is skipped. Use 'pruneAsyncProjectionDedupBefore' only for events older than+-- the subscription system can redeliver; pruning intentionally re-opens those+-- events for application if they are replayed later. applyAsyncProjection :: AsyncProjection -> RecordedEvent -> Tx.Transaction AsyncApplyOutcome applyAsyncProjection projection recorded = do-    status <--        Tx.statement-            (projection ^. #readModelName)-            lockReadModelStatusStmt-    case status of-        Just "live" -> applyAsyncProjectionUnfenced projection recorded-        _ -> pure AsyncFenced--{- | Apply one event without consulting the read-model registry fence.+  status <-+    Tx.statement+      (projection ^. #readModelName)+      lockReadModelStatusStmt+  case status of+    Just "live" -> applyAsyncProjectionUnfenced projection recorded+    _ -> pure AsyncFenced -This is exclusively the rebuild replay entry point: it retains normal dedup-semantics while permitting the designated rebuilder to write while the model is-@rebuilding@. Live workers must use 'applyAsyncProjection'.--}+-- | Apply one event without consulting the read-model registry fence.+--+-- This is exclusively the rebuild replay entry point: it retains normal dedup+-- semantics while permitting the designated rebuilder to write while the model is+-- @rebuilding@. Live workers must use 'applyAsyncProjection'. applyAsyncProjectionUnfenced :: AsyncProjection -> RecordedEvent -> Tx.Transaction AsyncApplyOutcome applyAsyncProjectionUnfenced projection recorded = do-    inserted <--        Tx.statement-            (projection ^. #name, eventIdToUuid ((projection ^. #idempotencyKey) recorded))-            insertProjectionDedupStmt-    if inserted-        then do-            (projection ^. #applyRecorded) recorded-            pure AsyncApplied-        else pure AsyncDuplicate--{- | Age out async-projection dedup rows older than the supplied timestamp.+  inserted <-+    Tx.statement+      (projection ^. #name, eventIdToUuid ((projection ^. #idempotencyKey) recorded))+      insertProjectionDedupStmt+  if inserted+    then do+      (projection ^. #applyRecorded) recorded+      pure AsyncApplied+    else pure AsyncDuplicate -Use this only beyond the subscription system's redelivery window; pruning-re-opens those events for application. It is not a rebuild reset. Supported-rebuilds use 'Keiro.ReadModel.Rebuild.startRebuild', which atomically deletes-only the named projections' keys while fencing writers and resetting the model.-Returns the number of rows pruned.--}+-- | Age out async-projection dedup rows older than the supplied timestamp.+--+-- Use this only beyond the subscription system's redelivery window; pruning+-- re-opens those events for application. It is not a rebuild reset. Supported+-- rebuilds use 'Keiro.ReadModel.Rebuild.startRebuild', which atomically deletes+-- only the named projections' keys while fencing writers and resetting the model.+-- Returns the number of rows pruned. pruneAsyncProjectionDedupBefore :: (Store :> es) => UTCTime -> Eff es Int64 pruneAsyncProjectionDedupBefore cutoff =-    runTransaction-        $ Tx.statement cutoff pruneProjectionDedupBeforeStmt--{- | Record 'keiro.projection.lag' for one async projection: how many events its-subscription is behind the global log head, computed as the store head global-position minus the subscription's checkpoint position (clamped at 0). A no-op-when no metrics handle is supplied. Call once per drain pass, after applying the-batch, so the gauge reflects the backlog the worker has left to catch up on.+  runTransaction+    $ Tx.statement cutoff pruneProjectionDedupBeforeStmt -There is no in-library polling drain loop today (the application drives-'applyAsyncProjection' per event), so this is the entry point an application-calls to surface lag for a subscription.--}+-- | Record 'keiro.projection.lag' for one async projection: how many events its+-- subscription is behind the global log head, computed as the store head global+-- position minus the subscription's checkpoint position (clamped at 0). A no-op+-- when no metrics handle is supplied. Call once per drain pass, after applying the+-- batch, so the gauge reflects the backlog the worker has left to catch up on.+--+-- There is no in-library polling drain loop today (the application drives+-- 'applyAsyncProjection' per event), so this is the entry point an application+-- calls to surface lag for a subscription. recordProjectionLag ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    AsyncProjection ->-    Eff es ()+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  AsyncProjection ->+  Eff es () recordProjectionLag metrics projection = do-    headPos <- storeHeadPosition-    checkpoint <--        fromMaybe (GlobalPosition 0)-            <$> readSubscriptionPosition (projection ^. #subscriptionName)-    Telemetry.recordProjectionLag metrics (positionGap headPos checkpoint)+  headPos <- storeHeadPosition+  checkpoint <-+    fromMaybe (GlobalPosition 0)+      <$> readSubscriptionPosition (projection ^. #subscriptionName)+  Telemetry.recordProjectionLag metrics (positionGap headPos checkpoint)  -- | The non-negative gap between the log head and a checkpoint, in events. positionGap :: GlobalPosition -> GlobalPosition -> Int64@@ -212,39 +204,39 @@  insertProjectionDedupStmt :: Statement (Text, UUID) Bool insertProjectionDedupStmt =-    preparable-        """-        INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)-        VALUES ($1, $2)-        ON CONFLICT (projection_name, event_id) DO NOTHING-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.uuid))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)+    VALUES ($1, $2)+    ON CONFLICT (projection_name, event_id) DO NOTHING+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.uuid))+    )+    ((> 0) <$> D.rowsAffected)  lockReadModelStatusStmt :: Statement Text (Maybe Text) lockReadModelStatusStmt =-    preparable-        """-        SELECT status-        FROM keiro.keiro_read_models-        WHERE name = $1-        FOR SHARE-        """-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (D.column (D.nonNullable D.text)))+  preparable+    """+    SELECT status+    FROM keiro.keiro_read_models+    WHERE name = $1+    FOR SHARE+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (D.column (D.nonNullable D.text)))  pruneProjectionDedupBeforeStmt :: Statement UTCTime Int64 pruneProjectionDedupBeforeStmt =-    preparable-        """-        DELETE FROM keiro.keiro_projection_dedup-        WHERE applied_at < $1-        """-        (E.param (E.nonNullable E.timestamptz))-        D.rowsAffected+  preparable+    """+    DELETE FROM keiro.keiro_projection_dedup+    WHERE applied_at < $1+    """+    (E.param (E.nonNullable E.timestamptz))+    D.rowsAffected  eventIdToUuid :: EventId -> UUID eventIdToUuid (EventId value) = value
src/Keiro/ReadModel.hs view
@@ -1,32 +1,31 @@-{- | Querying the read side, with explicit consistency.--A 'ReadModel' is a named, versioned SQL projection table plus the query that-reads it. Querying it does more than run SQL: 'runQuery' first verifies the-table's registered schema is current and 'Live' (rejecting a stale or-mid-rebuild model), then honours the requested 'ConsistencyMode' before-running the query in a transaction.--The consistency modes trade freshness against latency:--* 'Strong' — capture the store head position at query start and block until-  the model's subscription cursor reaches it.-* 'Eventual' — query immediately. Read-your-writes is the projection worker's-  responsibility under 'Eventual'.-* 'PositionWait' — block until the model's subscription has caught up to a-  target 'GlobalPosition' (typically the position returned by the command-  the caller just ran), giving read-your-writes against an asynchronous-  projection. 'waitFor' implements the polling loop and times out with-  'ReadModelWaitTimeout'.--Schema lifecycle (registration, status transitions) lives in-"Keiro.ReadModel.Schema", which is re-exported here.--Register each model once at projection startup with 'registerReadModel' before-serving queries. Queries fail with 'ReadModelUnregistered' when startup wiring-has not registered the model; they never create registry rows themselves.--}-module Keiro.ReadModel (-    -- * Definition+-- | Querying the read side, with explicit consistency.+--+-- A 'ReadModel' is a named, versioned SQL projection table plus the query that+-- reads it. Querying it does more than run SQL: 'runQuery' first verifies the+-- table's registered schema is current and 'Live' (rejecting a stale or+-- mid-rebuild model), then honours the requested 'ConsistencyMode' before+-- running the query in a transaction.+--+-- The consistency modes trade freshness against latency:+--+-- * 'Strong' — capture the store head position at query start and block until+--   the model's subscription cursor reaches it.+-- * 'Eventual' — query immediately. Read-your-writes is the projection worker's+--   responsibility under 'Eventual'.+-- * 'PositionWait' — block until the model's subscription has caught up to a+--   target 'GlobalPosition' (typically the position returned by the command+--   the caller just ran), giving read-your-writes against an asynchronous+--   projection. 'waitFor' implements the polling loop and times out with+--   'ReadModelWaitTimeout'.+--+-- Schema lifecycle (registration, status transitions) lives in+-- "Keiro.ReadModel.Schema", which is re-exported here.+--+-- Register each model once at projection startup with 'registerReadModel' before+-- serving queries. Queries fail with 'ReadModelUnregistered' when startup wiring+-- has not registered the model; they never create registry rows themselves.+module Keiro.ReadModel+  ( -- * Definition     ReadModel (..),     qualifiedTableName, @@ -49,7 +48,7 @@      -- * Schema lifecycle     module Keiro.ReadModel.Schema,-)+  ) where  import Control.Concurrent (threadDelay)@@ -70,297 +69,282 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude qualified -{- | A queryable read-side projection over a query input @q@ and result @r@.--* 'name' — logical identity, also the key in the @keiro_read_models@-  registry.-* 'tableName' — the underlying projection table.-* 'schema' — the PostgreSQL schema the read-model /data/ table lives in. The-  application qualifies its 'query' SQL against this schema (typically via-  'Keiro.Connection.qualifyTable' or 'qualifiedTableName'); Keiro does not-  rewrite 'query'. This is the application's data schema and is entirely-  separate from Keiro's own @keiro@ schema, where the @keiro_read_models@-  registry lives. It is deliberately not persisted (see 'ensureReadModel').-* 'subscriptionName' — the cursor that tracks how far the projection worker-  has consumed the event log; consulted by 'PositionWait'.-* 'version' \/ 'shapeHash' — schema identity; a query fails with-  'ReadModelStaleSchema' if the registered values diverge, forcing a rebuild.-* 'defaultConsistency' — the 'ConsistencyMode' used by 'runQuery'.-* 'strongScope' — the event-log head a 'Strong' query waits for.-* 'query' — the SQL read, as a 'Hasql.Transaction.Transaction'.--}+-- | A queryable read-side projection over a query input @q@ and result @r@.+--+-- * 'name' — logical identity, also the key in the @keiro_read_models@+--   registry.+-- * 'tableName' — the underlying projection table.+-- * 'schema' — the PostgreSQL schema the read-model /data/ table lives in. The+--   application qualifies its 'query' SQL against this schema (typically via+--   'Keiro.Connection.qualifyTable' or 'qualifiedTableName'); Keiro does not+--   rewrite 'query'. This is the application's data schema and is entirely+--   separate from Keiro's own @keiro@ schema, where the @keiro_read_models@+--   registry lives. It is deliberately not persisted (see 'ensureReadModel').+-- * 'subscriptionName' — the cursor that tracks how far the projection worker+--   has consumed the event log; consulted by 'PositionWait'.+-- * 'version' \/ 'shapeHash' — schema identity; a query fails with+--   'ReadModelStaleSchema' if the registered values diverge, forcing a rebuild.+-- * 'defaultConsistency' — the 'ConsistencyMode' used by 'runQuery'.+-- * 'strongScope' — the event-log head a 'Strong' query waits for.+-- * 'query' — the SQL read, as a 'Hasql.Transaction.Transaction'. data ReadModel q r = ReadModel-    { name :: !Text-    , tableName :: !Text-    , schema :: !Text-    , subscriptionName :: !Text-    , version :: !Int-    , shapeHash :: !Text-    , defaultConsistency :: !ConsistencyMode-    , strongScope :: !StrongScope-    , query :: !(q -> Tx.Transaction r)-    }-    deriving stock (Generic)+  { name :: !Text,+    tableName :: !Text,+    schema :: !Text,+    subscriptionName :: !Text,+    version :: !Int,+    shapeHash :: !Text,+    defaultConsistency :: !ConsistencyMode,+    strongScope :: !StrongScope,+    query :: !(q -> Tx.Transaction r)+  }+  deriving stock (Generic) -{- | The read model's fully-qualified, double-quoted table reference-@"schema"."table"@, for interpolation into the application's projection SQL.-Equal to @'Keiro.Connection.qualifyTable' ('schema' rm) ('tableName' rm)@.--}+-- | The read model's fully-qualified, double-quoted table reference+-- @"schema"."table"@, for interpolation into the application's projection SQL.+-- Equal to @'Keiro.Connection.qualifyTable' ('schema' rm) ('tableName' rm)@. qualifiedTableName :: ReadModel q r -> Text qualifiedTableName readModel =-    qualifyTable (readModel ^. #schema) (readModel ^. #tableName)--{- | How fresh a read must be before the query runs.+  qualifyTable (readModel ^. #schema) (readModel ^. #tableName) -'Strong' waits for the model's subscription to reach the store head captured-at query start according to the model's 'strongScope'. It is intended for-asynchronous read models with a worker advancing that subscription cursor;-inline-only models should use 'Eventual' because they have no subscription-worker to advance while waiting.-'PositionWait' blocks until the projection has caught up to a caller-supplied-target log position (or times out). 'Eventual' queries immediately.--}+-- | How fresh a read must be before the query runs.+--+-- 'Strong' waits for the model's subscription to reach the store head captured+-- at query start according to the model's 'strongScope'. It is intended for+-- asynchronous read models with a worker advancing that subscription cursor;+-- inline-only models should use 'Eventual' because they have no subscription+-- worker to advance while waiting.+-- 'PositionWait' blocks until the projection has caught up to a caller-supplied+-- target log position (or times out). 'Eventual' queries immediately. data ConsistencyMode-    = Strong-    | Eventual-    | PositionWait !PositionWaitOptions-    deriving stock (Generic, Eq, Show)--{- | Which log head a 'Strong' read must reach.--'EntireLog' preserves the original behavior and is live only when the model's-subscription observes every event. A category subscription should use-'CategoryHead' with its Kiroku category, so unrelated categories cannot hold-the read behind forever. A model fed by multiple categories should use-'PositionWait' for an explicit write position or 'EntireLog' with a matching-all-stream subscription.+  = Strong+  | Eventual+  | PositionWait !PositionWaitOptions+  deriving stock (Generic, Eq, Show) -Kiroku currently does not advance category checkpoints on empty fetches. If it-does so in a future release, category-scoped targets may become unnecessary,-but the explicit model contract remains valid.--}+-- | Which log head a 'Strong' read must reach.+--+-- 'EntireLog' preserves the original behavior and is live only when the model's+-- subscription observes every event. A category subscription should use+-- 'CategoryHead' with its Kiroku category, so unrelated categories cannot hold+-- the read behind forever. A model fed by multiple categories should use+-- 'PositionWait' for an explicit write position or 'EntireLog' with a matching+-- all-stream subscription.+--+-- Kiroku currently does not advance category checkpoints on empty fetches. If it+-- does so in a future release, category-scoped targets may become unnecessary,+-- but the explicit model contract remains valid. data StrongScope-    = EntireLog-    | CategoryHead !Text-    deriving stock (Generic, Eq, Show)--{- | Parameters for a 'PositionWait' query.+  = EntireLog+  | CategoryHead !Text+  deriving stock (Generic, Eq, Show) -* 'target' — the 'GlobalPosition' the projection must reach; 'Nothing'-  skips waiting entirely.-* 'timeoutMicros' — give up after this long with 'ReadModelWaitTimeout'.-* 'pollMicros' — delay between subscription-position checks.--}+-- | Parameters for a 'PositionWait' query.+--+-- * 'target' — the 'GlobalPosition' the projection must reach; 'Nothing'+--   skips waiting entirely.+-- * 'timeoutMicros' — give up after this long with 'ReadModelWaitTimeout'.+-- * 'pollMicros' — delay between subscription-position checks. data PositionWaitOptions = PositionWaitOptions-    { target :: !(Maybe GlobalPosition)-    , timeoutMicros :: !Int-    , pollMicros :: !Int-    }-    deriving stock (Generic, Eq, Show)+  { target :: !(Maybe GlobalPosition),+    timeoutMicros :: !Int,+    pollMicros :: !Int+  }+  deriving stock (Generic, Eq, Show) -{- | Default wait settings used by 'Strong': wait up to five seconds, polling-every 10ms, for the store head captured at query start.--}+-- | Default wait settings used by 'Strong': wait up to five seconds, polling+-- every 10ms, for the store head captured at query start. defaultStrongWaitOptions :: PositionWaitOptions defaultStrongWaitOptions =-    PositionWaitOptions-        { target = Nothing-        , timeoutMicros = 5000000-        , pollMicros = 10000-        }+  PositionWaitOptions+    { target = Nothing,+      timeoutMicros = 5000000,+      pollMicros = 10000+    }  -- | Why a read-model query could not run. data ReadModelError-    = {- | No registry row exists for the model. Register it once at projection-      startup with 'registerReadModel' before serving queries.-      -}-      ReadModelUnregistered !Text-    | {- | The registered schema (version or shape hash) differs from the-      model's current definition: name, expected vs. found version, then-      expected vs. found shape hash. The model must be rebuilt.-      -}-      ReadModelStaleSchema !Text !Int !Int !Text !Text-    | {- | A 'PositionWait' query timed out: model name, target position, and-      the last observed subscription position.-      -}-      ReadModelWaitTimeout !Text !GlobalPosition !GlobalPosition-    | {- | The model is registered but not 'Live' (e.g. rebuilding or-      abandoned): name and current status.-      -}-      ReadModelNotLive !Text !ReadModelStatus-    deriving stock (Generic, Eq, Show)+  = -- | No registry row exists for the model. Register it once at projection+    --       startup with 'registerReadModel' before serving queries.+    ReadModelUnregistered !Text+  | -- | The registered schema (version or shape hash) differs from the+    --       model's current definition: name, expected vs. found version, then+    --       expected vs. found shape hash. The model must be rebuilt.+    ReadModelStaleSchema !Text !Int !Int !Text !Text+  | -- | A 'PositionWait' query timed out: model name, target position, and+    --       the last observed subscription position.+    ReadModelWaitTimeout !Text !GlobalPosition !GlobalPosition+  | -- | The model is registered but not 'Live' (e.g. rebuilding or+    --       abandoned): name and current status.+    ReadModelNotLive !Text !ReadModelStatus+  deriving stock (Generic, Eq, Show) -{- | Query a read model using its 'defaultConsistency'. Validates schema and-liveness first, waits if the mode requires it, then runs the query.--}+-- | Query a read model using its 'defaultConsistency'. Validates schema and+-- liveness first, waits if the mode requires it, then runs the query. runQuery ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    ReadModel q r ->-    q ->-    Eff es (Either ReadModelError r)+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  ReadModel q r ->+  q ->+  Eff es (Either ReadModelError r) runQuery metrics readModel =-    runQueryWith metrics (readModel ^. #defaultConsistency) readModel+  runQueryWith metrics (readModel ^. #defaultConsistency) readModel -{- | Query a read model with an explicit 'ConsistencyMode', overriding its-default. Validates the model's schema and liveness, honours the wait mode,-then runs the query in a transaction.--}+-- | Query a read model with an explicit 'ConsistencyMode', overriding its+-- default. Validates the model's schema and liveness, honours the wait mode,+-- then runs the query in a transaction. runQueryWith ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    ConsistencyMode ->-    ReadModel q r ->-    q ->-    Eff es (Either ReadModelError r)+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  ConsistencyMode ->+  ReadModel q r ->+  q ->+  Eff es (Either ReadModelError r) runQueryWith metrics consistency readModel input = do-    schemaCheck <- ensureReadModel readModel-    case schemaCheck of+  schemaCheck <- ensureReadModel readModel+  case schemaCheck of+    Left err -> pure (Left err)+    Right () -> do+      waitResult <- waitIfNeeded metrics consistency readModel+      case waitResult of         Left err -> pure (Left err)-        Right () -> do-            waitResult <- waitIfNeeded metrics consistency readModel-            case waitResult of-                Left err -> pure (Left err)-                Right () -> Right <$> runTransaction ((readModel ^. #query) input)+        Right () -> Right <$> runTransaction ((readModel ^. #query) input) -{- | Block until the model's subscription has advanced to @targetPosition@,-polling at 'pollMicros' intervals. Returns @Right ()@ once caught up, or-'ReadModelWaitTimeout' if 'timeoutMicros' elapses first.--}+-- | Block until the model's subscription has advanced to @targetPosition@,+-- polling at 'pollMicros' intervals. Returns @Right ()@ once caught up, or+-- 'ReadModelWaitTimeout' if 'timeoutMicros' elapses first. waitFor ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    PositionWaitOptions ->-    ReadModel q r ->-    GlobalPosition ->-    Eff es (Either ReadModelError ())+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  PositionWaitOptions ->+  ReadModel q r ->+  GlobalPosition ->+  Eff es (Either ReadModelError ()) waitFor metrics options readModel targetPosition = do-    started <- liftIO getCurrentTime-    poll started (GlobalPosition 0)+  started <- liftIO getCurrentTime+  poll started (GlobalPosition 0)   where     poll started observed = do-        current <- readSubscriptionPosition (readModel ^. #subscriptionName)-        let observed' = fromMaybe observed current-        if observed' >= targetPosition-            then pure (Right ())+      current <- readSubscriptionPosition (readModel ^. #subscriptionName)+      let observed' = fromMaybe observed current+      if observed' >= targetPosition+        then pure (Right ())+        else do+          now <- liftIO getCurrentTime+          let elapsedMicros =+                Prelude.floor+                  (diffUTCTime now started Prelude.* 1000000)+          if elapsedMicros >= options ^. #timeoutMicros+            then do+              -- A genuine give-up: bump keiro.projection.wait.timeouts (no-op+              -- under a 'Nothing' handle) before surfacing the timeout.+              recordProjectionWaitTimeouts metrics 1+              pure+                (Left (ReadModelWaitTimeout (readModel ^. #name) targetPosition observed'))             else do-                now <- liftIO getCurrentTime-                let elapsedMicros =-                        Prelude.floor-                            (diffUTCTime now started Prelude.* 1000000)-                if elapsedMicros >= options ^. #timeoutMicros-                    then do-                        -- A genuine give-up: bump keiro.projection.wait.timeouts (no-op-                        -- under a 'Nothing' handle) before surfacing the timeout.-                        recordProjectionWaitTimeouts metrics 1-                        pure-                            (Left (ReadModelWaitTimeout (readModel ^. #name) targetPosition observed'))-                    else do-                        liftIO (threadDelay (options ^. #pollMicros))-                        poll started observed'+              liftIO (threadDelay (options ^. #pollMicros))+              poll started observed'  -- Note: the read model's 'schema' field is deliberately NOT persisted here. The -- registry keys on name/version/shapeHash/status (the model's schema identity); -- where the application's data table physically lives is a deployment/wiring -- concern, not part of that identity. See EP-4's Decision Log. ensureReadModel ::-    (Store :> es) =>-    ReadModel q r ->-    Eff es (Either ReadModelError ())+  (Store :> es) =>+  ReadModel q r ->+  Eff es (Either ReadModelError ()) ensureReadModel readModel = do-    found <- lookupReadModel (readModel ^. #name)-    pure $ case found of-        Just metadata -> validateMetadata readModel metadata-        Nothing -> Left (ReadModelUnregistered (readModel ^. #name))+  found <- lookupReadModel (readModel ^. #name)+  pure $ case found of+    Just metadata -> validateMetadata readModel metadata+    Nothing -> Left (ReadModelUnregistered (readModel ^. #name))  validateMetadata :: ReadModel q r -> ReadModelMetadata -> Either ReadModelError () validateMetadata readModel metadata-    | metadata ^. #version /= readModel ^. #version =-        stale-    | metadata ^. #shapeHash /= readModel ^. #shapeHash =-        stale-    | metadata ^. #status /= Live =-        Left (ReadModelNotLive (readModel ^. #name) (metadata ^. #status))-    | otherwise =-        Right ()+  | metadata ^. #version /= readModel ^. #version =+      stale+  | metadata ^. #shapeHash /= readModel ^. #shapeHash =+      stale+  | metadata ^. #status /= Live =+      Left (ReadModelNotLive (readModel ^. #name) (metadata ^. #status))+  | otherwise =+      Right ()   where     stale =-        Left-            ( ReadModelStaleSchema-                (readModel ^. #name)-                (readModel ^. #version)-                (metadata ^. #version)-                (readModel ^. #shapeHash)-                (metadata ^. #shapeHash)-            )+      Left+        ( ReadModelStaleSchema+            (readModel ^. #name)+            (readModel ^. #version)+            (metadata ^. #version)+            (readModel ^. #shapeHash)+            (metadata ^. #shapeHash)+        )  waitIfNeeded ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    ConsistencyMode ->-    ReadModel q r ->-    Eff es (Either ReadModelError ())+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  ConsistencyMode ->+  ReadModel q r ->+  Eff es (Either ReadModelError ()) waitIfNeeded metrics Strong readModel = do-    target <- case readModel ^. #strongScope of-        EntireLog -> storeHeadPosition-        CategoryHead category -> categoryHeadPosition category-    waitFor metrics (defaultStrongWaitOptions & #target ?~ target) readModel target+  target <- case readModel ^. #strongScope of+    EntireLog -> storeHeadPosition+    CategoryHead category -> categoryHeadPosition category+  waitFor metrics (defaultStrongWaitOptions & #target ?~ target) readModel target waitIfNeeded _ Eventual _ = pure (Right ()) waitIfNeeded metrics (PositionWait options) readModel =-    case options ^. #target of-        Nothing -> pure (Right ())-        Just targetPosition -> waitFor metrics options readModel targetPosition+  case options ^. #target of+    Nothing -> pure (Right ())+    Just targetPosition -> waitFor metrics options readModel targetPosition  readSubscriptionPosition ::-    (Store :> es) =>-    Text ->-    Eff es (Maybe GlobalPosition)+  (Store :> es) =>+  Text ->+  Eff es (Maybe GlobalPosition) readSubscriptionPosition subscriptionName =-    runTransaction-        $ Tx.statement subscriptionName lookupSubscriptionPositionStmt+  runTransaction+    $ Tx.statement subscriptionName lookupSubscriptionPositionStmt  lookupSubscriptionPositionStmt :: Statement Text (Maybe GlobalPosition) lookupSubscriptionPositionStmt =-    preparable-        """-        SELECT min(last_seen)-        FROM subscriptions-        WHERE subscription_name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (fmap GlobalPosition <$> D.column (D.nullable D.int8)))+  preparable+    """+    SELECT min(last_seen)+    FROM subscriptions+    WHERE subscription_name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (fmap GlobalPosition <$> D.column (D.nullable D.int8))) -{- | The global position of the most recent event in the @$all@ log, or-@GlobalPosition 0@ when the log is empty. 'readAllBackward' treats-@GlobalPosition 0@ as "after everything", so a limit of 1 returns the head.--}+-- | The global position of the most recent event in the @$all@ log, or+-- @GlobalPosition 0@ when the log is empty. 'readAllBackward' treats+-- @GlobalPosition 0@ as "after everything", so a limit of 1 returns the head. storeHeadPosition :: (Store :> es) => Eff es GlobalPosition storeHeadPosition = do-    recent <- readAllBackward (GlobalPosition 0) 1-    pure $ case Vector.toList recent of-        (event : _) -> event ^. #globalPosition-        [] -> GlobalPosition 0+  recent <- readAllBackward (GlobalPosition 0) 1+  pure $ case Vector.toList recent of+    (event : _) -> event ^. #globalPosition+    [] -> GlobalPosition 0 -{- | The latest global position originating in a Kiroku category, or-@GlobalPosition 0@ when that category has no events. This deliberately reads-Kiroku's indexed @streams@ and @$all@ membership tables because Kiroku 0.3 does-not export a category-head query.--}+-- | The latest global position originating in a Kiroku category, or+-- @GlobalPosition 0@ when that category has no events. This deliberately reads+-- Kiroku's indexed @streams@ and @$all@ membership tables because Kiroku 0.3 does+-- not export a category-head query. categoryHeadPosition :: (Store :> es) => Text -> Eff es GlobalPosition categoryHeadPosition category =-    runTransaction-        $ Tx.statement category categoryHeadPositionStmt+  runTransaction+    $ Tx.statement category categoryHeadPositionStmt  categoryHeadPositionStmt :: Statement Text GlobalPosition categoryHeadPositionStmt =-    preparable-        """-        SELECT COALESCE(max(se.stream_version), 0)-        FROM streams s-        JOIN stream_events se-          ON se.original_stream_id = s.stream_id-         AND se.stream_id = 0-        WHERE s.category = $1-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT COALESCE(max(se.stream_version), 0)+    FROM streams s+    JOIN stream_events se+      ON se.original_stream_id = s.stream_id+     AND se.stream_id = 0+    WHERE s.category = $1+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))
src/Keiro/ReadModel/Rebuild.hs view
@@ -1,39 +1,38 @@-{- | The supported offline read-model rebuild lifecycle.--Use this checklist rather than composing the low-level status transitions:--1. Call 'Keiro.ReadModel.Schema.registerReadModel' once at projection startup.-   Explicit registration makes misspelled or never-populated models fail with-   'ReadModelUnregistered' instead of appearing healthy.-2. Call 'startRebuild' with every feeding async projection name and the replay-   position. It atomically fences live writers, takes queries offline, truncates-   the data table, clears the named dedup keys, and resets the subscription-   checkpoint, preventing live/replay interleaving and all-deduplicated rebuilds.-3. Replay through 'Keiro.Projection.applyAsyncProjectionUnfenced'. This is the-   only apply path allowed to bypass the live-writer fence, while retaining-   deduplication inside the designated rebuild.-4. After replay catches up and application-specific verification succeeds, call-   'finishRebuild' with the same projection names and replay position. Its-   promotion guard refuses to serve a non-empty-log rebuild that applied no-   events.-5. If replay or verification fails, call 'abandonRebuild'. Queries remain-   unavailable instead of exposing partial data; repair or restore the table-   before beginning another rebuild.--Normal workers continue to call 'Keiro.Projection.applyAsyncProjection'. Its-registry lock fences them automatically while the model is rebuilding, but they-must not checkpoint an 'Keiro.Projection.AsyncFenced' event. Keiro does not yet-provide a shadow-table or online cutover mechanism; applications that need-zero-downtime rebuilds must build that orchestration above this lifecycle API.--}-module Keiro.ReadModel.Rebuild (-    RebuildError (..),+-- | The supported offline read-model rebuild lifecycle.+--+-- Use this checklist rather than composing the low-level status transitions:+--+-- 1. Call 'Keiro.ReadModel.Schema.registerReadModel' once at projection startup.+--    Explicit registration makes misspelled or never-populated models fail with+--    'ReadModelUnregistered' instead of appearing healthy.+-- 2. Call 'startRebuild' with every feeding async projection name and the replay+--    position. It atomically fences live writers, takes queries offline, truncates+--    the data table, clears the named dedup keys, and resets the subscription+--    checkpoint, preventing live/replay interleaving and all-deduplicated rebuilds.+-- 3. Replay through 'Keiro.Projection.applyAsyncProjectionUnfenced'. This is the+--    only apply path allowed to bypass the live-writer fence, while retaining+--    deduplication inside the designated rebuild.+-- 4. After replay catches up and application-specific verification succeeds, call+--    'finishRebuild' with the same projection names and replay position. Its+--    promotion guard refuses to serve a non-empty-log rebuild that applied no+--    events.+-- 5. If replay or verification fails, call 'abandonRebuild'. Queries remain+--    unavailable instead of exposing partial data; repair or restore the table+--    before beginning another rebuild.+--+-- Normal workers continue to call 'Keiro.Projection.applyAsyncProjection'. Its+-- registry lock fences them automatically while the model is rebuilding, but they+-- must not checkpoint an 'Keiro.Projection.AsyncFenced' event. Keiro does not yet+-- provide a shadow-table or online cutover mechanism; applications that need+-- zero-downtime rebuilds must build that orchestration above this lifecycle API.+module Keiro.ReadModel.Rebuild+  ( RebuildError (..),     startRebuild,     finishRebuild,     rebuild,     promote,     abandonRebuild,-)+  ) where  import Contravariant.Extras (contrazip2)@@ -51,146 +50,141 @@  -- | Why a rebuild could not be promoted. data RebuildError-    = {- | The model name and current store head when a rebuild started before-      existing events but applied none of its feeding projections.-      -}-      RebuildProducedNoApplies !Text !GlobalPosition-    deriving stock (Generic, Eq, Show)--{- | Atomically take a model offline, truncate its data table, clear the dedup-keys for its feeding async projections, and reset every member of its-subscription to the supplied replay position.+  = -- | The model name and current store head when a rebuild started before+    --       existing events but applied none of its feeding projections.+    RebuildProducedNoApplies !Text !GlobalPosition+  deriving stock (Generic, Eq, Show) -The registry transition runs first and holds the row lock that-'Keiro.Projection.applyAsyncProjection' uses as its writer fence. PostgreSQL-keeps the table truncate transactional. Kiroku's public checkpoint save is-monotonic, so this helper deliberately resets @subscriptions.last_seen@-directly inside the same fenced transaction.--}+-- | Atomically take a model offline, truncate its data table, clear the dedup+-- keys for its feeding async projections, and reset every member of its+-- subscription to the supplied replay position.+--+-- The registry transition runs first and holds the row lock that+-- 'Keiro.Projection.applyAsyncProjection' uses as its writer fence. PostgreSQL+-- keeps the table truncate transactional. Kiroku's public checkpoint save is+-- monotonic, so this helper deliberately resets @subscriptions.last_seen@+-- directly inside the same fenced transaction. startRebuild ::-    (Store :> es) =>-    ReadModel q r ->-    [Text] ->-    GlobalPosition ->-    Eff es ReadModelMetadata+  (Store :> es) =>+  ReadModel q r ->+  [Text] ->+  GlobalPosition ->+  Eff es ReadModelMetadata startRebuild readModel projectionNames replayFrom =-    runTransaction $ do-        metadata <- transitionReadModelTxFor readModel Rebuilding-        Tx.sql (TE.encodeUtf8 ("TRUNCATE TABLE " <> qualifiedTableName readModel))-        unless (null projectionNames) $-            Tx.statement projectionNames deleteProjectionDedupStmt-        Tx.statement-            (readModel ^. #subscriptionName, globalPositionToInt replayFrom)-            resetSubscriptionCheckpointStmt-        pure metadata--{- | Promote a completed rebuild in the same transaction as its safety check.+  runTransaction $ do+    metadata <- transitionReadModelTxFor readModel Rebuilding+    Tx.sql (TE.encodeUtf8 ("TRUNCATE TABLE " <> qualifiedTableName readModel))+    unless (null projectionNames) $+      Tx.statement projectionNames deleteProjectionDedupStmt+    Tx.statement+      (readModel ^. #subscriptionName, globalPositionToInt replayFrom)+      resetSubscriptionCheckpointStmt+    pure metadata -When async projection names are supplied, a store head beyond @replayFrom@-requires at least one new dedup row. 'startRebuild' deleted those rows, so their-count is the model-independent number of projection applications during this-rebuild. An empty projection-name list denotes an inline-only model and skips-the guard because no async dedup rows can exist for it.--}+-- | Promote a completed rebuild in the same transaction as its safety check.+--+-- When async projection names are supplied, a store head beyond @replayFrom@+-- requires at least one new dedup row. 'startRebuild' deleted those rows, so their+-- count is the model-independent number of projection applications during this+-- rebuild. An empty projection-name list denotes an inline-only model and skips+-- the guard because no async dedup rows can exist for it. finishRebuild ::-    (Store :> es) =>-    ReadModel q r ->-    [Text] ->-    GlobalPosition ->-    Eff es (Either RebuildError ReadModelMetadata)+  (Store :> es) =>+  ReadModel q r ->+  [Text] ->+  GlobalPosition ->+  Eff es (Either RebuildError ReadModelMetadata) finishRebuild readModel projectionNames replayFrom =-    runTransaction $ do-        headPosition <- Tx.statement () storeHeadPositionStmt-        applyCount <--            if null projectionNames-                then pure 0-                else Tx.statement projectionNames countProjectionDedupStmt-        if null projectionNames || applyCount > 0 || headPosition <= replayFrom-            then Right <$> transitionReadModelTxFor readModel Live-            else pure (Left (RebuildProducedNoApplies (readModel ^. #name) headPosition))+  runTransaction $ do+    headPosition <- Tx.statement () storeHeadPositionStmt+    applyCount <-+      if null projectionNames+        then pure 0+        else Tx.statement projectionNames countProjectionDedupStmt+    if null projectionNames || applyCount > 0 || headPosition <= replayFrom+      then Right <$> transitionReadModelTxFor readModel Live+      else pure (Left (RebuildProducedNoApplies (readModel ^. #name) headPosition)) -{- | Low-level status transition only. It does not truncate data, reset dedup-keys or checkpoints, or establish the complete rebuild workflow. Use-'startRebuild' for supported rebuilds.--}+-- | Low-level status transition only. It does not truncate data, reset dedup+-- keys or checkpoints, or establish the complete rebuild workflow. Use+-- 'startRebuild' for supported rebuilds. rebuild :: (Store :> es) => ReadModel q r -> Eff es ReadModelMetadata rebuild readModel =-    markRebuilding-        (readModel ^. #name)-        (readModel ^. #version)-        (readModel ^. #shapeHash)+  markRebuilding+    (readModel ^. #name)+    (readModel ^. #version)+    (readModel ^. #shapeHash) -{- | Low-level status transition only. It bypasses the empty-rebuild guard. Use-'finishRebuild' to promote a supported rebuild.--}+-- | Low-level status transition only. It bypasses the empty-rebuild guard. Use+-- 'finishRebuild' to promote a supported rebuild. promote :: (Store :> es) => ReadModel q r -> Eff es ReadModelMetadata promote readModel =-    markLive-        (readModel ^. #name)-        (readModel ^. #version)-        (readModel ^. #shapeHash)+  markLive+    (readModel ^. #name)+    (readModel ^. #version)+    (readModel ^. #shapeHash)  -- | Mark a model 'Abandoned', backing out of an in-progress rebuild. abandonRebuild :: (Store :> es) => ReadModel q r -> Eff es ReadModelMetadata abandonRebuild readModel =-    markAbandoned-        (readModel ^. #name)-        (readModel ^. #version)-        (readModel ^. #shapeHash)+  markAbandoned+    (readModel ^. #name)+    (readModel ^. #version)+    (readModel ^. #shapeHash)  transitionReadModelTxFor :: ReadModel q r -> ReadModelStatus -> Tx.Transaction ReadModelMetadata transitionReadModelTxFor readModel status =-    transitionReadModelTx-        (readModel ^. #name)-        (readModel ^. #version)-        (readModel ^. #shapeHash)-        status+  transitionReadModelTx+    (readModel ^. #name)+    (readModel ^. #version)+    (readModel ^. #shapeHash)+    status  deleteProjectionDedupStmt :: Statement [Text] () deleteProjectionDedupStmt =-    preparable-        """-        DELETE FROM keiro.keiro_projection_dedup-        WHERE projection_name = ANY($1)-        """-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_projection_dedup+    WHERE projection_name = ANY($1)+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    D.noResult  countProjectionDedupStmt :: Statement [Text] Int64 countProjectionDedupStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_projection_dedup-        WHERE projection_name = ANY($1)-        """-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_projection_dedup+    WHERE projection_name = ANY($1)+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    (D.singleRow (D.column (D.nonNullable D.int8)))  resetSubscriptionCheckpointStmt :: Statement (Text, Int64) () resetSubscriptionCheckpointStmt =-    preparable-        """-        UPDATE subscriptions-        SET last_seen = $2, updated_at = now()-        WHERE subscription_name = $1-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult+  preparable+    """+    UPDATE subscriptions+    SET last_seen = $2, updated_at = now()+    WHERE subscription_name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult  storeHeadPositionStmt :: Statement () GlobalPosition storeHeadPositionStmt =-    preparable-        """-        SELECT COALESCE(max(stream_version), 0)-        FROM stream_events-        WHERE stream_id = 0-        """-        E.noParams-        (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT COALESCE(max(stream_version), 0)+    FROM stream_events+    WHERE stream_id = 0+    """+    E.noParams+    (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))  globalPositionToInt :: GlobalPosition -> Int64 globalPositionToInt (GlobalPosition position) = position
src/Keiro/ReadModel/Schema.hs view
@@ -1,19 +1,18 @@-{- | The @keiro_read_models@ registry: schema identity and lifecycle status.--Every read model explicitly registers a row at projection startup recording its 'version', 'shapeHash', and-'ReadModelStatus'. The registry is what lets 'Keiro.ReadModel.runQuery'-refuse to serve a model whose code-side schema has drifted from the table on-disk, or one that is mid-rebuild. The status transitions ('markRebuilding',-'markLive', 'markAbandoned') drive the rebuild workflow in-"Keiro.ReadModel.Rebuild".--All operations run as single-statement 'Hasql.Transaction.Transaction's-against the @keiro_read_models@ table; an unrecognized stored status decodes-to 'UnknownStatus' so callers see the raw database value instead of a silent-fallback.--}-module Keiro.ReadModel.Schema (-    -- * Metadata+-- | The @keiro_read_models@ registry: schema identity and lifecycle status.+--+-- Every read model explicitly registers a row at projection startup recording its 'version', 'shapeHash', and+-- 'ReadModelStatus'. The registry is what lets 'Keiro.ReadModel.runQuery'+-- refuse to serve a model whose code-side schema has drifted from the table on+-- disk, or one that is mid-rebuild. The status transitions ('markRebuilding',+-- 'markLive', 'markAbandoned') drive the rebuild workflow in+-- "Keiro.ReadModel.Rebuild".+--+-- All operations run as single-statement 'Hasql.Transaction.Transaction's+-- against the @keiro_read_models@ table; an unrecognized stored status decodes+-- to 'UnknownStatus' so callers see the raw database value instead of a silent+-- fallback.+module Keiro.ReadModel.Schema+  ( -- * Metadata     ReadModelMetadata (..),     ReadModelStatus (..), @@ -26,7 +25,7 @@     markLive,     markAbandoned,     transitionReadModelTx,-)+  ) where  import Contravariant.Extras (contrazip3, contrazip4)@@ -40,166 +39,159 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude qualified -{- | Lifecycle status of a registered read model.--* 'Live' — current and queryable.-* 'Rebuilding' — being repopulated from the event log; not yet queryable.-* 'Paused' — temporarily not served.-* 'Abandoned' — a rebuild that was given up on.-* 'UnknownStatus' — a database value this library version does not recognize.--}+-- | Lifecycle status of a registered read model.+--+-- * 'Live' — current and queryable.+-- * 'Rebuilding' — being repopulated from the event log; not yet queryable.+-- * 'Paused' — temporarily not served.+-- * 'Abandoned' — a rebuild that was given up on.+-- * 'UnknownStatus' — a database value this library version does not recognize. data ReadModelStatus-    = Live-    | Rebuilding-    | Paused-    | Abandoned-    | UnknownStatus !Text-    deriving stock (Generic, Eq, Show)+  = Live+  | Rebuilding+  | Paused+  | Abandoned+  | UnknownStatus !Text+  deriving stock (Generic, Eq, Show) -{- | One row of the @keiro_read_models@ registry: a model's name, schema-identity ('version' and 'shapeHash'), the time it was last (re)built, and-its current 'status'.--}+-- | One row of the @keiro_read_models@ registry: a model's name, schema+-- identity ('version' and 'shapeHash'), the time it was last (re)built, and+-- its current 'status'. data ReadModelMetadata = ReadModelMetadata-    { name :: !Text-    , version :: !Int-    , shapeHash :: !Text-    , lastBuiltAt :: !(Maybe UTCTime)-    , status :: !ReadModelStatus-    }-    deriving stock (Generic, Eq, Show)+  { name :: !Text,+    version :: !Int,+    shapeHash :: !Text,+    lastBuiltAt :: !(Maybe UTCTime),+    status :: !ReadModelStatus+  }+  deriving stock (Generic, Eq, Show) -{- | Register a read model once at projection startup, inserting a 'Live' row-if none exists. Querying never performs this registration automatically, so-deployments that previously relied on their first query to register must add-this startup call. Idempotent:-an existing registration is returned unchanged (the @version@ and-@shapeHash@ are /not/ overwritten), so a query can compare them and detect-schema drift.--}+-- | Register a read model once at projection startup, inserting a 'Live' row+-- if none exists. Querying never performs this registration automatically, so+-- deployments that previously relied on their first query to register must add+-- this startup call. Idempotent:+-- an existing registration is returned unchanged (the @version@ and+-- @shapeHash@ are /not/ overwritten), so a query can compare them and detect+-- schema drift. registerReadModel :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata registerReadModel name version shapeHash =-    runTransaction-        $ Tx.statement-            (name, Prelude.fromIntegral version, shapeHash)-            registerReadModelStmt+  runTransaction+    $ Tx.statement+      (name, Prelude.fromIntegral version, shapeHash)+      registerReadModelStmt  -- | Look up a read model's registry row by name, if it exists. lookupReadModel :: (Store :> es) => Text -> Eff es (Maybe ReadModelMetadata) lookupReadModel name =-    runTransaction-        $ Tx.statement name lookupReadModelStmt+  runTransaction+    $ Tx.statement name lookupReadModelStmt -{- | Upsert the registry row to 'Rebuilding' at the given schema identity.-Marks the model as being repopulated so queries stop serving it until-'markLive'.--}+-- | Upsert the registry row to 'Rebuilding' at the given schema identity.+-- Marks the model as being repopulated so queries stop serving it until+-- 'markLive'. markRebuilding :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata markRebuilding name version shapeHash =-    runTransaction $ transitionReadModelTx name version shapeHash Rebuilding+  runTransaction $ transitionReadModelTx name version shapeHash Rebuilding -{- | Upsert the registry row to 'Live' at the given schema identity, stamping-@last_built_at@. Makes the model queryable again after a rebuild.--}+-- | Upsert the registry row to 'Live' at the given schema identity, stamping+-- @last_built_at@. Makes the model queryable again after a rebuild. markLive :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata markLive name version shapeHash =-    runTransaction $ transitionReadModelTx name version shapeHash Live+  runTransaction $ transitionReadModelTx name version shapeHash Live -{- | Upsert the registry row to 'Abandoned' at the given schema identity,-recording that a rebuild was given up on.--}+-- | Upsert the registry row to 'Abandoned' at the given schema identity,+-- recording that a rebuild was given up on. markAbandoned :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata markAbandoned name version shapeHash =-    runTransaction $ transitionReadModelTx name version shapeHash Abandoned+  runTransaction $ transitionReadModelTx name version shapeHash Abandoned -{- | Transaction-composable form of a registry status transition. Rebuild-orchestration uses this form so the status row lock, table reset, dedup reset,-and checkpoint reset share one database transaction.--}+-- | Transaction-composable form of a registry status transition. Rebuild+-- orchestration uses this form so the status row lock, table reset, dedup reset,+-- and checkpoint reset share one database transaction. transitionReadModelTx :: Text -> Int -> Text -> ReadModelStatus -> Tx.Transaction ReadModelMetadata transitionReadModelTx name version shapeHash status =-    Tx.statement-        (name, Prelude.fromIntegral version, shapeHash, statusToText status)-        transitionReadModelStmt+  Tx.statement+    (name, Prelude.fromIntegral version, shapeHash, statusToText status)+    transitionReadModelStmt  registerReadModelStmt :: Statement (Text, Int64, Text) ReadModelMetadata registerReadModelStmt =-    preparable-        """-        INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at)-        VALUES ($1, $2, $3, 'live', now())-        ON CONFLICT (name) DO UPDATE-          SET name = EXCLUDED.name-        RETURNING name, version, shape_hash, last_built_at, status-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-        )-        readModelMetadataSingle+  preparable+    """+    INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at)+    VALUES ($1, $2, $3, 'live', now())+    ON CONFLICT (name) DO UPDATE+      SET name = EXCLUDED.name+    RETURNING name, version, shape_hash, last_built_at, status+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+    )+    readModelMetadataSingle  lookupReadModelStmt :: Statement Text (Maybe ReadModelMetadata) lookupReadModelStmt =-    preparable-        """-        SELECT name, version, shape_hash, last_built_at, status-        FROM keiro.keiro_read_models-        WHERE name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.rowMaybe readModelMetadataDecoder)+  preparable+    """+    SELECT name, version, shape_hash, last_built_at, status+    FROM keiro.keiro_read_models+    WHERE name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe readModelMetadataDecoder)  transitionReadModelStmt :: Statement (Text, Int64, Text, Text) ReadModelMetadata transitionReadModelStmt =-    preparable-        """-        INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at, updated_at)-        VALUES ($1, $2, $3, $4, now(), now())-        ON CONFLICT (name) DO UPDATE-          SET version = EXCLUDED.version,-              shape_hash = EXCLUDED.shape_hash,-              status = EXCLUDED.status,-              last_built_at = CASE-                WHEN EXCLUDED.status = 'live' THEN now()-                ELSE keiro_read_models.last_built_at-              END,-              updated_at = now()-        RETURNING name, version, shape_hash, last_built_at, status-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        readModelMetadataSingle+  preparable+    """+    INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at, updated_at)+    VALUES ($1, $2, $3, $4, now(), now())+    ON CONFLICT (name) DO UPDATE+      SET version = EXCLUDED.version,+          shape_hash = EXCLUDED.shape_hash,+          status = EXCLUDED.status,+          last_built_at = CASE+            WHEN EXCLUDED.status = 'live' THEN now()+            ELSE keiro_read_models.last_built_at+          END,+          updated_at = now()+    RETURNING name, version, shape_hash, last_built_at, status+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    readModelMetadataSingle  readModelMetadataSingle :: D.Result ReadModelMetadata readModelMetadataSingle =-    D.singleRow readModelMetadataDecoder+  D.singleRow readModelMetadataDecoder  readModelMetadataDecoder :: D.Row ReadModelMetadata readModelMetadataDecoder =-    ReadModelMetadata-        <$> D.column (D.nonNullable D.text)-        <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nullable D.timestamptz)-        <*> (statusFromText <$> D.column (D.nonNullable D.text))+  ReadModelMetadata+    <$> D.column (D.nonNullable D.text)+    <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.timestamptz)+    <*> (statusFromText <$> D.column (D.nonNullable D.text))  statusToText :: ReadModelStatus -> Text statusToText = \case-    Live -> "live"-    Rebuilding -> "rebuilding"-    Paused -> "paused"-    Abandoned -> "abandoned"-    UnknownStatus raw -> raw+  Live -> "live"+  Rebuilding -> "rebuilding"+  Paused -> "paused"+  Abandoned -> "abandoned"+  UnknownStatus raw -> raw  statusFromText :: Text -> ReadModelStatus statusFromText = \case-    "live" -> Live-    "rebuilding" -> Rebuilding-    "paused" -> Paused-    "abandoned" -> Abandoned-    raw -> UnknownStatus raw+  "live" -> Live+  "rebuilding" -> Rebuilding+  "paused" -> Paused+  "abandoned" -> Abandoned+  raw -> UnknownStatus raw
src/Keiro/ReplayAudit.hs view
@@ -1,22 +1,21 @@ {-# LANGUAGE GADTs #-} -{- | Read-only, differential replay audits for aggregate event streams.--Routine deploys should first consume @keiro-dsl diff@'s replay-impact verdict:-a replay-neutral deploy touches no data, while an affected verdict supplies the-event types for 'AuditTargeted'. 'AuditFull' is intentionally reserved for-one-time runtime cutovers and forensics.--Selection is read-only and server-side. It uses Kiroku's indexed category,-event-type, and global-position schema to discover only candidate stream names;-each candidate is then hydrated through the public Store effect. The audit-never appends events, calls @verifyAndSnapshot@, or writes snapshots.--Hand-written services have no spec from which to derive an affected set. They-must supply a conservative set explicitly or choose 'AuditFull'.--}-module Keiro.ReplayAudit (-    AuditMode (..),+-- | Read-only, differential replay audits for aggregate event streams.+--+-- Routine deploys should first consume @keiro-dsl diff@'s replay-impact verdict:+-- a replay-neutral deploy touches no data, while an affected verdict supplies the+-- event types for 'AuditTargeted'. 'AuditFull' is intentionally reserved for+-- one-time runtime cutovers and forensics.+--+-- Selection is read-only and server-side. It uses Kiroku's indexed category,+-- event-type, and global-position schema to discover only candidate stream names;+-- each candidate is then hydrated through the public Store effect. The audit+-- never appends events, calls @verifyAndSnapshot@, or writes snapshots.+--+-- Hand-written services have no spec from which to derive an affected set. They+-- must supply a conservative set explicitly or choose 'AuditFull'.+module Keiro.ReplayAudit+  ( AuditMode (..),     AffectedSet (..),     AuditBudget (..),     defaultAuditBudget,@@ -31,7 +30,8 @@     auditTargets,     renderAuditReport,     auditExitCode,-) where+  )+where  import Contravariant.Extras (contrazip3, contrazip5) import Data.Int (Int32)@@ -48,14 +48,14 @@ import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Keiki.Core (BoolAlg, RegFile)-import Keiro.Command (-    CommandError,+import Keiro.Command+  ( CommandError,     Hydrated (..),     RunCommandOptions,     defaultRunCommandOptions,     hydrateFull,     hydrateSeeded,- )+  ) import Keiro.EventStream (EventStream, StateCodec) import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated) import Keiro.Prelude@@ -65,486 +65,485 @@ import Kiroku.Store.Effect (Store) import Kiroku.Store.Read (lookupStreamNames) import Kiroku.Store.Transaction (runTransaction)-import Kiroku.Store.Types (-    CategoryName (..),+import Kiroku.Store.Types+  ( CategoryName (..),     EventType (..),     GlobalPosition (..),     StreamId (..),     StreamName (..),     StreamVersion,- )+  ) import Kiroku.Store.Types qualified as StoreTypes import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude qualified  -- | Which streams to inspect. data AuditMode-    = AuditFull-    | AuditTargeted !AffectedSet-    deriving stock (Eq, Show, Generic)+  = AuditFull+  | AuditTargeted !AffectedSet+  deriving stock (Eq, Show, Generic)  -- | Conservative stored-data surface emitted by the DSL replay-impact diff. data AffectedSet = AffectedSet-    { affectedEventTypes :: !(Set EventType)-    , includeSnapshotStreams :: !Bool-    }-    deriving stock (Eq, Show, Generic)--{- | Per-run cost and resume controls.+  { affectedEventTypes :: !(Set EventType),+    includeSnapshotStreams :: !Bool+  }+  deriving stock (Eq, Show, Generic) -The checkpoint is the maximum global position of the last audited stream at-the selection snapshot. Re-running with that checkpoint selects only streams-whose latest event lies after it. A category that receives new events during-or after a run may intentionally cause a previously checked stream to be-selected again: its durable history changed and warrants another verdict.--}+-- | Per-run cost and resume controls.+--+-- The checkpoint is the maximum global position of the last audited stream at+-- the selection snapshot. Re-running with that checkpoint selects only streams+-- whose latest event lies after it. A category that receives new events during+-- or after a run may intentionally cause a previously checked stream to be+-- selected again: its durable history changed and warrants another verdict. data AuditBudget = AuditBudget-    { maxStreams :: !(Maybe Int)-    , parallelism :: !Int-    , resumeFrom :: !(Maybe GlobalPosition)-    }-    deriving stock (Eq, Show, Generic)+  { maxStreams :: !(Maybe Int),+    parallelism :: !Int,+    resumeFrom :: !(Maybe GlobalPosition)+  }+  deriving stock (Eq, Show, Generic)  defaultAuditBudget :: AuditBudget defaultAuditBudget =-    AuditBudget-        { maxStreams = Nothing-        , parallelism = 4-        , resumeFrom = Nothing-        }+  AuditBudget+    { maxStreams = Nothing,+      parallelism = 4,+      resumeFrom = Nothing+    }  -- | One typed aggregate/category assembly. data AuditTarget phi rs s ci co = AuditTarget-    { eventStream :: !(ValidatedEventStream phi rs s ci co)-    , category :: !Text-    , mkStream :: !(StreamName -> Maybe (Stream (EventStream phi rs s ci co)))-    }-    deriving stock (Generic)+  { eventStream :: !(ValidatedEventStream phi rs s ci co),+    category :: !Text,+    mkStream :: !(StreamName -> Maybe (Stream (EventStream phi rs s ci co)))+  }+  deriving stock (Generic)  -- | Existential packaging for services with multiple aggregate types. data SomeAuditTarget where-    SomeAuditTarget ::-        (BoolAlg phi (RegFile rs, ci), Eq co) =>-        AuditTarget phi rs s ci co ->-        SomeAuditTarget+  SomeAuditTarget ::+    (BoolAlg phi (RegFile rs, ci), Eq co) =>+    AuditTarget phi rs s ci co ->+    SomeAuditTarget  -- | Accept a raw store name only when it belongs to the expected category. streamInCategory :: Text -> StreamName -> Maybe (Stream eventStream) streamInCategory expected streamName =-    case StoreTypes.categoryName streamName of-        CategoryName actual-            | actual == expected -> Just (Stream streamName)-        _ -> Nothing+  case StoreTypes.categoryName streamName of+    CategoryName actual+      | actual == expected -> Just (Stream streamName)+    _ -> Nothing  -- | Replay result for one accepted stream name. data AuditOutcome-    = ReplayOk-        { streamVersion :: !StreamVersion-        , digest :: !(Maybe Text)-        }-    | ReplayFailed-        { commandError :: !CommandError-        }-    | SeedDivergence-        { seedVersion :: !StreamVersion-        , seededDigest :: !Text-        , fullDigest :: !Text-        }-    deriving stock (Eq, Show, Generic)+  = ReplayOk+      { streamVersion :: !StreamVersion,+        digest :: !(Maybe Text)+      }+  | ReplayFailed+      { commandError :: !CommandError+      }+  | SeedDivergence+      { seedVersion :: !StreamVersion,+        seededDigest :: !Text,+        fullDigest :: !Text+      }+  deriving stock (Eq, Show, Generic)  data StreamAuditResult = StreamAuditResult-    { streamName :: !StreamName-    , outcome :: !AuditOutcome-    }-    deriving stock (Eq, Show, Generic)+  { streamName :: !StreamName,+    outcome :: !AuditOutcome+  }+  deriving stock (Eq, Show, Generic)  data AuditReport = AuditReport-    { targetCategory :: !Text-    , mode :: !Text-    , results :: ![StreamAuditResult]-    , rejectedStreams :: ![StreamName]-    , streamsSelected :: !Int-    , streamsSkipped :: !Int-    , failures :: !Int-    , divergences :: !Int-    , checkpoint :: !(Maybe GlobalPosition)-    }-    deriving stock (Eq, Show, Generic)+  { targetCategory :: !Text,+    mode :: !Text,+    results :: ![StreamAuditResult],+    rejectedStreams :: ![StreamName],+    streamsSelected :: !Int,+    streamsSkipped :: !Int,+    failures :: !Int,+    divergences :: !Int,+    checkpoint :: !(Maybe GlobalPosition)+  }+  deriving stock (Eq, Show, Generic)  auditStream ::-    forall phi rs s ci co es.-    (Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    AuditTarget phi rs s ci co ->-    Stream (EventStream phi rs s ci co) ->-    Eff es AuditOutcome+  forall phi rs s ci co es.+  (Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  AuditTarget phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Eff es AuditOutcome auditStream target stream = do-    let raw = unvalidated (target ^. #eventStream)-        options = defaultRunCommandOptions-    hydrateFull options raw stream >>= \case-        Left err -> pure (ReplayFailed err)-        Right full ->-            case raw ^. #stateCodec of-                Nothing ->-                    pure-                        ReplayOk-                            { streamVersion = full ^. #streamVersion-                            , digest = Nothing-                            }-                Just codec -> auditSeed options raw codec full+  let raw = unvalidated (target ^. #eventStream)+      options = defaultRunCommandOptions+  hydrateFull options raw stream >>= \case+    Left err -> pure (ReplayFailed err)+    Right full ->+      case raw ^. #stateCodec of+        Nothing ->+          pure+            ReplayOk+              { streamVersion = full ^. #streamVersion,+                digest = Nothing+              }+        Just codec -> auditSeed options raw codec full   where     auditSeed ::-        RunCommandOptions ->-        EventStream phi rs s ci co ->-        StateCodec (s, RegFile rs) ->-        Hydrated rs s ->-        Eff es AuditOutcome+      RunCommandOptions ->+      EventStream phi rs s ci co ->+      StateCodec (s, RegFile rs) ->+      Hydrated rs s ->+      Eff es AuditOutcome     auditSeed options raw codec full = do-        let name = (raw ^. #resolveStreamName) stream-            fullValue = (codec ^. #encode) (full ^. #state, full ^. #registers)-            fullBytes = canonicalJsonBytes fullValue-            fullHash = replayDigest fullValue-        lookupSnapshotSeed name codec >>= \case-            SnapshotUnavailable _ ->+      let name = (raw ^. #resolveStreamName) stream+          fullValue = (codec ^. #encode) (full ^. #state, full ^. #registers)+          fullBytes = canonicalJsonBytes fullValue+          fullHash = replayDigest fullValue+      lookupSnapshotSeed name codec >>= \case+        SnapshotUnavailable _ ->+          pure+            ReplayOk+              { streamVersion = full ^. #streamVersion,+                digest = Just fullHash+              }+        SnapshotHit seed ->+          hydrateSeeded+            options+            raw+            stream+            (seed ^. #state)+            (seed ^. #registers)+            (seed ^. #streamVersion)+            >>= \case+              Left err ->                 pure-                    ReplayOk-                        { streamVersion = full ^. #streamVersion-                        , digest = Just fullHash-                        }-            SnapshotHit seed ->-                hydrateSeeded-                    options-                    raw-                    stream-                    (seed ^. #state)-                    (seed ^. #registers)-                    (seed ^. #streamVersion)-                    >>= \case-                        Left err ->-                            pure-                                SeedDivergence-                                    { seedVersion = seed ^. #streamVersion-                                    , seededDigest = "replay-failed:" <> Text.pack (show err)-                                    , fullDigest = fullHash-                                    }-                        Right seeded ->-                            let seededValue =-                                    (codec ^. #encode)-                                        (seeded ^. #state, seeded ^. #registers)-                                seededHash = replayDigest seededValue-                             in if canonicalJsonBytes seededValue == fullBytes-                                    then-                                        pure-                                            ReplayOk-                                                { streamVersion = full ^. #streamVersion-                                                , digest = Just fullHash-                                                }-                                    else-                                        pure-                                            SeedDivergence-                                                { seedVersion = seed ^. #streamVersion-                                                , seededDigest = seededHash-                                                , fullDigest = fullHash-                                                }+                  SeedDivergence+                    { seedVersion = seed ^. #streamVersion,+                      seededDigest = "replay-failed:" <> Text.pack (show err),+                      fullDigest = fullHash+                    }+              Right seeded ->+                let seededValue =+                      (codec ^. #encode)+                        (seeded ^. #state, seeded ^. #registers)+                    seededHash = replayDigest seededValue+                 in if canonicalJsonBytes seededValue == fullBytes+                      then+                        pure+                          ReplayOk+                            { streamVersion = full ^. #streamVersion,+                              digest = Just fullHash+                            }+                      else+                        pure+                          SeedDivergence+                            { seedVersion = seed ^. #streamVersion,+                              seededDigest = seededHash,+                              fullDigest = fullHash+                            }  auditStreams ::-    forall phi rs s ci co es.-    (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>-    AuditMode ->-    AuditBudget ->-    AuditTarget phi rs s ci co ->-    Eff es AuditReport+  forall phi rs s ci co es.+  (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  AuditMode ->+  AuditBudget ->+  AuditTarget phi rs s ci co ->+  Eff es AuditReport auditStreams auditMode budget target = do-    totalStreams <- categoryStreamCount (target ^. #category)-    selectedTotal <- selectedStreamCount auditMode (target ^. #category)-    let skipped =-            case auditMode of-                AuditFull -> 0-                AuditTargeted _ -> Prelude.max 0 (totalStreams Prelude.- selectedTotal)-        workerCount = Prelude.max 1 (budget ^. #parallelism)-        remaining0 = fmap (Prelude.max 0) (budget ^. #maxStreams)-        cursor0 = fromMaybe (GlobalPosition 0) (budget ^. #resumeFrom)-    (allResults, rejected, selected, finalCursor) <--        go workerCount remaining0 cursor0 [] [] 0-    let failureCount =-            Prelude.length rejected-                Prelude.+ Prelude.length-                    [ ()-                    | StreamAuditResult{outcome = ReplayFailed{}} <- allResults-                    ]-        divergenceCount =-            Prelude.length-                [ ()-                | StreamAuditResult{outcome = SeedDivergence{}} <- allResults-                ]-    pure-        AuditReport-            { targetCategory = target ^. #category-            , mode = case auditMode of AuditFull -> "full"; AuditTargeted _ -> "targeted"-            , results = allResults-            , rejectedStreams = rejected-            , streamsSelected = selected-            , streamsSkipped = skipped-            , failures = failureCount-            , divergences = divergenceCount-            , checkpoint =-                if selected == 0-                    then budget ^. #resumeFrom-                    else Just finalCursor-            }+  totalStreams <- categoryStreamCount (target ^. #category)+  selectedTotal <- selectedStreamCount auditMode (target ^. #category)+  let skipped =+        case auditMode of+          AuditFull -> 0+          AuditTargeted _ -> Prelude.max 0 (totalStreams Prelude.- selectedTotal)+      workerCount = Prelude.max 1 (budget ^. #parallelism)+      remaining0 = fmap (Prelude.max 0) (budget ^. #maxStreams)+      cursor0 = fromMaybe (GlobalPosition 0) (budget ^. #resumeFrom)+  (allResults, rejected, selected, finalCursor) <-+    go workerCount remaining0 cursor0 [] [] 0+  let failureCount =+        Prelude.length rejected+          Prelude.+ Prelude.length+            [ ()+            | StreamAuditResult {outcome = ReplayFailed {}} <- allResults+            ]+      divergenceCount =+        Prelude.length+          [ ()+          | StreamAuditResult {outcome = SeedDivergence {}} <- allResults+          ]+  pure+    AuditReport+      { targetCategory = target ^. #category,+        mode = case auditMode of AuditFull -> "full"; AuditTargeted _ -> "targeted",+        results = allResults,+        rejectedStreams = rejected,+        streamsSelected = selected,+        streamsSkipped = skipped,+        failures = failureCount,+        divergences = divergenceCount,+        checkpoint =+          if selected == 0+            then budget ^. #resumeFrom+            else Just finalCursor+      }   where     pageSize = 128      go ::-        Int ->-        Maybe Int ->-        GlobalPosition ->-        [StreamAuditResult] ->-        [StreamName] ->-        Int ->-        Eff es ([StreamAuditResult], [StreamName], Int, GlobalPosition)+      Int ->+      Maybe Int ->+      GlobalPosition ->+      [StreamAuditResult] ->+      [StreamName] ->+      Int ->+      Eff es ([StreamAuditResult], [StreamName], Int, GlobalPosition)     go workerCount remaining cursor resultAcc rejectedAcc selectedAcc-        | Just 0 <- remaining =-            pure-                ( Prelude.reverse resultAcc-                , Prelude.reverse rejectedAcc-                , selectedAcc-                , cursor+      | Just 0 <- remaining =+          pure+            ( Prelude.reverse resultAcc,+              Prelude.reverse rejectedAcc,+              selectedAcc,+              cursor+            )+      | otherwise = do+          let requestSize =+                maybe pageSize (Prelude.min pageSize) remaining+          page <- selectStreamPage auditMode (target ^. #category) cursor requestSize+          if Vector.null page+            then+              pure+                ( Prelude.reverse resultAcc,+                  Prelude.reverse rejectedAcc,+                  selectedAcc,+                  cursor                 )-        | otherwise = do-            let requestSize =-                    maybe pageSize (Prelude.min pageSize) remaining-            page <- selectStreamPage auditMode (target ^. #category) cursor requestSize-            if Vector.null page-                then-                    pure-                        ( Prelude.reverse resultAcc-                        , Prelude.reverse rejectedAcc-                        , selectedAcc-                        , cursor-                        )-                else do-                    names <- lookupStreamNames (Prelude.fst <$> Vector.toList page)-                    let resolved =-                            [ (streamName, streamValue)-                            | (streamId, _watermark) <- Vector.toList page-                            , Just streamName <- [Map.lookup streamId names]-                            , Just streamValue <- [(target ^. #mkStream) streamName]-                            ]-                        acceptedNames = Set.fromList (Prelude.fst <$> resolved)-                        pageNames =-                            [ streamName-                            | (streamId, _watermark) <- Vector.toList page-                            , Just streamName <- [Map.lookup streamId names]-                            ]-                        rejected =-                            Prelude.filter (`Set.notMember` acceptedNames) pageNames-                    audited <--                        runConcurrent-                            $ Async.pooledMapConcurrentlyN-                                workerCount-                                ( \(name, streamValue) ->-                                    StreamAuditResult name <$> auditStream target streamValue-                                )-                                resolved-                    let nextCursor = Prelude.snd (Vector.last page)-                        pageCount = Vector.length page-                        nextRemaining = (Prelude.- pageCount) <$> remaining-                    go-                        workerCount-                        nextRemaining-                        nextCursor-                        (Prelude.reverse audited <> resultAcc)-                        (Prelude.reverse rejected <> rejectedAcc)-                        (selectedAcc Prelude.+ pageCount)+            else do+              names <- lookupStreamNames (Prelude.fst <$> Vector.toList page)+              let resolved =+                    [ (streamName, streamValue)+                    | (streamId, _watermark) <- Vector.toList page,+                      Just streamName <- [Map.lookup streamId names],+                      Just streamValue <- [(target ^. #mkStream) streamName]+                    ]+                  acceptedNames = Set.fromList (Prelude.fst <$> resolved)+                  pageNames =+                    [ streamName+                    | (streamId, _watermark) <- Vector.toList page,+                      Just streamName <- [Map.lookup streamId names]+                    ]+                  rejected =+                    Prelude.filter (`Set.notMember` acceptedNames) pageNames+              audited <-+                runConcurrent+                  $ Async.pooledMapConcurrentlyN+                    workerCount+                    ( \(name, streamValue) ->+                        StreamAuditResult name <$> auditStream target streamValue+                    )+                    resolved+              let nextCursor = Prelude.snd (Vector.last page)+                  pageCount = Vector.length page+                  nextRemaining = (Prelude.- pageCount) <$> remaining+              go+                workerCount+                nextRemaining+                nextCursor+                (Prelude.reverse audited <> resultAcc)+                (Prelude.reverse rejected <> rejectedAcc)+                (selectedAcc Prelude.+ pageCount)  auditTargets ::-    (IOE :> es, Store :> es) =>-    AuditMode ->-    AuditBudget ->-    [SomeAuditTarget] ->-    Eff es [AuditReport]+  (IOE :> es, Store :> es) =>+  AuditMode ->+  AuditBudget ->+  [SomeAuditTarget] ->+  Eff es [AuditReport] auditTargets auditMode budget =-    traverse $ \(SomeAuditTarget target) -> auditStreams auditMode budget target+  traverse $ \(SomeAuditTarget target) -> auditStreams auditMode budget target  renderAuditReport :: AuditReport -> Text renderAuditReport report =-    Text.intercalate-        " "-        [ "replay-audit"-        , "category=" <> report ^. #targetCategory-        , "mode=" <> report ^. #mode-        , "selected=" <> textShow (report ^. #streamsSelected)-        , "skipped=" <> textShow (report ^. #streamsSkipped)-        , "failures=" <> textShow (report ^. #failures)-        , "divergences=" <> textShow (report ^. #divergences)-        , "checkpoint=" <> maybe "none" textShow (report ^. #checkpoint)-        ]+  Text.intercalate+    " "+    [ "replay-audit",+      "category=" <> report ^. #targetCategory,+      "mode=" <> report ^. #mode,+      "selected=" <> textShow (report ^. #streamsSelected),+      "skipped=" <> textShow (report ^. #streamsSkipped),+      "failures=" <> textShow (report ^. #failures),+      "divergences=" <> textShow (report ^. #divergences),+      "checkpoint=" <> maybe "none" textShow (report ^. #checkpoint)+    ]   where     textShow :: (Show a) => a -> Text     textShow = Text.pack . show  auditExitCode :: [AuditReport] -> Int auditExitCode reports-    | Prelude.any-        (\report -> report ^. #failures > 0 Prelude.|| report ^. #divergences > 0)-        reports =-        1-    | otherwise = 0+  | Prelude.any+      (\report -> report ^. #failures > 0 Prelude.|| report ^. #divergences > 0)+      reports =+      1+  | otherwise = 0  -- Selection -----------------------------------------------------------------  categoryStreamCount :: (Store :> es) => Text -> Eff es Int categoryStreamCount category =-    Prelude.fromIntegral <$> runTransaction (Tx.statement category categoryStreamCountStmt)+  Prelude.fromIntegral <$> runTransaction (Tx.statement category categoryStreamCountStmt)  selectedStreamCount :: (Store :> es) => AuditMode -> Text -> Eff es Int selectedStreamCount auditMode category =-    case auditMode of-        AuditFull -> categoryStreamCount category-        AuditTargeted affected ->-            Prelude.fromIntegral-                <$> runTransaction-                    ( Tx.statement-                        (category, eventTypeTexts affected, affected ^. #includeSnapshotStreams)-                        targetedStreamCountStmt-                    )+  case auditMode of+    AuditFull -> categoryStreamCount category+    AuditTargeted affected ->+      Prelude.fromIntegral+        <$> runTransaction+          ( Tx.statement+              (category, eventTypeTexts affected, affected ^. #includeSnapshotStreams)+              targetedStreamCountStmt+          )  selectStreamPage ::-    (Store :> es) =>-    AuditMode ->-    Text ->-    GlobalPosition ->-    Int ->-    Eff es (Vector (StreamId, GlobalPosition))+  (Store :> es) =>+  AuditMode ->+  Text ->+  GlobalPosition ->+  Int ->+  Eff es (Vector (StreamId, GlobalPosition)) selectStreamPage auditMode category (GlobalPosition cursor) limit =-    case auditMode of-        AuditFull ->-            runTransaction-                $ Tx.statement-                    (category, cursor, Prelude.fromIntegral limit)-                    fullStreamPageStmt-        AuditTargeted affected ->-            runTransaction-                $ Tx.statement-                    ( category-                    , eventTypeTexts affected-                    , affected ^. #includeSnapshotStreams-                    , cursor-                    , Prelude.fromIntegral limit-                    )-                    targetedStreamPageStmt+  case auditMode of+    AuditFull ->+      runTransaction+        $ Tx.statement+          (category, cursor, Prelude.fromIntegral limit)+          fullStreamPageStmt+    AuditTargeted affected ->+      runTransaction+        $ Tx.statement+          ( category,+            eventTypeTexts affected,+            affected ^. #includeSnapshotStreams,+            cursor,+            Prelude.fromIntegral limit+          )+          targetedStreamPageStmt  eventTypeTexts :: AffectedSet -> Vector Text eventTypeTexts affected =-    Vector.fromList-        [ text-        | EventType text <- Set.toAscList (affected ^. #affectedEventTypes)-        ]+  Vector.fromList+    [ text+    | EventType text <- Set.toAscList (affected ^. #affectedEventTypes)+    ]  categoryStreamCountStmt :: Statement Text Int64 categoryStreamCountStmt =-    preparable-        """-        SELECT count(*)-        FROM kiroku.streams-        WHERE category = $1-          AND stream_version > 0-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT count(*)+    FROM kiroku.streams+    WHERE category = $1+      AND stream_version > 0+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.int8)))  fullStreamPageStmt :: Statement (Text, Int64, Int32) (Vector (StreamId, GlobalPosition)) fullStreamPageStmt =-    preparable-        """-        SELECT s.stream_id, max(all_events.stream_version) AS watermark-        FROM kiroku.streams s-        JOIN kiroku.stream_events all_events-          ON all_events.stream_id = 0-         AND all_events.original_stream_id = s.stream_id-        WHERE s.category = $1-        GROUP BY s.stream_id-        HAVING max(all_events.stream_version) > $2-        ORDER BY watermark ASC-        LIMIT $3-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int4))-        )-        streamPageDecoder+  preparable+    """+    SELECT s.stream_id, max(all_events.stream_version) AS watermark+    FROM kiroku.streams s+    JOIN kiroku.stream_events all_events+      ON all_events.stream_id = 0+     AND all_events.original_stream_id = s.stream_id+    WHERE s.category = $1+    GROUP BY s.stream_id+    HAVING max(all_events.stream_version) > $2+    ORDER BY watermark ASC+    LIMIT $3+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int4))+    )+    streamPageDecoder  targetedStreamCountStmt :: Statement (Text, Vector Text, Bool) Int64 targetedStreamCountStmt =-    preparable-        (targetedSelectionCte <> "SELECT count(*) FROM selected")-        targetedSelectionEncoder-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    (targetedSelectionCte <> "SELECT count(*) FROM selected")+    targetedSelectionEncoder+    (D.singleRow (D.column (D.nonNullable D.int8)))  targetedStreamPageStmt ::-    Statement-        (Text, Vector Text, Bool, Int64, Int32)-        (Vector (StreamId, GlobalPosition))+  Statement+    (Text, Vector Text, Bool, Int64, Int32)+    (Vector (StreamId, GlobalPosition)) targetedStreamPageStmt =-    preparable-        ( targetedSelectionCte-            <> """-               SELECT stream_id, watermark-               FROM selected-               WHERE watermark > $4-               ORDER BY watermark ASC-               LIMIT $5-               """-        )-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-            (E.param (E.nonNullable E.bool))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int4))-        )-        streamPageDecoder--targetedSelectionEncoder :: E.Params (Text, Vector Text, Bool)-targetedSelectionEncoder =-    contrazip3+  preparable+    ( targetedSelectionCte+        <> """+           SELECT stream_id, watermark+           FROM selected+           WHERE watermark > $4+           ORDER BY watermark ASC+           LIMIT $5+           """+    )+    ( contrazip5         (E.param (E.nonNullable E.text))         (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))         (E.param (E.nonNullable E.bool))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int4))+    )+    streamPageDecoder +targetedSelectionEncoder :: E.Params (Text, Vector Text, Bool)+targetedSelectionEncoder =+  contrazip3+    (E.param (E.nonNullable E.text))+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    (E.param (E.nonNullable E.bool))+ targetedSelectionCte :: Text targetedSelectionCte =-    """-    WITH candidate_streams AS (-      SELECT DISTINCT s.stream_id-      FROM kiroku.streams s-      JOIN kiroku.stream_events source_events-        ON source_events.stream_id = 0-       AND source_events.original_stream_id = s.stream_id-      JOIN kiroku.events e ON e.event_id = source_events.event_id-      WHERE s.category = $1-        AND e.event_type = ANY($2::text[])-      UNION-      SELECT s.stream_id-      FROM kiroku.streams s-      JOIN keiro.keiro_snapshots snapshots ON snapshots.stream_id = s.stream_id-      WHERE $3-        AND s.category = $1-    ),-    selected AS (-      SELECT candidates.stream_id, max(all_events.stream_version) AS watermark-      FROM candidate_streams candidates-      JOIN kiroku.stream_events all_events-        ON all_events.stream_id = 0-       AND all_events.original_stream_id = candidates.stream_id-      GROUP BY candidates.stream_id-    )-    """+  """+  WITH candidate_streams AS (+    SELECT DISTINCT s.stream_id+    FROM kiroku.streams s+    JOIN kiroku.stream_events source_events+      ON source_events.stream_id = 0+     AND source_events.original_stream_id = s.stream_id+    JOIN kiroku.events e ON e.event_id = source_events.event_id+    WHERE s.category = $1+      AND e.event_type = ANY($2::text[])+    UNION+    SELECT s.stream_id+    FROM kiroku.streams s+    JOIN keiro.keiro_snapshots snapshots ON snapshots.stream_id = s.stream_id+    WHERE $3+      AND s.category = $1+  ),+  selected AS (+    SELECT candidates.stream_id, max(all_events.stream_version) AS watermark+    FROM candidate_streams candidates+    JOIN kiroku.stream_events all_events+      ON all_events.stream_id = 0+     AND all_events.original_stream_id = candidates.stream_id+    GROUP BY candidates.stream_id+  )+  """  streamPageDecoder :: D.Result (Vector (StreamId, GlobalPosition)) streamPageDecoder =-    D.rowVector-        $ (,)-        <$> (StreamId <$> D.column (D.nonNullable D.int8))-        <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+  D.rowVector+    $ (,)+    <$> (StreamId <$> D.column (D.nonNullable D.int8))+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))
src/Keiro/ReplayDigest.hs view
@@ -1,15 +1,15 @@-{- | Stable canonical encodings and SHA-256 digests for replay comparisons.--Canonicalization is Aeson's RFC 8785 implementation. Hashing is the maintained-@cryptohash-sha256@ implementation of FIPS 180-4, and hexadecimal rendering is-@base16-bytestring@'s RFC 4648 encoder. Correctness comparisons still use the-canonical bytes directly; the digest is their compact operator-facing-identifier.--}-module Keiro.ReplayDigest (-    canonicalJsonBytes,+-- | Stable canonical encodings and SHA-256 digests for replay comparisons.+--+-- Canonicalization is Aeson's RFC 8785 implementation. Hashing is the maintained+-- @cryptohash-sha256@ implementation of FIPS 180-4, and hexadecimal rendering is+-- @base16-bytestring@'s RFC 4648 encoder. Correctness comparisons still use the+-- canonical bytes directly; the digest is their compact operator-facing+-- identifier.+module Keiro.ReplayDigest+  ( canonicalJsonBytes,     replayDigest,-) where+  )+where  import Crypto.Hash.SHA256 qualified as SHA256 import Data.Aeson.RFC8785 qualified as RFC8785@@ -26,7 +26,7 @@ -- | SHA-256 over RFC 8785 canonical JSON, rendered as lower-case hexadecimal. replayDigest :: Value -> Text replayDigest =-    Text.decodeUtf8-        . Base16.encode-        . SHA256.hash-        . canonicalJsonBytes+  Text.decodeUtf8+    . Base16.encode+    . SHA256.hash+    . canonicalJsonBytes
src/Keiro/Router.hs view
@@ -1,34 +1,33 @@-{- | Stateless, content-based routing of events to commands.--A 'Router' is the stateless sibling of-'Keiro.ProcessManager.ProcessManager': for each incoming event it resolves-a data-dependent set of target streams /effectfully/ (typically via a-read-model query) and dispatches one command to each. Dispatch is-idempotent per resolved target identity: every command is appended under a-target-name-keyed deterministic id, and store-level duplicate rejections are-confirmed against that target before becoming a benign-'PMCommandDuplicate'. Redelivery therefore deduplicates every target resolved-again, regardless of target order. Because resolution is effectful, the target-set may drift between attempts; dispatches accumulate as the union of those-attempts, so callers that require one exact set must keep resolution stable for-a source event.--Use 'runRouterOnce' to dispatch a single event, or 'runRouterWorker' to run-the router as a live subscription draining a Shibuya adapter. Its retry and-source-event dead-letter contract is the same bounded Kiroku contract described-by "Keiro.ProcessManager": five total deliveries by default, followed by a-@kiroku.dead_letters@ write and atomic checkpoint advance.--A router's 'key' can join events from different source streams just as a-process manager's @correlate@ function can. The same ordering rule applies:-same-stream order is preserved, but different streams have no relative-business-order guarantee and may run concurrently under sharding. Keep routed-logic order-insensitive; see "Keiro.ProcessManager" for the worked example.-Each resolved target command (with its inline projections) commits in its own-transaction, so fan-out is idempotent rather than all-target atomic.--}-module Keiro.Router (-    -- * Definition+-- | Stateless, content-based routing of events to commands.+--+-- A 'Router' is the stateless sibling of+-- 'Keiro.ProcessManager.ProcessManager': for each incoming event it resolves+-- a data-dependent set of target streams /effectfully/ (typically via a+-- read-model query) and dispatches one command to each. Dispatch is+-- idempotent per resolved target identity: every command is appended under a+-- target-name-keyed deterministic id, and store-level duplicate rejections are+-- confirmed against that target before becoming a benign+-- 'PMCommandDuplicate'. Redelivery therefore deduplicates every target resolved+-- again, regardless of target order. Because resolution is effectful, the target+-- set may drift between attempts; dispatches accumulate as the union of those+-- attempts, so callers that require one exact set must keep resolution stable for+-- a source event.+--+-- Use 'runRouterOnce' to dispatch a single event, or 'runRouterWorker' to run+-- the router as a live subscription draining a Shibuya adapter. Its retry and+-- source-event dead-letter contract is the same bounded Kiroku contract described+-- by "Keiro.ProcessManager": five total deliveries by default, followed by a+-- @kiroku.dead_letters@ write and atomic checkpoint advance.+--+-- A router's 'key' can join events from different source streams just as a+-- process manager's @correlate@ function can. The same ordering rule applies:+-- same-stream order is preserved, but different streams have no relative+-- business-order guarantee and may run concurrently under sharding. Keep routed+-- logic order-insensitive; see "Keiro.ProcessManager" for the worked example.+-- Each resolved target command (with its inline projections) commits in its own+-- transaction, so fan-out is idempotent rather than all-target atomic.+module Keiro.Router+  ( -- * Definition     Router (..),     RouterResult (..), @@ -39,7 +38,7 @@     runRouterOnce,     runRouterWorkerWith,     runRouterWorker,-)+  ) where  import Data.ByteString qualified as ByteString@@ -60,8 +59,8 @@ import Keiro.EventStream (EventStream) import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated) import Keiro.Prelude-import Keiro.ProcessManager (-    DispatchFailure (..),+import Keiro.ProcessManager+  ( DispatchFailure (..),     PMCommand (..),     PMCommandResult (..),     PoisonPolicy (..),@@ -72,7 +71,7 @@     defaultWorkerOptions,     deterministicCommandId,     eventAlreadyIn,- )+  ) import Keiro.Projection (InlineProjection, runCommandWithProjections) import Keiro.Stream (Stream) import Keiro.Telemetry (recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)@@ -89,337 +88,330 @@ import Streamly.Data.Stream qualified as Streamly import Prelude (filter, fromIntegral, length, snd, zip, (+)) -{- | A stateless, content-based router (in the Enterprise Integration Patterns-sense): for each incoming event it resolves a data-dependent set of target-streams /effectfully/ and dispatches one command to each.--This is the stateless counterpart of 'Keiro.ProcessManager.ProcessManager'. It-has no manager state stream, no @correlate@, and no self-directed command. Its-sole new capability over the process manager is that target resolution runs in-@Eff es@ — typically a read-model query via 'Keiro.ReadModel.runQuery' — so the-fan-out set can be /looked up/ rather than computed purely from the event.--Dispatch is idempotent by construction: each target command is appended under a-deterministic identifier derived from @(name, key input, source event id,-resolved target stream name, occurrence)@ (see-'deterministicRouterCommandId'), pre-checked with 'eventAlreadyIn', and the-store's @DuplicateEvent@ rejection is confirmed against the target stream-before it is treated as benign. A redelivery deduplicates every target it-resolves again even if target order or membership changed. A target resolved-only on an earlier attempt keeps its immutable dispatch, and a newly resolved-target is dispatched on the later attempt; the cumulative set is therefore the-union of attempt outputs. Keep 'resolve' stable for a source event when the-exact recipient set matters.--Each dispatch also runs 'targetProjections' for the target aggregate in the same-append transaction. The function receives the concrete target stream so callers can-build projections closed over stream-local keys. Return @[]@ to preserve-append-only dispatch.--}+-- | A stateless, content-based router (in the Enterprise Integration Patterns+-- sense): for each incoming event it resolves a data-dependent set of target+-- streams /effectfully/ and dispatches one command to each.+--+-- This is the stateless counterpart of 'Keiro.ProcessManager.ProcessManager'. It+-- has no manager state stream, no @correlate@, and no self-directed command. Its+-- sole new capability over the process manager is that target resolution runs in+-- @Eff es@ — typically a read-model query via 'Keiro.ReadModel.runQuery' — so the+-- fan-out set can be /looked up/ rather than computed purely from the event.+--+-- Dispatch is idempotent by construction: each target command is appended under a+-- deterministic identifier derived from @(name, key input, source event id,+-- resolved target stream name, occurrence)@ (see+-- 'deterministicRouterCommandId'), pre-checked with 'eventAlreadyIn', and the+-- store's @DuplicateEvent@ rejection is confirmed against the target stream+-- before it is treated as benign. A redelivery deduplicates every target it+-- resolves again even if target order or membership changed. A target resolved+-- only on an earlier attempt keeps its immutable dispatch, and a newly resolved+-- target is dispatched on the later attempt; the cumulative set is therefore the+-- union of attempt outputs. Keep 'resolve' stable for a source event when the+-- exact recipient set matters.+--+-- Each dispatch also runs 'targetProjections' for the target aggregate in the same+-- append transaction. The function receives the concrete target stream so callers can+-- build projections closed over stream-local keys. Return @[]@ to preserve+-- append-only dispatch. data Router input targetPhi targetRs targetState targetCi targetCo es = Router-    { name :: !Text-    -- ^ Stable identifier; part of every dispatched command's deterministic id.-    , key :: !(input -> Text)-    -- ^ Correlation string for the source event (e.g. the transaction id).-    , resolve :: !(input -> Eff es [PMCommand targetCi])-    {- ^ The effectful seam: compute the data-dependent target set, typically-    @runQuery readModel q@.-    -}-    , targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo)-    -- ^ The aggregate every resolved command is dispatched to.-    , targetProjections :: !(Stream targetCi -> [InlineProjection targetCo])-    {- ^ Inline projections for the target aggregate, run in the same transaction-    as each dispatched command's append. Return @[]@ for append-only dispatch.-    -}-    }-    deriving stock (Generic)--{- | The outcome of a single 'runRouterOnce' invocation: one-'PMCommandResult' per resolved target, in resolution order.+  { -- | Stable identifier; part of every dispatched command's deterministic id.+    name :: !Text,+    -- | Correlation string for the source event (e.g. the transaction id).+    key :: !(input -> Text),+    -- | The effectful seam: compute the data-dependent target set, typically+    --     @runQuery readModel q@.+    resolve :: !(input -> Eff es [PMCommand targetCi]),+    -- | The aggregate every resolved command is dispatched to.+    targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo),+    -- | Inline projections for the target aggregate, run in the same transaction+    --     as each dispatched command's append. Return @[]@ for append-only dispatch.+    targetProjections :: !(Stream targetCi -> [InlineProjection targetCo])+  }+  deriving stock (Generic) -Unlike 'Keiro.ProcessManager.ProcessManagerResult' there is no manager-state-result, because a router has no state stream. A failed dispatch surfaces as a-'PMCommandFailed' element rather than an outer 'Either'.--}+-- | The outcome of a single 'runRouterOnce' invocation: one+-- 'PMCommandResult' per resolved target, in resolution order.+--+-- Unlike 'Keiro.ProcessManager.ProcessManagerResult' there is no manager-state+-- result, because a router has no state stream. A failed dispatch surfaces as a+-- 'PMCommandFailed' element rather than an outer 'Either'. newtype RouterResult target = RouterResult-    { commandResults :: [PMCommandResult target]-    }-    deriving stock (Generic, Eq, Show)--{- | Derive a stable, collision-resistant 'EventId' for a router dispatch from-@(router name, key input, source event id, resolved target stream name,-occurrence)@ via a v5 UUID.--Unlike 'Keiro.ProcessManager.deterministicCommandId' (which the process manager-still uses, soundly, because its command list is a pure function of the input),-the router keys the id by the target's identity rather than its position in the-resolved list: 'resolve' is effectful, so a redelivery may see the same targets-in a different order or a drifted set, and a positional id would then point at-the wrong target. The @occurrence@ is the index among commands in the same-resolve batch that address the same target stream (0 for the first), so-resolving the same target twice in one batch still yields distinct ids.+  { commandResults :: [PMCommandResult target]+  }+  deriving stock (Generic, Eq, Show) -The v5 name encodes every text field as length-prefixed UTF-8. This avoids both-delimiter ambiguity when names contain colons and character truncation for-non-ASCII names.--}+-- | Derive a stable, collision-resistant 'EventId' for a router dispatch from+-- @(router name, key input, source event id, resolved target stream name,+-- occurrence)@ via a v5 UUID.+--+-- Unlike 'Keiro.ProcessManager.deterministicCommandId' (which the process manager+-- still uses, soundly, because its command list is a pure function of the input),+-- the router keys the id by the target's identity rather than its position in the+-- resolved list: 'resolve' is effectful, so a redelivery may see the same targets+-- in a different order or a drifted set, and a positional id would then point at+-- the wrong target. The @occurrence@ is the index among commands in the same+-- resolve batch that address the same target stream (0 for the first), so+-- resolving the same target twice in one batch still yields distinct ids.+--+-- The v5 name encodes every text field as length-prefixed UTF-8. This avoids both+-- delimiter ambiguity when names contain colons and character truncation for+-- non-ASCII names. deterministicRouterCommandId :: Text -> Text -> EventId -> StreamName -> Int -> EventId deterministicRouterCommandId routerName correlationId sourceEventId targetStreamName occurrence =-    EventId-        $ UUID.V5.generateNamed UUID.V5.namespaceURL-        $ ByteString.unpack-        $ ByteString.concat-        $ fmap-            encodeField-            [ "keiro"-            , "router"-            , routerName-            , correlationId-            , UUID.toText (coerce sourceEventId)-            , coerce targetStreamName-            , Text.pack (show occurrence)-            ]+  EventId+    $ UUID.V5.generateNamed UUID.V5.namespaceURL+    $ ByteString.unpack+    $ ByteString.concat+    $ fmap+      encodeField+      [ "keiro",+        "router",+        routerName,+        correlationId,+        UUID.toText (coerce sourceEventId),+        coerce targetStreamName,+        Text.pack (show occurrence)+      ]   where     encodeField field =-        let bytes = Text.Encoding.encodeUtf8 field-         in ByteString.concat-                [ ByteString.Char8.pack (show (ByteString.length bytes))-                , ByteString.singleton 58-                , bytes-                ]--{- | Resolve the targets for one source event, then dispatch one command per-target with crash-safe, target-identity idempotency.--Unlike 'Keiro.ProcessManager.runProcessManagerOnce', whose pure command list-can safely use positional ids, a router derives each id from the resolved-target stream name and its same-stream occurrence. It skips ids already in the-target stream, otherwise runs 'Keiro.Projection.runCommandWithProjections', and-folds a @DuplicateEvent@ rejection only after confirming the attempted id is in-that target stream.+      let bytes = Text.Encoding.encodeUtf8 field+       in ByteString.concat+            [ ByteString.Char8.pack (show (ByteString.length bytes)),+              ByteString.singleton 58,+              bytes+            ] -Returns 'RouterResult' directly (no outer @Either CommandError@) because — unlike-the process manager — there is no manager-state append that can fail before-dispatch.--}+-- | Resolve the targets for one source event, then dispatch one command per+-- target with crash-safe, target-identity idempotency.+--+-- Unlike 'Keiro.ProcessManager.runProcessManagerOnce', whose pure command list+-- can safely use positional ids, a router derives each id from the resolved+-- target stream name and its same-stream occurrence. It skips ids already in the+-- target stream, otherwise runs 'Keiro.Projection.runCommandWithProjections', and+-- folds a @DuplicateEvent@ rejection only after confirming the attempted id is in+-- that target stream.+--+-- Returns 'RouterResult' directly (no outer @Either CommandError@) because — unlike+-- the process manager — there is no manager-state append that can fail before+-- dispatch. runRouterOnce ::-    forall input targetPhi targetRs targetState targetCi targetCo es.-    ( HasCallStack-    , IOE :> es-    , Store :> es-    , Error StoreError :> es-    , KirokuStoreResource :> es-    , BoolAlg targetPhi (RegFile targetRs, targetCi)-    , Eq targetCo-    ) =>-    RunCommandOptions ->-    Router input targetPhi targetRs targetState targetCi targetCo es ->-    RecordedEvent ->-    input ->-    Eff es (RouterResult (EventStream targetPhi targetRs targetState targetCi targetCo))+  forall input targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  Router input targetPhi targetRs targetState targetCi targetCo es ->+  RecordedEvent ->+  input ->+  Eff es (RouterResult (EventStream targetPhi targetRs targetState targetCi targetCo)) runRouterOnce options router sourceEvent input = do-    let correlationId = (router ^. #key) input-    commands <- (router ^. #resolve) input-    let named =-            [ (streamNameOf command, command)-            | command <- commands-            ]-        annotated = snd (mapAccumL occurrenceStep Map.empty (zip [0 ..] named))-        occurrenceStep seen (legacyIndex, (targetStreamName, command)) =-            let occurrence = Map.findWithDefault 0 targetStreamName seen-             in ( Map.insert targetStreamName (occurrence + 1) seen-                , (legacyIndex, occurrence, targetStreamName, command)-                )-    results <--        traverse-            (dispatchCommand correlationId (sourceEvent ^. #eventId))-            annotated-    pure (RouterResult results)+  let correlationId = (router ^. #key) input+  commands <- (router ^. #resolve) input+  let named =+        [ (streamNameOf command, command)+        | command <- commands+        ]+      annotated = snd (mapAccumL occurrenceStep Map.empty (zip [0 ..] named))+      occurrenceStep seen (legacyIndex, (targetStreamName, command)) =+        let occurrence = Map.findWithDefault 0 targetStreamName seen+         in ( Map.insert targetStreamName (occurrence + 1) seen,+              (legacyIndex, occurrence, targetStreamName, command)+            )+  results <-+    traverse+      (dispatchCommand correlationId (sourceEvent ^. #eventId))+      annotated+  pure (RouterResult results)   where     streamNameOf command =-        ((unvalidated (router ^. #targetEventStream)) ^. #resolveStreamName)-            (retarget (command ^. #target))+      ((unvalidated (router ^. #targetEventStream)) ^. #resolveStreamName)+        (retarget (command ^. #target))      dispatchCommand correlationId sourceEventId (legacyIndex, occurrence, targetStreamName, command) = do-        let commandId =-                deterministicRouterCommandId-                    (router ^. #name)-                    correlationId-                    sourceEventId-                    targetStreamName-                    occurrence-            -- Transition: dispatches written by keiro versions that derived-            -- positional ids must still dedup across the upgrade. Remove in a-            -- later release after the compatibility window closes.-            legacyCommandId =-                deterministicCommandId-                    (router ^. #name)-                    correlationId-                    sourceEventId-                    legacyIndex-            targetOptions = options & #eventIds .~ [commandId]-            targetEventStream = router ^. #targetEventStream-            targetStream = retarget (command ^. #target)-        commandAlreadyProcessed <- eventAlreadyIn options targetStreamName commandId-        legacyAlreadyProcessed <--            if commandAlreadyProcessed-                then pure False-                else eventAlreadyIn options targetStreamName legacyCommandId+      let commandId =+            deterministicRouterCommandId+              (router ^. #name)+              correlationId+              sourceEventId+              targetStreamName+              occurrence+          -- Transition: dispatches written by keiro versions that derived+          -- positional ids must still dedup across the upgrade. Remove in a+          -- later release after the compatibility window closes.+          legacyCommandId =+            deterministicCommandId+              (router ^. #name)+              correlationId+              sourceEventId+              legacyIndex+          targetOptions = options & #eventIds .~ [commandId]+          targetEventStream = router ^. #targetEventStream+          targetStream = retarget (command ^. #target)+      commandAlreadyProcessed <- eventAlreadyIn options targetStreamName commandId+      legacyAlreadyProcessed <-         if commandAlreadyProcessed-            then pure (PMCommandDuplicate commandId)-            else-                if legacyAlreadyProcessed-                    then pure (PMCommandDuplicate legacyCommandId)-                    else do-                        outcome <--                            runCommandWithProjections-                                targetOptions-                                targetEventStream-                                targetStream-                                (command ^. #command)-                                ((router ^. #targetProjections) (command ^. #target))-                        case outcome of-                            Right result -> pure (PMCommandAppended result)-                            Left err -> do-                                benign <- confirmBenignDuplicate targetStreamName commandId err-                                pure $ if benign then PMCommandDuplicate commandId else PMCommandFailed targetStreamName err+          then pure False+          else eventAlreadyIn options targetStreamName legacyCommandId+      if commandAlreadyProcessed+        then pure (PMCommandDuplicate commandId)+        else+          if legacyAlreadyProcessed+            then pure (PMCommandDuplicate legacyCommandId)+            else do+              outcome <-+                runCommandWithProjections+                  targetOptions+                  targetEventStream+                  targetStream+                  (command ^. #command)+                  ((router ^. #targetProjections) (command ^. #target))+              case outcome of+                Right result -> pure (PMCommandAppended result)+                Left err -> do+                  benign <- confirmBenignDuplicate targetStreamName commandId err+                  pure $ if benign then PMCommandDuplicate commandId else PMCommandFailed targetStreamName err      retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)     retarget = coerce -{- | Run a 'Router' as a live subscription over a Shibuya 'Adapter'.--Mirrors 'Keiro.ProcessManager.runProcessManagerWorker': it drains the adapter's-message stream, decoding each message to a @(RecordedEvent, input)@ pair and-dispatching it through 'runRouterOnce'.--Ack policy (see this plan's Decision Log):--  * a message that fails to decode follows the configured 'PoisonPolicy'-    (default: 'AckHalt' @HaltFatal@);-  * otherwise, after dispatch, if every 'PMCommandResult' is-    'PMCommandAppended' or 'PMCommandDuplicate' the message finalizes 'AckOk';-  * if any dispatch is 'PMCommandFailed', transient failures finalize-    'AckRetry', systemic deterministic failures finalize @AckHalt-    (HaltFatal …)@, and rejection-class failures follow-    'RejectedCommandPolicy'.--Benign domain rejections (a target aggregate refusing a "check" command because-no edge matches) must be modeled as /total/ transitions in the keiki transducer-(an ε-complement self-loop) so they never surface as 'PMCommandFailed' and-therefore never wedge the worker. When the rejection is genuinely-data-dependent, 'RejectedDeadLetter' persists a queryable-"Keiro.DeadLetter.DispatchDeadLetter" and acknowledges the source event;-'RejectedSkip' acknowledges and records only the metric. 'RejectedHalt' remains-the default.--The worker invokes each ingested message's 'Shibuya.Core.AckHandle.AckHandle'-@finalize@ exactly once with the decision, so the decision reaches the adapter.-Use 'runRouterWorkerWith' to override poison-message handling, rejected-command-handling, transient retry delay, or dispatch metrics. On a Kiroku-backed-adapter, 'AckRetry' remains bounded by the subscription @RetryPolicy@ (five-total deliveries by default). Exhaustion records the source event in-@kiroku.dead_letters@ with reason kind @max_attempts_exceeded@ and advances the-checkpoint. @KirokuAdapterConfig@ does not currently expose @retryPolicy@;-install 'Keiro.Telemetry.kirokuEventBridge' on Kiroku's @eventHandler@ to observe-the terminal event. The configurable sharded path forwards-@ShardedWorkerOptions.retryPolicy@ through the same Kiroku ladder; see-"Keiro.Subscription.Shard.Worker".--}+-- | Run a 'Router' as a live subscription over a Shibuya 'Adapter'.+--+-- Mirrors 'Keiro.ProcessManager.runProcessManagerWorker': it drains the adapter's+-- message stream, decoding each message to a @(RecordedEvent, input)@ pair and+-- dispatching it through 'runRouterOnce'.+--+-- Ack policy (see this plan's Decision Log):+--+--   * a message that fails to decode follows the configured 'PoisonPolicy'+--     (default: 'AckHalt' @HaltFatal@);+--   * otherwise, after dispatch, if every 'PMCommandResult' is+--     'PMCommandAppended' or 'PMCommandDuplicate' the message finalizes 'AckOk';+--   * if any dispatch is 'PMCommandFailed', transient failures finalize+--     'AckRetry', systemic deterministic failures finalize @AckHalt+--     (HaltFatal …)@, and rejection-class failures follow+--     'RejectedCommandPolicy'.+--+-- Benign domain rejections (a target aggregate refusing a "check" command because+-- no edge matches) must be modeled as /total/ transitions in the keiki transducer+-- (an ε-complement self-loop) so they never surface as 'PMCommandFailed' and+-- therefore never wedge the worker. When the rejection is genuinely+-- data-dependent, 'RejectedDeadLetter' persists a queryable+-- "Keiro.DeadLetter.DispatchDeadLetter" and acknowledges the source event;+-- 'RejectedSkip' acknowledges and records only the metric. 'RejectedHalt' remains+-- the default.+--+-- The worker invokes each ingested message's 'Shibuya.Core.AckHandle.AckHandle'+-- @finalize@ exactly once with the decision, so the decision reaches the adapter.+-- Use 'runRouterWorkerWith' to override poison-message handling, rejected-command+-- handling, transient retry delay, or dispatch metrics. On a Kiroku-backed+-- adapter, 'AckRetry' remains bounded by the subscription @RetryPolicy@ (five+-- total deliveries by default). Exhaustion records the source event in+-- @kiroku.dead_letters@ with reason kind @max_attempts_exceeded@ and advances the+-- checkpoint. @KirokuAdapterConfig@ does not currently expose @retryPolicy@;+-- install 'Keiro.Telemetry.kirokuEventBridge' on Kiroku's @eventHandler@ to observe+-- the terminal event. The configurable sharded path forwards+-- @ShardedWorkerOptions.retryPolicy@ through the same Kiroku ladder; see+-- "Keiro.Subscription.Shard.Worker". runRouterWorker ::-    forall msg input targetPhi targetRs targetState targetCi targetCo es.-    ( HasCallStack-    , IOE :> es-    , Store :> es-    , Error StoreError :> es-    , KirokuStoreResource :> es-    , BoolAlg targetPhi (RegFile targetRs, targetCi)-    , Eq targetCo-    ) =>-    RunCommandOptions ->-    Router input targetPhi targetRs targetState targetCi targetCo es ->-    Adapter es msg ->-    (msg -> Maybe (RecordedEvent, input)) ->-    Eff es ()+  forall msg input targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  Router input targetPhi targetRs targetState targetCi targetCo es ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es () runRouterWorker =-    runRouterWorkerWith defaultWorkerOptions+  runRouterWorkerWith defaultWorkerOptions  runRouterWorkerWith ::-    forall msg input targetPhi targetRs targetState targetCi targetCo es.-    ( HasCallStack-    , IOE :> es-    , Store :> es-    , Error StoreError :> es-    , KirokuStoreResource :> es-    , BoolAlg targetPhi (RegFile targetRs, targetCi)-    , Eq targetCo-    ) =>-    WorkerOptions es msg ->-    RunCommandOptions ->-    Router input targetPhi targetRs targetState targetCi targetCo es ->-    Adapter es msg ->-    (msg -> Maybe (RecordedEvent, input)) ->-    Eff es ()-runRouterWorkerWith workerOptions options router Adapter{source = adapterSource} decodeMessage =-    Streamly.fold Fold.drain-        $ Streamly.mapM handleIngested adapterSource+  forall msg input targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  WorkerOptions es msg ->+  RunCommandOptions ->+  Router input targetPhi targetRs targetState targetCi targetCo es ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runRouterWorkerWith workerOptions options router Adapter {source = adapterSource} decodeMessage =+  Streamly.fold Fold.drain+    $ Streamly.mapM handleIngested adapterSource   where     handleIngested :: Ingested es msg -> Eff es AckDecision-    handleIngested Ingested{envelope = env@Envelope{payload = message}, ack = AckHandle finalizeAck} = do-        decision <- case decodeMessage message of-            Nothing -> decideForPoison "router worker could not decode message" env-            Just (recorded, input) -> do-                let correlationId = (router ^. #key) input-                    attemptCount = envelopeAttemptCount env-                outcome <- tryError @StoreError (runRouterOnce options router recorded input)-                case outcome of-                    Left (_, storeErr) -> do-                        recordDispatchFailed (workerOptions ^. #metrics) 1-                        pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeErr))-                    Right (RouterResult results) -> ackDecisionFor recorded correlationId attemptCount results-        finalizeAck decision-        pure decision+    handleIngested Ingested {envelope = env@Envelope {payload = message}, ack = AckHandle finalizeAck} = do+      decision <- case decodeMessage message of+        Nothing -> decideForPoison "router worker could not decode message" env+        Just (recorded, input) -> do+          let correlationId = (router ^. #key) input+              attemptCount = envelopeAttemptCount env+          outcome <- tryError @StoreError (runRouterOnce options router recorded input)+          case outcome of+            Left (_, storeErr) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeErr))+            Right (RouterResult results) -> ackDecisionFor recorded correlationId attemptCount results+      finalizeAck decision+      pure decision      ackDecisionFor :: RecordedEvent -> Text -> Int -> [PMCommandResult target] -> Eff es AckDecision     ackDecisionFor sourceEvent correlationId attemptCount results = do-        let duplicateCount = commandDuplicateCount results-            failures =-                [ DispatchFailure emitIndex targetStreamName err-                | (emitIndex, PMCommandFailed targetStreamName err) <- zip [0 ..] results-                ]-        recordDispatchDuplicate (workerOptions ^. #metrics) duplicateCount-        recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length failures))-        decideForFailures-            workerOptions-            DispatcherRouter-            (router ^. #name)-            correlationId-            sourceEvent-            attemptCount-            failures+      let duplicateCount = commandDuplicateCount results+          failures =+            [ DispatchFailure emitIndex targetStreamName err+            | (emitIndex, PMCommandFailed targetStreamName err) <- zip [0 ..] results+            ]+      recordDispatchDuplicate (workerOptions ^. #metrics) duplicateCount+      recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length failures))+      decideForFailures+        workerOptions+        DispatcherRouter+        (router ^. #name)+        correlationId+        sourceEvent+        attemptCount+        failures      commandDuplicateCount :: [PMCommandResult target] -> Int64     commandDuplicateCount =-        fromIntegral . length . filter isDuplicateResult+      fromIntegral . length . filter isDuplicateResult       where         isDuplicateResult = \case-            PMCommandDuplicate{} -> True-            _ -> False+          PMCommandDuplicate {} -> True+          _ -> False      decideForPoison :: Text -> Envelope msg -> Eff es AckDecision     decideForPoison reason env = do-        recordDispatchPoison (workerOptions ^. #metrics) 1-        case workerOptions ^. #poisonPolicy of-            PoisonHalt -> pure (AckHalt (HaltFatal reason))-            PoisonSkip callback -> do-                callback env-                pure AckOk-            PoisonDeadLetter callback -> do-                callback env-                pure (AckDeadLetter (InvalidPayload reason))+      recordDispatchPoison (workerOptions ^. #metrics) 1+      case workerOptions ^. #poisonPolicy of+        PoisonHalt -> pure (AckHalt (HaltFatal reason))+        PoisonSkip callback -> do+          callback env+          pure AckOk+        PoisonDeadLetter callback -> do+          callback env+          pure (AckDeadLetter (InvalidPayload reason))      envelopeAttemptCount :: Envelope msg -> Int     envelopeAttemptCount env =-        case env ^. #attempt of-            Nothing -> 1-            Just (Attempt attempt) -> fromIntegral attempt + 1+      case env ^. #attempt of+        Nothing -> 1+        Just (Attempt attempt) -> fromIntegral attempt + 1
src/Keiro/Snapshot.hs view
@@ -1,27 +1,26 @@-{- | Snapshots: skipping replay by persisting folded aggregate state.--A snapshot stores an aggregate's @(state, registers)@ at a known stream-version so hydration can start there instead of replaying the whole event-log. 'hydrateWithSnapshot' loads the latest compatible snapshot for a-stream (returning a 'SnapshotSeed' the command runner replays /forward/-from), and 'writeSnapshot' persists one after an append when the stream's-'Keiro.EventStream.SnapshotPolicy' fires.--Compatibility is gated by the version, register-file shape hash, and-control-state shape/fold hash of the 'StateCodec': a snapshot is only loaded-when all three match the current codec, so an incompatible encoding, register-layout, or folded-state interpretation transparently falls back to a full-replay. The JSON encoding lives in "Keiro.Snapshot.Codec" and the SQL storage-in "Keiro.Snapshot.Schema", both re-exported here.--Snapshot version non-regression applies only while all three discriminators-stay the same. A writer with any discriminant changed may replace a-higher-version row, allowing codec rollback to recover. In a mixed-version-deployment incompatible writers can therefore thrash the one row per stream-and repeatedly force full replay; this affects performance, not correctness.--}-module Keiro.Snapshot (-    -- * Hydration seed+-- | Snapshots: skipping replay by persisting folded aggregate state.+--+-- A snapshot stores an aggregate's @(state, registers)@ at a known stream+-- version so hydration can start there instead of replaying the whole event+-- log. 'hydrateWithSnapshot' loads the latest compatible snapshot for a+-- stream (returning a 'SnapshotSeed' the command runner replays /forward/+-- from), and 'writeSnapshot' persists one after an append when the stream's+-- 'Keiro.EventStream.SnapshotPolicy' fires.+--+-- Compatibility is gated by the version, register-file shape hash, and+-- control-state shape/fold hash of the 'StateCodec': a snapshot is only loaded+-- when all three match the current codec, so an incompatible encoding, register+-- layout, or folded-state interpretation transparently falls back to a full+-- replay. The JSON encoding lives in "Keiro.Snapshot.Codec" and the SQL storage+-- in "Keiro.Snapshot.Schema", both re-exported here.+--+-- Snapshot version non-regression applies only while all three discriminators+-- stay the same. A writer with any discriminant changed may replace a+-- higher-version row, allowing codec rollback to recover. In a mixed-version+-- deployment incompatible writers can therefore thrash the one row per stream+-- and repeatedly force full replay; this affects performance, not correctness.+module Keiro.Snapshot+  ( -- * Hydration seed     SnapshotSeed (..),     SnapshotMissReason (..),     SnapshotLookup (..),@@ -34,7 +33,7 @@     -- * State codec and storage     module Keiro.Snapshot.Codec,     module Keiro.Snapshot.Schema,-)+  ) where  import Control.DeepSeq (force)@@ -49,126 +48,120 @@ import Kiroku.Store.Read (lookupStreamId) import Kiroku.Store.Types (StreamId, StreamName, StreamVersion) -{- | A decoded snapshot the command runner replays forward from: the folded-'state' and 'registers' as of 'streamVersion'. Hydration reads only the-events after 'streamVersion' instead of the entire stream.--}+-- | A decoded snapshot the command runner replays forward from: the folded+-- 'state' and 'registers' as of 'streamVersion'. Hydration reads only the+-- events after 'streamVersion' instead of the entire stream. data SnapshotSeed rs s = SnapshotSeed-    { state :: !s-    , registers :: !(RegFile rs)-    , streamVersion :: !StreamVersion-    }-    deriving stock (Generic)+  { state :: !s,+    registers :: !(RegFile rs),+    streamVersion :: !StreamVersion+  }+  deriving stock (Generic)  -- | Why a snapshot lookup produced no usable hydration seed. data SnapshotMissReason-    = SnapshotNoStream-    | SnapshotNotFound-    | SnapshotDecodeFailed !Text-    deriving stock (Eq, Show, Generic)+  = SnapshotNoStream+  | SnapshotNotFound+  | SnapshotDecodeFailed !Text+  deriving stock (Eq, Show, Generic)  -- | The observable result of looking up and decoding an aggregate snapshot. data SnapshotLookup rs s-    = SnapshotUnavailable !SnapshotMissReason-    | SnapshotHit !(SnapshotSeed rs s)-    deriving stock (Generic)+  = SnapshotUnavailable !SnapshotMissReason+  | SnapshotHit !(SnapshotSeed rs s)+  deriving stock (Generic) -{- | Look up the latest compatible snapshot and retain the reason when no-usable seed exists. A matching row that fails to decode is distinguished from-a missing stream or row so callers can report the persistent fallback.--}+-- | Look up the latest compatible snapshot and retain the reason when no+-- usable seed exists. A matching row that fails to decode is distinguished from+-- a missing stream or row so callers can report the persistent fallback. lookupSnapshotSeed ::-    (Store :> es) =>-    StreamName ->-    StateCodec (s, RegFile rs) ->-    Eff es (SnapshotLookup rs s)+  (Store :> es) =>+  StreamName ->+  StateCodec (s, RegFile rs) ->+  Eff es (SnapshotLookup rs s) lookupSnapshotSeed streamName codec = do-    streamId <- lookupStreamId streamName-    case streamId of-        Nothing -> pure (SnapshotUnavailable SnapshotNoStream)-        Just foundStreamId -> do-            row <--                lookupSnapshot-                    foundStreamId-                    (codec ^. #stateCodecVersion)-                    (codec ^. #shapeHash)-                    (codec ^. #stateShapeHash)-            pure $ case row of-                Nothing -> SnapshotUnavailable SnapshotNotFound-                Just snapshot ->-                    case (codec ^. #decode) (snapshot ^. #state) of-                        Left message -> SnapshotUnavailable (SnapshotDecodeFailed message)-                        Right (state, registers) ->-                            SnapshotHit-                                SnapshotSeed-                                    { state = state-                                    , registers = registers-                                    , streamVersion = snapshot ^. #streamVersion-                                    }--{- | Load the latest snapshot compatible with @codec@ for the named stream.+  streamId <- lookupStreamId streamName+  case streamId of+    Nothing -> pure (SnapshotUnavailable SnapshotNoStream)+    Just foundStreamId -> do+      row <-+        lookupSnapshot+          foundStreamId+          (codec ^. #stateCodecVersion)+          (codec ^. #shapeHash)+          (codec ^. #stateShapeHash)+      pure $ case row of+        Nothing -> SnapshotUnavailable SnapshotNotFound+        Just snapshot ->+          case (codec ^. #decode) (snapshot ^. #state) of+            Left message -> SnapshotUnavailable (SnapshotDecodeFailed message)+            Right (state, registers) ->+              SnapshotHit+                SnapshotSeed+                  { state = state,+                    registers = registers,+                    streamVersion = snapshot ^. #streamVersion+                  } -Returns 'Nothing' — meaning "replay from the beginning" — when the stream-has no id yet, has no snapshot matching all three codec discriminators, or has-a snapshot whose bytes fail to decode. Decode failure is treated as a benign-miss rather than an error, so a corrupt or stale snapshot never blocks-hydration.--}+-- | Load the latest snapshot compatible with @codec@ for the named stream.+--+-- Returns 'Nothing' — meaning "replay from the beginning" — when the stream+-- has no id yet, has no snapshot matching all three codec discriminators, or has+-- a snapshot whose bytes fail to decode. Decode failure is treated as a benign+-- miss rather than an error, so a corrupt or stale snapshot never blocks+-- hydration. hydrateWithSnapshot ::-    (Store :> es) =>-    StreamName ->-    StateCodec (s, RegFile rs) ->-    Eff es (Maybe (SnapshotSeed rs s))+  (Store :> es) =>+  StreamName ->+  StateCodec (s, RegFile rs) ->+  Eff es (Maybe (SnapshotSeed rs s)) hydrateWithSnapshot streamName codec = do-    lookupSnapshotSeed streamName codec <&> \case-        SnapshotUnavailable _ -> Nothing-        SnapshotHit seed -> Just seed+  lookupSnapshotSeed streamName codec <&> \case+    SnapshotUnavailable _ -> Nothing+    SnapshotHit seed -> Just seed -{- | Strictly encode @state@ with @codec@, forcing the complete JSON value and-returning an 'ErrorCall' raised by a partial encoder or an uninitialized keiki-register. Other exception types deliberately remain visible to the caller.--}+-- | Strictly encode @state@ with @codec@, forcing the complete JSON value and+-- returning an 'ErrorCall' raised by a partial encoder or an uninitialized keiki+-- register. Other exception types deliberately remain visible to the caller. encodeSnapshotStrict :: StateCodec state -> state -> IO (Either ErrorCall Value) encodeSnapshotStrict codec state =-    try @ErrorCall (evaluate (force ((codec ^. #encode) state)))+  try @ErrorCall (evaluate (force ((codec ^. #encode) state))) -{- | Upsert a JSON value that has already been encoded and forced. Keeping this-separate from 'writeSnapshot' lets post-commit callers prove encoding is safe-before they touch the store. For fixed codec discriminators, stale versions-are ignored. Any changed discriminator may replace a higher-version row to-permit codec rollback; see the module header.--}+-- | Upsert a JSON value that has already been encoded and forced. Keeping this+-- separate from 'writeSnapshot' lets post-commit callers prove encoding is safe+-- before they touch the store. For fixed codec discriminators, stale versions+-- are ignored. Any changed discriminator may replace a higher-version row to+-- permit codec rollback; see the module header. writeSnapshotEncoded ::-    (Store :> es) =>-    StreamId ->-    StreamVersion ->-    StateCodec state ->-    Value ->-    Eff es ()+  (Store :> es) =>+  StreamId ->+  StreamVersion ->+  StateCodec state ->+  Value ->+  Eff es () writeSnapshotEncoded streamId streamVersion codec encoded =-    writeSnapshotRow-        SnapshotWrite-            { streamId = streamId-            , streamVersion = streamVersion-            , state = encoded-            , stateCodecVersion = codec ^. #stateCodecVersion-            , regfileShapeHash = codec ^. #shapeHash-            , stateShapeHash = codec ^. #stateShapeHash-            }+  writeSnapshotRow+    SnapshotWrite+      { streamId = streamId,+        streamVersion = streamVersion,+        state = encoded,+        stateCodecVersion = codec ^. #stateCodecVersion,+        regfileShapeHash = codec ^. #shapeHash,+        stateShapeHash = codec ^. #stateShapeHash+      } -{- | Encode @state@ with @codec@ and upsert it as the snapshot for the given-stream at @streamVersion@. This compatibility helper preserves the historical-lazy encoding behavior; post-commit advisory paths should call-'encodeSnapshotStrict' first and pass the result to 'writeSnapshotEncoded'. For-fixed codec discriminators stale writes are ignored, while an incompatible-codec may replace a newer row to permit rollback.--}+-- | Encode @state@ with @codec@ and upsert it as the snapshot for the given+-- stream at @streamVersion@. This compatibility helper preserves the historical+-- lazy encoding behavior; post-commit advisory paths should call+-- 'encodeSnapshotStrict' first and pass the result to 'writeSnapshotEncoded'. For+-- fixed codec discriminators stale writes are ignored, while an incompatible+-- codec may replace a newer row to permit rollback. writeSnapshot ::-    (Store :> es) =>-    StreamId ->-    StreamVersion ->-    StateCodec state ->-    state ->-    Eff es ()+  (Store :> es) =>+  StreamId ->+  StreamVersion ->+  StateCodec state ->+  state ->+  Eff es () writeSnapshot streamId streamVersion codec state =-    writeSnapshotEncoded streamId streamVersion codec ((codec ^. #encode) state)+  writeSnapshotEncoded streamId streamVersion codec ((codec ^. #encode) state)
src/Keiro/Snapshot/Codec.hs view
@@ -1,30 +1,29 @@-{- | The default JSON encoding for aggregate snapshots.--'defaultStateCodecWithFold' builds the recommended 'StateCodec' for a-hand-written service's @(state, registers)@ pair, serializing it as a JSON-object @{ "state": …, "registers": … }@ and composing a hand-owned-'FoldVersion' into the snapshot discriminator. Change the fold version in the-same edit that changes the service's event-folding behavior.--'defaultStateCodec' builds the underlying codec for the @(state, registers)@ pair-of a keiki machine, serializing it as a JSON object @{ "state": …,-"registers": … }@. The state half uses its 'ToJSON' \/ 'FromJSON'-instances; the register half uses keiki's register-file JSON encoding. The-codec derives separate hashes for the control-state shape and register-file-layout, so structural changes transparently invalidate older snapshots (see-"Keiro.Snapshot").--Generated services use 'withFoldFingerprint' directly with a fingerprint-derived from their spec. Hand-written guard and update function bodies are not-structurally inspectable, so hand-written services should use-'defaultStateCodecWithFold' and maintain its explicit 'FoldVersion'.--}-module Keiro.Snapshot.Codec (-    FoldVersion (..),+-- | The default JSON encoding for aggregate snapshots.+--+-- 'defaultStateCodecWithFold' builds the recommended 'StateCodec' for a+-- hand-written service's @(state, registers)@ pair, serializing it as a JSON+-- object @{ "state": …, "registers": … }@ and composing a hand-owned+-- 'FoldVersion' into the snapshot discriminator. Change the fold version in the+-- same edit that changes the service's event-folding behavior.+--+-- 'defaultStateCodec' builds the underlying codec for the @(state, registers)@ pair+-- of a keiki machine, serializing it as a JSON object @{ "state": …,+-- "registers": … }@. The state half uses its 'ToJSON' \/ 'FromJSON'+-- instances; the register half uses keiki's register-file JSON encoding. The+-- codec derives separate hashes for the control-state shape and register-file+-- layout, so structural changes transparently invalidate older snapshots (see+-- "Keiro.Snapshot").+--+-- Generated services use 'withFoldFingerprint' directly with a fingerprint+-- derived from their spec. Hand-written guard and update function bodies are not+-- structurally inspectable, so hand-written services should use+-- 'defaultStateCodecWithFold' and maintain its explicit 'FoldVersion'.+module Keiro.Snapshot.Codec+  ( FoldVersion (..),     defaultStateCodec,     defaultStateCodecWithFold,     withFoldFingerprint,-)+  ) where  import Data.Aeson (Result (..), object, withObject, (.:))@@ -38,99 +37,95 @@ import Keiro.EventStream (StateCodec (..)) import Keiro.Prelude -{- | A 'StateCodec' that serializes a @(state, registers)@ pair to a JSON-object, tagging it with the supplied codec version and hashes derived from the-control-state datatype and register-file layout.--This function does not identify changes to hand-written guards, register-updates, emitted outputs, targets, or helper functions used by the fold. Prefer-'defaultStateCodecWithFold' for hand-written services. Generated services use-'withFoldFingerprint' with a fingerprint derived from their spec.--}+-- | A 'StateCodec' that serializes a @(state, registers)@ pair to a JSON+-- object, tagging it with the supplied codec version and hashes derived from the+-- control-state datatype and register-file layout.+--+-- This function does not identify changes to hand-written guards, register+-- updates, emitted outputs, targets, or helper functions used by the fold. Prefer+-- 'defaultStateCodecWithFold' for hand-written services. Generated services use+-- 'withFoldFingerprint' with a fingerprint derived from their spec. defaultStateCodec ::-    forall rs s.-    (CanonicalStateShape s, FromJSON s, KnownRegFileShape rs, RegFileToJSON rs, ToJSON s) =>-    Int ->-    StateCodec (s, RegFile rs)+  forall rs s.+  (CanonicalStateShape s, FromJSON s, KnownRegFileShape rs, RegFileToJSON rs, ToJSON s) =>+  Int ->+  StateCodec (s, RegFile rs) defaultStateCodec version =-    StateCodec-        { stateCodecVersion = version-        , shapeHash = regFileShapeHash (Proxy @rs)-        , stateShapeHash = Shape.stateShapeHash (Proxy @s)-        , encode = \(state, registers) ->-            object-                [ "state" Aeson..= state-                , "registers" Aeson..= regFileToJSON registers-                ]-        , decode = decodeSnapshotValue-        }--{- | A hand-owned identity for a hand-written service's event fold.+  StateCodec+    { stateCodecVersion = version,+      shapeHash = regFileShapeHash (Proxy @rs),+      stateShapeHash = Shape.stateShapeHash (Proxy @s),+      encode = \(state, registers) ->+        object+          [ "state" Aeson..= state,+            "registers" Aeson..= regFileToJSON registers+          ],+      decode = decodeSnapshotValue+    } -The DSL derives a fold fingerprint from the spec automatically; a hand-written-service has no spec, so its fold identity must be owned by hand. Treat the-token as a change detector, not an encoding version: keep it stable while fold-semantics are stable, and change it in the same edit that changes any guard,-register update, emitted output, or target state, including logic in helper-functions the fold calls. A convention such as @"orders-fold-v3"@ keeps the-token greppable and reviewable. Forgetting to bump it recreates the silent-stale-snapshot hazard this type exists to prevent; see-'defaultStateCodecWithFold'.--}+-- | A hand-owned identity for a hand-written service's event fold.+--+-- The DSL derives a fold fingerprint from the spec automatically; a hand-written+-- service has no spec, so its fold identity must be owned by hand. Treat the+-- token as a change detector, not an encoding version: keep it stable while fold+-- semantics are stable, and change it in the same edit that changes any guard,+-- register update, emitted output, or target state, including logic in helper+-- functions the fold calls. A convention such as @"orders-fold-v3"@ keeps the+-- token greppable and reviewable. Forgetting to bump it recreates the silent+-- stale-snapshot hazard this type exists to prevent; see+-- 'defaultStateCodecWithFold'. newtype FoldVersion = FoldVersion Text-    deriving stock (Eq, Show)--{- | The default snapshot codec for hand-written services: 'defaultStateCodec'-with a hand-owned 'FoldVersion' composed into the control-state discriminator-via 'withFoldFingerprint'.+  deriving stock (Eq, Show) -Prefer this over bare 'defaultStateCodec' whenever the service's fold is-hand-written. A changed token changes the stored discriminator, so an old-snapshot is simply not found and hydration falls back to a full replay of the-event log: a performance cost, never wrong state. The rendered discriminator-stays operator-readable as @<state-shape-hash>;fold=<token>@.--}+-- | The default snapshot codec for hand-written services: 'defaultStateCodec'+-- with a hand-owned 'FoldVersion' composed into the control-state discriminator+-- via 'withFoldFingerprint'.+--+-- Prefer this over bare 'defaultStateCodec' whenever the service's fold is+-- hand-written. A changed token changes the stored discriminator, so an old+-- snapshot is simply not found and hydration falls back to a full replay of the+-- event log: a performance cost, never wrong state. The rendered discriminator+-- stays operator-readable as @<state-shape-hash>;fold=<token>@. defaultStateCodecWithFold ::-    forall rs s.-    (CanonicalStateShape s, FromJSON s, KnownRegFileShape rs, RegFileToJSON rs, ToJSON s) =>-    FoldVersion ->-    Int ->-    StateCodec (s, RegFile rs)+  forall rs s.+  (CanonicalStateShape s, FromJSON s, KnownRegFileShape rs, RegFileToJSON rs, ToJSON s) =>+  FoldVersion ->+  Int ->+  StateCodec (s, RegFile rs) defaultStateCodecWithFold (FoldVersion token) version =-    withFoldFingerprint token (defaultStateCodec version)--{- | Compose a caller-supplied fold identity into the control-state-discriminator.+  withFoldFingerprint token (defaultStateCodec version) -The fingerprint is a change detector, not an encoding version. Keep it stable-when fold semantics are stable and change it whenever guards, updates, targets,-or other event-folding behavior changes. The rendered form remains-operator-readable as @<state-shape-hash>;fold=<fingerprint>@.--}+-- | Compose a caller-supplied fold identity into the control-state+-- discriminator.+--+-- The fingerprint is a change detector, not an encoding version. Keep it stable+-- when fold semantics are stable and change it whenever guards, updates, targets,+-- or other event-folding behavior changes. The rendered form remains+-- operator-readable as @<state-shape-hash>;fold=<fingerprint>@. withFoldFingerprint :: Text -> StateCodec state -> StateCodec state withFoldFingerprint fingerprint codec =-    codec-        { stateShapeHash =-            codec ^. #stateShapeHash <> ";fold=" <> fingerprint-        }+  codec+    { stateShapeHash =+        codec ^. #stateShapeHash <> ";fold=" <> fingerprint+    }  decodeSnapshotValue ::-    forall rs s.-    (FromJSON s, RegFileToJSON rs) =>-    Value ->-    Either Text (s, RegFile rs)+  forall rs s.+  (FromJSON s, RegFileToJSON rs) =>+  Value ->+  Either Text (s, RegFile rs) decodeSnapshotValue value =-    case parseEither parser value of-        Left message -> Left (Text.pack message)-        Right pair -> Right pair+  case parseEither parser value of+    Left message -> Left (Text.pack message)+    Right pair -> Right pair   where     parser = withObject "Keiro snapshot" $ \objectValue -> do-        stateValue <- objectValue .: "state"-        registerValue <- objectValue .: "registers"-        state <- case Aeson.fromJSON stateValue of-            Error message -> fail ("state: " <> message)-            Success decoded -> pure decoded-        registers <- case regFileFromJSON @rs registerValue of-            Left message -> fail ("registers: " <> message)-            Right decoded -> pure decoded-        pure (state, registers)+      stateValue <- objectValue .: "state"+      registerValue <- objectValue .: "registers"+      state <- case Aeson.fromJSON stateValue of+        Error message -> fail ("state: " <> message)+        Success decoded -> pure decoded+      registers <- case regFileFromJSON @rs registerValue of+        Left message -> fail ("registers: " <> message)+        Right decoded -> pure decoded+      pure (state, registers)
src/Keiro/Snapshot/Schema.hs view
@@ -1,33 +1,32 @@-{- | The @keiro_snapshots@ table: persistence for aggregate snapshots.--One row per stream holds the latest snapshot of its folded state as JSONB,-tagged with the 'stateCodecVersion', 'regfileShapeHash', and 'stateShapeHash'-that produced it. 'lookupSnapshot' fetches the newest row matching all three-discriminators (so incompatible snapshots are simply not found). Within one-discriminator tuple, 'writeSnapshotRow' keeps only the highest stream version,-so a late or out-of-order write cannot regress the snapshot.--A write with a /different/ discriminator deliberately replaces the row even-at a lower stream version. This lets a rolled-back deployment-reclaim the single snapshot slot instead of being locked out by a newer codec-forever. During a mixed-version deployment, however, writers with incompatible-codecs can thrash that row and each side will miss the other's snapshot. The-cost is repeated full replay, never incorrect state: the event log remains the-source of truth.--This module is the storage layer beneath "Keiro.Snapshot"; callers normally-go through 'Keiro.Snapshot.hydrateWithSnapshot' and-'Keiro.Snapshot.writeSnapshot' rather than these statements directly.--}-module Keiro.Snapshot.Schema (-    -- * Rows+-- | The @keiro_snapshots@ table: persistence for aggregate snapshots.+--+-- One row per stream holds the latest snapshot of its folded state as JSONB,+-- tagged with the 'stateCodecVersion', 'regfileShapeHash', and 'stateShapeHash'+-- that produced it. 'lookupSnapshot' fetches the newest row matching all three+-- discriminators (so incompatible snapshots are simply not found). Within one+-- discriminator tuple, 'writeSnapshotRow' keeps only the highest stream version,+-- so a late or out-of-order write cannot regress the snapshot.+--+-- A write with a /different/ discriminator deliberately replaces the row even+-- at a lower stream version. This lets a rolled-back deployment+-- reclaim the single snapshot slot instead of being locked out by a newer codec+-- forever. During a mixed-version deployment, however, writers with incompatible+-- codecs can thrash that row and each side will miss the other's snapshot. The+-- cost is repeated full replay, never incorrect state: the event log remains the+-- source of truth.+--+-- This module is the storage layer beneath "Keiro.Snapshot"; callers normally+-- go through 'Keiro.Snapshot.hydrateWithSnapshot' and+-- 'Keiro.Snapshot.writeSnapshot' rather than these statements directly.+module Keiro.Snapshot.Schema+  ( -- * Rows     SnapshotRow (..),     SnapshotWrite (..),      -- * Storage     lookupSnapshot,     writeSnapshotRow,-)+  ) where  import Contravariant.Extras (contrazip4, contrazip6)@@ -42,129 +41,125 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude qualified -{- | A snapshot row as read back from @keiro_snapshots@: the stored 'state'-JSON, the 'streamVersion' it captures, the 'stateCodecVersion' and-'regfileShapeHash' and 'stateShapeHash' that gate compatibility, and the create/update-timestamps.--}+-- | A snapshot row as read back from @keiro_snapshots@: the stored 'state'+-- JSON, the 'streamVersion' it captures, the 'stateCodecVersion' and+-- 'regfileShapeHash' and 'stateShapeHash' that gate compatibility, and the create/update+-- timestamps. data SnapshotRow = SnapshotRow-    { streamId :: !StreamId-    , streamVersion :: !StreamVersion-    , state :: !Value-    , stateCodecVersion :: !Int-    , regfileShapeHash :: !Text-    , stateShapeHash :: !Text-    , createdAt :: !UTCTime-    , updatedAt :: !UTCTime-    }-    deriving stock (Generic, Eq, Show)+  { streamId :: !StreamId,+    streamVersion :: !StreamVersion,+    state :: !Value,+    stateCodecVersion :: !Int,+    regfileShapeHash :: !Text,+    stateShapeHash :: !Text,+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show) -{- | The fields needed to write a snapshot — 'SnapshotRow' minus the-database-managed timestamps.--}+-- | The fields needed to write a snapshot — 'SnapshotRow' minus the+-- database-managed timestamps. data SnapshotWrite = SnapshotWrite-    { streamId :: !StreamId-    , streamVersion :: !StreamVersion-    , state :: !Value-    , stateCodecVersion :: !Int-    , regfileShapeHash :: !Text-    , stateShapeHash :: !Text-    }-    deriving stock (Generic, Eq, Show)+  { streamId :: !StreamId,+    streamVersion :: !StreamVersion,+    state :: !Value,+    stateCodecVersion :: !Int,+    regfileShapeHash :: !Text,+    stateShapeHash :: !Text+  }+  deriving stock (Generic, Eq, Show) -{- | Fetch the latest snapshot for a stream that matches all three codec-discriminators. Returns 'Nothing' when no compatible snapshot exists, so an-incompatible one is treated as absent.--}+-- | Fetch the latest snapshot for a stream that matches all three codec+-- discriminators. Returns 'Nothing' when no compatible snapshot exists, so an+-- incompatible one is treated as absent. lookupSnapshot ::-    (Store :> es) =>-    StreamId ->-    Int ->-    Text ->-    Text ->-    Eff es (Maybe SnapshotRow)+  (Store :> es) =>+  StreamId ->+  Int ->+  Text ->+  Text ->+  Eff es (Maybe SnapshotRow) lookupSnapshot streamId version shapeHash stateShapeHash =-    runTransaction-        $ Tx.statement-            (streamIdToInt streamId, Prelude.fromIntegral version, shapeHash, stateShapeHash)-            lookupSnapshotStmt+  runTransaction+    $ Tx.statement+      (streamIdToInt streamId, Prelude.fromIntegral version, shapeHash, stateShapeHash)+      lookupSnapshotStmt -{- | Upsert a snapshot row for its stream. For the same discriminator tuple,-the write only takes effect when its 'streamVersion' is at least the stored-one. Any incompatible discriminator replaces the row even at a lower version-so codec rollback can make progress; see the module header for the-mixed-deployment performance caveat.--}+-- | Upsert a snapshot row for its stream. For the same discriminator tuple,+-- the write only takes effect when its 'streamVersion' is at least the stored+-- one. Any incompatible discriminator replaces the row even at a lower version+-- so codec rollback can make progress; see the module header for the+-- mixed-deployment performance caveat. writeSnapshotRow ::-    (Store :> es) =>-    SnapshotWrite ->-    Eff es ()+  (Store :> es) =>+  SnapshotWrite ->+  Eff es () writeSnapshotRow snapshot =-    runTransaction-        $ Tx.statement (snapshotWriteParams snapshot) writeSnapshotStmt+  runTransaction+    $ Tx.statement (snapshotWriteParams snapshot) writeSnapshotStmt  lookupSnapshotStmt :: Statement (Int64, Int64, Text, Text) (Maybe SnapshotRow) lookupSnapshotStmt =-    preparable-        """-        SELECT stream_id, stream_version, state, state_codec_version, regfile_shape_hash, state_shape_hash, created_at, updated_at-        FROM keiro.keiro_snapshots-        WHERE stream_id = $1-          AND state_codec_version = $2-          AND regfile_shape_hash = $3-          AND state_shape_hash = $4-        ORDER BY stream_version DESC-        LIMIT 1-        """-        ( contrazip4-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.rowMaybe snapshotRowDecoder)+  preparable+    """+    SELECT stream_id, stream_version, state, state_codec_version, regfile_shape_hash, state_shape_hash, created_at, updated_at+    FROM keiro.keiro_snapshots+    WHERE stream_id = $1+      AND state_codec_version = $2+      AND regfile_shape_hash = $3+      AND state_shape_hash = $4+    ORDER BY stream_version DESC+    LIMIT 1+    """+    ( contrazip4+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe snapshotRowDecoder)  writeSnapshotStmt :: Statement (Int64, Int64, Value, Int64, Text, Text) () writeSnapshotStmt =-    preparable-        """-        INSERT INTO keiro.keiro_snapshots-          (stream_id, stream_version, state, state_codec_version, regfile_shape_hash, state_shape_hash)-        VALUES-          ($1, $2, $3, $4, $5, $6)-        ON CONFLICT (stream_id) DO UPDATE-          SET stream_version = EXCLUDED.stream_version,-              state = EXCLUDED.state,-              state_codec_version = EXCLUDED.state_codec_version,-              regfile_shape_hash = EXCLUDED.regfile_shape_hash,-              state_shape_hash = EXCLUDED.state_shape_hash,-              updated_at = now()-          WHERE keiro_snapshots.stream_version <= EXCLUDED.stream_version-             OR keiro_snapshots.state_codec_version <> EXCLUDED.state_codec_version-             OR keiro_snapshots.regfile_shape_hash <> EXCLUDED.regfile_shape_hash-             OR keiro_snapshots.state_shape_hash <> EXCLUDED.state_shape_hash-        """-        ( contrazip6-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_snapshots+      (stream_id, stream_version, state, state_codec_version, regfile_shape_hash, state_shape_hash)+    VALUES+      ($1, $2, $3, $4, $5, $6)+    ON CONFLICT (stream_id) DO UPDATE+      SET stream_version = EXCLUDED.stream_version,+          state = EXCLUDED.state,+          state_codec_version = EXCLUDED.state_codec_version,+          regfile_shape_hash = EXCLUDED.regfile_shape_hash,+          state_shape_hash = EXCLUDED.state_shape_hash,+          updated_at = now()+      WHERE keiro_snapshots.stream_version <= EXCLUDED.stream_version+         OR keiro_snapshots.state_codec_version <> EXCLUDED.state_codec_version+         OR keiro_snapshots.regfile_shape_hash <> EXCLUDED.regfile_shape_hash+         OR keiro_snapshots.state_shape_hash <> EXCLUDED.state_shape_hash+    """+    ( contrazip6+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  snapshotRowDecoder :: D.Row SnapshotRow snapshotRowDecoder =-    SnapshotRow-        <$> (StreamId <$> D.column (D.nonNullable D.int8))-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.jsonb)-        <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)+  SnapshotRow+    <$> (StreamId <$> D.column (D.nonNullable D.int8))+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.jsonb)+    <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)  streamIdToInt :: StreamId -> Int64 streamIdToInt (StreamId value) = value@@ -174,10 +169,10 @@  snapshotWriteParams :: SnapshotWrite -> (Int64, Int64, Value, Int64, Text, Text) snapshotWriteParams snapshot =-    ( streamIdToInt (snapshot ^. #streamId)-    , streamVersionToInt (snapshot ^. #streamVersion)-    , snapshot ^. #state-    , Prelude.fromIntegral (snapshot ^. #stateCodecVersion)-    , snapshot ^. #regfileShapeHash-    , snapshot ^. #stateShapeHash-    )+  ( streamIdToInt (snapshot ^. #streamId),+    streamVersionToInt (snapshot ^. #streamVersion),+    snapshot ^. #state,+    Prelude.fromIntegral (snapshot ^. #stateCodecVersion),+    snapshot ^. #regfileShapeHash,+    snapshot ^. #stateShapeHash+  )
src/Keiro/Subscription/Shard.hs view
@@ -1,36 +1,35 @@-{- | Cooperative consumer-group ownership for category subscriptions (EP-51).--kiroku already partitions a category into @N@ buckets (consumer-group members) by-a stable hash of the originating stream id, and keeps a per-member checkpoint, so-@N@ disjoint readers can drain a busy category in parallel. What kiroku leaves to-the operator is /membership/: "exactly one live process must own each member-index at a time" is, by itself, a manual @[0..N-1]@ wiring. This module supplies-the missing operability layer — a __lease__ over each bucket so a pool of-identical worker processes agree, with no external coordinator, on who owns which-bucket right now and re-divide the buckets automatically when a worker joins,-leaves, or dies.--The storage and SQL live in "Keiro.Subscription.Shard.Schema"; this module is the-typed 'Eff'-level surface over kiroku's 'Store':--* 'freshWorkerId' mints the per-process owner id.-* 'acquireOwnedBuckets' is the one-pass reconcile: renew the leases this worker-  still holds, then claim up to a /fair share/ more (taking over any expired-  leases), returning the buckets owned after the pass.-* 'renewOwnedBuckets' / 'relinquish' are the heartbeat and the graceful release.-* 'ensureShards' / 'ownershipSnapshot' populate and read the table.--The lease, not a held lock, is the ownership mechanism: a transaction-scoped-advisory lock auto-releases at transaction end (so it cannot span a worker's-multi-transaction lifetime) and a session-scoped lock has no connection affinity-through kiroku's pooled 'Store' — the same finding-'Keiro.Workflow.Resume.WorkflowResumeOptions' records for the resume worker. A-renewable @lease_expires_at@ timestamp gives lifetime ownership and automatic-failover without depending on connection affinity, and disjointness rests on the-@FOR UPDATE SKIP LOCKED@ claim, not on the liveness estimate being exact.--}-module Keiro.Subscription.Shard (-    -- * Worker identity+-- | Cooperative consumer-group ownership for category subscriptions (EP-51).+--+-- kiroku already partitions a category into @N@ buckets (consumer-group members) by+-- a stable hash of the originating stream id, and keeps a per-member checkpoint, so+-- @N@ disjoint readers can drain a busy category in parallel. What kiroku leaves to+-- the operator is /membership/: "exactly one live process must own each member+-- index at a time" is, by itself, a manual @[0..N-1]@ wiring. This module supplies+-- the missing operability layer — a __lease__ over each bucket so a pool of+-- identical worker processes agree, with no external coordinator, on who owns which+-- bucket right now and re-divide the buckets automatically when a worker joins,+-- leaves, or dies.+--+-- The storage and SQL live in "Keiro.Subscription.Shard.Schema"; this module is the+-- typed 'Eff'-level surface over kiroku's 'Store':+--+-- * 'freshWorkerId' mints the per-process owner id.+-- * 'acquireOwnedBuckets' is the one-pass reconcile: renew the leases this worker+--   still holds, then claim up to a /fair share/ more (taking over any expired+--   leases), returning the buckets owned after the pass.+-- * 'renewOwnedBuckets' / 'relinquish' are the heartbeat and the graceful release.+-- * 'ensureShards' / 'ownershipSnapshot' populate and read the table.+--+-- The lease, not a held lock, is the ownership mechanism: a transaction-scoped+-- advisory lock auto-releases at transaction end (so it cannot span a worker's+-- multi-transaction lifetime) and a session-scoped lock has no connection affinity+-- through kiroku's pooled 'Store' — the same finding+-- 'Keiro.Workflow.Resume.WorkflowResumeOptions' records for the resume worker. A+-- renewable @lease_expires_at@ timestamp gives lifetime ownership and automatic+-- failover without depending on connection affinity, and disjointness rests on the+-- @FOR UPDATE SKIP LOCKED@ claim, not on the liveness estimate being exact.+module Keiro.Subscription.Shard+  ( -- * Worker identity     WorkerId (..),     freshWorkerId, @@ -47,7 +46,7 @@      -- * Fair-share helper     fairShareTarget,-)+  ) where  import Data.Set (Set)@@ -57,15 +56,15 @@ import Effectful (Eff, IOE, (:>)) import Effectful.Exception (Exception, throwIO) import Keiro.Prelude-import Keiro.Subscription.Shard.Schema (-    WorkerId (..),+import Keiro.Subscription.Shard.Schema+  ( WorkerId (..),     claimShardsTx,     ensureShardRows,     listShardCounts,     listShardOwnership,     releaseShardsTx,     renewLeaseTx,- )+  ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Subscription.Types (SubscriptionName (..)) import Kiroku.Store.Transaction (runTransaction)@@ -74,111 +73,104 @@ freshWorkerId :: (IOE :> es) => Eff es WorkerId freshWorkerId = WorkerId <$> liftIO UUIDv4.nextRandom -{- | Everything an ownership pass needs for one @(subscription, worker)@: which-subscription is being sharded, this worker's id, the fixed bucket count @N@, and-how long a claim/renew is valid before it expires.--}+-- | Everything an ownership pass needs for one @(subscription, worker)@: which+-- subscription is being sharded, this worker's id, the fixed bucket count @N@, and+-- how long a claim/renew is valid before it expires. data ShardLease = ShardLease-    { subscriptionName :: !SubscriptionName-    , workerId :: !WorkerId-    , shardCount :: !Int-    -- ^ @N@; the fixed number of buckets for this subscription name.-    , leaseTtl :: !NominalDiffTime-    -- ^ How long a claim or renewal keeps a bucket before it expires.-    }-    deriving stock (Generic)+  { subscriptionName :: !SubscriptionName,+    workerId :: !WorkerId,+    -- | @N@; the fixed number of buckets for this subscription name.+    shardCount :: !Int,+    -- | How long a claim or renewal keeps a bucket before it expires.+    leaseTtl :: !NominalDiffTime+  }+  deriving stock (Generic)  data ShardCountMismatch = ShardCountMismatch-    { mismatchSubscriptionName :: !Text-    , mismatchConfigured :: !Int-    , mismatchFound :: ![Int]-    }-    deriving stock (Generic, Eq, Show)-    deriving anyclass (Exception)+  { mismatchSubscriptionName :: !Text,+    mismatchConfigured :: !Int,+    mismatchFound :: ![Int]+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (Exception) -{- | The fair-share claim target: @ceil(N / liveWorkers)@. When @k@ workers are-live they collectively claim all @N@ buckets and no single worker hogs them. A-non-positive @liveWorkers@ is treated as one (claim everything).--}+-- | The fair-share claim target: @ceil(N / liveWorkers)@. When @k@ workers are+-- live they collectively claim all @N@ buckets and no single worker hogs them. A+-- non-positive @liveWorkers@ is treated as one (claim everything). fairShareTarget :: Int -> Int -> Int fairShareTarget shardCount liveWorkers =-    let k = max 1 liveWorkers-     in (shardCount + k - 1) `div` k+  let k = max 1 liveWorkers+   in (shardCount + k - 1) `div` k -{- | Idempotently populate the @N@ shard rows for this subscription. Safe to call-on every worker startup ('ensureShardRows' uses @ON CONFLICT DO NOTHING@).--}+-- | Idempotently populate the @N@ shard rows for this subscription. Safe to call+-- on every worker startup ('ensureShardRows' uses @ON CONFLICT DO NOTHING@). ensureShards :: (Store :> es) => ShardLease -> Eff es () ensureShards lease = do-    counts <- runTransaction $ do-        ensureShardRows (subscriptionName lease) (shardCount lease)-        listShardCounts (subscriptionName lease)-    let configured = shardCount lease-        found = [n | (n, _) <- counts, n /= configured]-    unless (null found) $-        throwIO-            ShardCountMismatch-                { mismatchSubscriptionName = case subscriptionName lease of-                    SubscriptionName name -> name-                , mismatchConfigured = configured-                , mismatchFound = found-                }--{- | One ownership-reconcile pass: in a single transaction, renew the leases this-worker still holds, then — if it holds fewer than its fair share — claim __one__-more bucket (unowned or expired). Returns the set of buckets owned __after__ the-pass.--Claiming __one at a time__ is deliberate and is what makes a pool of identical-workers converge to a fair split without any external coordinator. If a cold-worker grabbed its whole fair share at once it could, racing alone before its-peers' first pass, monopolise every bucket and then never see the idle peers-(they own nothing, so they are invisible in the lease table). Taking one bucket-per pass instead means concurrently-starting workers each grab one, become-visible after the first pass, and climb to an even share together; a worker-joining a balanced pool only picks up buckets freed by an expired lease-(failover). Ownership spreads over up to @N@ reconcile intervals — a deliberate-trade of spin-up latency for coordinator-free fairness.+  counts <- runTransaction $ do+    ensureShardRows (subscriptionName lease) (shardCount lease)+    listShardCounts (subscriptionName lease)+  let configured = shardCount lease+      found = [n | (n, _) <- counts, n /= configured]+  unless (null found) $+    throwIO+      ShardCountMismatch+        { mismatchSubscriptionName = case subscriptionName lease of+            SubscriptionName name -> name,+          mismatchConfigured = configured,+          mismatchFound = found+        } -@liveWorkers@ is the caller's estimate of how many workers are currently live-(see 'ownershipSnapshot'); it tunes the fair-share target and self-corrects next-pass. It never causes double ownership, because the @FOR UPDATE SKIP LOCKED@-claim is the real exclusion mechanism.--}+-- | One ownership-reconcile pass: in a single transaction, renew the leases this+-- worker still holds, then — if it holds fewer than its fair share — claim __one__+-- more bucket (unowned or expired). Returns the set of buckets owned __after__ the+-- pass.+--+-- Claiming __one at a time__ is deliberate and is what makes a pool of identical+-- workers converge to a fair split without any external coordinator. If a cold+-- worker grabbed its whole fair share at once it could, racing alone before its+-- peers' first pass, monopolise every bucket and then never see the idle peers+-- (they own nothing, so they are invisible in the lease table). Taking one bucket+-- per pass instead means concurrently-starting workers each grab one, become+-- visible after the first pass, and climb to an even share together; a worker+-- joining a balanced pool only picks up buckets freed by an expired lease+-- (failover). Ownership spreads over up to @N@ reconcile intervals — a deliberate+-- trade of spin-up latency for coordinator-free fairness.+--+-- @liveWorkers@ is the caller's estimate of how many workers are currently live+-- (see 'ownershipSnapshot'); it tunes the fair-share target and self-corrects next+-- pass. It never causes double ownership, because the @FOR UPDATE SKIP LOCKED@+-- claim is the real exclusion mechanism. acquireOwnedBuckets :: (IOE :> es, Store :> es) => ShardLease -> Int -> Eff es (Set Int) acquireOwnedBuckets lease liveWorkers = do-    now <- liftIO getCurrentTime-    let target = fairShareTarget (shardCount lease) liveWorkers-    runTransaction $ do-        held <- renewLeaseTx (subscriptionName lease) (workerId lease) now (leaseTtl lease)-        -- Claim at most one bucket per pass (see the note above on convergence).-        claimed <--            if length held < target-                then claimShardsTx (subscriptionName lease) (workerId lease) 1 now (leaseTtl lease)-                else pure []-        pure (Set.fromList held <> Set.fromList claimed)+  now <- liftIO getCurrentTime+  let target = fairShareTarget (shardCount lease) liveWorkers+  runTransaction $ do+    held <- renewLeaseTx (subscriptionName lease) (workerId lease) now (leaseTtl lease)+    -- Claim at most one bucket per pass (see the note above on convergence).+    claimed <-+      if length held < target+        then claimShardsTx (subscriptionName lease) (workerId lease) 1 now (leaseTtl lease)+        else pure []+    pure (Set.fromList held <> Set.fromList claimed) -{- | Renew only — write a fresh expiry for every bucket this worker still holds-and return them. Used when a worker wants to heartbeat without claiming more.--}+-- | Renew only — write a fresh expiry for every bucket this worker still holds+-- and return them. Used when a worker wants to heartbeat without claiming more. renewOwnedBuckets :: (IOE :> es, Store :> es) => ShardLease -> Eff es (Set Int) renewOwnedBuckets lease = do-    now <- liftIO getCurrentTime-    held <- runTransaction (renewLeaseTx (subscriptionName lease) (workerId lease) now (leaseTtl lease))-    pure (Set.fromList held)+  now <- liftIO getCurrentTime+  held <- runTransaction (renewLeaseTx (subscriptionName lease) (workerId lease) now (leaseTtl lease))+  pure (Set.fromList held) -{- | Graceful release of the given buckets (clean shutdown), so they are-claimable immediately instead of after lease expiry.--}+-- | Graceful release of the given buckets (clean shutdown), so they are+-- claimable immediately instead of after lease expiry. relinquish :: (Store :> es) => ShardLease -> Set Int -> Eff es () relinquish lease buckets =-    runTransaction (releaseShardsTx (subscriptionName lease) (workerId lease) (Set.toList buckets))+  runTransaction (releaseShardsTx (subscriptionName lease) (workerId lease) (Set.toList buckets)) -{- | Read every bucket's @(bucket, owner, lease_expires_at)@ for this-subscription. The worker uses it to estimate @liveWorkers@ (count of distinct-non-expired owners) before an 'acquireOwnedBuckets' pass.--}+-- | Read every bucket's @(bucket, owner, lease_expires_at)@ for this+-- subscription. The worker uses it to estimate @liveWorkers@ (count of distinct+-- non-expired owners) before an 'acquireOwnedBuckets' pass. ownershipSnapshot ::-    (Store :> es) => ShardLease -> Eff es [(Int, Maybe WorkerId, Maybe UTCTime)]+  (Store :> es) => ShardLease -> Eff es [(Int, Maybe WorkerId, Maybe UTCTime)] ownershipSnapshot lease =-    runTransaction (listShardOwnership (subscriptionName lease))+  runTransaction (listShardOwnership (subscriptionName lease))
src/Keiro/Subscription/Shard/Schema.hs view
@@ -1,28 +1,27 @@-{- | The @keiro_subscription_shards@ table: storage and lease logic for-cooperative consumer-group ownership (EP-51).--A category subscription can be split across @N@ buckets — kiroku consumer-group-member indices in @[0, N)@ — so that @N@ cooperating workers each drain a-disjoint slice of the keyspace. This module owns the durable __assignment__-layer: one row per @(subscription_name, bucket)@ recording which worker holds it-right now, as a renewable __lease__ (an owner id plus an expiry timestamp). A-live worker renews its lease on a heartbeat; a dead worker stops renewing, its-lease expires, and another worker re-claims the bucket (failover). It does-__not__ store event positions — kiroku's per-member checkpoints-(@(subscription_name, consumer_group_member)@) do that, so a re-homed bucket-resumes where its previous owner left off.--The statements here are 'Hasql.Transaction.Transaction'-flavoured so callers can-compose several into one short transaction (e.g. renew-then-claim in-'Keiro.Subscription.Shard.acquireOwnedBuckets'); the typed 'Eff'-level wrappers-that run them through kiroku's pool live in "Keiro.Subscription.Shard". The-claim uses @FOR UPDATE SKIP LOCKED@ over the claimable rows, exactly as-'Keiro.Timer.Schema.claimDueTimer' does, so two workers racing the same bucket-can never both win — a stale "how many workers are live" estimate only changes-how aggressively a worker claims, never whether ownership stays disjoint.--}-module Keiro.Subscription.Shard.Schema (-    -- * Worker identity+-- | The @keiro_subscription_shards@ table: storage and lease logic for+-- cooperative consumer-group ownership (EP-51).+--+-- A category subscription can be split across @N@ buckets — kiroku consumer-group+-- member indices in @[0, N)@ — so that @N@ cooperating workers each drain a+-- disjoint slice of the keyspace. This module owns the durable __assignment__+-- layer: one row per @(subscription_name, bucket)@ recording which worker holds it+-- right now, as a renewable __lease__ (an owner id plus an expiry timestamp). A+-- live worker renews its lease on a heartbeat; a dead worker stops renewing, its+-- lease expires, and another worker re-claims the bucket (failover). It does+-- __not__ store event positions — kiroku's per-member checkpoints+-- (@(subscription_name, consumer_group_member)@) do that, so a re-homed bucket+-- resumes where its previous owner left off.+--+-- The statements here are 'Hasql.Transaction.Transaction'-flavoured so callers can+-- compose several into one short transaction (e.g. renew-then-claim in+-- 'Keiro.Subscription.Shard.acquireOwnedBuckets'); the typed 'Eff'-level wrappers+-- that run them through kiroku's pool live in "Keiro.Subscription.Shard". The+-- claim uses @FOR UPDATE SKIP LOCKED@ over the claimable rows, exactly as+-- 'Keiro.Timer.Schema.claimDueTimer' does, so two workers racing the same bucket+-- can never both win — a stale "how many workers are live" estimate only changes+-- how aggressively a worker claims, never whether ownership stays disjoint.+module Keiro.Subscription.Shard.Schema+  ( -- * Worker identity     WorkerId (..),      -- * Lease statements (composable within a transaction)@@ -32,7 +31,7 @@     releaseShardsTx,     listShardOwnership,     listShardCounts,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)@@ -46,182 +45,176 @@ import Kiroku.Store.Subscription.Types (SubscriptionName (..)) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | A per-process unique id naming the owner in a lease row. A UUID minted once-at worker start ('Keiro.Subscription.Shard.freshWorkerId'); two restarts of the-same binary get two different ids, so a restarted process never inherits the-dead process's leases — it claims afresh once the old leases expire.--}+-- | A per-process unique id naming the owner in a lease row. A UUID minted once+-- at worker start ('Keiro.Subscription.Shard.freshWorkerId'); two restarts of the+-- same binary get two different ids, so a restarted process never inherits the+-- dead process's leases — it claims afresh once the old leases expire. newtype WorkerId = WorkerId UUID-    deriving stock (Eq, Ord, Show)+  deriving stock (Eq, Ord, Show) -{- | Idempotently insert the @N@ rows @(name, bucket = 0..N-1, shard_count = N)@-with @owner_worker_id@ left @NULL@. @ON CONFLICT DO NOTHING@ makes calling it-on every worker startup safe; it converges the table to exactly @N@ rows.--}+-- | Idempotently insert the @N@ rows @(name, bucket = 0..N-1, shard_count = N)@+-- with @owner_worker_id@ left @NULL@. @ON CONFLICT DO NOTHING@ makes calling it+-- on every worker startup safe; it converges the table to exactly @N@ rows. ensureShardRows :: SubscriptionName -> Int -> Tx.Transaction () ensureShardRows (SubscriptionName name) shardCount =-    Tx.statement (name, fromIntegral shardCount) ensureShardRowsStmt+  Tx.statement (name, fromIntegral shardCount) ensureShardRowsStmt -{- | Claim up to @targetCount@ buckets that are currently unowned __or__ whose-lease has expired (@owner_worker_id IS NULL OR lease_expires_at < now@), in one-statement, returning the bucket numbers actually claimed. @FOR UPDATE SKIP-LOCKED@ over the claimable rows is the exclusion mechanism: two workers racing-the same bucket cannot both win. The TTL is added to @now@ here (in Haskell) to-form the new @lease_expires_at@.--}+-- | Claim up to @targetCount@ buckets that are currently unowned __or__ whose+-- lease has expired (@owner_worker_id IS NULL OR lease_expires_at < now@), in one+-- statement, returning the bucket numbers actually claimed. @FOR UPDATE SKIP+-- LOCKED@ over the claimable rows is the exclusion mechanism: two workers racing+-- the same bucket cannot both win. The TTL is added to @now@ here (in Haskell) to+-- form the new @lease_expires_at@. claimShardsTx ::-    SubscriptionName -> WorkerId -> Int -> UTCTime -> NominalDiffTime -> Tx.Transaction [Int]+  SubscriptionName -> WorkerId -> Int -> UTCTime -> NominalDiffTime -> Tx.Transaction [Int] claimShardsTx (SubscriptionName name) (WorkerId worker) targetCount now ttl =-    fmap (fmap fromIntegral) $-        Tx.statement-            (name, now, addUTCTime ttl now, worker, fromIntegral targetCount)-            claimShardsStmt+  fmap (fmap fromIntegral) $+    Tx.statement+      (name, now, addUTCTime ttl now, worker, fromIntegral targetCount)+      claimShardsStmt -{- | Renew every lease this worker still holds: write a fresh @lease_expires_at =-now + ttl@ and @heartbeat_at = now@ for each row it owns, returning the buckets-still held. A bucket stolen after this worker's lease lapsed is owned by someone-else and so is __not__ in the result — that is how a worker learns it lost a-bucket and stops reading it.--}+-- | Renew every lease this worker still holds: write a fresh @lease_expires_at =+-- now + ttl@ and @heartbeat_at = now@ for each row it owns, returning the buckets+-- still held. A bucket stolen after this worker's lease lapsed is owned by someone+-- else and so is __not__ in the result — that is how a worker learns it lost a+-- bucket and stops reading it. renewLeaseTx :: SubscriptionName -> WorkerId -> UTCTime -> NominalDiffTime -> Tx.Transaction [Int] renewLeaseTx (SubscriptionName name) (WorkerId worker) now ttl =-    fmap (fmap fromIntegral) $-        Tx.statement (name, now, addUTCTime ttl now, worker) renewLeaseStmt+  fmap (fmap fromIntegral) $+    Tx.statement (name, now, addUTCTime ttl now, worker) renewLeaseStmt -{- | Graceful relinquish: clear ownership of the given buckets this worker holds-so they become claimable immediately, without waiting for lease expiry. Called-on clean shutdown. Only rows still owned by @worker@ are affected, so a bucket-already stolen is left untouched.--}+-- | Graceful relinquish: clear ownership of the given buckets this worker holds+-- so they become claimable immediately, without waiting for lease expiry. Called+-- on clean shutdown. Only rows still owned by @worker@ are affected, so a bucket+-- already stolen is left untouched. releaseShardsTx :: SubscriptionName -> WorkerId -> [Int] -> Tx.Transaction () releaseShardsTx (SubscriptionName name) (WorkerId worker) buckets =-    Tx.statement (name, worker, fmap fromIntegral buckets) releaseShardsStmt+  Tx.statement (name, worker, fmap fromIntegral buckets) releaseShardsStmt -{- | Observability/test read of @(bucket, owner, lease_expires_at)@ for one-subscription, ordered by bucket.--}+-- | Observability/test read of @(bucket, owner, lease_expires_at)@ for one+-- subscription, ordered by bucket. listShardOwnership :: SubscriptionName -> Tx.Transaction [(Int, Maybe WorkerId, Maybe UTCTime)] listShardOwnership (SubscriptionName name) =-    Tx.statement name listShardOwnershipStmt+  Tx.statement name listShardOwnershipStmt  -- | Read existing shard-count groups for a subscription as @(shard_count, rows)@. listShardCounts :: SubscriptionName -> Tx.Transaction [(Int, Int)] listShardCounts (SubscriptionName name) =-    Tx.statement name listShardCountsStmt+  Tx.statement name listShardCountsStmt  ensureShardRowsStmt :: Statement (Text, Int32) () ensureShardRowsStmt =-    preparable-        """-        INSERT INTO keiro.keiro_subscription_shards (subscription_name, bucket, shard_count)-        SELECT $1, g, $2-        FROM generate_series(0, $2 - 1) AS g-        ON CONFLICT (subscription_name, bucket) DO NOTHING-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_subscription_shards (subscription_name, bucket, shard_count)+    SELECT $1, g, $2+    FROM generate_series(0, $2 - 1) AS g+    ON CONFLICT (subscription_name, bucket) DO NOTHING+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    D.noResult  claimShardsStmt :: Statement (Text, UTCTime, UTCTime, UUID, Int32) [Int32] claimShardsStmt =-    preparable-        """-        WITH claimable AS (-          SELECT bucket-          FROM keiro.keiro_subscription_shards-          WHERE subscription_name = $1-            AND (owner_worker_id IS NULL OR lease_expires_at < $2)-          ORDER BY bucket-          LIMIT $5-          FOR UPDATE SKIP LOCKED-        )-        UPDATE keiro.keiro_subscription_shards s-        SET owner_worker_id = $4,-            lease_expires_at = $3,-            heartbeat_at = $2,-            updated_at = $2-        FROM claimable c-        WHERE s.subscription_name = $1 AND s.bucket = c.bucket-        RETURNING s.bucket-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.int4))-        )-        (D.rowList (D.column (D.nonNullable D.int4)))+  preparable+    """+    WITH claimable AS (+      SELECT bucket+      FROM keiro.keiro_subscription_shards+      WHERE subscription_name = $1+        AND (owner_worker_id IS NULL OR lease_expires_at < $2)+      ORDER BY bucket+      LIMIT $5+      FOR UPDATE SKIP LOCKED+    )+    UPDATE keiro.keiro_subscription_shards s+    SET owner_worker_id = $4,+        lease_expires_at = $3,+        heartbeat_at = $2,+        updated_at = $2+    FROM claimable c+    WHERE s.subscription_name = $1 AND s.bucket = c.bucket+    RETURNING s.bucket+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowList (D.column (D.nonNullable D.int4)))  renewLeaseStmt :: Statement (Text, UTCTime, UTCTime, UUID) [Int32] renewLeaseStmt =-    preparable-        """-        UPDATE keiro.keiro_subscription_shards-        SET lease_expires_at = $3,-            heartbeat_at = $2,-            updated_at = $2-        WHERE subscription_name = $1-          AND owner_worker_id = $4-        RETURNING bucket-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.uuid))-        )-        (D.rowList (D.column (D.nonNullable D.int4)))+  preparable+    """+    UPDATE keiro.keiro_subscription_shards+    SET lease_expires_at = $3,+        heartbeat_at = $2,+        updated_at = $2+    WHERE subscription_name = $1+      AND owner_worker_id = $4+    RETURNING bucket+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.uuid))+    )+    (D.rowList (D.column (D.nonNullable D.int4)))  releaseShardsStmt :: Statement (Text, UUID, [Int32]) () releaseShardsStmt =-    preparable-        """-        UPDATE keiro.keiro_subscription_shards-        SET owner_worker_id = NULL,-            lease_expires_at = NULL,-            updated_at = now()-        WHERE subscription_name = $1-          AND owner_worker_id = $2-          AND bucket = ANY($3)-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int4))))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_subscription_shards+    SET owner_worker_id = NULL,+        lease_expires_at = NULL,+        updated_at = now()+    WHERE subscription_name = $1+      AND owner_worker_id = $2+      AND bucket = ANY($3)+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int4))))+    )+    D.noResult  listShardOwnershipStmt :: Statement Text [(Int, Maybe WorkerId, Maybe UTCTime)] listShardOwnershipStmt =-    preparable-        """-        SELECT bucket, owner_worker_id, lease_expires_at-        FROM keiro.keiro_subscription_shards-        WHERE subscription_name = $1-        ORDER BY bucket-        """-        (E.param (E.nonNullable E.text))-        (D.rowList ownershipRowDecoder)+  preparable+    """+    SELECT bucket, owner_worker_id, lease_expires_at+    FROM keiro.keiro_subscription_shards+    WHERE subscription_name = $1+    ORDER BY bucket+    """+    (E.param (E.nonNullable E.text))+    (D.rowList ownershipRowDecoder)  listShardCountsStmt :: Statement Text [(Int, Int)] listShardCountsStmt =-    preparable-        """-        SELECT shard_count, count(*)::int-        FROM keiro.keiro_subscription_shards-        WHERE subscription_name = $1-        GROUP BY shard_count-        ORDER BY shard_count-        """-        (E.param (E.nonNullable E.text))-        (D.rowList ((,) <$> (fromIntegral <$> D.column (D.nonNullable D.int4)) <*> (fromIntegral <$> D.column (D.nonNullable D.int4))))+  preparable+    """+    SELECT shard_count, count(*)::int+    FROM keiro.keiro_subscription_shards+    WHERE subscription_name = $1+    GROUP BY shard_count+    ORDER BY shard_count+    """+    (E.param (E.nonNullable E.text))+    (D.rowList ((,) <$> (fromIntegral <$> D.column (D.nonNullable D.int4)) <*> (fromIntegral <$> D.column (D.nonNullable D.int4))))  ownershipRowDecoder :: D.Row (Int, Maybe WorkerId, Maybe UTCTime) ownershipRowDecoder =-    (,,)-        <$> (fromIntegral <$> D.column (D.nonNullable D.int4))-        <*> (fmap WorkerId <$> D.column (D.nullable D.uuid))-        <*> D.column (D.nullable D.timestamptz)+  (,,)+    <$> (fromIntegral <$> D.column (D.nonNullable D.int4))+    <*> (fmap WorkerId <$> D.column (D.nullable D.uuid))+    <*> D.column (D.nullable D.timestamptz)
src/Keiro/Subscription/Shard/Worker.hs view
@@ -1,69 +1,68 @@-{- | The sharded category-subscription worker: a rebalance loop that turns-leased bucket ownership ("Keiro.Subscription.Shard") into running kiroku-consumer-group readers (EP-51).--Start the __same__ worker binary @N@ times — on @N@ hosts, in @N@ containers, in-an autoscaling group — each calling 'runShardedSubscriptionGroup' with the same-'SubscriptionName' and 'ShardedWorkerOptions', and the workers cooperatively-partition the category among themselves: every event is processed at least once-and none are skipped. A brief ownership overlap can deliver an event more than-once, so handlers must be idempotent. No external coordinator-(etcd/ZooKeeper/Consul) — coordination lives in the-@keiro_subscription_shards@ lease table.--== One pass--'reconcileShardsOnce' is the single testable unit (like-'Keiro.Workflow.Resume.resumeWorkflowsOnce'):--1. Estimate how many workers are live from the lease table.-2. 'Keiro.Subscription.Shard.acquireOwnedBuckets' — renew the leases this worker-   still holds and claim up to a fair share more (taking over expired leases).-3. __Shed__ any buckets held beyond the fair share when more than one worker is-   live, so a worker that grabbed too many on a cold start (when it briefly-   believed it was alone) gives the excess back and the pool converges to an-   even split. A lone worker never sheds — it must own every bucket.-4. Reconcile readers: open a kiroku consumer-group reader for each newly-owned-   bucket, stop the reader for each newly-lost bucket. A bucket that moves owners-   resumes at its own kiroku per-member checkpoint. Each event is acknowledged-   only after its handler returns, so a shed bucket's checkpoint never covers an-   unprocessed event and no event is dropped.--'runShardedSubscriptionGroup' is the loop driver: mint a 'WorkerId', ensure the-shard rows exist, then run 'reconcileShardsOnce' every @renewInterval@ forever.-On graceful shutdown (the loop thread is killed and can run cleanup) a @finally@-stops every reader this worker holds and relinquishes its leases so another-worker can claim immediately. A real process crash still recovers by lease-expiry.--== Why @IO@, not @Eff@--Like 'Keiro.Workflow.Resume.runWorkflowResumeWorkerPush', this worker manages-long-lived 'Control.Concurrent' reader threads and takes the 'KirokuStore' handle-directly (each lease pass runs through 'Kiroku.Store.Effect.runStoreIO'), so its-natural home is 'IO'. The compatibility handler is an ordinary-@'RecordedEvent' -> 'IO' ()@; 'runShardedSubscriptionGroupAck' exposes the-per-event 'ShardAck' surface for handlers that need an explicit retry or-dead-letter decision. A sharded subscription delivers at-least-once (a brief-overlap is possible while a bucket changes owners), so the handler must be-idempotent — keyed on @eventId@ — exactly as keiro's async-projection guidance-already requires.--A synchronous exception from either handler is retried in place according to-'retryPolicy', using 'handlerRetryDelay'. Exhausting the bounded delivery budget-records the event in kiroku's @kiroku.dead_letters@ table and advances to the next-event. 'ShardReaderDied' therefore reports stream-level failures, not ordinary-handler exceptions. An asynchronous exception from shedding or shutdown writes-no acknowledgement, so the event is redelivered by the next owner.--A zombie worker that misses renewals past @leaseTtl@ may continue reading briefly-after another worker claims its bucket, which can duplicate deliveries. It cannot-regress the consumer-group checkpoint: kiroku's checkpoint upsert is monotonic-(@GREATEST@), so a late acknowledgement from the laggard never moves progress-backward.--}-module Keiro.Subscription.Shard.Worker (-    -- * Options+-- | The sharded category-subscription worker: a rebalance loop that turns+-- leased bucket ownership ("Keiro.Subscription.Shard") into running kiroku+-- consumer-group readers (EP-51).+--+-- Start the __same__ worker binary @N@ times — on @N@ hosts, in @N@ containers, in+-- an autoscaling group — each calling 'runShardedSubscriptionGroup' with the same+-- 'SubscriptionName' and 'ShardedWorkerOptions', and the workers cooperatively+-- partition the category among themselves: every event is processed at least once+-- and none are skipped. A brief ownership overlap can deliver an event more than+-- once, so handlers must be idempotent. No external coordinator+-- (etcd/ZooKeeper/Consul) — coordination lives in the+-- @keiro_subscription_shards@ lease table.+--+-- == One pass+--+-- 'reconcileShardsOnce' is the single testable unit (like+-- 'Keiro.Workflow.Resume.resumeWorkflowsOnce'):+--+-- 1. Estimate how many workers are live from the lease table.+-- 2. 'Keiro.Subscription.Shard.acquireOwnedBuckets' — renew the leases this worker+--    still holds and claim up to a fair share more (taking over expired leases).+-- 3. __Shed__ any buckets held beyond the fair share when more than one worker is+--    live, so a worker that grabbed too many on a cold start (when it briefly+--    believed it was alone) gives the excess back and the pool converges to an+--    even split. A lone worker never sheds — it must own every bucket.+-- 4. Reconcile readers: open a kiroku consumer-group reader for each newly-owned+--    bucket, stop the reader for each newly-lost bucket. A bucket that moves owners+--    resumes at its own kiroku per-member checkpoint. Each event is acknowledged+--    only after its handler returns, so a shed bucket's checkpoint never covers an+--    unprocessed event and no event is dropped.+--+-- 'runShardedSubscriptionGroup' is the loop driver: mint a 'WorkerId', ensure the+-- shard rows exist, then run 'reconcileShardsOnce' every @renewInterval@ forever.+-- On graceful shutdown (the loop thread is killed and can run cleanup) a @finally@+-- stops every reader this worker holds and relinquishes its leases so another+-- worker can claim immediately. A real process crash still recovers by lease+-- expiry.+--+-- == Why @IO@, not @Eff@+--+-- Like 'Keiro.Workflow.Resume.runWorkflowResumeWorkerPush', this worker manages+-- long-lived 'Control.Concurrent' reader threads and takes the 'KirokuStore' handle+-- directly (each lease pass runs through 'Kiroku.Store.Effect.runStoreIO'), so its+-- natural home is 'IO'. The compatibility handler is an ordinary+-- @'RecordedEvent' -> 'IO' ()@; 'runShardedSubscriptionGroupAck' exposes the+-- per-event 'ShardAck' surface for handlers that need an explicit retry or+-- dead-letter decision. A sharded subscription delivers at-least-once (a brief+-- overlap is possible while a bucket changes owners), so the handler must be+-- idempotent — keyed on @eventId@ — exactly as keiro's async-projection guidance+-- already requires.+--+-- A synchronous exception from either handler is retried in place according to+-- 'retryPolicy', using 'handlerRetryDelay'. Exhausting the bounded delivery budget+-- records the event in kiroku's @kiroku.dead_letters@ table and advances to the next+-- event. 'ShardReaderDied' therefore reports stream-level failures, not ordinary+-- handler exceptions. An asynchronous exception from shedding or shutdown writes+-- no acknowledgement, so the event is redelivered by the next owner.+--+-- A zombie worker that misses renewals past @leaseTtl@ may continue reading briefly+-- after another worker claims its bucket, which can duplicate deliveries. It cannot+-- regress the consumer-group checkpoint: kiroku's checkpoint upsert is monotonic+-- (@GREATEST@), so a late acknowledgement from the laggard never moves progress+-- backward.+module Keiro.Subscription.Shard.Worker+  ( -- * Options     ShardWorkerError (..),     ShardedWorkerOptions (..),     ShardedWorkerConfigError (..),@@ -82,7 +81,7 @@     reconcileShardsOnce,     runShardedSubscriptionGroup,     runShardedSubscriptionGroupAck,-)+  ) where  import Control.Concurrent (forkFinally, killThread, threadDelay)@@ -101,20 +100,20 @@ import Data.Time (NominalDiffTime) import Data.UUID.V4 qualified as UUIDv4 import Keiro.Prelude-import Keiro.Subscription.Shard (-    ShardLease (..),+import Keiro.Subscription.Shard+  ( ShardLease (..),     WorkerId (..),     acquireOwnedBuckets,     ensureShards,     fairShareTarget,     ownershipSnapshot,     relinquish,- )+  ) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Effect (runStoreIO) import Kiroku.Store.Subscription.Stream (AckItem (..), subscriptionAckStream)-import Kiroku.Store.Subscription.Types (-    ConsumerGroup (..),+import Kiroku.Store.Subscription.Types+  ( ConsumerGroup (..),     DeadLetterReason (..),     RetryDelay (..),     RetryPolicy (..),@@ -123,7 +122,7 @@     SubscriptionTarget,     defaultRetryPolicy,     defaultSubscriptionConfig,- )+  ) import Kiroku.Store.Subscription.Types qualified as Sub import Kiroku.Store.Types (RecordedEvent) import Numeric.Natural (Natural)@@ -131,298 +130,291 @@ import Streamly.Data.Stream qualified as Stream  data ShardWorkerError-    = ShardSnapshotFailed !Text-    | ShardAcquireFailed !Text-    | ShardReaderDied !Int !Text-    | ShardEnsureFailed !Text-    deriving stock (Generic, Eq, Show)+  = ShardSnapshotFailed !Text+  | ShardAcquireFailed !Text+  | ShardReaderDied !Int !Text+  | ShardEnsureFailed !Text+  deriving stock (Generic, Eq, Show)  -- | Per-event disposition returned by an acknowledgement-aware shard handler. data ShardAck-    = -- | Processing completed; the checkpoint may advance past this event.-      ShardAckOk-    | -- | Redeliver after the delay, bounded by 'retryPolicy'.-      ShardAckRetry !RetryDelay-    | -- | Record the event in kiroku's dead-letter table and advance immediately.-      ShardAckDeadLetter !DeadLetterReason-    deriving stock (Generic, Eq, Show)+  = -- | Processing completed; the checkpoint may advance past this event.+    ShardAckOk+  | -- | Redeliver after the delay, bounded by 'retryPolicy'.+    ShardAckRetry !RetryDelay+  | -- | Record the event in kiroku's dead-letter table and advance immediately.+    ShardAckDeadLetter !DeadLetterReason+  deriving stock (Generic, Eq, Show)  -- | One delivery to an acknowledgement-aware shard handler. data ShardDelivery = ShardDelivery-    { event :: !RecordedEvent-    -- ^ The delivered event.-    , attempt :: !Word-    -- ^ Zero-based redelivery count: 0 initially, 1 after the first retry, and so on.-    , bucket :: !Int-    -- ^ The consumer-group member owned by this reader.-    }-    deriving stock (Generic, Eq, Show)+  { -- | The delivered event.+    event :: !RecordedEvent,+    -- | Zero-based redelivery count: 0 initially, 1 after the first retry, and so on.+    attempt :: !Word,+    -- | The consumer-group member owned by this reader.+    bucket :: !Int+  }+  deriving stock (Generic, Eq, Show)  type ShardEventHandler = ShardDelivery -> IO ShardAck  -- | How a sharded worker pool runs one subscription. data ShardedWorkerOptions = ShardedWorkerOptions-    { shardCount :: !Int-    -- ^ @N@ buckets, fixed per subscription name (every worker must agree on @N@).-    , leaseTtl :: !NominalDiffTime-    -- ^ How long a claim/renew keeps a bucket before it expires (default 30 s).-    , renewInterval :: !NominalDiffTime-    {- ^ Gap between reconcile passes; well under 'leaseTtl' so a live worker renews-    several times per TTL (default 10 s ⇒ ~3 renews per 30 s TTL). A single-    missed renewal does not lose ownership; a dead worker loses every bucket-    within one TTL.-    -}-    , target :: !SubscriptionTarget-    -- ^ The category (or @AllStreams@) to shard.-    , batchSize :: !Int32-    -- ^ Events per database fetch per bucket reader (default 100).-    , bufferSize :: !Natural-    -- ^ Per-reader bridge queue capacity (default 256; one item is in flight).-    , handlerRetryDelay :: !RetryDelay-    -- ^ Delay before redelivering after a synchronous handler exception (default 1 s).-    , retryPolicy :: !RetryPolicy-    -- ^ Maximum total deliveries before retry exhaustion dead-letters the event.-    , onShardError :: !(Maybe (ShardWorkerError -> IO ()))-    -- ^ Optional error hook. Wire this to the application logger in production.-    }-    deriving stock (Generic)+  { -- | @N@ buckets, fixed per subscription name (every worker must agree on @N@).+    shardCount :: !Int,+    -- | How long a claim/renew keeps a bucket before it expires (default 30 s).+    leaseTtl :: !NominalDiffTime,+    -- | Gap between reconcile passes; well under 'leaseTtl' so a live worker renews+    --     several times per TTL (default 10 s ⇒ ~3 renews per 30 s TTL). A single+    --     missed renewal does not lose ownership; a dead worker loses every bucket+    --     within one TTL.+    renewInterval :: !NominalDiffTime,+    -- | The category (or @AllStreams@) to shard.+    target :: !SubscriptionTarget,+    -- | Events per database fetch per bucket reader (default 100).+    batchSize :: !Int32,+    -- | Per-reader bridge queue capacity (default 256; one item is in flight).+    bufferSize :: !Natural,+    -- | Delay before redelivering after a synchronous handler exception (default 1 s).+    handlerRetryDelay :: !RetryDelay,+    -- | Maximum total deliveries before retry exhaustion dead-letters the event.+    retryPolicy :: !RetryPolicy,+    -- | Optional error hook. Wire this to the application logger in production.+    onShardError :: !(Maybe (ShardWorkerError -> IO ()))+  }+  deriving stock (Generic)  data ShardedWorkerConfigError-    = InvalidShardCount !Int-    | InvalidShardLeaseTtl !NominalDiffTime-    | InvalidShardRenewInterval !NominalDiffTime-    | InvalidShardLeaseRenewInterval !NominalDiffTime !NominalDiffTime-    | InvalidShardBatchSize !Int32-    | InvalidShardBufferSize !Natural-    | InvalidShardHandlerRetryDelay !RetryDelay-    | InvalidShardRetryMaxAttempts !Int-    deriving stock (Generic, Eq, Show)+  = InvalidShardCount !Int+  | InvalidShardLeaseTtl !NominalDiffTime+  | InvalidShardRenewInterval !NominalDiffTime+  | InvalidShardLeaseRenewInterval !NominalDiffTime !NominalDiffTime+  | InvalidShardBatchSize !Int32+  | InvalidShardBufferSize !Natural+  | InvalidShardHandlerRetryDelay !RetryDelay+  | InvalidShardRetryMaxAttempts !Int+  deriving stock (Generic, Eq, Show) -{- | Sensible defaults for a sharded worker: 30 s lease, 10 s renew, batch 100,-buffer 256. Supply the category target and the bucket count @N@.--}+-- | Sensible defaults for a sharded worker: 30 s lease, 10 s renew, batch 100,+-- buffer 256. Supply the category target and the bucket count @N@. defaultShardedWorkerOptions :: SubscriptionTarget -> Int -> ShardedWorkerOptions defaultShardedWorkerOptions target' shardCount' =-    ShardedWorkerOptions-        { shardCount = shardCount'-        , leaseTtl = 30-        , renewInterval = 10-        , target = target'-        , batchSize = 100-        , bufferSize = 256-        , handlerRetryDelay = RetryDelay 1-        , retryPolicy = defaultRetryPolicy-        , onShardError = Nothing-        }+  ShardedWorkerOptions+    { shardCount = shardCount',+      leaseTtl = 30,+      renewInterval = 10,+      target = target',+      batchSize = 100,+      bufferSize = 256,+      handlerRetryDelay = RetryDelay 1,+      retryPolicy = defaultRetryPolicy,+      onShardError = Nothing+    }  -- | Validate sharded worker options before starting a worker pool member. mkShardedWorkerOptions :: ShardedWorkerOptions -> Either ShardedWorkerConfigError ShardedWorkerOptions mkShardedWorkerOptions opts-    | opts ^. #shardCount < 1 = Left (InvalidShardCount (opts ^. #shardCount))-    | opts ^. #leaseTtl <= 0 = Left (InvalidShardLeaseTtl (opts ^. #leaseTtl))-    | opts ^. #renewInterval <= 0 = Left (InvalidShardRenewInterval (opts ^. #renewInterval))-    | opts ^. #leaseTtl <= opts ^. #renewInterval =-        Left (InvalidShardLeaseRenewInterval (opts ^. #leaseTtl) (opts ^. #renewInterval))-    | opts ^. #batchSize < 1 = Left (InvalidShardBatchSize (opts ^. #batchSize))-    | opts ^. #bufferSize < 1 = Left (InvalidShardBufferSize (opts ^. #bufferSize))-    | RetryDelay delay <- opts ^. #handlerRetryDelay-    , delay < 0 =-        Left (InvalidShardHandlerRetryDelay (opts ^. #handlerRetryDelay))-    | RetryPolicy attempts <- opts ^. #retryPolicy-    , attempts < 1 =-        Left (InvalidShardRetryMaxAttempts attempts)-    | otherwise = Right opts+  | opts ^. #shardCount < 1 = Left (InvalidShardCount (opts ^. #shardCount))+  | opts ^. #leaseTtl <= 0 = Left (InvalidShardLeaseTtl (opts ^. #leaseTtl))+  | opts ^. #renewInterval <= 0 = Left (InvalidShardRenewInterval (opts ^. #renewInterval))+  | opts ^. #leaseTtl <= opts ^. #renewInterval =+      Left (InvalidShardLeaseRenewInterval (opts ^. #leaseTtl) (opts ^. #renewInterval))+  | opts ^. #batchSize < 1 = Left (InvalidShardBatchSize (opts ^. #batchSize))+  | opts ^. #bufferSize < 1 = Left (InvalidShardBufferSize (opts ^. #bufferSize))+  | RetryDelay delay <- opts ^. #handlerRetryDelay,+    delay < 0 =+      Left (InvalidShardHandlerRetryDelay (opts ^. #handlerRetryDelay))+  | RetryPolicy attempts <- opts ^. #retryPolicy,+    attempts < 1 =+      Left (InvalidShardRetryMaxAttempts attempts)+  | otherwise = Right opts -{- | A live per-bucket reader: the action that stops it (cancels the kiroku-subscription and kills the drain thread).--}+-- | A live per-bucket reader: the action that stops it (cancels the kiroku+-- subscription and kills the drain thread). newtype RunningReader = RunningReader {stopReader :: IO ()}  reportShardError :: ShardedWorkerOptions -> ShardWorkerError -> IO () reportShardError opts err =-    for_ (opts ^. #onShardError) ($ err)+  for_ (opts ^. #onShardError) ($ err)  acquireOutcome :: Set Int -> Either Text (Set Int) -> (Set Int, Maybe ShardWorkerError) acquireOutcome previous = \case-    Right owned -> (owned, Nothing)-    Left err -> (previous, Just (ShardAcquireFailed err))--{- | Run one ownership-reconcile pass and bring the set of running readers in-line with the buckets owned afterwards. Returns the buckets this worker owns-after the pass. The @readers@ ref maps each owned bucket to its live reader.+  Right owned -> (owned, Nothing)+  Left err -> (previous, Just (ShardAcquireFailed err)) -This is the testable unit: a single call claims/renews/sheds leases and-starts/stops readers once, with no loop of its own.--}+-- | Run one ownership-reconcile pass and bring the set of running readers in+-- line with the buckets owned afterwards. Returns the buckets this worker owns+-- after the pass. The @readers@ ref maps each owned bucket to its live reader.+--+-- This is the testable unit: a single call claims/renews/sheds leases and+-- starts/stops readers once, with no loop of its own. reconcileShardsOnce ::-    KirokuStore ->-    ShardLease ->-    ShardedWorkerOptions ->-    IORef (Map Int RunningReader) ->-    ShardEventHandler ->-    IO (Set Int)+  KirokuStore ->+  ShardLease ->+  ShardedWorkerOptions ->+  IORef (Map Int RunningReader) ->+  ShardEventHandler ->+  IO (Set Int) reconcileShardsOnce store lease opts readers handler = do-    now <- getCurrentTime-    current <- readIORef readers-    -- Estimate live workers: distinct owners with a non-expired lease, plus self.-    snapResult <- runStoreIO store (ownershipSnapshot lease)-    snap <- case snapResult of-        Right rows -> pure rows-        Left err -> do-            reportShardError opts (ShardSnapshotFailed (Text.pack (show err)))-            pure []-    let liveOwners = Set.fromList [w | (_, Just w, Just expiresAt) <- snap, expiresAt > now]-        liveWorkers = Set.size (Set.insert (lease ^. #workerId) liveOwners)-        shareTarget = fairShareTarget (lease ^. #shardCount) liveWorkers-    -- Renew held + claim up to fair share.-    claimedResult <- runStoreIO store (acquireOwnedBuckets lease liveWorkers)-    let previousOwned = Map.keysSet current-        (claimed, mAcquireError) = acquireOutcome previousOwned (first (Text.pack . show) claimedResult)-    for_ mAcquireError (reportShardError opts)-    -- Shed any excess above the fair share so a cold-start over-claim self-balances-    -- (never when alone: a lone worker must own everything).-    owned <--        if liveWorkers > 1 && Set.size claimed > shareTarget-            then do-                let excess = Set.fromList (drop shareTarget (sort (Set.toList claimed)))-                _ <- runStoreIO store (relinquish lease excess)-                pure (claimed `Set.difference` excess)-            else pure claimed-    -- Bring readers in line with `owned`: start newly-owned, stop newly-lost.-    let running = Map.keysSet current-        toStart = owned `Set.difference` running-        toStop = running `Set.difference` owned-    for_ (Set.toList toStop) $ \bucket ->-        for_ (Map.lookup bucket current) stopReader-    started <--        traverse-            (\bucket -> (,) bucket <$> startReader store lease opts readers handler bucket)-            (Set.toList toStart)-    atomicModifyIORef' readers $ \m ->-        let afterStop = foldr Map.delete m (Set.toList toStop)-            afterStart = foldr (\(b, r) -> Map.insert b r) afterStop started-         in (afterStart, ())-    pure owned+  now <- getCurrentTime+  current <- readIORef readers+  -- Estimate live workers: distinct owners with a non-expired lease, plus self.+  snapResult <- runStoreIO store (ownershipSnapshot lease)+  snap <- case snapResult of+    Right rows -> pure rows+    Left err -> do+      reportShardError opts (ShardSnapshotFailed (Text.pack (show err)))+      pure []+  let liveOwners = Set.fromList [w | (_, Just w, Just expiresAt) <- snap, expiresAt > now]+      liveWorkers = Set.size (Set.insert (lease ^. #workerId) liveOwners)+      shareTarget = fairShareTarget (lease ^. #shardCount) liveWorkers+  -- Renew held + claim up to fair share.+  claimedResult <- runStoreIO store (acquireOwnedBuckets lease liveWorkers)+  let previousOwned = Map.keysSet current+      (claimed, mAcquireError) = acquireOutcome previousOwned (first (Text.pack . show) claimedResult)+  for_ mAcquireError (reportShardError opts)+  -- Shed any excess above the fair share so a cold-start over-claim self-balances+  -- (never when alone: a lone worker must own everything).+  owned <-+    if liveWorkers > 1 && Set.size claimed > shareTarget+      then do+        let excess = Set.fromList (drop shareTarget (sort (Set.toList claimed)))+        _ <- runStoreIO store (relinquish lease excess)+        pure (claimed `Set.difference` excess)+      else pure claimed+  -- Bring readers in line with `owned`: start newly-owned, stop newly-lost.+  let running = Map.keysSet current+      toStart = owned `Set.difference` running+      toStop = running `Set.difference` owned+  for_ (Set.toList toStop) $ \bucket ->+    for_ (Map.lookup bucket current) stopReader+  started <-+    traverse+      (\bucket -> (,) bucket <$> startReader store lease opts readers handler bucket)+      (Set.toList toStart)+  atomicModifyIORef' readers $ \m ->+    let afterStop = foldr Map.delete m (Set.toList toStop)+        afterStart = foldr (\(b, r) -> Map.insert b r) afterStop started+     in (afterStart, ())+  pure owned -{- | Open a kiroku consumer-group reader for one bucket and fork a thread that-drains it into the handler. The returned 'RunningReader' cancels the-subscription (which terminates the drain) and kills the thread.--}+-- | Open a kiroku consumer-group reader for one bucket and fork a thread that+-- drains it into the handler. The returned 'RunningReader' cancels the+-- subscription (which terminates the drain) and kills the thread. startReader ::-    KirokuStore ->-    ShardLease ->-    ShardedWorkerOptions ->-    IORef (Map Int RunningReader) ->-    ShardEventHandler ->-    Int ->-    IO RunningReader+  KirokuStore ->+  ShardLease ->+  ShardedWorkerOptions ->+  IORef (Map Int RunningReader) ->+  ShardEventHandler ->+  Int ->+  IO RunningReader startReader store lease opts readers handler bucket = do-    stopping <- newIORef False-    let subConfig =-            (defaultSubscriptionConfig (lease ^. #subscriptionName) (opts ^. #target) (\_ -> pure Continue))-                { Sub.batchSize = opts ^. #batchSize-                , Sub.consumerGroup =-                    Just (ConsumerGroup{member = fromIntegral bucket, size = fromIntegral (opts ^. #shardCount)})-                , Sub.retryPolicy = opts ^. #retryPolicy-                }-        handleItem item = do-            outcome <--                handler-                    ShardDelivery-                        { event = ackEvent item-                        , attempt = ackAttempt item-                        , bucket = bucket-                        }-                    `catch` handlerException-            atomically (putTMVar (ackReply item) (toSubscriptionResult outcome))-        handlerException err =-            case fromException err of-                Just async -> throwIO (async :: SomeAsyncException)-                Nothing -> pure (ShardAckRetry (opts ^. #handlerRetryDelay))-    (stream, cancelAction) <- subscriptionAckStream store subConfig (opts ^. #bufferSize)-    tid <--        forkFinally (Stream.fold (Fold.drainMapM handleItem) stream) $ \result -> do-            intentional <- readIORef stopping-            unless intentional $ do-                atomicModifyIORef' readers (\m -> (Map.delete bucket m, ()))-                let reason = case result of-                        Left err -> Text.pack (displayException (err :: SomeException))-                        Right _ -> "reader stream ended"-                reportShardError opts (ShardReaderDied bucket reason)-    pure-        ( RunningReader $ do-            writeIORef stopping True-            cancelAction-            killThread tid-        )+  stopping <- newIORef False+  let subConfig =+        (defaultSubscriptionConfig (lease ^. #subscriptionName) (opts ^. #target) (\_ -> pure Continue))+          { Sub.batchSize = opts ^. #batchSize,+            Sub.consumerGroup =+              Just (ConsumerGroup {member = fromIntegral bucket, size = fromIntegral (opts ^. #shardCount)}),+            Sub.retryPolicy = opts ^. #retryPolicy+          }+      handleItem item = do+        outcome <-+          handler+            ShardDelivery+              { event = ackEvent item,+                attempt = ackAttempt item,+                bucket = bucket+              }+            `catch` handlerException+        atomically (putTMVar (ackReply item) (toSubscriptionResult outcome))+      handlerException err =+        case fromException err of+          Just async -> throwIO (async :: SomeAsyncException)+          Nothing -> pure (ShardAckRetry (opts ^. #handlerRetryDelay))+  (stream, cancelAction) <- subscriptionAckStream store subConfig (opts ^. #bufferSize)+  tid <-+    forkFinally (Stream.fold (Fold.drainMapM handleItem) stream) $ \result -> do+      intentional <- readIORef stopping+      unless intentional $ do+        atomicModifyIORef' readers (\m -> (Map.delete bucket m, ()))+        let reason = case result of+              Left err -> Text.pack (displayException (err :: SomeException))+              Right _ -> "reader stream ended"+        reportShardError opts (ShardReaderDied bucket reason)+  pure+    ( RunningReader $ do+        writeIORef stopping True+        cancelAction+        killThread tid+    )  toSubscriptionResult :: ShardAck -> SubscriptionResult toSubscriptionResult = \case-    ShardAckOk -> Continue-    ShardAckRetry delay -> Retry delay-    ShardAckDeadLetter reason -> DeadLetter reason--{- | The loop driver: mint a 'WorkerId', ensure the @N@ shard rows exist, then-'reconcileShardsOnce' every @renewInterval@ forever. On shutdown (the loop thread-is killed) a @finally@ stops every reader this worker holds, so a crashed-worker's buckets stop being read immediately; its leases then expire and a-surviving worker re-claims them.+  ShardAckOk -> Continue+  ShardAckRetry delay -> Retry delay+  ShardAckDeadLetter reason -> DeadLetter reason -The fixed @renewInterval@ between passes (the 'threadDelay' in 'loop') is the-__rebalance-signal seam__ (EP-51 Milestone 6). The shipped default is a pure poll:-correctness — disjointness and failover — rests entirely on the lease table and-does not depend on any notification. The EP-50 'Keiro.Wake' channel wakes on event-/appends/ (@kiroku.events@), which is a different event than a shard ownership-/change/; signalling a prompt rebalance on a worker join or a voluntary relinquish-would ride a dedicated @keiro_shard_rebalance@ @NOTIFY@ fired on claim/release, and-the swap is local to this one 'threadDelay' (replace it with a bounded wait on that-channel, exactly as 'Keiro.Workflow.Resume.runPollLoopWith' does for appends). Left-as the poll default here because it is a latency optimisation, not a correctness-requirement.--}+-- | The loop driver: mint a 'WorkerId', ensure the @N@ shard rows exist, then+-- 'reconcileShardsOnce' every @renewInterval@ forever. On shutdown (the loop thread+-- is killed) a @finally@ stops every reader this worker holds, so a crashed+-- worker's buckets stop being read immediately; its leases then expire and a+-- surviving worker re-claims them.+--+-- The fixed @renewInterval@ between passes (the 'threadDelay' in 'loop') is the+-- __rebalance-signal seam__ (EP-51 Milestone 6). The shipped default is a pure poll:+-- correctness — disjointness and failover — rests entirely on the lease table and+-- does not depend on any notification. The EP-50 'Keiro.Wake' channel wakes on event+-- /appends/ (@kiroku.events@), which is a different event than a shard ownership+-- /change/; signalling a prompt rebalance on a worker join or a voluntary relinquish+-- would ride a dedicated @keiro_shard_rebalance@ @NOTIFY@ fired on claim/release, and+-- the swap is local to this one 'threadDelay' (replace it with a bounded wait on that+-- channel, exactly as 'Keiro.Workflow.Resume.runPollLoopWith' does for appends). Left+-- as the poll default here because it is a latency optimisation, not a correctness+-- requirement. runShardedSubscriptionGroup ::-    KirokuStore ->-    SubscriptionName ->-    ShardedWorkerOptions ->-    (RecordedEvent -> IO ()) ->-    IO ()+  KirokuStore ->+  SubscriptionName ->+  ShardedWorkerOptions ->+  (RecordedEvent -> IO ()) ->+  IO () runShardedSubscriptionGroup store subName opts handler =-    runShardedSubscriptionGroupAck store subName opts $ \delivery -> do-        handler (delivery ^. #event)-        pure ShardAckOk+  runShardedSubscriptionGroupAck store subName opts $ \delivery -> do+    handler (delivery ^. #event)+    pure ShardAckOk -{- | Acknowledgement-aware loop driver. Unlike the compatibility wrapper, the-handler decides whether each event advances, retries, or dead-letters.--}+-- | Acknowledgement-aware loop driver. Unlike the compatibility wrapper, the+-- handler decides whether each event advances, retries, or dead-letters. runShardedSubscriptionGroupAck ::-    KirokuStore ->-    SubscriptionName ->-    ShardedWorkerOptions ->-    ShardEventHandler ->-    IO ()+  KirokuStore ->+  SubscriptionName ->+  ShardedWorkerOptions ->+  ShardEventHandler ->+  IO () runShardedSubscriptionGroupAck store subName opts handler = do-    worker <- WorkerId <$> UUIDv4.nextRandom-    let lease =-            ShardLease-                { subscriptionName = subName-                , workerId = worker-                , shardCount = opts ^. #shardCount-                , leaseTtl = opts ^. #leaseTtl-                }-    ensured <- runStoreIO store (ensureShards lease)-    case ensured of-        Right () -> pure ()-        Left err -> reportShardError opts (ShardEnsureFailed (Text.pack (show err)))-    readers <- newIORef Map.empty-    loop lease readers `finally` cleanup lease readers+  worker <- WorkerId <$> UUIDv4.nextRandom+  let lease =+        ShardLease+          { subscriptionName = subName,+            workerId = worker,+            shardCount = opts ^. #shardCount,+            leaseTtl = opts ^. #leaseTtl+          }+  ensured <- runStoreIO store (ensureShards lease)+  case ensured of+    Right () -> pure ()+    Left err -> reportShardError opts (ShardEnsureFailed (Text.pack (show err)))+  readers <- newIORef Map.empty+  loop lease readers `finally` cleanup lease readers   where     delayMicros = max 1 (round (realToFrac (opts ^. #renewInterval) * 1e6 :: Double))     loop lease readers =-        forever $ do-            _ <- reconcileShardsOnce store lease opts readers handler-            threadDelay delayMicros+      forever $ do+        _ <- reconcileShardsOnce store lease opts readers handler+        threadDelay delayMicros     cleanup lease readers = do-        current <- readIORef readers-        for_ (Map.elems current) stopReader-        _ <- runStoreIO store (relinquish lease (Map.keysSet current))-        pure ()+      current <- readIORef readers+      for_ (Map.elems current) stopReader+      _ <- runStoreIO store (relinquish lease (Map.keysSet current))+      pure ()
src/Keiro/Telemetry.hs view
@@ -1,31 +1,30 @@-{- | Thin OpenTelemetry surface for the keiro library.--This module is the single place keiro reaches for @hs-opentelemetry-api@-and @hs-opentelemetry-semantic-conventions@. Callers configure a 'Tracer'-on the application side (typically via @hs-opentelemetry-sdk@'s-'OpenTelemetry.Trace.makeTracer'), then pass it through to keiro's-publisher / consumer / command surfaces.--When no tracer is supplied, every helper degrades to a thin pass-through-(it calls the body and returns its value), so applications that do not-yet wire OpenTelemetry are unaffected.--# Attribute keys--keiro links @hs-opentelemetry-semantic-conventions@ @1.40.0.0@ (generated-from spec @v1.40@) directly. Every messaging.* / db.* typed 'AttributeKey'-the keiro audit cites (@docs/research/opentelemetry-semconv-audit.md@) is-imported from @OpenTelemetry.SemanticConventions@ and re-exported from this-module, so 'Keiro.Telemetry' remains the one-stop telemetry surface for the-library while every convention name is anchored to the spec-generated module-rather than a hand-typed string.--Only the @keiro.*@ keys ('keiro_stream_name', 'keiro_retry_attempt',-'keiro_events_appended', 'keiro_replay_divergence') are defined locally: they-are bespoke to keiro and have no upstream equivalent.--}-module Keiro.Telemetry (-    -- * Span helpers+-- | Thin OpenTelemetry surface for the keiro library.+--+-- This module is the single place keiro reaches for @hs-opentelemetry-api@+-- and @hs-opentelemetry-semantic-conventions@. Callers configure a 'Tracer'+-- on the application side (typically via @hs-opentelemetry-sdk@'s+-- 'OpenTelemetry.Trace.makeTracer'), then pass it through to keiro's+-- publisher / consumer / command surfaces.+--+-- When no tracer is supplied, every helper degrades to a thin pass-through+-- (it calls the body and returns its value), so applications that do not+-- yet wire OpenTelemetry are unaffected.+--+-- # Attribute keys+--+-- keiro links @hs-opentelemetry-semantic-conventions@ @1.40.0.0@ (generated+-- from spec @v1.40@) directly. Every messaging.* / db.* typed 'AttributeKey'+-- the keiro audit cites (@docs/research/opentelemetry-semconv-audit.md@) is+-- imported from @OpenTelemetry.SemanticConventions@ and re-exported from this+-- module, so 'Keiro.Telemetry' remains the one-stop telemetry surface for the+-- library while every convention name is anchored to the spec-generated module+-- rather than a hand-typed string.+--+-- Only the @keiro.*@ keys ('keiro_stream_name', 'keiro_retry_attempt',+-- 'keiro_events_appended', 'keiro_replay_divergence') are defined locally: they+-- are bespoke to keiro and have no upstream equivalent.+module Keiro.Telemetry+  ( -- * Span helpers     Tracer,     withProducerSpan,     withConsumerSpan,@@ -155,22 +154,33 @@      -- * Kiroku observability bridge     kirokuEventBridge,-)+  ) where +import Keiro.Inbox.Kafka (KafkaInboundRecord)+import Keiro.Integration.Event+  ( IntegrationEvent,+    TraceContext (..),+    headerTraceParent,+    headerTraceState,+  )+import Keiro.Outbox.Kafka (KafkaProducerRecord)+import Keiro.Prelude+import Keiro.Workflow.Types (StepName (..), WorkflowId (..), WorkflowName (..))+import Kiroku.Store.Observability (KirokuEvent (..)) import "base" Control.Exception (bracket) import "base" GHC.Stack (HasCallStack) import "bytestring" Data.ByteString qualified as ByteString import "hs-opentelemetry-api" OpenTelemetry.Attributes (emptyAttributes) import "hs-opentelemetry-api" OpenTelemetry.Attributes.Key (AttributeKey (..)) import "hs-opentelemetry-api" OpenTelemetry.Context (insertSpan, lookupSpan)-import "hs-opentelemetry-api" OpenTelemetry.Context.ThreadLocal (-    attachContext,+import "hs-opentelemetry-api" OpenTelemetry.Context.ThreadLocal+  ( attachContext,     detachContext,     getContext,- )-import "hs-opentelemetry-api" OpenTelemetry.Metric.Core (-    Counter,+  )+import "hs-opentelemetry-api" OpenTelemetry.Metric.Core+  ( Counter,     Gauge,     Histogram,     Meter,@@ -181,9 +191,9 @@     meterCreateCounterInt64,     meterCreateGaugeInt64,     meterCreateHistogram,- )-import "hs-opentelemetry-api" OpenTelemetry.Trace.Core (-    InstrumentationLibrary (..),+  )+import "hs-opentelemetry-api" OpenTelemetry.Trace.Core+  ( InstrumentationLibrary (..),     Span,     SpanArguments (..),     SpanKind (..),@@ -192,9 +202,13 @@     defaultSpanArguments,     inSpan',     wrapSpanContext,- )-import "hs-opentelemetry-semantic-conventions" OpenTelemetry.SemanticConventions (-    db_collection_name,+  )+import "hs-opentelemetry-propagator-w3c" OpenTelemetry.Propagator.W3CTraceContext+  ( decodeSpanContext,+    encodeSpanContext,+  )+import "hs-opentelemetry-semantic-conventions" OpenTelemetry.SemanticConventions+  ( db_collection_name,     db_namespace,     db_operation_name,     db_system_name,@@ -208,42 +222,24 @@     messaging_operation_name,     messaging_operation_type,     messaging_system,- )+  ) import "text" Data.Text qualified as Text import "text" Data.Text.Encoding qualified as TE import "unliftio-core" Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO) -import Keiro.Inbox.Kafka (KafkaInboundRecord)-import Keiro.Integration.Event (-    IntegrationEvent,-    TraceContext (..),-    headerTraceParent,-    headerTraceState,- )-import Keiro.Outbox.Kafka (KafkaProducerRecord)-import Keiro.Prelude-import Keiro.Workflow.Types (StepName (..), WorkflowId (..), WorkflowName (..))-import Kiroku.Store.Observability (KirokuEvent (..))--import "hs-opentelemetry-propagator-w3c" OpenTelemetry.Propagator.W3CTraceContext (-    decodeSpanContext,-    encodeSpanContext,- )- -- --------------------------------------------------------------------------- -- Bespoke keiro AttributeKeys -- --------------------------------------------------------------------------- -{- $semconv_keys-The messaging.* / db.* 'AttributeKey's re-exported here are imported-directly from 'OpenTelemetry.SemanticConventions'-(@hs-opentelemetry-semantic-conventions@ @1.40.0.0@). They are surfaced-from this module so 'Keiro.Telemetry' stays the single telemetry import-for the library; their definitions live upstream.--The @keiro_*@ keys below are bespoke to keiro and have no upstream-equivalent, so they are defined locally.--}+-- $semconv_keys+-- The messaging.* / db.* 'AttributeKey's re-exported here are imported+-- directly from 'OpenTelemetry.SemanticConventions'+-- (@hs-opentelemetry-semantic-conventions@ @1.40.0.0@). They are surfaced+-- from this module so 'Keiro.Telemetry' stays the single telemetry import+-- for the library; their definitions live upstream.+--+-- The @keiro_*@ keys below are bespoke to keiro and have no upstream+-- equivalent, so they are defined locally.  keiro_stream_name :: AttributeKey Text keiro_stream_name = AttributeKey "keiro.stream.name"@@ -270,246 +266,238 @@ -- Span helpers -- --------------------------------------------------------------------------- -{- | Run @body@ inside a @Producer@-kind span named @"send " <> destination@-populated with the messaging attributes prescribed by-@docs/research/opentelemetry-semconv-audit.md@ for the outbox publish-site.--When the supplied 'Tracer' is 'Nothing', the body runs unwrapped and the-helper is a no-op pass-through. This keeps the cost of the helper at-"one 'Maybe' branch" for applications that have not yet configured a-tracer.--The body receives the producer 'Span' so it can record a publish failure-via 'recordPublishError'.--}+-- | Run @body@ inside a @Producer@-kind span named @"send " <> destination@+-- populated with the messaging attributes prescribed by+-- @docs/research/opentelemetry-semconv-audit.md@ for the outbox publish+-- site.+--+-- When the supplied 'Tracer' is 'Nothing', the body runs unwrapped and the+-- helper is a no-op pass-through. This keeps the cost of the helper at+-- "one 'Maybe' branch" for applications that have not yet configured a+-- tracer.+--+-- The body receives the producer 'Span' so it can record a publish failure+-- via 'recordPublishError'. withProducerSpan ::-    (MonadUnliftIO m, HasCallStack) =>-    Maybe Tracer ->-    IntegrationEvent ->-    KafkaProducerRecord ->-    (Maybe Span -> m a) ->-    m a+  (MonadUnliftIO m, HasCallStack) =>+  Maybe Tracer ->+  IntegrationEvent ->+  KafkaProducerRecord ->+  (Maybe Span -> m a) ->+  m a withProducerSpan Nothing _ _ body = body Nothing withProducerSpan (Just tracer) event record body =-    inSpan' tracer name args $ \sp -> do-        setProducerAttributes sp event record-        body (Just sp)+  inSpan' tracer name args $ \sp -> do+    setProducerAttributes sp event record+    body (Just sp)   where     name = "send " <> (event ^. #destination)-    args = defaultSpanArguments{kind = Producer}--{- | Run @body@ inside a @Consumer@-kind span named @"process " <> topic@.--Like 'withProducerSpan', the helper is a pass-through under a 'Nothing'-tracer. The 'KafkaInboundRecord' is required so the helper can populate-@messaging.kafka.offset@ and @messaging.destination.partition.id@-without the caller threading them separately.+    args = defaultSpanArguments {kind = Producer} -The optional 'Text' is a consumer group name, recorded as-@messaging.consumer.group.name@ when present. The 'IntegrationEvent' is-attached when present (decode succeeded), so @messaging.message.id@-is set; otherwise the helper records only the headers known from the-broker record.--}+-- | Run @body@ inside a @Consumer@-kind span named @"process " <> topic@.+--+-- Like 'withProducerSpan', the helper is a pass-through under a 'Nothing'+-- tracer. The 'KafkaInboundRecord' is required so the helper can populate+-- @messaging.kafka.offset@ and @messaging.destination.partition.id@+-- without the caller threading them separately.+--+-- The optional 'Text' is a consumer group name, recorded as+-- @messaging.consumer.group.name@ when present. The 'IntegrationEvent' is+-- attached when present (decode succeeded), so @messaging.message.id@+-- is set; otherwise the helper records only the headers known from the+-- broker record. withConsumerSpan ::-    (MonadUnliftIO m, HasCallStack) =>-    Maybe Tracer ->-    -- | consumer group name (optional)-    Maybe Text ->-    KafkaInboundRecord ->-    -- | decoded envelope; 'Nothing' on a decode failure path-    Maybe IntegrationEvent ->-    (Maybe Span -> m a) ->-    m a+  (MonadUnliftIO m, HasCallStack) =>+  Maybe Tracer ->+  -- | consumer group name (optional)+  Maybe Text ->+  KafkaInboundRecord ->+  -- | decoded envelope; 'Nothing' on a decode failure path+  Maybe IntegrationEvent ->+  (Maybe Span -> m a) ->+  m a withConsumerSpan Nothing _ _ _ body = body Nothing withConsumerSpan (Just tracer) consumerGroup record mEvent body =-    withRemoteParent (record ^. #headers) $-        inSpan' tracer name args $ \sp -> do-            setConsumerAttributes sp consumerGroup record mEvent-            body (Just sp)+  withRemoteParent (record ^. #headers) $+    inSpan' tracer name args $ \sp -> do+      setConsumerAttributes sp consumerGroup record mEvent+      body (Just sp)   where     name = "process " <> (record ^. #topic)-    args = defaultSpanArguments{kind = Consumer}--{- | Run @body@ with the OpenTelemetry context temporarily augmented by a-parent span extracted from the supplied header list via the W3C-TraceContext propagator.--When no @traceparent@ header is present (or it cannot be parsed) the-body runs unwrapped, so the helper is safe to call unconditionally.+    args = defaultSpanArguments {kind = Consumer} -This is the bridge that makes a 'Consumer'-kind span open in this-process a *child* of the 'Producer'-kind span that emitted the message-in the upstream process, joining the two traces by trace id.--}+-- | Run @body@ with the OpenTelemetry context temporarily augmented by a+-- parent span extracted from the supplied header list via the W3C+-- TraceContext propagator.+--+-- When no @traceparent@ header is present (or it cannot be parsed) the+-- body runs unwrapped, so the helper is safe to call unconditionally.+--+-- This is the bridge that makes a 'Consumer'-kind span open in this+-- process a *child* of the 'Producer'-kind span that emitted the message+-- in the upstream process, joining the two traces by trace id. withRemoteParent ::-    (MonadUnliftIO m) => [(Text, Text)] -> m a -> m a+  (MonadUnliftIO m) => [(Text, Text)] -> m a -> m a withRemoteParent hs body =-    case parentSpanContext hs of-        Nothing -> body-        Just spanCtx -> withRunInIO $ \runInIO -> do-            ctx <- getContext-            let newCtx = insertSpan (wrapSpanContext spanCtx) ctx-            bracket-                (attachContext newCtx)-                detachContext-                (const (runInIO body))+  case parentSpanContext hs of+    Nothing -> body+    Just spanCtx -> withRunInIO $ \runInIO -> do+      ctx <- getContext+      let newCtx = insertSpan (wrapSpanContext spanCtx) ctx+      bracket+        (attachContext newCtx)+        detachContext+        (const (runInIO body))   where     parentSpanContext hsList =-        let tp = fmap TE.encodeUtf8 (Prelude.lookup headerTraceParent hsList)-            ts = fmap TE.encodeUtf8 (Prelude.lookup headerTraceState hsList)-         in decodeSpanContext tp ts+      let tp = fmap TE.encodeUtf8 (Prelude.lookup headerTraceParent hsList)+          ts = fmap TE.encodeUtf8 (Prelude.lookup headerTraceState hsList)+       in decodeSpanContext tp ts -{- | Open an @Internal@ span around a command run, named after the resolved-stream identifier. Attributes capture the stream name and (when the-caller supplies it) the retry attempt number. The number of events-appended is attached after a successful append by the caller via-'addAttribute span keiro_events_appended n'.--}+-- | Open an @Internal@ span around a command run, named after the resolved+-- stream identifier. Attributes capture the stream name and (when the+-- caller supplies it) the retry attempt number. The number of events+-- appended is attached after a successful append by the caller via+-- 'addAttribute span keiro_events_appended n'. withCommandSpan ::-    (MonadUnliftIO m, HasCallStack) =>-    Maybe Tracer ->-    -- | resolved stream name-    Text ->-    -- | retry attempt (1-based); 'Nothing' to omit-    Maybe Int64 ->-    (Maybe Span -> m a) ->-    m a+  (MonadUnliftIO m, HasCallStack) =>+  Maybe Tracer ->+  -- | resolved stream name+  Text ->+  -- | retry attempt (1-based); 'Nothing' to omit+  Maybe Int64 ->+  (Maybe Span -> m a) ->+  m a withCommandSpan Nothing _ _ body = body Nothing withCommandSpan (Just tracer) streamName retryAttempt body =-    inSpan' tracer streamName args $ \sp -> do-        addAttribute sp (unkey keiro_stream_name) streamName-        case retryAttempt of-            Nothing -> pure ()-            Just n -> addAttribute sp (unkey keiro_retry_attempt) n-        body (Just sp)+  inSpan' tracer streamName args $ \sp -> do+    addAttribute sp (unkey keiro_stream_name) streamName+    case retryAttempt of+      Nothing -> pure ()+      Just n -> addAttribute sp (unkey keiro_retry_attempt) n+    body (Just sp)   where-    args = defaultSpanArguments{kind = Internal}+    args = defaultSpanArguments {kind = Internal} -{- | Open an @Internal@ span around a workflow run (or a single step/resume when-a 'StepName' is supplied), named @"workflow " <> name@. Attributes carry the-bespoke @keiro.workflow.name@, @keiro.workflow.id@, and — when present —-@keiro.workflow.step@ keys. Like 'withCommandSpan', a 'Nothing' tracer makes the-helper a pass-through, so it is safe to call unconditionally.--}+-- | Open an @Internal@ span around a workflow run (or a single step/resume when+-- a 'StepName' is supplied), named @"workflow " <> name@. Attributes carry the+-- bespoke @keiro.workflow.name@, @keiro.workflow.id@, and — when present —+-- @keiro.workflow.step@ keys. Like 'withCommandSpan', a 'Nothing' tracer makes the+-- helper a pass-through, so it is safe to call unconditionally. withWorkflowSpan ::-    (MonadUnliftIO m, HasCallStack) =>-    Maybe Tracer ->-    WorkflowName ->-    WorkflowId ->-    Maybe StepName ->-    (Maybe Span -> m a) ->-    m a+  (MonadUnliftIO m, HasCallStack) =>+  Maybe Tracer ->+  WorkflowName ->+  WorkflowId ->+  Maybe StepName ->+  (Maybe Span -> m a) ->+  m a withWorkflowSpan Nothing _ _ _ body = body Nothing withWorkflowSpan (Just tracer) name wid mStep body =-    inSpan' tracer spanName args $ \sp -> do-        addAttribute sp (unkey keiro_workflow_name) (unWorkflowName name)-        addAttribute sp (unkey keiro_workflow_id) (unWorkflowId wid)-        case mStep of-            Nothing -> pure ()-            Just s -> addAttribute sp (unkey keiro_workflow_step) (unStepName s)-        body (Just sp)+  inSpan' tracer spanName args $ \sp -> do+    addAttribute sp (unkey keiro_workflow_name) (unWorkflowName name)+    addAttribute sp (unkey keiro_workflow_id) (unWorkflowId wid)+    case mStep of+      Nothing -> pure ()+      Just s -> addAttribute sp (unkey keiro_workflow_step) (unStepName s)+    body (Just sp)   where     spanName = "workflow " <> unWorkflowName name-    args = defaultSpanArguments{kind = Internal}+    args = defaultSpanArguments {kind = Internal}  -- --------------------------------------------------------------------------- -- W3C TraceContext bridge -- --------------------------------------------------------------------------- -{- | Read the current thread-local span context and format it as a-'TraceContext'. Returns 'Nothing' when no span is active on the current-thread.--The application is expected to have already configured the W3C-propagator on its 'TracerProvider' (so the propagator is responsible for-on-the-wire framing); this helper is the keiro-side bridge between the-in-memory span and the keiro 'TraceContext' record stored on-'IntegrationEvent' envelopes and outbox rows.--}+-- | Read the current thread-local span context and format it as a+-- 'TraceContext'. Returns 'Nothing' when no span is active on the current+-- thread.+--+-- The application is expected to have already configured the W3C+-- propagator on its 'TracerProvider' (so the propagator is responsible for+-- on-the-wire framing); this helper is the keiro-side bridge between the+-- in-memory span and the keiro 'TraceContext' record stored on+-- 'IntegrationEvent' envelopes and outbox rows. traceContextFromCurrentSpan :: (MonadIO m) => m (Maybe TraceContext) traceContextFromCurrentSpan = do-    ctx <- getContext-    case lookupSpan ctx of-        Nothing -> pure Nothing-        Just sp -> do-            (traceparentBytes, tracestateBytes) <- liftIO (encodeSpanContext sp)-            let traceparent = TE.decodeUtf8 traceparentBytes-                tracestate-                    | ByteString.null tracestateBytes = Nothing-                    | otherwise = Just (TE.decodeUtf8 tracestateBytes)-            pure (Just (TraceContext traceparent tracestate))+  ctx <- getContext+  case lookupSpan ctx of+    Nothing -> pure Nothing+    Just sp -> do+      (traceparentBytes, tracestateBytes) <- liftIO (encodeSpanContext sp)+      let traceparent = TE.decodeUtf8 traceparentBytes+          tracestate+            | ByteString.null tracestateBytes = Nothing+            | otherwise = Just (TE.decodeUtf8 tracestateBytes)+      pure (Just (TraceContext traceparent tracestate)) -{- | Lift a 'TraceContext' out of a flat @[(Text, Text)]@ header list. This-is the mirror image of 'integrationHeaders': it does not validate the-@traceparent@ format (the W3C propagator's parser does that at consume-time); it merely converts the on-the-wire pair of headers to the keiro-envelope record.--}+-- | Lift a 'TraceContext' out of a flat @[(Text, Text)]@ header list. This+-- is the mirror image of 'integrationHeaders': it does not validate the+-- @traceparent@ format (the W3C propagator's parser does that at consume+-- time); it merely converts the on-the-wire pair of headers to the keiro+-- envelope record. traceContextFromHeaders :: [(Text, Text)] -> Maybe TraceContext traceContextFromHeaders hs = case Prelude.lookup headerTraceParent hs of-    Nothing -> Nothing-    Just tp -> Just (TraceContext tp (Prelude.lookup headerTraceState hs))--{- | Append the W3C @traceparent@ / @tracestate@ headers for the current-thread-local span to the supplied header list. When no span is active-on the current thread, the input list is returned unchanged.+  Nothing -> Nothing+  Just tp -> Just (TraceContext tp (Prelude.lookup headerTraceState hs)) -Used by adapters that build a flat header list from their own envelope-(e.g. the outbox publisher) so the headers carry the active span-context even if the caller did not capture a 'TraceContext' onto the-event explicitly.--}+-- | Append the W3C @traceparent@ / @tracestate@ headers for the current+-- thread-local span to the supplied header list. When no span is active+-- on the current thread, the input list is returned unchanged.+--+-- Used by adapters that build a flat header list from their own envelope+-- (e.g. the outbox publisher) so the headers carry the active span+-- context even if the caller did not capture a 'TraceContext' onto the+-- event explicitly. injectTraceContext :: (MonadIO m) => [(Text, Text)] -> m [(Text, Text)] injectTraceContext hs = do-    mctx <- traceContextFromCurrentSpan-    pure $ case mctx of-        Nothing -> hs-        Just tc ->-            hs-                ++ [(headerTraceParent, tc ^. #traceparent)]-                ++ maybe [] (\ts -> [(headerTraceState, ts)]) (tc ^. #tracestate)+  mctx <- traceContextFromCurrentSpan+  pure $ case mctx of+    Nothing -> hs+    Just tc ->+      hs+        ++ [(headerTraceParent, tc ^. #traceparent)]+        ++ maybe [] (\ts -> [(headerTraceState, ts)]) (tc ^. #tracestate)  -- --------------------------------------------------------------------------- -- Internal helpers -- ---------------------------------------------------------------------------  setProducerAttributes ::-    (MonadIO m) => Span -> IntegrationEvent -> KafkaProducerRecord -> m ()+  (MonadIO m) => Span -> IntegrationEvent -> KafkaProducerRecord -> m () setProducerAttributes sp event _record = do-    addAttribute sp (unkey messaging_system) ("kafka" :: Text)-    addAttribute sp (unkey messaging_operation_type) ("publish" :: Text)-    addAttribute sp (unkey messaging_operation_name) ("send" :: Text)-    addAttribute sp (unkey messaging_destination_name) (event ^. #destination)-    addAttribute sp (unkey messaging_message_id) (event ^. #messageId)-    case event ^. #key of-        Nothing -> pure ()-        Just k -> addAttribute sp (unkey messaging_kafka_message_key) k+  addAttribute sp (unkey messaging_system) ("kafka" :: Text)+  addAttribute sp (unkey messaging_operation_type) ("publish" :: Text)+  addAttribute sp (unkey messaging_operation_name) ("send" :: Text)+  addAttribute sp (unkey messaging_destination_name) (event ^. #destination)+  addAttribute sp (unkey messaging_message_id) (event ^. #messageId)+  case event ^. #key of+    Nothing -> pure ()+    Just k -> addAttribute sp (unkey messaging_kafka_message_key) k  setConsumerAttributes ::-    (MonadIO m) =>-    Span ->-    Maybe Text ->-    KafkaInboundRecord ->-    Maybe IntegrationEvent ->-    m ()+  (MonadIO m) =>+  Span ->+  Maybe Text ->+  KafkaInboundRecord ->+  Maybe IntegrationEvent ->+  m () setConsumerAttributes sp consumerGroup record mEvent = do-    addAttribute sp (unkey messaging_system) ("kafka" :: Text)-    addAttribute sp (unkey messaging_operation_type) ("process" :: Text)-    addAttribute sp (unkey messaging_operation_name) ("process" :: Text)-    addAttribute sp (unkey messaging_destination_name) (record ^. #topic)-    addAttribute sp (unkey messaging_destination_partition_id) (showText (record ^. #partition))-    addAttribute sp (unkey messaging_kafka_offset) (record ^. #offset)-    case record ^. #key of-        Nothing -> pure ()-        Just k -> addAttribute sp (unkey messaging_kafka_message_key) k-    case consumerGroup of-        Nothing -> pure ()-        Just g -> addAttribute sp (unkey messaging_consumer_group_name) g-    case mEvent of-        Nothing -> pure ()-        Just event -> addAttribute sp (unkey messaging_message_id) (event ^. #messageId)+  addAttribute sp (unkey messaging_system) ("kafka" :: Text)+  addAttribute sp (unkey messaging_operation_type) ("process" :: Text)+  addAttribute sp (unkey messaging_operation_name) ("process" :: Text)+  addAttribute sp (unkey messaging_destination_name) (record ^. #topic)+  addAttribute sp (unkey messaging_destination_partition_id) (showText (record ^. #partition))+  addAttribute sp (unkey messaging_kafka_offset) (record ^. #offset)+  case record ^. #key of+    Nothing -> pure ()+    Just k -> addAttribute sp (unkey messaging_kafka_message_key) k+  case consumerGroup of+    Nothing -> pure ()+    Just g -> addAttribute sp (unkey messaging_consumer_group_name) g+  case mEvent of+    Nothing -> pure ()+    Just event -> addAttribute sp (unkey messaging_message_id) (event ^. #messageId)  showText :: (Show a) => a -> Text showText = Text.pack . show@@ -518,394 +506,469 @@ -- Metrics surface -- --------------------------------------------------------------------------- -{- $metrics-Alongside the span helpers above, 'Keiro.Telemetry' exposes the library's-metrics surface: a 'KeiroMetrics' record holding every instrument keiro-records (built once from a 'Meter' by 'newKeiroMetrics'), and one-@record*@ helper per instrument that takes a @'Maybe' 'KeiroMetrics'@ and-no-ops on 'Nothing'. This mirrors the @'Maybe' 'Tracer'@ opt-in the span-helpers use: an application that never configures a 'MeterProvider' pays-only one 'Maybe' branch per recording site. The per-instrument name, unit,-kind, and description are catalogued in-@docs/research/opentelemetry-semconv-audit.md@.--}+-- $metrics+-- Alongside the span helpers above, 'Keiro.Telemetry' exposes the library's+-- metrics surface: a 'KeiroMetrics' record holding every instrument keiro+-- records (built once from a 'Meter' by 'newKeiroMetrics'), and one+-- @record*@ helper per instrument that takes a @'Maybe' 'KeiroMetrics'@ and+-- no-ops on 'Nothing'. This mirrors the @'Maybe' 'Tracer'@ opt-in the span+-- helpers use: an application that never configures a 'MeterProvider' pays+-- only one 'Maybe' branch per recording site. The per-instrument name, unit,+-- kind, and description are catalogued in+-- @docs/research/opentelemetry-semconv-audit.md@. -{- | The instrumentation scope keiro tags all its metric instruments with.-Mirrors the @"keiro"@ scope name the span helpers use on the application's-'Tracer'.--}+-- | The instrumentation scope keiro tags all its metric instruments with.+-- Mirrors the @"keiro"@ scope name the span helpers use on the application's+-- 'Tracer'. keiroInstrumentationLibrary :: InstrumentationLibrary keiroInstrumentationLibrary =-    InstrumentationLibrary-        { libraryName = "keiro"-        , libraryVersion = ""-        , librarySchemaUrl = ""-        , libraryAttributes = emptyAttributes-        }+  InstrumentationLibrary+    { libraryName = "keiro",+      libraryVersion = "",+      librarySchemaUrl = "",+      libraryAttributes = emptyAttributes+    }  keiroOutboxBacklogName :: Text keiroOutboxBacklogName = "keiro.outbox.backlog"+ keiroOutboxPublishedName :: Text keiroOutboxPublishedName = "keiro.outbox.published"+ keiroOutboxRetriedName :: Text keiroOutboxRetriedName = "keiro.outbox.retried"+ keiroOutboxDeadletteredName :: Text keiroOutboxDeadletteredName = "keiro.outbox.deadlettered"+ keiroOutboxReclaimedName :: Text keiroOutboxReclaimedName = "keiro.outbox.reclaimed"+ keiroInboxProcessedName :: Text keiroInboxProcessedName = "keiro.inbox.processed"+ keiroInboxDuplicatesName :: Text keiroInboxDuplicatesName = "keiro.inbox.duplicates"+ keiroInboxFailedName :: Text keiroInboxFailedName = "keiro.inbox.failed"+ keiroInboxPoisonedName :: Text keiroInboxPoisonedName = "keiro.inbox.poisoned"+ keiroInboxBacklogName :: Text keiroInboxBacklogName = "keiro.inbox.backlog"+ keiroTimerBacklogName :: Text keiroTimerBacklogName = "keiro.timer.backlog"+ keiroTimerFireLagName :: Text keiroTimerFireLagName = "keiro.timer.fire.lag"+ keiroTimerAttemptsName :: Text keiroTimerAttemptsName = "keiro.timer.attempts"+ keiroTimerStuckName :: Text keiroTimerStuckName = "keiro.timer.stuck"+ keiroTimerRequeuedName :: Text keiroTimerRequeuedName = "keiro.timer.requeued"+ keiroProjectionLagName :: Text keiroProjectionLagName = "keiro.projection.lag"+ keiroProjectionWaitTimeoutsName :: Text keiroProjectionWaitTimeoutsName = "keiro.projection.wait.timeouts"+ keiroCommandConflictsName :: Text keiroCommandConflictsName = "keiro.command.conflicts"+ keiroCommandRetriesName :: Text keiroCommandRetriesName = "keiro.command.retries"+ keiroCommandDuplicatesName :: Text keiroCommandDuplicatesName = "keiro.command.duplicates"+ keiroSnapshotDecodeFailuresName :: Text keiroSnapshotDecodeFailuresName = "keiro.snapshot.decode.failures"+ keiroSnapshotEncodeFailuresName :: Text keiroSnapshotEncodeFailuresName = "keiro.snapshot.encode.failures"+ keiroSnapshotReadHitsName :: Text keiroSnapshotReadHitsName = "keiro.snapshot.read.hits"+ keiroSnapshotReadMissesName :: Text keiroSnapshotReadMissesName = "keiro.snapshot.read.misses"+ keiroSnapshotWriteFailuresName :: Text keiroSnapshotWriteFailuresName = "keiro.snapshot.write.failures"+ keiroSnapshotApplyDivergenceName :: Text keiroSnapshotApplyDivergenceName = "keiro.snapshot.apply.divergence"+ keiroSnapshotSeedDivergenceName :: Text keiroSnapshotSeedDivergenceName = "keiro.snapshot.seed.divergence"+ keiroDispatchFailedName :: Text keiroDispatchFailedName = "keiro.dispatch.failed"+ keiroDispatchDeadletteredName :: Text keiroDispatchDeadletteredName = "keiro.dispatch.deadlettered"+ keiroSubscriptionDeadletteredName :: Text keiroSubscriptionDeadletteredName = "keiro.subscription.deadlettered"+ keiroDispatchDuplicatesName :: Text keiroDispatchDuplicatesName = "keiro.dispatch.duplicates"+ keiroDispatchPoisonName :: Text keiroDispatchPoisonName = "keiro.dispatch.poison"+ keiroWorkflowStepsExecutedName :: Text keiroWorkflowStepsExecutedName = "keiro.workflow.steps.executed"+ keiroWorkflowStepsReplayedName :: Text keiroWorkflowStepsReplayedName = "keiro.workflow.steps.replayed"+ keiroWorkflowResumedName :: Text keiroWorkflowResumedName = "keiro.workflow.resumed"+ keiroWorkflowFailedName :: Text keiroWorkflowFailedName = "keiro.workflow.failed"+ keiroWorkflowResumeErrorsName :: Text keiroWorkflowResumeErrorsName = "keiro.workflow.resume.errors"+ keiroWorkflowLeaseSkippedName :: Text keiroWorkflowLeaseSkippedName = "keiro.workflow.lease.skipped"+ keiroWorkflowJournalLengthName :: Text keiroWorkflowJournalLengthName = "keiro.workflow.journal.length"+ keiroWorkflowAwakeablesPendingName :: Text keiroWorkflowAwakeablesPendingName = "keiro.workflow.awakeables.pending"+ keiroWorkflowActiveName :: Text keiroWorkflowActiveName = "keiro.workflow.active" -{- | All metric instruments the keiro library records, built once from a-'Meter' by 'newKeiroMetrics'. Workers accept a @'Maybe' 'KeiroMetrics'@ and-treat 'Nothing' as "record nothing"; the per-instrument recording helpers in-this module take @'Maybe' 'KeiroMetrics'@ so call sites stay one-liners.--Instrument kinds follow the keiro metrics policy: backlog and lag are-synchronous gauges recorded by each worker per poll pass; tallies are-monotonic counters; distributions are histograms. See-@docs/research/opentelemetry-semconv-audit.md@ for the per-instrument-name / unit / kind / description catalogue.--}+-- | All metric instruments the keiro library records, built once from a+-- 'Meter' by 'newKeiroMetrics'. Workers accept a @'Maybe' 'KeiroMetrics'@ and+-- treat 'Nothing' as "record nothing"; the per-instrument recording helpers in+-- this module take @'Maybe' 'KeiroMetrics'@ so call sites stay one-liners.+--+-- Instrument kinds follow the keiro metrics policy: backlog and lag are+-- synchronous gauges recorded by each worker per poll pass; tallies are+-- monotonic counters; distributions are histograms. See+-- @docs/research/opentelemetry-semconv-audit.md@ for the per-instrument+-- name / unit / kind / description catalogue. data KeiroMetrics = KeiroMetrics-    { outboxBacklog :: Gauge Int64-    , outboxPublished :: Counter Int64-    , outboxRetried :: Counter Int64-    , outboxDeadlettered :: Counter Int64-    , outboxReclaimed :: Counter Int64-    , inboxProcessed :: Counter Int64-    , inboxDuplicates :: Counter Int64-    , inboxFailed :: Counter Int64-    , inboxPoisoned :: Counter Int64-    , inboxBacklog :: Gauge Int64-    , timerBacklog :: Gauge Int64-    , timerFireLag :: Histogram-    , timerAttempts :: Histogram-    , timerStuck :: Gauge Int64-    , timerRequeued :: Counter Int64-    , projectionLag :: Gauge Int64-    , projectionWaitTimeouts :: Counter Int64-    , commandConflicts :: Counter Int64-    , commandRetries :: Counter Int64-    , commandDuplicates :: Counter Int64-    , snapshotDecodeFailures :: Counter Int64-    , snapshotEncodeFailures :: Counter Int64-    , snapshotReadHits :: Counter Int64-    , snapshotReadMisses :: Counter Int64-    , snapshotWriteFailures :: Counter Int64-    , snapshotApplyDivergence :: Counter Int64-    , snapshotSeedDivergence :: Counter Int64-    , dispatchFailed :: Counter Int64-    , dispatchDeadlettered :: Counter Int64-    , subscriptionDeadlettered :: Counter Int64-    , dispatchDuplicates :: Counter Int64-    , dispatchPoison :: Counter Int64-    , workflowStepsExecuted :: Counter Int64-    , workflowStepsReplayed :: Counter Int64-    , workflowResumed :: Counter Int64-    , workflowFailed :: Counter Int64-    , workflowResumeErrors :: Counter Int64-    , workflowLeaseSkipped :: Counter Int64-    , workflowActive :: Gauge Int64-    , workflowJournalLength :: Histogram-    , workflowAwakeablesPending :: Gauge Int64-    }+  { outboxBacklog :: Gauge Int64,+    outboxPublished :: Counter Int64,+    outboxRetried :: Counter Int64,+    outboxDeadlettered :: Counter Int64,+    outboxReclaimed :: Counter Int64,+    inboxProcessed :: Counter Int64,+    inboxDuplicates :: Counter Int64,+    inboxFailed :: Counter Int64,+    inboxPoisoned :: Counter Int64,+    inboxBacklog :: Gauge Int64,+    timerBacklog :: Gauge Int64,+    timerFireLag :: Histogram,+    timerAttempts :: Histogram,+    timerStuck :: Gauge Int64,+    timerRequeued :: Counter Int64,+    projectionLag :: Gauge Int64,+    projectionWaitTimeouts :: Counter Int64,+    commandConflicts :: Counter Int64,+    commandRetries :: Counter Int64,+    commandDuplicates :: Counter Int64,+    snapshotDecodeFailures :: Counter Int64,+    snapshotEncodeFailures :: Counter Int64,+    snapshotReadHits :: Counter Int64,+    snapshotReadMisses :: Counter Int64,+    snapshotWriteFailures :: Counter Int64,+    snapshotApplyDivergence :: Counter Int64,+    snapshotSeedDivergence :: Counter Int64,+    dispatchFailed :: Counter Int64,+    dispatchDeadlettered :: Counter Int64,+    subscriptionDeadlettered :: Counter Int64,+    dispatchDuplicates :: Counter Int64,+    dispatchPoison :: Counter Int64,+    workflowStepsExecuted :: Counter Int64,+    workflowStepsReplayed :: Counter Int64,+    workflowResumed :: Counter Int64,+    workflowFailed :: Counter Int64,+    workflowResumeErrors :: Counter Int64,+    workflowLeaseSkipped :: Counter Int64,+    workflowActive :: Gauge Int64,+    workflowJournalLength :: Histogram,+    workflowAwakeablesPending :: Gauge Int64+  } -{- | Construct every keiro metric instrument from a 'Meter'. Call this once at-application start after building an SDK 'OpenTelemetry.Metric.Core.MeterProvider'-and obtaining a 'Meter' (e.g. @getMeter mp keiroInstrumentationLibrary@), then-thread the resulting 'KeiroMetrics' into workers as @'Just' metrics@. Under a-no-op meter every instrument is itself a no-op, so this is safe to call-unconditionally.--}+-- | Construct every keiro metric instrument from a 'Meter'. Call this once at+-- application start after building an SDK 'OpenTelemetry.Metric.Core.MeterProvider'+-- and obtaining a 'Meter' (e.g. @getMeter mp keiroInstrumentationLibrary@), then+-- thread the resulting 'KeiroMetrics' into workers as @'Just' metrics@. Under a+-- no-op meter every instrument is itself a no-op, so this is safe to call+-- unconditionally. newKeiroMetrics :: (MonadIO m) => Meter -> m KeiroMetrics newKeiroMetrics meter = liftIO $ do-    outboxBacklog' <- gaugeI64 keiroOutboxBacklogName "{event}" "Outbox rows awaiting publish."-    outboxPublished' <- counterI64 keiroOutboxPublishedName "{event}" "Outbox events successfully published."-    outboxRetried' <- counterI64 keiroOutboxRetriedName "{event}" "Outbox publish attempts that failed and will retry."-    outboxDeadlettered' <- counterI64 keiroOutboxDeadletteredName "{event}" "Outbox events parked after exhausting retries."-    outboxReclaimed' <- counterI64 keiroOutboxReclaimedName "{event}" "Outbox rows reclaimed from a crashed or stalled publisher."-    inboxProcessed' <- counterI64 keiroInboxProcessedName "{message}" "Inbox messages processed successfully."-    inboxDuplicates' <- counterI64 keiroInboxDuplicatesName "{message}" "Inbox messages skipped as duplicates."-    inboxFailed' <- counterI64 keiroInboxFailedName "{message}" "Inbox messages whose handler failed."-    inboxPoisoned' <- counterI64 keiroInboxPoisonedName "{message}" "Inbox messages dead-lettered after exhausting handler attempts."-    inboxBacklog' <- gaugeI64 keiroInboxBacklogName "{message}" "Inbox messages awaiting processing."-    timerBacklog' <- gaugeI64 keiroTimerBacklogName "{timer}" "Due timers awaiting firing."-    timerFireLag' <- histogram keiroTimerFireLagName "ms" "Delay between a timer's scheduled time and when it fired."-    timerAttempts' <- histogram keiroTimerAttemptsName "{attempt}" "Number of attempts a timer took to fire."-    timerStuck' <- gaugeI64 keiroTimerStuckName "{timer}" "Timers stuck in the Firing state past threshold."-    timerRequeued' <- counterI64 keiroTimerRequeuedName "{timer}" "Timers moved from firing back to scheduled after a stale claim."-    projectionLag' <- gaugeI64 keiroProjectionLagName "{event}" "Events between the log head and a projection's checkpoint."-    projectionWaitTimeouts' <- counterI64 keiroProjectionWaitTimeoutsName "{timeout}" "Position-wait calls that timed out before the projection caught up."-    commandConflicts' <- counterI64 keiroCommandConflictsName "{conflict}" "Optimistic-concurrency conflicts observed by command runners."-    commandRetries' <- counterI64 keiroCommandRetriesName "{retry}" "Command retry attempts started after an optimistic-concurrency conflict."-    commandDuplicates' <- counterI64 keiroCommandDuplicatesName "{event}" "Command appends rejected as duplicate deterministic event ids."-    snapshotDecodeFailures' <- counterI64 keiroSnapshotDecodeFailuresName "{failure}" "Snapshot rows whose bytes failed to decode; hydration fell back to full replay."-    snapshotEncodeFailures' <- counterI64 keiroSnapshotEncodeFailuresName "{failure}" "Post-commit snapshot encodes that failed and were swallowed."-    snapshotReadHits' <- counterI64 keiroSnapshotReadHitsName "{read}" "Snapshot lookups that yielded a usable hydration seed."-    snapshotReadMisses' <- counterI64 keiroSnapshotReadMissesName "{read}" "Snapshot lookups that fell back to full replay."-    snapshotWriteFailures' <- counterI64 keiroSnapshotWriteFailuresName "{failure}" "Post-commit snapshot writes that failed and were swallowed."-    snapshotApplyDivergence' <- counterI64 keiroSnapshotApplyDivergenceName "{failure}" "Just-appended event batches that failed to replay from the pre-command state; the stream is poisoned and its next hydration will fail."-    snapshotSeedDivergence' <- counterI64 keiroSnapshotSeedDivergenceName "{failure}" "Sampled snapshot seeds whose encoded state disagreed with a full replay through the seed version."-    dispatchFailed' <- counterI64 keiroDispatchFailedName "{command}" "Process-manager/router dispatch commands that failed."-    dispatchDeadlettered' <- counterI64 keiroDispatchDeadletteredName "{command}" "Rejected process-manager/router dispatch commands handled by dead-letter or skip policy."-    subscriptionDeadlettered' <- counterI64 keiroSubscriptionDeadletteredName "{event}" "Kiroku source events dead-lettered by an explicit disposition or retry exhaustion."-    dispatchDuplicates' <- counterI64 keiroDispatchDuplicatesName "{command}" "Process-manager/router dispatch commands skipped as duplicate deterministic event ids."-    dispatchPoison' <- counterI64 keiroDispatchPoisonName "{message}" "Process-manager/router worker messages classified as poison."-    workflowStepsExecuted' <- counterI64 keiroWorkflowStepsExecutedName "{step}" "Workflow steps that ran their action (a journal miss)."-    workflowStepsReplayed' <- counterI64 keiroWorkflowStepsReplayedName "{step}" "Workflow steps short-circuited to a recorded result (a journal hit)."-    workflowResumed' <- counterI64 keiroWorkflowResumedName "{workflow}" "Workflow re-invocations performed by the resume worker."-    workflowFailed' <- counterI64 keiroWorkflowFailedName "{workflow}" "Workflow instances marked terminally failed by the resume worker."-    workflowResumeErrors' <- counterI64 keiroWorkflowResumeErrorsName "{error}" "Transient store errors observed by the workflow resume worker."-    workflowLeaseSkipped' <- counterI64 keiroWorkflowLeaseSkippedName "{workflow}" "Workflow instances skipped because another worker owns their lease."-    workflowActive' <- gaugeI64 keiroWorkflowActiveName "{workflow}" "Workflow runs currently in progress in this process."-    workflowJournalLength' <- histogram keiroWorkflowJournalLengthName "{event}" "Journal event count of a workflow at completion."-    workflowAwakeablesPending' <- gaugeI64 keiroWorkflowAwakeablesPendingName "{awakeable}" "Awakeables awaiting an external signal."-    pure-        KeiroMetrics-            { outboxBacklog = outboxBacklog'-            , outboxPublished = outboxPublished'-            , outboxRetried = outboxRetried'-            , outboxDeadlettered = outboxDeadlettered'-            , outboxReclaimed = outboxReclaimed'-            , inboxProcessed = inboxProcessed'-            , inboxDuplicates = inboxDuplicates'-            , inboxFailed = inboxFailed'-            , inboxPoisoned = inboxPoisoned'-            , inboxBacklog = inboxBacklog'-            , timerBacklog = timerBacklog'-            , timerFireLag = timerFireLag'-            , timerAttempts = timerAttempts'-            , timerStuck = timerStuck'-            , timerRequeued = timerRequeued'-            , projectionLag = projectionLag'-            , projectionWaitTimeouts = projectionWaitTimeouts'-            , commandConflicts = commandConflicts'-            , commandRetries = commandRetries'-            , commandDuplicates = commandDuplicates'-            , snapshotDecodeFailures = snapshotDecodeFailures'-            , snapshotEncodeFailures = snapshotEncodeFailures'-            , snapshotReadHits = snapshotReadHits'-            , snapshotReadMisses = snapshotReadMisses'-            , snapshotWriteFailures = snapshotWriteFailures'-            , snapshotApplyDivergence = snapshotApplyDivergence'-            , snapshotSeedDivergence = snapshotSeedDivergence'-            , dispatchFailed = dispatchFailed'-            , dispatchDeadlettered = dispatchDeadlettered'-            , subscriptionDeadlettered = subscriptionDeadlettered'-            , dispatchDuplicates = dispatchDuplicates'-            , dispatchPoison = dispatchPoison'-            , workflowStepsExecuted = workflowStepsExecuted'-            , workflowStepsReplayed = workflowStepsReplayed'-            , workflowResumed = workflowResumed'-            , workflowFailed = workflowFailed'-            , workflowResumeErrors = workflowResumeErrors'-            , workflowLeaseSkipped = workflowLeaseSkipped'-            , workflowActive = workflowActive'-            , workflowJournalLength = workflowJournalLength'-            , workflowAwakeablesPending = workflowAwakeablesPending'-            }+  outboxBacklog' <- gaugeI64 keiroOutboxBacklogName "{event}" "Outbox rows awaiting publish."+  outboxPublished' <- counterI64 keiroOutboxPublishedName "{event}" "Outbox events successfully published."+  outboxRetried' <- counterI64 keiroOutboxRetriedName "{event}" "Outbox publish attempts that failed and will retry."+  outboxDeadlettered' <- counterI64 keiroOutboxDeadletteredName "{event}" "Outbox events parked after exhausting retries."+  outboxReclaimed' <- counterI64 keiroOutboxReclaimedName "{event}" "Outbox rows reclaimed from a crashed or stalled publisher."+  inboxProcessed' <- counterI64 keiroInboxProcessedName "{message}" "Inbox messages processed successfully."+  inboxDuplicates' <- counterI64 keiroInboxDuplicatesName "{message}" "Inbox messages skipped as duplicates."+  inboxFailed' <- counterI64 keiroInboxFailedName "{message}" "Inbox messages whose handler failed."+  inboxPoisoned' <- counterI64 keiroInboxPoisonedName "{message}" "Inbox messages dead-lettered after exhausting handler attempts."+  inboxBacklog' <- gaugeI64 keiroInboxBacklogName "{message}" "Inbox messages awaiting processing."+  timerBacklog' <- gaugeI64 keiroTimerBacklogName "{timer}" "Due timers awaiting firing."+  timerFireLag' <- histogram keiroTimerFireLagName "ms" "Delay between a timer's scheduled time and when it fired."+  timerAttempts' <- histogram keiroTimerAttemptsName "{attempt}" "Number of attempts a timer took to fire."+  timerStuck' <- gaugeI64 keiroTimerStuckName "{timer}" "Timers stuck in the Firing state past threshold."+  timerRequeued' <- counterI64 keiroTimerRequeuedName "{timer}" "Timers moved from firing back to scheduled after a stale claim."+  projectionLag' <- gaugeI64 keiroProjectionLagName "{event}" "Events between the log head and a projection's checkpoint."+  projectionWaitTimeouts' <- counterI64 keiroProjectionWaitTimeoutsName "{timeout}" "Position-wait calls that timed out before the projection caught up."+  commandConflicts' <- counterI64 keiroCommandConflictsName "{conflict}" "Optimistic-concurrency conflicts observed by command runners."+  commandRetries' <- counterI64 keiroCommandRetriesName "{retry}" "Command retry attempts started after an optimistic-concurrency conflict."+  commandDuplicates' <- counterI64 keiroCommandDuplicatesName "{event}" "Command appends rejected as duplicate deterministic event ids."+  snapshotDecodeFailures' <- counterI64 keiroSnapshotDecodeFailuresName "{failure}" "Snapshot rows whose bytes failed to decode; hydration fell back to full replay."+  snapshotEncodeFailures' <- counterI64 keiroSnapshotEncodeFailuresName "{failure}" "Post-commit snapshot encodes that failed and were swallowed."+  snapshotReadHits' <- counterI64 keiroSnapshotReadHitsName "{read}" "Snapshot lookups that yielded a usable hydration seed."+  snapshotReadMisses' <- counterI64 keiroSnapshotReadMissesName "{read}" "Snapshot lookups that fell back to full replay."+  snapshotWriteFailures' <- counterI64 keiroSnapshotWriteFailuresName "{failure}" "Post-commit snapshot writes that failed and were swallowed."+  snapshotApplyDivergence' <- counterI64 keiroSnapshotApplyDivergenceName "{failure}" "Just-appended event batches that failed to replay from the pre-command state; the stream is poisoned and its next hydration will fail."+  snapshotSeedDivergence' <- counterI64 keiroSnapshotSeedDivergenceName "{failure}" "Sampled snapshot seeds whose encoded state disagreed with a full replay through the seed version."+  dispatchFailed' <- counterI64 keiroDispatchFailedName "{command}" "Process-manager/router dispatch commands that failed."+  dispatchDeadlettered' <- counterI64 keiroDispatchDeadletteredName "{command}" "Rejected process-manager/router dispatch commands handled by dead-letter or skip policy."+  subscriptionDeadlettered' <- counterI64 keiroSubscriptionDeadletteredName "{event}" "Kiroku source events dead-lettered by an explicit disposition or retry exhaustion."+  dispatchDuplicates' <- counterI64 keiroDispatchDuplicatesName "{command}" "Process-manager/router dispatch commands skipped as duplicate deterministic event ids."+  dispatchPoison' <- counterI64 keiroDispatchPoisonName "{message}" "Process-manager/router worker messages classified as poison."+  workflowStepsExecuted' <- counterI64 keiroWorkflowStepsExecutedName "{step}" "Workflow steps that ran their action (a journal miss)."+  workflowStepsReplayed' <- counterI64 keiroWorkflowStepsReplayedName "{step}" "Workflow steps short-circuited to a recorded result (a journal hit)."+  workflowResumed' <- counterI64 keiroWorkflowResumedName "{workflow}" "Workflow re-invocations performed by the resume worker."+  workflowFailed' <- counterI64 keiroWorkflowFailedName "{workflow}" "Workflow instances marked terminally failed by the resume worker."+  workflowResumeErrors' <- counterI64 keiroWorkflowResumeErrorsName "{error}" "Transient store errors observed by the workflow resume worker."+  workflowLeaseSkipped' <- counterI64 keiroWorkflowLeaseSkippedName "{workflow}" "Workflow instances skipped because another worker owns their lease."+  workflowActive' <- gaugeI64 keiroWorkflowActiveName "{workflow}" "Workflow runs currently in progress in this process."+  workflowJournalLength' <- histogram keiroWorkflowJournalLengthName "{event}" "Journal event count of a workflow at completion."+  workflowAwakeablesPending' <- gaugeI64 keiroWorkflowAwakeablesPendingName "{awakeable}" "Awakeables awaiting an external signal."+  pure+    KeiroMetrics+      { outboxBacklog = outboxBacklog',+        outboxPublished = outboxPublished',+        outboxRetried = outboxRetried',+        outboxDeadlettered = outboxDeadlettered',+        outboxReclaimed = outboxReclaimed',+        inboxProcessed = inboxProcessed',+        inboxDuplicates = inboxDuplicates',+        inboxFailed = inboxFailed',+        inboxPoisoned = inboxPoisoned',+        inboxBacklog = inboxBacklog',+        timerBacklog = timerBacklog',+        timerFireLag = timerFireLag',+        timerAttempts = timerAttempts',+        timerStuck = timerStuck',+        timerRequeued = timerRequeued',+        projectionLag = projectionLag',+        projectionWaitTimeouts = projectionWaitTimeouts',+        commandConflicts = commandConflicts',+        commandRetries = commandRetries',+        commandDuplicates = commandDuplicates',+        snapshotDecodeFailures = snapshotDecodeFailures',+        snapshotEncodeFailures = snapshotEncodeFailures',+        snapshotReadHits = snapshotReadHits',+        snapshotReadMisses = snapshotReadMisses',+        snapshotWriteFailures = snapshotWriteFailures',+        snapshotApplyDivergence = snapshotApplyDivergence',+        snapshotSeedDivergence = snapshotSeedDivergence',+        dispatchFailed = dispatchFailed',+        dispatchDeadlettered = dispatchDeadlettered',+        subscriptionDeadlettered = subscriptionDeadlettered',+        dispatchDuplicates = dispatchDuplicates',+        dispatchPoison = dispatchPoison',+        workflowStepsExecuted = workflowStepsExecuted',+        workflowStepsReplayed = workflowStepsReplayed',+        workflowResumed = workflowResumed',+        workflowFailed = workflowFailed',+        workflowResumeErrors = workflowResumeErrors',+        workflowLeaseSkipped = workflowLeaseSkipped',+        workflowActive = workflowActive',+        workflowJournalLength = workflowJournalLength',+        workflowAwakeablesPending = workflowAwakeablesPending'+      }   where     counterI64 :: Text -> Text -> Text -> IO (Counter Int64)     counterI64 name unit desc =-        meterCreateCounterInt64 meter name (Just unit) (Just desc) defaultAdvisoryParameters+      meterCreateCounterInt64 meter name (Just unit) (Just desc) defaultAdvisoryParameters     gaugeI64 :: Text -> Text -> Text -> IO (Gauge Int64)     gaugeI64 name unit desc =-        meterCreateGaugeInt64 meter name (Just unit) (Just desc) defaultAdvisoryParameters+      meterCreateGaugeInt64 meter name (Just unit) (Just desc) defaultAdvisoryParameters     histogram :: Text -> Text -> Text -> IO Histogram     histogram name unit desc =-        meterCreateHistogram meter name (Just unit) (Just desc) defaultAdvisoryParameters+      meterCreateHistogram meter name (Just unit) (Just desc) defaultAdvisoryParameters  -- Internal: record an Int64 on the counter selected by @sel@, or do nothing. recordCounter ::-    (MonadIO m) => (KeiroMetrics -> Counter Int64) -> Maybe KeiroMetrics -> Int64 -> m ()+  (MonadIO m) => (KeiroMetrics -> Counter Int64) -> Maybe KeiroMetrics -> Int64 -> m () recordCounter _ Nothing _ = pure () recordCounter sel (Just ms) n = liftIO (counterAdd (sel ms) n emptyAttributes)  -- Internal: record an Int64 on the gauge selected by @sel@, or do nothing. recordGaugeI64 ::-    (MonadIO m) => (KeiroMetrics -> Gauge Int64) -> Maybe KeiroMetrics -> Int64 -> m ()+  (MonadIO m) => (KeiroMetrics -> Gauge Int64) -> Maybe KeiroMetrics -> Int64 -> m () recordGaugeI64 _ Nothing _ = pure () recordGaugeI64 sel (Just ms) n = liftIO (gaugeRecord (sel ms) n emptyAttributes)  -- Internal: record a Double on the histogram selected by @sel@, or do nothing. recordHistogram ::-    (MonadIO m) => (KeiroMetrics -> Histogram) -> Maybe KeiroMetrics -> Double -> m ()+  (MonadIO m) => (KeiroMetrics -> Histogram) -> Maybe KeiroMetrics -> Double -> m () recordHistogram _ Nothing _ = pure () recordHistogram sel (Just ms) v = liftIO (histogramRecord (sel ms) v emptyAttributes)  recordOutboxBacklog :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordOutboxBacklog = recordGaugeI64 outboxBacklog+ recordOutboxPublished :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordOutboxPublished = recordCounter outboxPublished+ recordOutboxRetried :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordOutboxRetried = recordCounter outboxRetried+ recordOutboxDeadlettered :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordOutboxDeadlettered = recordCounter outboxDeadlettered+ recordOutboxReclaimed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordOutboxReclaimed = recordCounter outboxReclaimed+ recordInboxProcessed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordInboxProcessed = recordCounter inboxProcessed+ recordInboxDuplicates :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordInboxDuplicates = recordCounter inboxDuplicates+ recordInboxFailed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordInboxFailed = recordCounter inboxFailed+ recordInboxPoisoned :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordInboxPoisoned = recordCounter inboxPoisoned+ recordInboxBacklog :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordInboxBacklog = recordGaugeI64 inboxBacklog+ recordTimerBacklog :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordTimerBacklog = recordGaugeI64 timerBacklog+ recordTimerFireLag :: (MonadIO m) => Maybe KeiroMetrics -> Double -> m () recordTimerFireLag = recordHistogram timerFireLag+ recordTimerAttempts :: (MonadIO m) => Maybe KeiroMetrics -> Double -> m () recordTimerAttempts = recordHistogram timerAttempts+ recordTimerStuck :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordTimerStuck = recordGaugeI64 timerStuck+ recordTimerRequeued :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordTimerRequeued = recordCounter timerRequeued+ recordProjectionLag :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordProjectionLag = recordGaugeI64 projectionLag+ recordProjectionWaitTimeouts :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordProjectionWaitTimeouts = recordCounter projectionWaitTimeouts+ recordCommandConflicts :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordCommandConflicts = recordCounter commandConflicts+ recordCommandRetries :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordCommandRetries = recordCounter commandRetries+ recordCommandDuplicates :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordCommandDuplicates = recordCounter commandDuplicates+ recordSnapshotDecodeFailures :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotDecodeFailures = recordCounter snapshotDecodeFailures+ recordSnapshotEncodeFailures :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotEncodeFailures = recordCounter snapshotEncodeFailures+ recordSnapshotReadHits :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotReadHits = recordCounter snapshotReadHits+ recordSnapshotReadMisses :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotReadMisses = recordCounter snapshotReadMisses+ recordSnapshotWriteFailures :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotWriteFailures = recordCounter snapshotWriteFailures+ recordSnapshotApplyDivergence :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotApplyDivergence = recordCounter snapshotApplyDivergence+ recordSnapshotSeedDivergence :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotSeedDivergence = recordCounter snapshotSeedDivergence+ recordDispatchFailed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordDispatchFailed = recordCounter dispatchFailed+ recordDispatchDeadLettered :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordDispatchDeadLettered = recordCounter dispatchDeadlettered+ recordSubscriptionDeadLettered :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSubscriptionDeadLettered = recordCounter subscriptionDeadlettered+ recordDispatchDuplicate :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordDispatchDuplicate = recordCounter dispatchDuplicates+ recordDispatchPoison :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordDispatchPoison = recordCounter dispatchPoison+ recordWorkflowStepExecuted :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowStepExecuted = recordCounter workflowStepsExecuted+ recordWorkflowStepReplayed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowStepReplayed = recordCounter workflowStepsReplayed+ recordWorkflowResumed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowResumed = recordCounter workflowResumed+ recordWorkflowFailed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowFailed = recordCounter workflowFailed+ recordWorkflowResumeErrors :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowResumeErrors = recordCounter workflowResumeErrors+ recordWorkflowLeaseSkipped :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowLeaseSkipped = recordCounter workflowLeaseSkipped+ recordWorkflowActive :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowActive = recordGaugeI64 workflowActive+ recordWorkflowJournalLength :: (MonadIO m) => Maybe KeiroMetrics -> Double -> m () recordWorkflowJournalLength = recordHistogram workflowJournalLength+ recordWorkflowAwakeablesPending :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordWorkflowAwakeablesPending = recordGaugeI64 workflowAwakeablesPending -{- | Feed Kiroku store events into Keiro metrics, then delegate every event to-the application's existing event handler. Install this as (or inside) the-@eventHandler@ on Kiroku's @ConnectionSettings@ at store construction; pass-@const (pure ())@ when there is no other handler.--'KirokuEventSubscriptionDeadLettered' is the terminal retry-exhaustion signal:-the acknowledgement bridge exposes the current delivery attempt, but does not-tell a handler that its retry reply consumed the final attempt. For current-dead-letter depth, query Kiroku's durable table rather than treating this-monotonic counter as a gauge:--> SELECT count(*) FROM kiroku.dead_letters WHERE subscription_name = $1--The delegate runs synchronously, matching Kiroku's event-handler contract, so-it should remain fast and non-blocking.--}+-- | Feed Kiroku store events into Keiro metrics, then delegate every event to+-- the application's existing event handler. Install this as (or inside) the+-- @eventHandler@ on Kiroku's @ConnectionSettings@ at store construction; pass+-- @const (pure ())@ when there is no other handler.+--+-- 'KirokuEventSubscriptionDeadLettered' is the terminal retry-exhaustion signal:+-- the acknowledgement bridge exposes the current delivery attempt, but does not+-- tell a handler that its retry reply consumed the final attempt. For current+-- dead-letter depth, query Kiroku's durable table rather than treating this+-- monotonic counter as a gauge:+--+-- > SELECT count(*) FROM kiroku.dead_letters WHERE subscription_name = $1+--+-- The delegate runs synchronously, matching Kiroku's event-handler contract, so+-- it should remain fast and non-blocking. kirokuEventBridge :: Maybe KeiroMetrics -> (KirokuEvent -> IO ()) -> KirokuEvent -> IO () kirokuEventBridge metrics delegate event = do-    case event of-        KirokuEventSubscriptionDeadLettered{} -> recordSubscriptionDeadLettered metrics 1-        _ -> pure ()-    delegate event+  case event of+    KirokuEventSubscriptionDeadLettered {} -> recordSubscriptionDeadLettered metrics 1+    _ -> pure ()+  delegate event
src/Keiro/Timer.hs view
@@ -1,20 +1,19 @@-{- | Durable timers for process managers.--A process manager schedules a timer ('scheduleTimerTx', in its own append-transaction) to be woken at a future time — a saga timeout, a retry delay, a-deadline. The 'runTimerWorker' loop claims one due timer at a time with-@FOR UPDATE SKIP LOCKED@ (so multiple workers can run safely), hands it to a-caller-supplied @fire@ action that typically dispatches a command back into-the manager, and marks it fired once the resulting event id is known. A-timer left @Firing@ by a crash becomes claimable again after the worker's-configured stale-claim timeout, giving at-least-once firing.--The wire types live in "Keiro.Timer.Types" and the SQL storage in-"Keiro.Timer.Schema"; both are re-exported here so most callers need only-import @Keiro.Timer@.--}-module Keiro.Timer (-    -- * Timer types+-- | Durable timers for process managers.+--+-- A process manager schedules a timer ('scheduleTimerTx', in its own append+-- transaction) to be woken at a future time — a saga timeout, a retry delay, a+-- deadline. The 'runTimerWorker' loop claims one due timer at a time with+-- @FOR UPDATE SKIP LOCKED@ (so multiple workers can run safely), hands it to a+-- caller-supplied @fire@ action that typically dispatches a command back into+-- the manager, and marks it fired once the resulting event id is known. A+-- timer left @Firing@ by a crash becomes claimable again after the worker's+-- configured stale-claim timeout, giving at-least-once firing.+--+-- The wire types live in "Keiro.Timer.Types" and the SQL storage in+-- "Keiro.Timer.Schema"; both are re-exported here so most callers need only+-- import @Keiro.Timer@.+module Keiro.Timer+  ( -- * Timer types     TimerId (..),     TimerRequest (..),     TimerRow (..),@@ -44,21 +43,21 @@     mkTimerWorkerOptions,     runTimerWorker,     runTimerWorkerWith,-)+  ) where  import Data.Text qualified as Text import Data.Time.Clock (NominalDiffTime, diffUTCTime) import Effectful (Eff, IOE, (:>)) import Keiro.Prelude-import Keiro.Telemetry (-    KeiroMetrics,+import Keiro.Telemetry+  ( KeiroMetrics,     recordTimerAttempts,     recordTimerBacklog,     recordTimerFireLag,     recordTimerRequeued,     recordTimerStuck,- )+  ) import Keiro.Timer.Schema import Keiro.Timer.Types import Kiroku.Store.Effect (Store)@@ -66,114 +65,110 @@  -- | Options controlling 'runTimerWorkerWith'. data TimerWorkerOptions = TimerWorkerOptions-    { maxAttempts :: Maybe Int-    {- ^ When @Just n@, a claimed timer whose post-claim @attempts@ exceeds @n@ is-    moved to 'Dead' (via 'deadLetterTimer') instead of being fired. @Nothing@-    never auto-dead-letters (the historical behavior).-    -}-    , requeueStuckAfter :: !(Maybe NominalDiffTime)-    {- ^ When @Just ttl@, each worker pass first moves @Firing@ timers whose-    @updated_at@ is at least @ttl@ old back to 'Scheduled'. A fire action that-    runs longer than this timeout may be fired again; timer handlers must be-    idempotent under keiro's at-least-once timer contract. @Nothing@ disables-    automatic requeue for callers that run their own recovery.-    -}-    }-    deriving stock (Generic, Eq, Show)+  { -- | When @Just n@, a claimed timer whose post-claim @attempts@ exceeds @n@ is+    --     moved to 'Dead' (via 'deadLetterTimer') instead of being fired. @Nothing@+    --     never auto-dead-letters (the historical behavior).+    maxAttempts :: Maybe Int,+    -- | When @Just ttl@, each worker pass first moves @Firing@ timers whose+    --     @updated_at@ is at least @ttl@ old back to 'Scheduled'. A fire action that+    --     runs longer than this timeout may be fired again; timer handlers must be+    --     idempotent under keiro's at-least-once timer contract. @Nothing@ disables+    --     automatic requeue for callers that run their own recovery.+    requeueStuckAfter :: !(Maybe NominalDiffTime)+  }+  deriving stock (Generic, Eq, Show)  data TimerWorkerConfigError-    = InvalidTimerMaxAttempts !Int-    | InvalidTimerRequeueStuckAfter !NominalDiffTime-    deriving stock (Generic, Eq, Show)+  = InvalidTimerMaxAttempts !Int+  | InvalidTimerRequeueStuckAfter !NominalDiffTime+  deriving stock (Generic, Eq, Show)  -- | The default worker policy: never auto-dead-letter; requeue stale firings after five minutes. defaultTimerWorkerOptions :: TimerWorkerOptions-defaultTimerWorkerOptions = TimerWorkerOptions{maxAttempts = Nothing, requeueStuckAfter = Just 300}+defaultTimerWorkerOptions = TimerWorkerOptions {maxAttempts = Nothing, requeueStuckAfter = Just 300}  -- | Validate timer worker options before starting a worker loop. mkTimerWorkerOptions :: TimerWorkerOptions -> Either TimerWorkerConfigError TimerWorkerOptions mkTimerWorkerOptions opts =-    case (opts ^. #maxAttempts, opts ^. #requeueStuckAfter) of-        (Just attempts, _) | attempts < 0 -> Left (InvalidTimerMaxAttempts attempts)-        (_, Just ttl) | ttl <= 0 -> Left (InvalidTimerRequeueStuckAfter ttl)-        _ -> Right opts--{- | Claim and fire at most one timer due at @now@, applying the given-'TimerWorkerOptions'.--Atomically claims the earliest due timer (marking it @Firing@). If the options-set @maxAttempts = Just n@ and the timer's post-claim @attempts@ exceeds @n@, it-is dead-lettered ('Dead', with an explanatory @last_error@) instead of fired —-rather than ping-ponging forever on a timer that never completes. Before-claiming, the worker requeues stale @Firing@ rows according to-'requeueStuckAfter'. Otherwise the caller's @fire@ action runs and — if it-returns the id of the event it produced — the timer is marked @Fired@. Returns-the claimed 'TimerRow' (the row as claimed, before any dead-letter or fire-UPDATE), or 'Nothing' when nothing is due. A @fire@ that returns 'Nothing'-leaves the timer @Firing@ until it becomes stale and is requeued on a later-worker pass.--A timer may fire more than once if a worker crashes after the external action-but before 'markTimerFired', or if @fire@ takes longer than 'requeueStuckAfter'.-Handlers must therefore be idempotent.+  case (opts ^. #maxAttempts, opts ^. #requeueStuckAfter) of+    (Just attempts, _) | attempts < 0 -> Left (InvalidTimerMaxAttempts attempts)+    (_, Just ttl) | ttl <= 0 -> Left (InvalidTimerRequeueStuckAfter ttl)+    _ -> Right opts -Note 'claimDueTimer' increments @attempts@ before this check, so the comparison-sees the post-claim count: with @maxAttempts = Just 0@ the very first claim-dead-letters; with @Just 2@ the third claim does.--}+-- | Claim and fire at most one timer due at @now@, applying the given+-- 'TimerWorkerOptions'.+--+-- Atomically claims the earliest due timer (marking it @Firing@). If the options+-- set @maxAttempts = Just n@ and the timer's post-claim @attempts@ exceeds @n@, it+-- is dead-lettered ('Dead', with an explanatory @last_error@) instead of fired —+-- rather than ping-ponging forever on a timer that never completes. Before+-- claiming, the worker requeues stale @Firing@ rows according to+-- 'requeueStuckAfter'. Otherwise the caller's @fire@ action runs and — if it+-- returns the id of the event it produced — the timer is marked @Fired@. Returns+-- the claimed 'TimerRow' (the row as claimed, before any dead-letter or fire+-- UPDATE), or 'Nothing' when nothing is due. A @fire@ that returns 'Nothing'+-- leaves the timer @Firing@ until it becomes stale and is requeued on a later+-- worker pass.+--+-- A timer may fire more than once if a worker crashes after the external action+-- but before 'markTimerFired', or if @fire@ takes longer than 'requeueStuckAfter'.+-- Handlers must therefore be idempotent.+--+-- Note 'claimDueTimer' increments @attempts@ before this check, so the comparison+-- sees the post-claim count: with @maxAttempts = Just 0@ the very first claim+-- dead-letters; with @Just 2@ the third claim does. runTimerWorkerWith ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    TimerWorkerOptions ->-    UTCTime ->-    (TimerRow -> Eff es (Maybe EventId)) ->-    Eff es (Maybe TimerRow)+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  TimerWorkerOptions ->+  UTCTime ->+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es (Maybe TimerRow) runTimerWorkerWith metrics options now fire = do-    for_ (options ^. #requeueStuckAfter) $ \ttl -> do-        requeued <- requeueStuckTimers ttl now-        recordTimerRequeued metrics (fromIntegral requeued)-    -- Gauges recorded once per pass, before the claim, off the counts the worker-    -- already needs its 'Store' for: the backlog as the worker sees it at the-    -- start of the pass (including the row it is about to claim), and the number-    -- of rows stranded in 'Firing' by earlier passes that never completed. Each-    -- is a no-op under a 'Nothing' handle.-    backlog <- countDueTimers now-    recordTimerBacklog metrics (fromIntegral backlog)-    stuck <- countStuckTimers now anyStuckTimer-    recordTimerStuck metrics (fromIntegral stuck)-    due <- claimDueTimer now-    case due of-        Nothing -> pure Nothing-        Just timer -> do-            -- Histograms for the claimed timer: how late it fired and how many-            -- attempts it has now taken. EP-33 declared 'keiro.timer.fire.lag' in-            -- milliseconds, so the seconds 'diffUTCTime' yields are scaled by 1000.-            -- The lag is non-negative because only fire_at <= now rows are claimable.-            recordTimerFireLag metrics (realToFrac (now `diffUTCTime` (timer ^. #fireAt)) * 1000)-            recordTimerAttempts metrics (fromIntegral (timer ^. #attempts))-            case options ^. #maxAttempts of-                Just attemptCeiling-                    | (timer ^. #attempts) > attemptCeiling -> do-                        _ <--                            deadLetterTimer-                                (timer ^. #timerId)-                                ("timer exceeded attempt ceiling of " <> Text.pack (show attemptCeiling))-                        pure (Just timer)-                _ -> do-                    fired <- fire timer-                    for_ fired (\eventId -> void (markTimerFired (timer ^. #timerId) eventId))-                    pure (Just timer)+  for_ (options ^. #requeueStuckAfter) $ \ttl -> do+    requeued <- requeueStuckTimers ttl now+    recordTimerRequeued metrics (fromIntegral requeued)+  -- Gauges recorded once per pass, before the claim, off the counts the worker+  -- already needs its 'Store' for: the backlog as the worker sees it at the+  -- start of the pass (including the row it is about to claim), and the number+  -- of rows stranded in 'Firing' by earlier passes that never completed. Each+  -- is a no-op under a 'Nothing' handle.+  backlog <- countDueTimers now+  recordTimerBacklog metrics (fromIntegral backlog)+  stuck <- countStuckTimers now anyStuckTimer+  recordTimerStuck metrics (fromIntegral stuck)+  due <- claimDueTimer now+  case due of+    Nothing -> pure Nothing+    Just timer -> do+      -- Histograms for the claimed timer: how late it fired and how many+      -- attempts it has now taken. EP-33 declared 'keiro.timer.fire.lag' in+      -- milliseconds, so the seconds 'diffUTCTime' yields are scaled by 1000.+      -- The lag is non-negative because only fire_at <= now rows are claimable.+      recordTimerFireLag metrics (realToFrac (now `diffUTCTime` (timer ^. #fireAt)) * 1000)+      recordTimerAttempts metrics (fromIntegral (timer ^. #attempts))+      case options ^. #maxAttempts of+        Just attemptCeiling+          | (timer ^. #attempts) > attemptCeiling -> do+              _ <-+                deadLetterTimer+                  (timer ^. #timerId)+                  ("timer exceeded attempt ceiling of " <> Text.pack (show attemptCeiling))+              pure (Just timer)+        _ -> do+          fired <- fire timer+          for_ fired (\eventId -> void (markTimerFired (timer ^. #timerId) eventId))+          pure (Just timer) -{- | Claim and fire at most one timer due at @now@ using-'defaultTimerWorkerOptions' (no attempt ceiling). Equivalent to-@'runTimerWorkerWith' 'defaultTimerWorkerOptions'@; the default has no attempt-ceiling and requeues claims left @Firing@ for five minutes. See-'runTimerWorkerWith' for the full semantics.--}+-- | Claim and fire at most one timer due at @now@ using+-- 'defaultTimerWorkerOptions' (no attempt ceiling). Equivalent to+-- @'runTimerWorkerWith' 'defaultTimerWorkerOptions'@; the default has no attempt+-- ceiling and requeues claims left @Firing@ for five minutes. See+-- 'runTimerWorkerWith' for the full semantics. runTimerWorker ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    UTCTime ->-    (TimerRow -> Eff es (Maybe EventId)) ->-    Eff es (Maybe TimerRow)+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  UTCTime ->+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es (Maybe TimerRow) runTimerWorker metrics = runTimerWorkerWith metrics defaultTimerWorkerOptions
src/Keiro/Timer/Schema.hs view
@@ -1,18 +1,17 @@-{- | The @keiro_timers@ table: storage and claim logic for durable timers.--Holds one row per scheduled timer with its 'TimerStatus' lifecycle.-'scheduleTimerTx' inserts (or re-arms a still-@Scheduled@ timer with the same-id) inside the caller's transaction; 'claimDueTimer' atomically picks the-single earliest due timer with @FOR UPDATE SKIP LOCKED@ and moves it to-@Firing@, so competing workers never claim the same timer; 'markTimerFired'-records completion and the produced event id. Stale @Firing@ rows are requeued-by 'requeueStuckTimers' so a crashed worker does not strand a timer forever.--Callers normally use the re-exports from "Keiro.Timer" rather than this-module directly.--}-module Keiro.Timer.Schema (-    -- * Rows and status+-- | The @keiro_timers@ table: storage and claim logic for durable timers.+--+-- Holds one row per scheduled timer with its 'TimerStatus' lifecycle.+-- 'scheduleTimerTx' inserts (or re-arms a still-@Scheduled@ timer with the same+-- id) inside the caller's transaction; 'claimDueTimer' atomically picks the+-- single earliest due timer with @FOR UPDATE SKIP LOCKED@ and moves it to+-- @Firing@, so competing workers never claim the same timer; 'markTimerFired'+-- records completion and the produced event id. Stale @Firing@ rows are requeued+-- by 'requeueStuckTimers' so a crashed worker does not strand a timer forever.+--+-- Callers normally use the re-exports from "Keiro.Timer" rather than this+-- module directly.+module Keiro.Timer.Schema+  ( -- * Rows and status     TimerStatus (..),     TimerRow (..), @@ -34,7 +33,7 @@     requeueStuckTimer,     cancelTimer,     deadLetterTimer,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip6)@@ -51,405 +50,391 @@ import Kiroku.Store.Types (EventId (..)) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | A timer's lifecycle state.--* 'Scheduled' — waiting for its 'fireAt'; claimable.-* 'Firing' — claimed by a worker and being processed; stale rows become-  claimable again when 'requeueStuckTimers' moves them back to 'Scheduled'.-* 'Fired' — successfully fired; terminal.-* 'Cancelled' — withdrawn before firing.-* 'Dead' — abandoned after exceeding the attempt ceiling; terminal; carries an-  optional @last_error@ describing why it was given up on.--}+-- | A timer's lifecycle state.+--+-- * 'Scheduled' — waiting for its 'fireAt'; claimable.+-- * 'Firing' — claimed by a worker and being processed; stale rows become+--   claimable again when 'requeueStuckTimers' moves them back to 'Scheduled'.+-- * 'Fired' — successfully fired; terminal.+-- * 'Cancelled' — withdrawn before firing.+-- * 'Dead' — abandoned after exceeding the attempt ceiling; terminal; carries an+--   optional @last_error@ describing why it was given up on. data TimerStatus-    = Scheduled-    | Firing-    | Fired-    | Cancelled-    | Dead-    deriving stock (Generic, Eq, Show)+  = Scheduled+  | Firing+  | Fired+  | Cancelled+  | Dead+  deriving stock (Generic, Eq, Show) -{- | A timer row as stored: the original 'TimerRequest' fields plus the live-'status', the 'attempts' count (incremented on each claim), and the-'firedEventId' recorded once it fires.--}+-- | A timer row as stored: the original 'TimerRequest' fields plus the live+-- 'status', the 'attempts' count (incremented on each claim), and the+-- 'firedEventId' recorded once it fires. data TimerRow = TimerRow-    { timerId :: !TimerId-    , processManagerName :: !Text-    , correlationId :: !Text-    , fireAt :: !UTCTime-    , payload :: !Value-    , status :: !TimerStatus-    , attempts :: !Int-    , firedEventId :: !(Maybe EventId)-    }-    deriving stock (Generic, Eq, Show)+  { timerId :: !TimerId,+    processManagerName :: !Text,+    correlationId :: !Text,+    fireAt :: !UTCTime,+    payload :: !Value,+    status :: !TimerStatus,+    attempts :: !Int,+    firedEventId :: !(Maybe EventId)+  }+  deriving stock (Generic, Eq, Show) -{- | Criteria selecting timers stranded in 'Firing'. A row is "stuck" when its-'status' is @firing@ and it matches every set bound: 'minAge' (it has been-firing at least this long, measured from @updated_at@) and 'minAttempts' (it-has been claimed at least this many times). Both unset selects every @firing@-row.--}+-- | Criteria selecting timers stranded in 'Firing'. A row is "stuck" when its+-- 'status' is @firing@ and it matches every set bound: 'minAge' (it has been+-- firing at least this long, measured from @updated_at@) and 'minAttempts' (it+-- has been claimed at least this many times). Both unset selects every @firing@+-- row. data StuckTimerFilter = StuckTimerFilter-    { minAge :: !(Maybe NominalDiffTime)-    , minAttempts :: !(Maybe Int)-    }-    deriving stock (Generic, Eq, Show)+  { minAge :: !(Maybe NominalDiffTime),+    minAttempts :: !(Maybe Int)+  }+  deriving stock (Generic, Eq, Show)  -- | Select every @firing@ row regardless of age or attempts. anyStuckTimer :: StuckTimerFilter anyStuckTimer = StuckTimerFilter Nothing Nothing -{- | Schedule a timer inside the caller's transaction (typically a process-manager's append). Upserts on 'timerId': a conflicting row is re-armed only-while it is still @Scheduled@, so a timer that has already fired or been-cancelled is not resurrected.--}+-- | Schedule a timer inside the caller's transaction (typically a process+-- manager's append). Upserts on 'timerId': a conflicting row is re-armed only+-- while it is still @Scheduled@, so a timer that has already fired or been+-- cancelled is not resurrected. scheduleTimerTx :: TimerRequest -> Tx.Transaction () scheduleTimerTx request =-    Tx.statement-        ( timerIdToUuid (request ^. #timerId)-        , request ^. #processManagerName-        , request ^. #correlationId-        , request ^. #fireAt-        , request ^. #payload-        , statusToText Scheduled-        )-        scheduleTimerStmt--{- | Schedule a timer only if no row with the same 'timerId' already exists.+  Tx.statement+    ( timerIdToUuid (request ^. #timerId),+      request ^. #processManagerName,+      request ^. #correlationId,+      request ^. #fireAt,+      request ^. #payload,+      statusToText Scheduled+    )+    scheduleTimerStmt -This is for callers whose first arm must win, such as durable workflow sleeps:-every resume pass re-runs the sleep arm until the timer fires, and preserving-the original 'fireAt' keeps the sleep measured from the first arm. Process-managers that intentionally push a deadline back should keep using-'scheduleTimerTx'. Returns 'True' when this call inserted the row and 'False'-when an existing timer won.--}+-- | Schedule a timer only if no row with the same 'timerId' already exists.+--+-- This is for callers whose first arm must win, such as durable workflow sleeps:+-- every resume pass re-runs the sleep arm until the timer fires, and preserving+-- the original 'fireAt' keeps the sleep measured from the first arm. Process+-- managers that intentionally push a deadline back should keep using+-- 'scheduleTimerTx'. Returns 'True' when this call inserted the row and 'False'+-- when an existing timer won. scheduleTimerOnceTx :: TimerRequest -> Tx.Transaction Bool scheduleTimerOnceTx request =-    Tx.statement-        ( timerIdToUuid (request ^. #timerId)-        , request ^. #processManagerName-        , request ^. #correlationId-        , request ^. #fireAt-        , request ^. #payload-        , statusToText Scheduled-        )-        scheduleTimerOnceStmt+  Tx.statement+    ( timerIdToUuid (request ^. #timerId),+      request ^. #processManagerName,+      request ^. #correlationId,+      request ^. #fireAt,+      request ^. #payload,+      statusToText Scheduled+    )+    scheduleTimerOnceStmt -{- | Atomically claim the single earliest timer due at @now@, moving it to-@Firing@ and bumping its attempt count. Uses @FOR UPDATE SKIP LOCKED@ so-concurrent workers each get a distinct timer. Returns 'Nothing' when none is-due.--}+-- | Atomically claim the single earliest timer due at @now@, moving it to+-- @Firing@ and bumping its attempt count. Uses @FOR UPDATE SKIP LOCKED@ so+-- concurrent workers each get a distinct timer. Returns 'Nothing' when none is+-- due. claimDueTimer :: (Store :> es) => UTCTime -> Eff es (Maybe TimerRow) claimDueTimer now =-    runTransaction $-        Tx.statement now claimDueTimerStmt+  runTransaction $+    Tx.statement now claimDueTimerStmt -{- | Mark a claimed timer @Fired@, recording the id of the event its firing-produced. Returns 'False' when the row left @Firing@ while the fire action was-running (for example, it was requeued, cancelled, or dead-lettered).--}+-- | Mark a claimed timer @Fired@, recording the id of the event its firing+-- produced. Returns 'False' when the row left @Firing@ while the fire action was+-- running (for example, it was requeued, cancelled, or dead-lettered). markTimerFired :: (Store :> es) => TimerId -> EventId -> Eff es Bool markTimerFired timerId eventId =-    runTransaction $-        Tx.statement (timerIdToUuid timerId, eventIdToUuid eventId) markTimerFiredStmt+  runTransaction $+    Tx.statement (timerIdToUuid timerId, eventIdToUuid eventId) markTimerFiredStmt -{- | Count timers that are @scheduled@ and already due at @now@ — the timer-backlog. Read-only; mirrors 'claimDueTimer''s WHERE clause but counts rather-than locking, so it never claims or mutates a row.--}+-- | Count timers that are @scheduled@ and already due at @now@ — the timer+-- backlog. Read-only; mirrors 'claimDueTimer''s WHERE clause but counts rather+-- than locking, so it never claims or mutates a row. countDueTimers :: (Store :> es) => UTCTime -> Eff es Int countDueTimers now =-    runTransaction $-        Tx.statement now countDueTimersStmt+  runTransaction $+    Tx.statement now countDueTimersStmt -{- | Count timers stranded in @Firing@ that match the given 'StuckTimerFilter' —-the same "stuck" predicate 'findStuckTimers' lists, evaluated against @now@.-Read-only. 'anyStuckTimer' counts every @firing@ row.--}+-- | Count timers stranded in @Firing@ that match the given 'StuckTimerFilter' —+-- the same "stuck" predicate 'findStuckTimers' lists, evaluated against @now@.+-- Read-only. 'anyStuckTimer' counts every @firing@ row. countStuckTimers :: (Store :> es) => UTCTime -> StuckTimerFilter -> Eff es Int countStuckTimers now stuckFilter =-    runTransaction $-        Tx.statement (cutoff, fmap fromIntegral (stuckFilter ^. #minAttempts)) countStuckTimersStmt+  runTransaction $+    Tx.statement (cutoff, fmap fromIntegral (stuckFilter ^. #minAttempts)) countStuckTimersStmt   where     cutoff = fmap (\age -> addUTCTime (negate age) now) (stuckFilter ^. #minAge) -{- | List timers stranded in @Firing@ that match the given 'StuckTimerFilter'.-The @minAge@ bound is evaluated against @now@: a row qualifies when its-@updated_at@ is at least @minAge@ in the past (cutoff @now - minAge@). Results-are ordered oldest-first by @updated_at@.--}+-- | List timers stranded in @Firing@ that match the given 'StuckTimerFilter'.+-- The @minAge@ bound is evaluated against @now@: a row qualifies when its+-- @updated_at@ is at least @minAge@ in the past (cutoff @now - minAge@). Results+-- are ordered oldest-first by @updated_at@. findStuckTimers ::-    (Store :> es) => UTCTime -> StuckTimerFilter -> Eff es [TimerRow]+  (Store :> es) => UTCTime -> StuckTimerFilter -> Eff es [TimerRow] findStuckTimers now stuckFilter =-    runTransaction $-        Tx.statement (cutoff, fmap fromIntegral (stuckFilter ^. #minAttempts)) findStuckTimersStmt+  runTransaction $+    Tx.statement (cutoff, fmap fromIntegral (stuckFilter ^. #minAttempts)) findStuckTimersStmt   where     cutoff = fmap (\age -> addUTCTime (negate age) now) (stuckFilter ^. #minAge) -{- | Move every timer stranded in @Firing@ for at least @olderThan@ back to-@Scheduled@. The statement preserves @fire_at@, so a due timer becomes-claimable on the same worker pass. Returns the number of rows requeued.--}+-- | Move every timer stranded in @Firing@ for at least @olderThan@ back to+-- @Scheduled@. The statement preserves @fire_at@, so a due timer becomes+-- claimable on the same worker pass. Returns the number of rows requeued. requeueStuckTimers :: (Store :> es) => NominalDiffTime -> UTCTime -> Eff es Int requeueStuckTimers olderThan now =-    runTransaction $-        Tx.statement cutoff requeueStuckTimersStmt+  runTransaction $+    Tx.statement cutoff requeueStuckTimersStmt   where     cutoff = addUTCTime (negate olderThan) now -{- | Move a timer from @Firing@ back to @Scheduled@ so the ordinary claim loop-re-fires it. Leaves @fire_at@ unchanged, so a due timer becomes immediately-re-claimable. Idempotent: only @firing@ rows match, so re-running on an-already-requeued row affects nothing. Returns 'True' when a row changed.--}+-- | Move a timer from @Firing@ back to @Scheduled@ so the ordinary claim loop+-- re-fires it. Leaves @fire_at@ unchanged, so a due timer becomes immediately+-- re-claimable. Idempotent: only @firing@ rows match, so re-running on an+-- already-requeued row affects nothing. Returns 'True' when a row changed. requeueStuckTimer :: (Store :> es) => TimerId -> Eff es Bool requeueStuckTimer timerId =-    runTransaction $-        Tx.statement (timerIdToUuid timerId) requeueStuckTimerStmt+  runTransaction $+    Tx.statement (timerIdToUuid timerId) requeueStuckTimerStmt -{- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Cancelled@-state so it never fires. Terminal rows (@fired@, @cancelled@, @dead@) are left-untouched. Idempotent. Returns 'True' when a row changed.--}+-- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Cancelled@+-- state so it never fires. Terminal rows (@fired@, @cancelled@, @dead@) are left+-- untouched. Idempotent. Returns 'True' when a row changed. cancelTimer :: (Store :> es) => TimerId -> Eff es Bool cancelTimer timerId =-    runTransaction $-        Tx.statement (timerIdToUuid timerId) cancelTimerStmt+  runTransaction $+    Tx.statement (timerIdToUuid timerId) cancelTimerStmt -{- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Dead@ state,-recording @reason@ in @last_error@ so an operator can see why it was abandoned-(@SELECT * FROM keiro_timers WHERE status = 'dead'@). Terminal rows are left-untouched. Idempotent. Returns 'True' when a row changed.--}+-- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Dead@ state,+-- recording @reason@ in @last_error@ so an operator can see why it was abandoned+-- (@SELECT * FROM keiro_timers WHERE status = 'dead'@). Terminal rows are left+-- untouched. Idempotent. Returns 'True' when a row changed. deadLetterTimer :: (Store :> es) => TimerId -> Text -> Eff es Bool deadLetterTimer timerId reason =-    runTransaction $-        Tx.statement (timerIdToUuid timerId, reason) deadLetterTimerStmt+  runTransaction $+    Tx.statement (timerIdToUuid timerId, reason) deadLetterTimerStmt  scheduleTimerStmt :: Statement (UUID, Text, Text, UTCTime, Value, Text) () scheduleTimerStmt =-    preparable-        """-        INSERT INTO keiro.keiro_timers-          (timer_id, process_manager_name, correlation_id, fire_at, payload, status)-        VALUES-          ($1, $2, $3, $4, $5, $6)-        ON CONFLICT (timer_id) DO UPDATE-          SET process_manager_name = EXCLUDED.process_manager_name,-              correlation_id = EXCLUDED.correlation_id,-              fire_at = EXCLUDED.fire_at,-              payload = EXCLUDED.payload,-              status = EXCLUDED.status,-              updated_at = now()-          WHERE keiro_timers.status = 'scheduled'-        """-        ( contrazip6-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_timers+      (timer_id, process_manager_name, correlation_id, fire_at, payload, status)+    VALUES+      ($1, $2, $3, $4, $5, $6)+    ON CONFLICT (timer_id) DO UPDATE+      SET process_manager_name = EXCLUDED.process_manager_name,+          correlation_id = EXCLUDED.correlation_id,+          fire_at = EXCLUDED.fire_at,+          payload = EXCLUDED.payload,+          status = EXCLUDED.status,+          updated_at = now()+      WHERE keiro_timers.status = 'scheduled'+    """+    ( contrazip6+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  scheduleTimerOnceStmt :: Statement (UUID, Text, Text, UTCTime, Value, Text) Bool scheduleTimerOnceStmt =-    preparable-        """-        INSERT INTO keiro.keiro_timers-          (timer_id, process_manager_name, correlation_id, fire_at, payload, status)-        VALUES-          ($1, $2, $3, $4, $5, $6)-        ON CONFLICT (timer_id) DO NOTHING-        """-        ( contrazip6-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.text))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    INSERT INTO keiro.keiro_timers+      (timer_id, process_manager_name, correlation_id, fire_at, payload, status)+    VALUES+      ($1, $2, $3, $4, $5, $6)+    ON CONFLICT (timer_id) DO NOTHING+    """+    ( contrazip6+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)  claimDueTimerStmt :: Statement UTCTime (Maybe TimerRow) claimDueTimerStmt =-    preparable-        """-        WITH due AS (-          SELECT timer_id-          FROM keiro.keiro_timers-          WHERE status = 'scheduled'-            AND fire_at <= $1-          ORDER BY fire_at, timer_id-          LIMIT 1-          FOR UPDATE SKIP LOCKED-        )-        UPDATE keiro.keiro_timers kt-        SET status = 'firing',-            attempts = kt.attempts + 1,-            updated_at = now()-        FROM due-        WHERE kt.timer_id = due.timer_id-        RETURNING kt.timer_id, kt.process_manager_name, kt.correlation_id, kt.fire_at,-          kt.payload, kt.status, kt.attempts, kt.fired_event_id-        """-        (E.param (E.nonNullable E.timestamptz))-        (D.rowMaybe timerRowDecoder)+  preparable+    """+    WITH due AS (+      SELECT timer_id+      FROM keiro.keiro_timers+      WHERE status = 'scheduled'+        AND fire_at <= $1+      ORDER BY fire_at, timer_id+      LIMIT 1+      FOR UPDATE SKIP LOCKED+    )+    UPDATE keiro.keiro_timers kt+    SET status = 'firing',+        attempts = kt.attempts + 1,+        updated_at = now()+    FROM due+    WHERE kt.timer_id = due.timer_id+    RETURNING kt.timer_id, kt.process_manager_name, kt.correlation_id, kt.fire_at,+      kt.payload, kt.status, kt.attempts, kt.fired_event_id+    """+    (E.param (E.nonNullable E.timestamptz))+    (D.rowMaybe timerRowDecoder)  markTimerFiredStmt :: Statement (UUID, UUID) Bool markTimerFiredStmt =-    preparable-        """-        UPDATE keiro.keiro_timers-        SET status = 'fired',-            fired_event_id = $2,-            updated_at = now()-        WHERE timer_id = $1-          AND status = 'firing'-        """-        ( contrazip2-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.uuid))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_timers+    SET status = 'fired',+        fired_event_id = $2,+        updated_at = now()+    WHERE timer_id = $1+      AND status = 'firing'+    """+    ( contrazip2+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.uuid))+    )+    ((> 0) <$> D.rowsAffected)  countDueTimersStmt :: Statement UTCTime Int countDueTimersStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_timers-        WHERE status = 'scheduled'-          AND fire_at <= $1-        """-        (E.param (E.nonNullable E.timestamptz))-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_timers+    WHERE status = 'scheduled'+      AND fire_at <= $1+    """+    (E.param (E.nonNullable E.timestamptz))+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))  countStuckTimersStmt :: Statement (Maybe UTCTime, Maybe Int64) Int countStuckTimersStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_timers-        WHERE status = 'firing'-          AND ($1::timestamptz IS NULL OR updated_at <= $1)-          AND ($2::bigint IS NULL OR attempts >= $2)-        """-        ( contrazip2-            (E.param (E.nullable E.timestamptz))-            (E.param (E.nullable E.int8))-        )-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_timers+    WHERE status = 'firing'+      AND ($1::timestamptz IS NULL OR updated_at <= $1)+      AND ($2::bigint IS NULL OR attempts >= $2)+    """+    ( contrazip2+        (E.param (E.nullable E.timestamptz))+        (E.param (E.nullable E.int8))+    )+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))  findStuckTimersStmt :: Statement (Maybe UTCTime, Maybe Int64) [TimerRow] findStuckTimersStmt =-    preparable-        """-        SELECT timer_id, process_manager_name, correlation_id, fire_at,-          payload, status, attempts, fired_event_id-        FROM keiro.keiro_timers-        WHERE status = 'firing'-          AND ($1::timestamptz IS NULL OR updated_at <= $1)-          AND ($2::bigint IS NULL OR attempts >= $2)-        ORDER BY updated_at, timer_id-        """-        ( contrazip2-            (E.param (E.nullable E.timestamptz))-            (E.param (E.nullable E.int8))-        )-        (D.rowList timerRowDecoder)+  preparable+    """+    SELECT timer_id, process_manager_name, correlation_id, fire_at,+      payload, status, attempts, fired_event_id+    FROM keiro.keiro_timers+    WHERE status = 'firing'+      AND ($1::timestamptz IS NULL OR updated_at <= $1)+      AND ($2::bigint IS NULL OR attempts >= $2)+    ORDER BY updated_at, timer_id+    """+    ( contrazip2+        (E.param (E.nullable E.timestamptz))+        (E.param (E.nullable E.int8))+    )+    (D.rowList timerRowDecoder)  requeueStuckTimersStmt :: Statement UTCTime Int requeueStuckTimersStmt =-    preparable-        """-        UPDATE keiro.keiro_timers-        SET status = 'scheduled',-            updated_at = now()-        WHERE status = 'firing'-          AND updated_at <= $1-        """-        (E.param (E.nonNullable E.timestamptz))-        (fromIntegral <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_timers+    SET status = 'scheduled',+        updated_at = now()+    WHERE status = 'firing'+      AND updated_at <= $1+    """+    (E.param (E.nonNullable E.timestamptz))+    (fromIntegral <$> D.rowsAffected)  requeueStuckTimerStmt :: Statement UUID Bool requeueStuckTimerStmt =-    preparable-        """-        UPDATE keiro.keiro_timers-        SET status = 'scheduled',-            updated_at = now()-        WHERE timer_id = $1-          AND status = 'firing'-        """-        (E.param (E.nonNullable E.uuid))-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_timers+    SET status = 'scheduled',+        updated_at = now()+    WHERE timer_id = $1+      AND status = 'firing'+    """+    (E.param (E.nonNullable E.uuid))+    ((> 0) <$> D.rowsAffected)  cancelTimerStmt :: Statement UUID Bool cancelTimerStmt =-    preparable-        """-        UPDATE keiro.keiro_timers-        SET status = 'cancelled',-            updated_at = now()-        WHERE timer_id = $1-          AND status IN ('scheduled', 'firing')-        """-        (E.param (E.nonNullable E.uuid))-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_timers+    SET status = 'cancelled',+        updated_at = now()+    WHERE timer_id = $1+      AND status IN ('scheduled', 'firing')+    """+    (E.param (E.nonNullable E.uuid))+    ((> 0) <$> D.rowsAffected)  deadLetterTimerStmt :: Statement (UUID, Text) Bool deadLetterTimerStmt =-    preparable-        """-        UPDATE keiro.keiro_timers-        SET status = 'dead',-            last_error = $2,-            updated_at = now()-        WHERE timer_id = $1-          AND status IN ('scheduled', 'firing')-        """-        ( contrazip2-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_timers+    SET status = 'dead',+        last_error = $2,+        updated_at = now()+    WHERE timer_id = $1+      AND status IN ('scheduled', 'firing')+    """+    ( contrazip2+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)  timerRowDecoder :: D.Row TimerRow timerRowDecoder =-    TimerRow-        <$> (TimerId <$> D.column (D.nonNullable D.uuid))-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable D.jsonb)-        <*> D.column (D.nonNullable (D.refine statusFromText D.text))-        <*> (fromIntegral <$> D.column (D.nonNullable D.int8))-        <*> (fmap EventId <$> D.column (D.nullable D.uuid))+  TimerRow+    <$> (TimerId <$> D.column (D.nonNullable D.uuid))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.jsonb)+    <*> D.column (D.nonNullable (D.refine statusFromText D.text))+    <*> (fromIntegral <$> D.column (D.nonNullable D.int8))+    <*> (fmap EventId <$> D.column (D.nullable D.uuid))  statusToText :: TimerStatus -> Text statusToText = \case-    Scheduled -> "scheduled"-    Firing -> "firing"-    Fired -> "fired"-    Cancelled -> "cancelled"-    Dead -> "dead"+  Scheduled -> "scheduled"+  Firing -> "firing"+  Fired -> "fired"+  Cancelled -> "cancelled"+  Dead -> "dead"  statusFromText :: Text -> Either Text TimerStatus statusFromText = \case-    "scheduled" -> Right Scheduled-    "firing" -> Right Firing-    "fired" -> Right Fired-    "cancelled" -> Right Cancelled-    "dead" -> Right Dead-    other -> Left ("unknown keiro_timers.status: " <> other)+  "scheduled" -> Right Scheduled+  "firing" -> Right Firing+  "fired" -> Right Fired+  "cancelled" -> Right Cancelled+  "dead" -> Right Dead+  other -> Left ("unknown keiro_timers.status: " <> other)  timerIdToUuid :: TimerId -> UUID timerIdToUuid (TimerId uuid) = uuid
src/Keiro/Timer/Types.hs view
@@ -1,35 +1,32 @@-{- | Identity and request types for durable timers, separated from their SQL-storage ("Keiro.Timer.Schema") so process-manager code can construct timer-requests without depending on the persistence layer.--}-module Keiro.Timer.Types (-    TimerId (..),+-- | Identity and request types for durable timers, separated from their SQL+-- storage ("Keiro.Timer.Schema") so process-manager code can construct timer+-- requests without depending on the persistence layer.+module Keiro.Timer.Types+  ( TimerId (..),     TimerRequest (..),-)+  ) where  import Data.UUID (UUID) import Keiro.Prelude -{- | A timer's stable identifier. A caller-chosen id makes scheduling-idempotent (rescheduling the same id updates rather than duplicates).--}+-- | A timer's stable identifier. A caller-chosen id makes scheduling+-- idempotent (rescheduling the same id updates rather than duplicates). newtype TimerId = TimerId UUID-    deriving stock (Generic, Eq, Ord, Show)--{- | A request to schedule a timer.+  deriving stock (Generic, Eq, Ord, Show) -* 'timerId' — stable id; rescheduling the same id is idempotent.-* 'processManagerName' \/ 'correlationId' — identify the saga instance the-  timer belongs to, so the firing can be routed back to it.-* 'fireAt' — the earliest time the timer becomes due.-* 'payload' — opaque JSON carried through to the @fire@ action.--}+-- | A request to schedule a timer.+--+-- * 'timerId' — stable id; rescheduling the same id is idempotent.+-- * 'processManagerName' \/ 'correlationId' — identify the saga instance the+--   timer belongs to, so the firing can be routed back to it.+-- * 'fireAt' — the earliest time the timer becomes due.+-- * 'payload' — opaque JSON carried through to the @fire@ action. data TimerRequest = TimerRequest-    { timerId :: !TimerId-    , processManagerName :: !Text-    , correlationId :: !Text-    , fireAt :: !UTCTime-    , payload :: !Value-    }-    deriving stock (Generic, Eq, Show)+  { timerId :: !TimerId,+    processManagerName :: !Text,+    correlationId :: !Text,+    fireAt :: !UTCTime,+    payload :: !Value+  }+  deriving stock (Generic, Eq, Show)
src/Keiro/Wake.hs view
@@ -1,48 +1,47 @@-{- | A wake signal for keiro's poll-loop workers (EP-50, LISTEN/NOTIFY push delivery).--keiro's background workers — the workflow resume worker, the durable-timer worker,-the outbox publisher — make progress by polling: run one "claim, process, commit"-pass, then sleep a fixed interval. That fixed sleep is also the worst-case latency-(the resume worker's default is a full second). This module lets a worker instead-/wait to be woken/: it blocks until either a relevant append notification arrives —-meaning "something was appended, go look" — or a bounded fallback timeout elapses.--== Where the wake comes from (no new connection)--The kiroku event store already fires a Postgres @NOTIFY@ on channel @\<schema\>.events@-(default @kiroku.events@) on every append, via a @notify_events()@ trigger on the-@streams@ table, and already runs __one dedicated long-lived listener connection per-store__ ('Kiroku.Store.Notification.Notifier', started once by-'Kiroku.Store.Connection.withStore'). That listener fans every notification out to an-in-process broadcast channel, @notifier.tickChan :: 'Control.Concurrent.STM.TChan' ()@.--A 'WakeSignal' built by 'wakeSignalFromStore' duplicates that broadcast channel-('Control.Concurrent.STM.dupTChan' — an STM operation, __not__ a database connection),-so N keiro workers over one store share the single existing listener connection and N-cheap STM cursors. Push therefore adds __zero__ new long-lived connections: the only-listener connection is kiroku's pre-existing @kiroku-listener@, amortized across all-subscribers. The query pool is sized exactly as before.--== Push is an optimization over a durable poll, never a replacement--A Postgres @NOTIFY@ is best-effort: if the listener is momentarily disconnected the-notification is dropped, and the payload is advisory. So correctness must never depend-on a notification arriving. 'waitForWake' always takes a fallback timeout: a missed-notification only delays the next pass to that fallback interval, exactly as the old-fixed-poll loop did. The channel and payload are kiroku's-(@\<schema\>.events@; @stream_name,stream_id,stream_version@); keiro treats the-notification as an opaque wake and re-queries durably, so it ignores the payload.--}-module Keiro.Wake (-    WakeSignal (..),+-- | A wake signal for keiro's poll-loop workers (EP-50, LISTEN/NOTIFY push delivery).+--+-- keiro's background workers — the workflow resume worker, the durable-timer worker,+-- the outbox publisher — make progress by polling: run one "claim, process, commit"+-- pass, then sleep a fixed interval. That fixed sleep is also the worst-case latency+-- (the resume worker's default is a full second). This module lets a worker instead+-- /wait to be woken/: it blocks until either a relevant append notification arrives —+-- meaning "something was appended, go look" — or a bounded fallback timeout elapses.+--+-- == Where the wake comes from (no new connection)+--+-- The kiroku event store already fires a Postgres @NOTIFY@ on channel @\<schema\>.events@+-- (default @kiroku.events@) on every append, via a @notify_events()@ trigger on the+-- @streams@ table, and already runs __one dedicated long-lived listener connection per+-- store__ ('Kiroku.Store.Notification.Notifier', started once by+-- 'Kiroku.Store.Connection.withStore'). That listener fans every notification out to an+-- in-process broadcast channel, @notifier.tickChan :: 'Control.Concurrent.STM.TChan' ()@.+--+-- A 'WakeSignal' built by 'wakeSignalFromStore' duplicates that broadcast channel+-- ('Control.Concurrent.STM.dupTChan' — an STM operation, __not__ a database connection),+-- so N keiro workers over one store share the single existing listener connection and N+-- cheap STM cursors. Push therefore adds __zero__ new long-lived connections: the only+-- listener connection is kiroku's pre-existing @kiroku-listener@, amortized across all+-- subscribers. The query pool is sized exactly as before.+--+-- == Push is an optimization over a durable poll, never a replacement+--+-- A Postgres @NOTIFY@ is best-effort: if the listener is momentarily disconnected the+-- notification is dropped, and the payload is advisory. So correctness must never depend+-- on a notification arriving. 'waitForWake' always takes a fallback timeout: a missed+-- notification only delays the next pass to that fallback interval, exactly as the old+-- fixed-poll loop did. The channel and payload are kiroku's+-- (@\<schema\>.events@; @stream_name,stream_id,stream_version@); keiro treats the+-- notification as an opaque wake and re-queries durably, so it ignores the payload.+module Keiro.Wake+  ( WakeSignal (..),     WakeReason (..),     wakeSignalFromStore,     neverWake,-)+  ) where -import Control.Concurrent.STM (-    TChan,+import Control.Concurrent.STM+  ( TChan,     atomically,     check,     dupTChan,@@ -51,62 +50,57 @@     readTChan,     readTVar,     registerDelay,- )+  ) import Kiroku.Store.Connection (KirokuStore (..)) import Kiroku.Store.Notification (Notifier (..)) -{- | Why a 'waitForWake' returned: a notification arrived, or the fallback timeout-elapsed. Both mean "run another pass"; the distinction is available for telemetry.--}+-- | Why a 'waitForWake' returned: a notification arrived, or the fallback timeout+-- elapsed. Both mean "run another pass"; the distinction is available for telemetry. data WakeReason = WokenByNotify | WokenByTimeout-    deriving stock (Eq, Show)+  deriving stock (Eq, Show) -{- | A source of "something was appended, go look" wake-ups, layered over a bounded-fallback timeout so a missed notification never stalls progress.--}+-- | A source of "something was appended, go look" wake-ups, layered over a bounded+-- fallback timeout so a missed notification never stalls progress. newtype WakeSignal = WakeSignal-    { waitForWake :: Int -> IO WakeReason-    {- ^ Block until a notification arrives OR the given fallback timeout-    (microseconds) elapses, whichever is first. Returns which happened.-    -}-    }+  { -- | Block until a notification arrives OR the given fallback timeout+    --     (microseconds) elapses, whichever is first. Returns which happened.+    waitForWake :: Int -> IO WakeReason+  } -{- | Build a 'WakeSignal' from a running kiroku store's notifier. Duplicates the-store's broadcast tick channel ('dupTChan') __once__ here, so this consumer has its-own cursor and never steals another consumer's ticks, and so ticks arriving between-waits queue in the duplicated channel rather than being lost. Opens __no__ new-database connection: it rides the single dedicated listener connection the store-already holds.--}+-- | Build a 'WakeSignal' from a running kiroku store's notifier. Duplicates the+-- store's broadcast tick channel ('dupTChan') __once__ here, so this consumer has its+-- own cursor and never steals another consumer's ticks, and so ticks arriving between+-- waits queue in the duplicated channel rather than being lost. Opens __no__ new+-- database connection: it rides the single dedicated listener connection the store+-- already holds. wakeSignalFromStore :: KirokuStore -> IO WakeSignal wakeSignalFromStore store = do-    myChan <- atomically (dupTChan (tickChan (notifier store)))-    pure (WakeSignal (waitOn myChan))+  myChan <- atomically (dupTChan (tickChan (notifier store)))+  pure (WakeSignal (waitOn myChan))   where     waitOn :: TChan () -> Int -> IO WakeReason     waitOn myChan timeoutMicros = do-        timer <- registerDelay timeoutMicros-        atomically $-            ( do-                -- A tick is queued: collapse any backlog so one wait returns once per-                -- "there is new work" episode (the worker re-queries durably anyway).-                _ <- readTChan myChan-                drain myChan-                pure WokenByNotify-            )-                `orElse` (readTVar timer >>= check >> pure WokenByTimeout)+      timer <- registerDelay timeoutMicros+      atomically $+        ( do+            -- A tick is queued: collapse any backlog so one wait returns once per+            -- "there is new work" episode (the worker re-queries durably anyway).+            _ <- readTChan myChan+            drain myChan+            pure WokenByNotify+        )+          `orElse` (readTVar timer >>= check >> pure WokenByTimeout)      drain ch = do-        empty <- isEmptyTChan ch-        if empty then pure () else readTChan ch >> drain ch+      empty <- isEmptyTChan ch+      if empty then pure () else readTChan ch >> drain ch -{- | A 'WakeSignal' that never fires a notification — every wait elapses the fallback-timeout. Used to simulate "all NOTIFYs dropped" (proving push is an optimization over-the durable poll) and to give a fixed-poll worker an unchanged cadence under the same-push-aware driver.--}+-- | A 'WakeSignal' that never fires a notification — every wait elapses the fallback+-- timeout. Used to simulate "all NOTIFYs dropped" (proving push is an optimization over+-- the durable poll) and to give a fixed-poll worker an unchanged cadence under the same+-- push-aware driver. neverWake :: WakeSignal neverWake = WakeSignal $ \timeoutMicros -> do-    timer <- registerDelay timeoutMicros-    atomically (readTVar timer >>= check)-    pure WokenByTimeout+  timer <- registerDelay timeoutMicros+  atomically (readTVar timer >>= check)+  pure WokenByTimeout
src/Keiro/Workflow.hs view
@@ -1,1087 +1,1051 @@ {-# LANGUAGE TypeFamilies #-} -{- | The durable workflow runtime: the @Workflow@ effect, named-step-journaling, replay, and suspension.--== What this gives you--Write a long-running process as an ordinary @effectful@ computation and run-it with 'runWorkflow'. Each @'step' name action@ either runs @action@ and-records ("journals") its result, or — on a replay after a crash — returns the-previously recorded result /without/ re-running the side effect. The journal-is a kiroku stream named @wf:\<name\>-\<id\>@ ('workflowStreamName'); there is-no separate history table. Because a workflow can pause (waiting for a timer,-an external signal, or a child), 'runWorkflow' returns a 'WorkflowOutcome'-('Completed' or 'Suspended').--Step side effects are at-least-once across process crashes. If the process-crashes after @action@ runs but before the journal append commits, a later-resume has no record of that step and runs @action@ again. Step bodies that call-external systems must therefore be idempotent, typically by deriving an-idempotency key from the workflow identity and step name and passing it to the-external system.--Replay is keyed by step name, not by source position or code identity. Renaming-a step intentionally orphans the old journal entry and runs the renamed step as-new work; changing the meaning of a step while keeping the same name is the-author's responsibility. Use 'patch' for cross-cutting workflow-body changes-that need an explicit old/new branch.--== Contract recap for downstream plans (the v2 MasterPlan)--* The authoring surface is the @Workflow@ effect with 'step', 'awaitStep',-  'currentWorkflow', and 'freshOrdinal'. Add new primitives (sleep,-  awakeable, child) as functions that go /through/ this effect so a single-  import stays the workflow surface.-* 'awaitStep' is the suspension primitive every wake source builds on: it-  returns a journaled result if present, otherwise runs an idempotent-  /arming/ action once and suspends the run. The arming action MUST be-  idempotent — a suspended-then-resumed workflow re-enters 'awaitStep' from-  the top on every resume until the result is journaled, so it re-runs @arm@-  each time (e.g. schedule a timer with a deterministic id so repeats-  collapse to a no-op).-* A wake source's external completion path (a timer firing,-  @signalAwakeable@, a child finishing) calls 'appendJournalEntry' (or-  'appendJournalEntryReturningId') with a 'StepRecorded' whose @stepName@ is-  the awaited step name; the next 'runWorkflow' then takes the 'awaitStep'-  hit path and proceeds.-* The journal codec ('workflowJournalCodec') and the reserved step-name-  prefixes ('sleepStepPrefix' = @"sleep:"@, 'awakeableStepPrefix' = @"awk:"@,-  'childStepPrefix' = @"child:"@) are integration contracts: suspensions are-  journaled as ordinary 'StepRecorded' events with these prefixes, never as-  new event types, so the replay loop stays uniform.-* Per-run options live in one record, 'WorkflowRunOptions' (EP-41 adds a-  snapshot policy, EP-44 adds metrics/tracer); 'runWorkflowWith' is the-  single canonical entry EP-42's resume worker re-invokes through.-* The derived @keiro_workflows@ instance row is maintained by journal append-  transactions. Terminal markers ('WorkflowCompleted', 'WorkflowCancelled',-  'WorkflowFailed') freeze the instance as completed/cancelled/failed, and the-  resume worker uses its attempt/lease fields for crash recovery.-* Discovery (EP-42) is 'findUnfinishedWorkflowIds' plus 'completedStepName';-  it needs no kiroku prefix subscription.--> __Build gotcha__ (EP-38's migration adds @keiro_workflow_steps@): adding a-> new @.sql@ file under @keiro-migrations/sql-migrations/@ does not trigger-> recompilation of @Keiro.Migrations@ (cabal says "Up to date" even with-> @-fforce-recomp@, because @embedDir@ is a Template Haskell directory read-> GHC's recompilation checker does not track per-file). After adding a-> migration, edit a comment in @keiro-migrations/src/Keiro/Migrations.hs@ or-> run @cabal clean@ before building.--}-module Keiro.Workflow (-    -- * The effect and authoring surface-    Workflow,-    step,-    awaitStep,-    currentWorkflow,-    currentRunGeneration,-    freshOrdinal,-    continueAsNew,-    restoreSeed,-    patch,--    -- * Running a workflow-    runWorkflow,-    runWorkflowWith,-    WorkflowRunOptions (..),-    LeaseHeartbeat (..),-    defaultWorkflowRunOptions,--    -- * Journal append helpers (used by wake-source plans)-    JournalAppendOutcome (..),-    prepareJournalAppend,-    appendJournalEntry,-    appendJournalEntryReturningId,-    deterministicJournalId,--    -- * Errors thrown by the runtime-    WorkflowError (..),-    WorkflowLeaseLost (..),--    -- * Re-exported core contracts-    module Keiro.Workflow.Types,-    WorkflowStepRow (..),-    recordStepTx,-    loadStepIndex,-    stepExists,-    currentGeneration,-    findUnfinishedWorkflowIds,-    setWorkflowWakeAfterTx,-    clearWorkflowWakeAfterTx,-)-where--import Control.Exception (Exception)-import Data.Aeson qualified as Aeson-import Data.IORef (-    IORef,-    atomicModifyIORef',-    newIORef,-    readIORef,- )-import Data.Int (Int32)-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Set (Set)-import Data.Set qualified as Set-import Data.Text qualified as Text-import Data.Time (NominalDiffTime)-import Data.UUID.V5 qualified as UUID.V5-import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, (:>))-import Effectful.Dispatch.Dynamic (EffectHandler, interpret, localSeqUnlift, send)-import Effectful.Error.Static (Error, tryError)-import Effectful.Exception (bracket_, catch, throwIO)-import Keiro.Codec (decodeRecorded, encodeForAppendWithMetadata)-import Keiro.EventStream (SnapshotPolicy (..), Terminality (..))-import Keiro.Prelude-import Keiro.Snapshot (SnapshotMissReason (..))-import Keiro.Snapshot.Policy (shouldSnapshot)-import Keiro.Telemetry (-    KeiroMetrics,-    Tracer,-    recordSnapshotDecodeFailures,-    recordSnapshotReadHits,-    recordSnapshotReadMisses,-    recordSnapshotWriteFailures,-    recordWorkflowActive,-    recordWorkflowJournalLength,-    recordWorkflowStepExecuted,-    recordWorkflowStepReplayed,-    withWorkflowSpan,- )-import Keiro.Workflow.Instance (-    WorkflowStatus (..),-    markInstanceSuspended,-    renewInstanceLease,-    upsertInstanceTx,- )-import Keiro.Workflow.Schema (WorkflowStepRow (..), clearWorkflowWakeAfterTx, currentGeneration, findUnfinishedWorkflowIds, loadStepIndex, lockWorkflowStepTx, lookupStepResult, lookupStepResultTx, recordStepTx, setWorkflowWakeAfterTx, stepExists)-import Keiro.Workflow.Snapshot (lookupWorkflowSnapshot, writeWorkflowSnapshot)-import Keiro.Workflow.Types-import Kiroku.Store.Effect (Store)-import Kiroku.Store.Error (StoreError)-import Kiroku.Store.Read (readStreamForwardStream)-import Kiroku.Store.Transaction (AppendConflict, appendToStreamTx, prepareEventsIO, runTransaction)-import Kiroku.Store.Types (AppendResult (..), EventData, EventId (..), ExpectedVersion (..), StreamId, StreamVersion (..))-import Streamly.Data.Fold qualified as Fold-import Streamly.Data.Stream qualified as Streamly-import System.IO.Unsafe (unsafePerformIO)-import "hasql-transaction" Hasql.Transaction qualified as Tx---- ------------------------------------------------------------------------------ The effect--- -----------------------------------------------------------------------------{- | The durable workflow effect. Its operations are interpreted by-'runWorkflow' / 'runWorkflowWith', which journal and replay them.--}-data Workflow :: Effect where-    {- | Run a side-effecting action under a name, journaling its result; on-    replay, return the recorded result without re-running the action.-    -}-    Step :: (Aeson.ToJSON a, Aeson.FromJSON a) => StepName -> m a -> Workflow m a-    {- | Return the awaited step's journaled result, or run the (idempotent)-    arming action once and suspend the run.-    -}-    Await :: (Aeson.FromJSON a) => StepName -> m () -> Workflow m a-    -- | The running workflow's identity (for keying wake sources).-    CurrentWorkflow :: Workflow m (WorkflowName, WorkflowId)-    -- | The journal generation this run is operating on.-    CurrentRunGeneration :: Workflow m Int-    -- | A per-run, per-namespace counter for deterministic ordinal step names.-    FreshOrdinal :: Text -> Workflow m Int-    {- | EP-48: snapshot the carried seed, rotate onto a fresh journal generation,-    and unwind this run; the next run/resume continues from the seed. Never-    returns to the caller within this run (result type is fully polymorphic).-    -}-    ContinueAsNew :: (Aeson.ToJSON s) => s -> Workflow m a-    {- | EP-49: decide and journal a cross-cutting branch — returns the stable-    'Bool' branch decision for the given patch. Fresh instances get 'True'-    (new branch); instances already in flight when the patch shipped get-    'False' (old branch). The decision is journaled on first encounter and-    replayed verbatim thereafter.-    -}-    Patch :: PatchId -> Workflow m Bool--type instance DispatchOf Workflow = Dynamic--{- | Run @action@ under @name@, journaling its encoded result. On a replay where-@name@ is already journaled, the recorded result is returned and @action@ is-not run. If the process crashed after @action@ ran but before the journal-commit, the action runs again on resume: workflow step side effects are-at-least-once at the step boundary.--The returned value is always the JSON round-trip of the recorded result,-including on the first run. A lossy or rejecting @ToJSON@\/@FromJSON@ pair is-therefore observed immediately rather than only after a crash and replay.--Requires @'Aeson.ToJSON' a@ (to journal the result) and @'Aeson.FromJSON' a@-(to decode it on replay).--}-step :: (Workflow :> es, Aeson.ToJSON a, Aeson.FromJSON a) => StepName -> Eff es a -> Eff es a-step name action = send (Step name action)--{- | Look up @name@ in the journal. If a wake source has already recorded its-completion (a 'StepRecorded' whose @stepName@ is @name@, carrying the-resolved result), decode and return it. Otherwise run @arm@ exactly once-(the wake source's idempotent job — schedule a timer, register an awakeable,-spawn a child) and __suspend__ this run, so 'runWorkflow' returns 'Suspended'.--@arm@ must be idempotent: every resume re-runs it until the result is-journaled.--}-awaitStep :: (Workflow :> es, Aeson.FromJSON a) => StepName -> Eff es () -> Eff es a-awaitStep name arm = send (Await name arm)---- | The identity of the workflow currently running.-currentWorkflow :: (Workflow :> es) => Eff es (WorkflowName, WorkflowId)-currentWorkflow = send CurrentWorkflow--{- | The journal generation this run is operating on. Wake sources include it-in their durable identities so a generation opened by 'continueAsNew' never-collides with prior-generation rows.--}-currentRunGeneration :: (Workflow :> es) => Eff es Int-currentRunGeneration = send CurrentRunGeneration--{- | A per-run, per-namespace counter (starting at 0). Used by convenience-forms of wake sources (e.g. @sleep@ → @"sleep:0"@) to derive a deterministic,-replay-stable ordinal name. Note: ordinal names are only stable if the order-of @awaitStep@-style calls does not change across deploys; the named forms-are the stable primitives.--}-freshOrdinal :: (Workflow :> es) => Text -> Eff es Int-freshOrdinal namespace = send (FreshOrdinal namespace)--{- | Continue this workflow /as new/ (EP-48): snapshot the carried @seed@ onto a-fresh journal generation, journal a terminal rotation marker on the current-generation, and unwind this run. The next run or resume of the same logical-@('WorkflowName', 'WorkflowId')@ starts against the fresh generation, hydrated-from the seed, with an empty (bounded) journal.--This is how a workflow that runs an /unbounded/ number of steps — a poller, a-per-day rolling process — keeps its per-generation journal bounded so replay-and hydration stay fast forever. The result type is fully polymorphic (@a@)-because control never returns to the caller within /this/ run: the rotated-continuation runs in the next run/resume. Read the carried seed back at the top-of the workflow body with 'restoreSeed'.--}-continueAsNew :: (Workflow :> es, Aeson.ToJSON s) => s -> Eff es a-continueAsNew seed = send (ContinueAsNew seed)--{- | Restore the seed carried by the previous generation's 'continueAsNew', or-return @def@ on the first generation (EP-48). Implemented as an ordinary-journaled @step@ under the reserved 'continueSeedStepName': on a generation that-was rotated into, the seed step was journaled (and snapshotted) by the rotation,-so this @step@ hits it and returns the carried value without re-running; on the-very first generation it misses and records @def@. Call it once at the top of a-workflow body that uses 'continueAsNew'.--}-restoreSeed :: (Workflow :> es, Aeson.ToJSON s, Aeson.FromJSON s) => s -> Eff es s-restoreSeed def = step (StepName continueSeedStepName) (pure def)--{- | Decide a cross-cutting branch for an in-flight-vs-fresh code change, and-journal the decision so every later replay observes the same branch (EP-49).--@patch (PatchId "fraud-check-v2")@ returns 'True' only when that id was present-in 'activePatches' when this workflow generation first started. The generation-records its active set under 'patchSetStepName' exactly once; on the first-encounter each individual patch decision is journaled under @patch:\<patchId\>@,-and every replay returns the recorded 'Bool'. Add a patch id to 'activePatches'-in the deploy that introduces the corresponding 'patch' call; remove it only-after deleting that call from the workflow body.--This is an /escape hatch/ for changes that cross-cut multiple steps. For the-common case — one step changed — do __not__ use 'patch': rename the step's-'StepName' instead. A renamed step has no journaled history under its new name,-so its action runs fresh on the next replay, which is exactly the right-behaviour for a single-step change. Reach for 'patch' only when an in-flight-instance would be left incoherent by the new code (e.g. the change adds, removes,-or reorders steps, or changes the meaning of an already-journaled step result).--}-patch :: (Workflow :> es) => PatchId -> Eff es Bool-patch pid = send (Patch pid)---- ------------------------------------------------------------------------------ Per-run options--- -----------------------------------------------------------------------------{- | Lease renewal coordinates for a resume-worker-owned workflow run.--The runtime renews this lease immediately before each fresh step action and-unresolved await arm. Direct 'runWorkflow' calls leave it 'Nothing'.--}-data LeaseHeartbeat = LeaseHeartbeat-    { owner :: !Text-    , ttl :: !NominalDiffTime-    }-    deriving stock (Generic, Eq, Show)--{- | Options for a single workflow run. This is the canonical home for-per-run options across the v2 initiative — EP-41 adds the snapshot policy,-EP-44 adds metrics/tracer fields, all additive. Extend it additively; never-break the field set EP-38/EP-41 established.--}-data WorkflowRunOptions = WorkflowRunOptions-    { snapshotPolicy :: !(SnapshotPolicy WorkflowState)-    {- ^ When to persist a snapshot of the accumulated step-result map after a-    step append (and at completion, for 'OnTerminal'). Default 'Never'-    (EP-38 behaviour: every run/resume does a full version-0 replay).-    -}-    , pageSize :: !Int32-    -- ^ Page size for the journal pre-load read.-    , metrics :: !(Maybe KeiroMetrics)-    {- ^ EP-44: when 'Just', the runtime records the @keiro.workflow.*@ instruments-    (steps executed/replayed, active count, journal length). 'Nothing' is the-    no-op default, so a run with 'defaultWorkflowRunOptions' records nothing.-    -}-    , tracer :: !(Maybe Tracer)-    {- ^ EP-44: when 'Just', the runtime opens a @workflow \<name\>@ 'Internal' span-    around the run. 'Nothing' runs the body unwrapped.-    -}-    , activePatches :: !(Set PatchId)-    {- ^ Patch ids currently active in this deployed workflow code. A fresh-    workflow generation records this set once under 'patchSetStepName', and-    each 'patch' call returns 'True' iff its id was in that recorded set.-    -}-    , leaseHeartbeat :: !(Maybe LeaseHeartbeat)-    {- ^ Resume-worker lease coordinates. When present, fresh workflow-    boundaries renew the lease and throw 'WorkflowLeaseLost' if another owner-    has taken it. 'Nothing' keeps direct runs free of lease traffic.-    -}-    }-    deriving stock (Generic)--{- | Sensible defaults: no snapshotting, a journal pre-load page size of 100,-and no telemetry (metrics/tracer 'Nothing'). A default-options run replays-and behaves exactly as EP-38 did.--}-defaultWorkflowRunOptions :: WorkflowRunOptions-defaultWorkflowRunOptions =-    WorkflowRunOptions-        { snapshotPolicy = Never-        , pageSize = 100-        , metrics = Nothing-        , tracer = Nothing-        , activePatches = Set.empty-        , leaseHeartbeat = Nothing-        }---- ------------------------------------------------------------------------------ Errors and the suspension sentinel--- -----------------------------------------------------------------------------{- | Errors the workflow runtime raises (via 'throwIO', so they surface-through the surrounding store/IO error channel).--}-data WorkflowError-    = {- | A journaled step result could not be decoded into the type the-      replaying @step@/@awaitStep@ expects (step name, decode message). The-      result type changed incompatibly — a programmer error.-      -}-      WorkflowStepDecodeError !Text !Text-    | -- | A journal event could not be decoded during pre-load.-      WorkflowJournalDecodeError !Text-    | -- | A journal event could not be encoded for append.-      WorkflowJournalEncodeError !Text-    | -- | Appending a journal entry failed for a non-conflict reason.-      WorkflowJournalAppendError !Text-    deriving stock (Eq, Show)--instance Exception WorkflowError--{- | The resume worker no longer owns the workflow instance lease.--Thrown before a fresh step action or unresolved await arm, so the run stops-before performing further side effects. Resume workers classify this as a-lease skip rather than a workflow crash.--}-data WorkflowLeaseLost = WorkflowLeaseLost-    deriving stock (Eq, Show)--instance Exception WorkflowLeaseLost---- | Internal sentinel thrown to unwind a suspended run up to 'runWorkflowWith'.-data WorkflowSuspend = WorkflowSuspend-    deriving stock (Show)--instance Exception WorkflowSuspend---- | Internal sentinel thrown when a cancellation marker appears mid-run.-data WorkflowCancelPending = WorkflowCancelPending-    deriving stock (Show)--instance Exception WorkflowCancelPending--{- | Internal sentinel thrown by the 'ContinueAsNew' handler to unwind a-rotating run up to 'runWorkflowWith' (EP-48), carrying the JSON-encoded seed for-the next generation. Mirrors 'WorkflowSuspend': a non-returning unwind the run-entry point catches and turns into an outcome ('ContinuedAsNew').--}-newtype WorkflowRotate = WorkflowRotate Aeson.Value-    deriving stock (Show)--instance Exception WorkflowRotate---- ------------------------------------------------------------------------------ Running--- -----------------------------------------------------------------------------{- | Process-wide count of workflow runs currently in flight, backing the-@keiro.workflow.active@ gauge (EP-44). 'runWorkflowWith' brackets each run with-@+1@/@-1@ and samples the gauge on both edges, so the exported last-value-reflects the true live count whether a run is mid-flight or finished. A-process-global 'IORef' is the lightest faithful implementation (the gauge is a-last-value-wins level, not a per-run delta), mirroring how the other keiro-backlog/level gauges are recorded with a value the runtime already holds.--}-{-# NOINLINE activeCountRef #-}-activeCountRef :: IORef Int64-activeCountRef = unsafePerformIO (newIORef 0)--{- | Run a workflow computation, journaling each 'step' and replaying any-already-journaled steps. Returns 'Completed' when the computation finishes-(a 'WorkflowCompleted' marker is journaled) or 'Suspended' when it pauses at-an unresolved 'awaitStep'.--Equivalent to @'runWorkflowWith' 'defaultWorkflowRunOptions'@.--}-runWorkflow ::-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    WorkflowName ->-    WorkflowId ->-    Eff (Workflow : es) a ->-    Eff es (WorkflowOutcome a)-runWorkflow = runWorkflowWith defaultWorkflowRunOptions--{- | 'runWorkflow' with explicit 'WorkflowRunOptions'. This is the single-canonical run entry point; EP-42's resume worker re-invokes through it so-resumed runs honor the same options.--If the workflow's journal already carries a 'WorkflowCancelled' marker (a child-cancelled by its parent, EP-43), the run short-circuits immediately and returns-'Cancelled' without executing any step. The handler also re-checks that marker-on step/await/patch miss paths and after a fresh step action returns, so a-mid-run cancellation stops at the next workflow boundary. A cancellation that-lands after the check but before/during the user action may still let that one-action run; durable workflow steps remain at-least-once at boundaries. If the-journal carries a 'WorkflowFailed' marker, the run likewise short-circuits to-'Failed'. To /propagate/ a finished child's result to its parent, drive the-child through 'Keiro.Workflow.Child.runChildWorkflow' rather than this function-directly.--}-runWorkflowWith ::-    forall a es.-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    WorkflowRunOptions ->-    WorkflowName ->-    WorkflowId ->-    Eff (Workflow : es) a ->-    Eff es (WorkflowOutcome a)-runWorkflowWith options name wid action = do-    -- EP-48: resolve the CURRENT (highest) generation once per run and operate-    -- only on it. A never-rotating workflow stays at generation 0, so naming,-    -- load, and append are byte-for-byte as before. A rotated workflow resolves-    -- to its newest generation, so discovery/resume transparently continue there.-    gen <- currentGeneration name wid-    -- Cancellation short-circuit (EP-43): a workflow whose journal carries a-    -- WorkflowCancelled marker makes no further progress. The index row for that-    -- marker is keyed under 'cancelledStepName' on the current generation, so a-    -- single existence check is enough and we never run the user action.-    cancelled <- stepExists name wid gen cancelledStepName-    failed <- stepExists name wid gen failedStepName-    case (cancelled, failed) of-        (True, _) -> pure Cancelled-        (_, True) -> pure Failed-        _ -> runActive gen-  where-    -- EP-44 telemetry handles, pulled from the run options once. Both default-    -- to 'Nothing' (see 'defaultWorkflowRunOptions'), so a default-options run-    -- records nothing and opens no span — the no-op idiom holds end to end.-    mMetrics = options ^. #metrics-    mTracer = options ^. #tracer-    runActive :: Int -> Eff es (WorkflowOutcome a)-    runActive gen =-        -- EP-44: maintain the process-wide live-run count and sample the-        -- @keiro.workflow.active@ gauge on both entry and exit, and open the-        -- whole-run @workflow \<name\>@ span (step 'Nothing'). The body is-        -- unchanged from EP-41 except for the journal-length recording below.-        bracket_-            (liftIO (atomicModifyIORef' activeCountRef (\n -> (n + 1, ()))) >> sampleActive)-            (liftIO (atomicModifyIORef' activeCountRef (\n -> (n - 1, ()))) >> sampleActive)-            (withWorkflowSpan mTracer name wid Nothing (\_sp -> interpreted))-      where-        sampleActive = liftIO (readIORef activeCountRef) >>= recordWorkflowActive mMetrics-        interpreted = do-            initial <- loadJournal options name wid gen-            initial' <- recordPatchSetIfFresh gen initial-            journalRef <- liftIO (newIORef initial')-            ordinalRef <- liftIO (newIORef Map.empty)-            let runHandler = interpret (handler gen journalRef ordinalRef) action-            outcome <--                (Completed <$> runHandler)-                    `catch` (\WorkflowSuspend -> pure Suspended)-                    `catch` (\WorkflowCancelPending -> pure Cancelled)-                    `catch` ( \(WorkflowRotate seedJson) ->-                                rotateGeneration-                                    mMetrics-                                    (options ^. #activePatches)-                                    name-                                    wid-                                    gen-                                    seedJson-                            )-            case outcome of-                Completed result -> do-                    now <- liftIO getCurrentTime-                    finalMap <- liftIO (readIORef journalRef)-                    -- Idempotent: only appends (and so only snapshots) when the completion-                    -- marker is not already journaled. On a replay of an already-completed-                    -- workflow this is 'Nothing' and no terminal snapshot is taken (one was-                    -- already taken on the original completing run, if the policy fired).-                    mAppend <- appendCompletion name wid gen now-                    for_ mAppend $ \appendResult ->-                        when-                            ( shouldSnapshot-                                (options ^. #snapshotPolicy)-                                Terminal-                                finalMap-                                (appendResult ^. #streamVersion)-                            )-                            (writeWorkflowSnapshotAdvisory mMetrics (appendResult ^. #streamId) (appendResult ^. #streamVersion) finalMap)-                    -- EP-44: record one @keiro.workflow.journal.length@ observation per-                    -- completing run (the 'Completed' path only, never 'Suspended'),-                    -- including a replay that completes again. Length is the recorded-                    -- step map plus the WorkflowCompleted marker.-                    recordWorkflowJournalLength mMetrics (fromIntegral (Map.size finalMap + 1))-                    pure (Completed result)-                Suspended -> markInstanceSuspended name wid >> pure Suspended-                Cancelled -> pure Cancelled-                Failed -> pure Failed-                -- EP-48: the run unwound via 'WorkflowRotate'; 'rotateGeneration'-                -- already journaled the seed step on the next generation and the-                -- rotation marker on this one, so there is nothing more to do here.-                ContinuedAsNew -> pure ContinuedAsNew-        -- Generation 0 has no rotation moment at which to record the patch set,-        -- so it retains the fresh-journal path. Rotated generations receive the-        -- set atomically with their seed in 'rotateGeneration'; this fallback-        -- also keeps generations produced by a pre-change worker compatible.-        recordPatchSetIfFresh runGen initial = do-            let patches = options ^. #activePatches-                freshStart = Map.keysSet initial `Set.isSubsetOf` Set.singleton continueSeedStepName-            if freshStart && not (Set.null patches)-                then do-                    let encoded = Aeson.toJSON (map unPatchId (Set.toList patches))-                    now <- liftIO getCurrentTime-                    appendJournal name wid runGen (StepRecorded patchSetStepName encoded now) >>= \case-                        JournalAppended{} -> pure (Map.insert patchSetStepName encoded initial)-                        JournalAlreadyPresent stored -> pure (Map.insert patchSetStepName stored initial)-                        JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))-                else pure initial-    handler ::-        Int ->-        IORef (Map Text Aeson.Value) ->-        IORef (Map Text Int) ->-        EffectHandler Workflow es-    handler gen journalRef ordinalRef env operation = case operation of-        Step (StepName key) act -> do-            journal <- liftIO (readIORef journalRef)-            case Map.lookup key journal of-                Just stored -> do-                    -- Hit: the step is already journaled, so its recorded result is-                    -- returned without re-running @act@ — a replay.-                    recordWorkflowStepReplayed mMetrics 1-                    decodeStored key stored-                Nothing -> do-                    renewLease-                    checkCancellationPending name wid gen-                    a <- localSeqUnlift env (\unlift -> unlift act)-                    checkCancellationPending name wid gen-                    let encoded = Aeson.toJSON a-                    now <- liftIO getCurrentTime-                    appendOutcome <- appendJournal name wid gen (StepRecorded key encoded now)-                    case appendOutcome of-                        JournalAppended appendResult -> do-                            -- Miss: @act@ ran and was journaled — a fresh execution.-                            recordWorkflowStepExecuted mMetrics 1-                            newMap <--                                liftIO-                                    ( atomicModifyIORef' journalRef $ \m ->-                                        let m' = Map.insert key encoded m in (m', m')-                                    )-                            -- Evaluate the snapshot policy on the post-append map and version;-                            -- a step is never the terminal marker, hence @False@.-                            when-                                ( shouldSnapshot-                                    (options ^. #snapshotPolicy)-                                    NotTerminal-                                    newMap-                                    (appendResult ^. #streamVersion)-                                )-                                (writeWorkflowSnapshotAdvisory mMetrics (appendResult ^. #streamId) (appendResult ^. #streamVersion) newMap)-                            decodeStored key encoded-                        JournalAlreadyPresent stored -> do-                            liftIO-                                ( atomicModifyIORef' journalRef $ \m ->-                                    (Map.insert key stored m, ())-                                )-                            decodeStored key stored-                        JournalAppendConflict err ->-                            throwIO (WorkflowJournalAppendError (Text.pack (show err)))-        Await (StepName key) arm -> do-            journal <- liftIO (readIORef journalRef)-            case Map.lookup key journal of-                Just stored -> do-                    -- An awaitStep hit means the wake source already resolved this step;-                    -- the recorded result is returned without arming — a replay. An-                    -- awaitStep miss arms and suspends: no user @action@ ran, so it is-                    -- not a step execution and records nothing here.-                    recordWorkflowStepReplayed mMetrics 1-                    decodeStored key stored-                Nothing ->-                    -- The in-memory map can omit a wake completion journaled-                    -- while a snapshotting run was mid-flight. The step index-                    -- is written transactionally with every append, so consult-                    -- it before arming and suspending.-                    lookupStepResult name wid gen key >>= \case-                        Just stored -> do-                            liftIO-                                ( atomicModifyIORef' journalRef $ \m ->-                                    (Map.insert key stored m, ())-                                )-                            recordWorkflowStepReplayed mMetrics 1-                            decodeStored key stored-                        Nothing -> do-                            renewLease-                            checkCancellationPending name wid gen-                            localSeqUnlift env (\unlift -> unlift arm)-                            throwIO WorkflowSuspend-        CurrentWorkflow -> pure (name, wid)-        CurrentRunGeneration -> pure gen-        FreshOrdinal namespace ->-            liftIO . atomicModifyIORef' ordinalRef $ \counters ->-                let n = Map.findWithDefault 0 namespace counters-                 in (Map.insert namespace (n + 1) counters, n)-        -- EP-48: encode the carried seed and throw the rotation sentinel, which-        -- 'runWorkflowWith' catches and turns into 'rotateGeneration'. Never-        -- returns to the caller within this run (result type is polymorphic).-        ContinueAsNew seed -> throwIO (WorkflowRotate (Aeson.toJSON seed))-        -- EP-49: decide and journal a cross-cutting branch. Mirrors the 'Step'-        -- hit/miss shape, but the miss path computes the decision from the-        -- patch set recorded when this workflow generation first started.-        Patch pid -> do-            let key = patchStepName pid-            journal <- liftIO (readIORef journalRef)-            case Map.lookup key journal of-                Just stored ->-                    -- Hit: the decision was made on an earlier run; replay it verbatim.-                    decodeStored key stored-                Nothing -> do-                    checkCancellationPending name wid gen-                    recordedSet <- case Map.lookup patchSetStepName journal of-                        Nothing -> pure []-                        Just stored -> decodeStored patchSetStepName stored-                    let decision = unPatchId pid `elem` (recordedSet :: [Text])-                        encoded = Aeson.toJSON decision-                    now <- liftIO getCurrentTime-                    appendOutcome <- appendJournal name wid gen (StepRecorded key encoded now)-                    case appendOutcome of-                        JournalAppended{} -> do-                            liftIO-                                ( atomicModifyIORef' journalRef $ \m ->-                                    (Map.insert key encoded m, ())-                                )-                            pure decision-                        JournalAlreadyPresent stored -> do-                            liftIO-                                ( atomicModifyIORef' journalRef $ \m ->-                                    (Map.insert key stored m, ())-                                )-                            decodeStored key stored-                        JournalAppendConflict err ->-                            throwIO (WorkflowJournalAppendError (Text.pack (show err)))-      where-        renewLease =-            for_ (options ^. #leaseHeartbeat) $ \heartbeat -> do-                renewed <--                    renewInstanceLease-                        (heartbeat ^. #owner)-                        (heartbeat ^. #ttl)-                        name-                        wid-                unless renewed (throwIO WorkflowLeaseLost)---- | Decode a stored journal result into the type the caller expects.-decodeStored :: (Aeson.FromJSON a) => Text -> Aeson.Value -> Eff es a-decodeStored key stored = case Aeson.fromJSON stored of-    Aeson.Success a -> pure a-    Aeson.Error message -> throwIO (WorkflowStepDecodeError key (Text.pack message))--checkCancellationPending :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Eff es ()-checkCancellationPending name wid gen = do-    cancelled <- stepExists name wid gen cancelledStepName-    when cancelled (throwIO WorkflowCancelPending)--{- | Pre-load a workflow's journal stream into a @step name -> result@ map.--If a compatible snapshot exists ('loadWorkflowSnapshot'), seed the map from it-and read only the journal events /after/ the snapshot's version ("tail-replay"). The reconstructed map is the journal state as the snapshotting run-saw it. A wake completion journaled concurrently with that run can fall at or-before the snapshot version yet be absent from the seed, so the map may-under-approximate the journal. The @Await@ handler compensates by consulting-the authoritative @keiro_workflow_steps@ index on a map miss; that index is-written transactionally with every journal append.--A missing, mismatched, or undecodable snapshot is recorded as a miss (and, for-undecodable bytes, a decode failure) before the read falls back to a full-replay from version 0.-'WorkflowCompleted' contributes nothing to the map.--}-loadJournal ::-    (IOE :> es, Store :> es) =>-    WorkflowRunOptions ->-    WorkflowName ->-    WorkflowId ->-    Int ->-    Eff es (Map Text Aeson.Value)-loadJournal options name wid gen = do-    let journalName = workflowGenerationStreamName name wid gen-    snapshot <- lookupWorkflowSnapshot journalName-    (seedMap, fromVersion) <- case snapshot of-        Right (m, v) -> do-            recordSnapshotReadHits (options ^. #metrics) 1-            pure (m, v)-        Left reason -> do-            recordSnapshotReadMisses (options ^. #metrics) 1-            case reason of-                SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1-                _ -> pure ()-            pure (Map.empty, StreamVersion 0)-    let-        events = readStreamForwardStream journalName fromVersion (options ^. #pageSize)-    Streamly.fold (Fold.foldlM' accumulate (pure seedMap)) events-  where-    accumulate journal recorded =-        case decodeRecorded workflowJournalCodec recorded of-            Right (StepRecorded key value _) -> pure (Map.insert key value journal)-            Right (WorkflowCompleted _) -> pure journal-            Right (WorkflowCancelled _) -> pure journal-            Right (WorkflowFailed _ _) -> pure journal-            Right (WorkflowContinuedAsNew _ _) -> pure journal -- a rotation marker carries no step result-            Left err -> throwIO (WorkflowJournalDecodeError (Text.pack (show err)))---- ------------------------------------------------------------------------------ Journal append helpers--- -----------------------------------------------------------------------------data JournalAppendOutcome-    = JournalAppended !AppendResult-    | JournalAlreadyPresent !Aeson.Value-    | JournalAppendConflict !AppendConflict-    deriving stock (Eq, Show)--prepareJournalAppend ::-    (IOE :> es) =>-    WorkflowName ->-    WorkflowId ->-    Int ->-    WorkflowJournalEvent ->-    Eff es (Tx.Transaction JournalAppendOutcome)-prepareJournalAppend name wid gen event = do-    let key = journalKey event-        entryId = deterministicJournalId name wid gen key-        requestedEntryId = case event of-            -- Resurrection removes the derived failure-marker index row while-            -- retaining append-only journal history. A later failure on the-            -- same generation therefore needs a fresh UUIDv7; step locking and-            -- the in-transaction index check still deduplicate concurrent-            -- failure writers.-            WorkflowFailed{} -> Nothing-            _ -> Just entryId-        row = journalRow name wid gen event-        (status, mLastError) = instanceStatusForEvent event-        journalName = workflowGenerationStreamName name wid gen-        lockKey =-            Text.intercalate-                "/"-                [unWorkflowId wid, unWorkflowName name, Text.pack (show gen), key]-    base <- case encodeForAppendWithMetadata workflowJournalCodec Nothing event of-        Right encoded -> pure encoded-        Left err -> throwIO (WorkflowJournalEncodeError (Text.pack (show err)))-    let entry = base & #eventId .~ requestedEntryId :: EventData-    prepared <- prepareEventsIO [entry]-    now <- liftIO getCurrentTime-    pure $ do-        lockWorkflowStepTx lockKey-        lookupStepResultTx (unWorkflowId wid) (unWorkflowName name) gen key >>= \case-            Just stored -> pure (JournalAlreadyPresent stored)-            Nothing ->-                appendToStreamTx journalName AnyVersion prepared now >>= \case-                    Left err -> pure (JournalAppendConflict err)-                    Right appendResult ->-                        JournalAppended appendResult-                            <$ recordStepTx row-                            <* upsertInstanceTx-                                (unWorkflowId wid)-                                (unWorkflowName name)-                                (fromIntegral gen)-                                status-                                mLastError--appendJournal :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Int -> WorkflowJournalEvent -> Eff es JournalAppendOutcome-appendJournal name wid gen event =-    prepareJournalAppend name wid gen event >>= runTransaction--{- | Append a journal event to a workflow's journal stream (and keep its-index row consistent), idempotently. If the entry already exists this is a-no-op returning the would-be event id.--This is the integration helper a wake source's external-completion path uses-to record an awaited step's resolution. The append uses a deterministic event-id derived from @("keiro" : "workflow" : name : id : stepName)@ so concurrent-or retried writes collapse to one row.--}-appendJournalEntry :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> WorkflowJournalEvent -> Eff es ()-appendJournalEntry name wid event = void (appendJournalEntryReturningId name wid event)--{- | Like 'appendJournalEntry' but returns the (deterministic) 'EventId' of-the entry. EP-39's fired timer needs this for @markTimerFired@.--}-appendJournalEntryReturningId :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> WorkflowJournalEvent -> Eff es EventId-appendJournalEntryReturningId name wid event = do-    -- EP-48: a wake source (timer fired, signalAwakeable, child completion)-    -- resolves the awaited step on whichever generation the suspended run is-    -- parked on — always the current (highest) one, since runs only ever operate-    -- on the current generation. Resolve it here so the append and its-    -- deterministic id are namespaced by that generation.-    gen <- currentGeneration name wid-    let key = journalKey event-        entryId = deterministicJournalId name wid gen key-    appendJournal name wid gen event >>= \case-        JournalAppended{} -> pure entryId-        JournalAlreadyPresent{} -> pure entryId-        JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))--{- | Append a journal entry only if it is not already journaled, returning the-'AppendResult' of the fresh append (or 'Nothing' if it already existed). Used-on the completion path so a terminal ('OnTerminal') snapshot can be taken from-the completing run's 'AppendResult', while a replay of an already-completed-workflow is a no-op.--}-appendCompletion :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Int -> UTCTime -> Eff es (Maybe AppendResult)-appendCompletion name wid gen now = do-    appendJournal name wid gen (WorkflowCompleted now) >>= \case-        JournalAppended appendResult -> pure (Just appendResult)-        JournalAlreadyPresent{} -> pure Nothing-        JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))--{- | Perform a continue-as-new rotation (EP-48): close generation @gen@ and open-generation @gen + 1@, seeded with @seedJson@ and the deployed patch set. Returns-'ContinuedAsNew'.--The next generation's seed and non-empty patch set are appended in one-transaction before the old generation's rotation marker. The seed advances-@MAX(generation)@ — and therefore 'currentGeneration' — to @gen + 1@, while the-same commit makes patch decisions available before any asynchronous wake writer-can append to the new generation. After that commit, any re-run resolves the-current generation to @gen + 1@, hydrates from the seed and patch set, and never-re-enters generation @gen@. We then append the terminal-'WorkflowContinuedAsNew' marker on generation @gen@. Every append is guarded by-an existence check and uses a deterministic, generation-namespaced id, so the-whole rotation is idempotent.--The seed carries state forward and the patch-set entry freezes code-evolution-decisions. We snapshot their map at the newest fresh append's version so the-next generation hydrates in O(1). The snapshot is advisory, so it is written-unconditionally on rotation regardless of the run's 'snapshotPolicy' — rotation-is exactly when a fresh snapshot earns its keep.--}-rotateGeneration ::-    forall a es.-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    Maybe KeiroMetrics ->-    Set PatchId ->-    WorkflowName ->-    WorkflowId ->-    Int ->-    Aeson.Value ->-    Eff es (WorkflowOutcome a)-rotateGeneration mMetrics patches name wid gen seedJson = do-    let nextGen = gen + 1-        encodedPatches = Aeson.toJSON (map unPatchId (Set.toList patches))-        patchEvent =-            StepRecorded patchSetStepName encodedPatches-    now <- liftIO getCurrentTime-    seedTx <--        prepareJournalAppend-            name-            wid-            nextGen-            (StepRecorded continueSeedStepName seedJson now)-    patchTx <--        if Set.null patches-            then pure Nothing-            else-                Just-                    <$> prepareJournalAppend-                        name-                        wid-                        nextGen-                        (patchEvent now)-    -- 1. Seed and patch set on the NEXT generation in one transaction. A-    -- conflict condemns the whole transaction before it returns its diagnostic-    -- outcome, so no seed-without-patch intermediate state can commit.-    (seedOutcome, patchOutcome) <--        runTransaction $ do-            seedResult <- seedTx-            condemnOnConflict seedResult-            patchResult <- traverse id patchTx-            traverse_ condemnOnConflict patchResult-            pure (seedResult, patchResult)-    throwOnConflict seedOutcome-    traverse_ throwOnConflict patchOutcome-    let seedValue = recordedValue seedJson seedOutcome-        snapshotState =-            maybe-                (Map.singleton continueSeedStepName seedValue)-                ( \outcome ->-                    Map.fromList-                        [ (continueSeedStepName, seedValue)-                        , (patchSetStepName, recordedValue encodedPatches outcome)-                        ]-                )-                patchOutcome-        snapshotAppend =-            case patchOutcome of-                Just (JournalAppended appendResult) -> Just appendResult-                _ -> case seedOutcome of-                    JournalAppended appendResult -> Just appendResult-                    _ -> Nothing-    for_ snapshotAppend $ \appendResult ->-        writeWorkflowSnapshotAdvisory-            mMetrics-            (appendResult ^. #streamId)-            (appendResult ^. #streamVersion)-            snapshotState-    -- 2. Terminal rotation marker on the CURRENT generation (audit + closes it).-    appendJournal name wid gen (WorkflowContinuedAsNew nextGen now) >>= \case-        JournalAppended{} -> pure ()-        JournalAlreadyPresent{} -> pure ()-        JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))-    pure ContinuedAsNew-  where-    condemnOnConflict = \case-        JournalAppendConflict{} -> Tx.condemn-        _ -> pure ()-    throwOnConflict = \case-        JournalAppendConflict err ->-            throwIO (WorkflowJournalAppendError (Text.pack (show err)))-        _ -> pure ()-    recordedValue fallback = \case-        JournalAlreadyPresent stored -> stored-        _ -> fallback--{- | Snapshot a workflow state after its journal append has committed. The-snapshot is advisory: a store failure is counted and cannot turn the-already-durable workflow transition into a failed run.--}-writeWorkflowSnapshotAdvisory ::-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    Maybe KeiroMetrics ->-    StreamId ->-    StreamVersion ->-    WorkflowState ->-    Eff es ()-writeWorkflowSnapshotAdvisory mMetrics streamId version state = do-    -- WorkflowState is already a Map Text Value assembled from journaled step-    -- results, so this path has no aggregate RegFile/uninit encode to guard.-    outcome <- tryError @StoreError (writeWorkflowSnapshot streamId version state)-    case outcome of-        Right () -> pure ()-        Left _ -> recordSnapshotWriteFailures mMetrics 1--instanceStatusForEvent :: WorkflowJournalEvent -> (WorkflowStatus, Maybe Text)-instanceStatusForEvent = \case-    StepRecorded{} -> (WfRunning, Nothing)-    WorkflowCompleted{} -> (WfCompleted, Nothing)-    WorkflowCancelled{} -> (WfCancelled, Nothing)-    WorkflowFailed reason _ -> (WfFailed, Just reason)-    WorkflowContinuedAsNew{} -> (WfRunning, Nothing)---- | The reserved step-name key a journal event indexes under.-journalKey :: WorkflowJournalEvent -> Text-journalKey = \case-    StepRecorded{stepName = key} -> key-    WorkflowCompleted{} -> completedStepName-    WorkflowCancelled{} -> cancelledStepName-    WorkflowFailed{} -> failedStepName-    WorkflowContinuedAsNew{} -> continuedAsNewStepName---- | The index row corresponding to a journal event, on the given generation.-journalRow :: WorkflowName -> WorkflowId -> Int -> WorkflowJournalEvent -> WorkflowStepRow-journalRow name wid gen = \case-    StepRecorded key value t ->-        WorkflowStepRow-            { workflowId = unWorkflowId wid-            , workflowName = unWorkflowName name-            , generation = gen-            , stepName = key-            , result = value-            , recordedAt = t-            }-    WorkflowCompleted t ->-        WorkflowStepRow-            { workflowId = unWorkflowId wid-            , workflowName = unWorkflowName name-            , generation = gen-            , stepName = completedStepName-            , result = Aeson.Null-            , recordedAt = t-            }-    WorkflowCancelled t ->-        WorkflowStepRow-            { workflowId = unWorkflowId wid-            , workflowName = unWorkflowName name-            , generation = gen-            , stepName = cancelledStepName-            , result = Aeson.Null-            , recordedAt = t-            }-    WorkflowFailed r t ->-        WorkflowStepRow-            { workflowId = unWorkflowId wid-            , workflowName = unWorkflowName name-            , generation = gen-            , stepName = failedStepName-            , result = Aeson.toJSON r-            , recordedAt = t-            }-    WorkflowContinuedAsNew g t ->-        WorkflowStepRow-            { workflowId = unWorkflowId wid-            , workflowName = unWorkflowName name-            , generation = gen-            , stepName = continuedAsNewStepName-            , result = Aeson.toJSON g -- the NEXT generation this rotation opens-            , recordedAt = t-            }--{- | A stable, collision-resistant journal-event id from-@("keiro" : "workflow" : name : id : generation : stepName)@ via a v5 UUID.-Mirrors 'Keiro.ProcessManager.deterministicCommandId': the same inputs always-yield the same id, so a re-append of the same step collapses to the same row.--The /generation/ (EP-48) is part of the id so a step named @"s1"@ in-generation 0 and the same name in generation 1 produce __different__ kiroku-event ids — they live on different physical streams, but the event id is-global, so namespacing it by generation keeps rotated generations from-colliding on the deterministic id.--}-deterministicJournalId :: WorkflowName -> WorkflowId -> Int -> Text -> EventId-deterministicJournalId (WorkflowName name) (WorkflowId wid) gen key =-    EventId $-        UUID.V5.generateNamed UUID.V5.namespaceURL $-            fmap (fromIntegral . fromEnum) $-                Text.unpack $-                    Text.intercalate ":" ["keiro", "workflow", name, wid, Text.pack (show gen), key]+-- | The durable workflow runtime: the @Workflow@ effect, named-step+-- journaling, replay, and suspension.+--+-- == What this gives you+--+-- Write a long-running process as an ordinary @effectful@ computation and run+-- it with 'runWorkflow'. Each @'step' name action@ either runs @action@ and+-- records ("journals") its result, or — on a replay after a crash — returns the+-- previously recorded result /without/ re-running the side effect. The journal+-- is a kiroku stream named @wf:\<name\>-\<id\>@ ('workflowStreamName'); there is+-- no separate history table. Because a workflow can pause (waiting for a timer,+-- an external signal, or a child), 'runWorkflow' returns a 'WorkflowOutcome'+-- ('Completed' or 'Suspended').+--+-- Step side effects are at-least-once across process crashes. If the process+-- crashes after @action@ runs but before the journal append commits, a later+-- resume has no record of that step and runs @action@ again. Step bodies that call+-- external systems must therefore be idempotent, typically by deriving an+-- idempotency key from the workflow identity and step name and passing it to the+-- external system.+--+-- Replay is keyed by step name, not by source position or code identity. Renaming+-- a step intentionally orphans the old journal entry and runs the renamed step as+-- new work; changing the meaning of a step while keeping the same name is the+-- author's responsibility. Use 'patch' for cross-cutting workflow-body changes+-- that need an explicit old/new branch.+--+-- == Contract recap for downstream plans (the v2 MasterPlan)+--+-- * The authoring surface is the @Workflow@ effect with 'step', 'awaitStep',+--   'currentWorkflow', and 'freshOrdinal'. Add new primitives (sleep,+--   awakeable, child) as functions that go /through/ this effect so a single+--   import stays the workflow surface.+-- * 'awaitStep' is the suspension primitive every wake source builds on: it+--   returns a journaled result if present, otherwise runs an idempotent+--   /arming/ action once and suspends the run. The arming action MUST be+--   idempotent — a suspended-then-resumed workflow re-enters 'awaitStep' from+--   the top on every resume until the result is journaled, so it re-runs @arm@+--   each time (e.g. schedule a timer with a deterministic id so repeats+--   collapse to a no-op).+-- * A wake source's external completion path (a timer firing,+--   @signalAwakeable@, a child finishing) calls 'appendJournalEntry' (or+--   'appendJournalEntryReturningId') with a 'StepRecorded' whose @stepName@ is+--   the awaited step name; the next 'runWorkflow' then takes the 'awaitStep'+--   hit path and proceeds.+-- * The journal codec ('workflowJournalCodec') and the reserved step-name+--   prefixes ('sleepStepPrefix' = @"sleep:"@, 'awakeableStepPrefix' = @"awk:"@,+--   'childStepPrefix' = @"child:"@) are integration contracts: suspensions are+--   journaled as ordinary 'StepRecorded' events with these prefixes, never as+--   new event types, so the replay loop stays uniform.+-- * Per-run options live in one record, 'WorkflowRunOptions' (EP-41 adds a+--   snapshot policy, EP-44 adds metrics/tracer); 'runWorkflowWith' is the+--   single canonical entry EP-42's resume worker re-invokes through.+-- * The derived @keiro_workflows@ instance row is maintained by journal append+--   transactions. Terminal markers ('WorkflowCompleted', 'WorkflowCancelled',+--   'WorkflowFailed') freeze the instance as completed/cancelled/failed, and the+--   resume worker uses its attempt/lease fields for crash recovery.+-- * Discovery (EP-42) is 'findUnfinishedWorkflowIds' plus 'completedStepName';+--   it needs no kiroku prefix subscription.+--+-- > __Build gotcha__ (EP-38's migration adds @keiro_workflow_steps@): adding a+-- > new @.sql@ file under @keiro-migrations/sql-migrations/@ does not trigger+-- > recompilation of @Keiro.Migrations@ (cabal says "Up to date" even with+-- > @-fforce-recomp@, because @embedDir@ is a Template Haskell directory read+-- > GHC's recompilation checker does not track per-file). After adding a+-- > migration, edit a comment in @keiro-migrations/src/Keiro/Migrations.hs@ or+-- > run @cabal clean@ before building.+module Keiro.Workflow+  ( -- * The effect and authoring surface+    Workflow,+    step,+    awaitStep,+    currentWorkflow,+    currentRunGeneration,+    freshOrdinal,+    continueAsNew,+    restoreSeed,+    patch,++    -- * Running a workflow+    runWorkflow,+    runWorkflowWith,+    WorkflowRunOptions (..),+    LeaseHeartbeat (..),+    defaultWorkflowRunOptions,++    -- * Journal append helpers (used by wake-source plans)+    JournalAppendOutcome (..),+    prepareJournalAppend,+    appendJournalEntry,+    appendJournalEntryReturningId,+    deterministicJournalId,++    -- * Errors thrown by the runtime+    WorkflowError (..),+    WorkflowLeaseLost (..),++    -- * Re-exported core contracts+    module Keiro.Workflow.Types,+    WorkflowStepRow (..),+    recordStepTx,+    loadStepIndex,+    stepExists,+    currentGeneration,+    findUnfinishedWorkflowIds,+    setWorkflowWakeAfterTx,+    clearWorkflowWakeAfterTx,+  )+where++import Control.Exception (Exception)+import Data.Aeson qualified as Aeson+import Data.IORef+  ( IORef,+    atomicModifyIORef',+    newIORef,+    readIORef,+  )+import Data.Int (Int32)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Time (NominalDiffTime)+import Data.UUID.V5 qualified as UUID.V5+import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, (:>))+import Effectful.Dispatch.Dynamic (EffectHandler, interpret, localSeqUnlift, send)+import Effectful.Error.Static (Error, tryError)+import Effectful.Exception (bracket_, catch, throwIO)+import Keiro.Codec (decodeRecorded, encodeForAppendWithMetadata)+import Keiro.EventStream (SnapshotPolicy (..), Terminality (..))+import Keiro.Prelude+import Keiro.Snapshot (SnapshotMissReason (..))+import Keiro.Snapshot.Policy (shouldSnapshot)+import Keiro.Telemetry+  ( KeiroMetrics,+    Tracer,+    recordSnapshotDecodeFailures,+    recordSnapshotReadHits,+    recordSnapshotReadMisses,+    recordSnapshotWriteFailures,+    recordWorkflowActive,+    recordWorkflowJournalLength,+    recordWorkflowStepExecuted,+    recordWorkflowStepReplayed,+    withWorkflowSpan,+  )+import Keiro.Workflow.Instance+  ( WorkflowStatus (..),+    markInstanceSuspended,+    renewInstanceLease,+    upsertInstanceTx,+  )+import Keiro.Workflow.Schema (WorkflowStepRow (..), clearWorkflowWakeAfterTx, currentGeneration, findUnfinishedWorkflowIds, loadStepIndex, lockWorkflowStepTx, lookupStepResult, lookupStepResultTx, recordStepTx, setWorkflowWakeAfterTx, stepExists)+import Keiro.Workflow.Snapshot (lookupWorkflowSnapshot, writeWorkflowSnapshot)+import Keiro.Workflow.Types+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Read (readStreamForwardStream)+import Kiroku.Store.Transaction (AppendConflict, appendToStreamTx, prepareEventsIO, runTransaction)+import Kiroku.Store.Types (AppendResult (..), EventData, EventId (..), ExpectedVersion (..), StreamId, StreamVersion (..))+import Streamly.Data.Fold qualified as Fold+import Streamly.Data.Stream qualified as Streamly+import System.IO.Unsafe (unsafePerformIO)+import "hasql-transaction" Hasql.Transaction qualified as Tx++-- ---------------------------------------------------------------------------+-- The effect+-- ---------------------------------------------------------------------------++-- | The durable workflow effect. Its operations are interpreted by+-- 'runWorkflow' / 'runWorkflowWith', which journal and replay them.+data Workflow :: Effect where+  -- | Run a side-effecting action under a name, journaling its result; on+  --     replay, return the recorded result without re-running the action.+  Step :: (Aeson.ToJSON a, Aeson.FromJSON a) => StepName -> m a -> Workflow m a+  -- | Return the awaited step's journaled result, or run the (idempotent)+  --     arming action once and suspend the run.+  Await :: (Aeson.FromJSON a) => StepName -> m () -> Workflow m a+  -- | The running workflow's identity (for keying wake sources).+  CurrentWorkflow :: Workflow m (WorkflowName, WorkflowId)+  -- | The journal generation this run is operating on.+  CurrentRunGeneration :: Workflow m Int+  -- | A per-run, per-namespace counter for deterministic ordinal step names.+  FreshOrdinal :: Text -> Workflow m Int+  -- | EP-48: snapshot the carried seed, rotate onto a fresh journal generation,+  --     and unwind this run; the next run/resume continues from the seed. Never+  --     returns to the caller within this run (result type is fully polymorphic).+  ContinueAsNew :: (Aeson.ToJSON s) => s -> Workflow m a+  -- | EP-49: decide and journal a cross-cutting branch — returns the stable+  --     'Bool' branch decision for the given patch. Fresh instances get 'True'+  --     (new branch); instances already in flight when the patch shipped get+  --     'False' (old branch). The decision is journaled on first encounter and+  --     replayed verbatim thereafter.+  Patch :: PatchId -> Workflow m Bool++type instance DispatchOf Workflow = Dynamic++-- | Run @action@ under @name@, journaling its encoded result. On a replay where+-- @name@ is already journaled, the recorded result is returned and @action@ is+-- not run. If the process crashed after @action@ ran but before the journal+-- commit, the action runs again on resume: workflow step side effects are+-- at-least-once at the step boundary.+--+-- The returned value is always the JSON round-trip of the recorded result,+-- including on the first run. A lossy or rejecting @ToJSON@\/@FromJSON@ pair is+-- therefore observed immediately rather than only after a crash and replay.+--+-- Requires @'Aeson.ToJSON' a@ (to journal the result) and @'Aeson.FromJSON' a@+-- (to decode it on replay).+step :: (Workflow :> es, Aeson.ToJSON a, Aeson.FromJSON a) => StepName -> Eff es a -> Eff es a+step name action = send (Step name action)++-- | Look up @name@ in the journal. If a wake source has already recorded its+-- completion (a 'StepRecorded' whose @stepName@ is @name@, carrying the+-- resolved result), decode and return it. Otherwise run @arm@ exactly once+-- (the wake source's idempotent job — schedule a timer, register an awakeable,+-- spawn a child) and __suspend__ this run, so 'runWorkflow' returns 'Suspended'.+--+-- @arm@ must be idempotent: every resume re-runs it until the result is+-- journaled.+awaitStep :: (Workflow :> es, Aeson.FromJSON a) => StepName -> Eff es () -> Eff es a+awaitStep name arm = send (Await name arm)++-- | The identity of the workflow currently running.+currentWorkflow :: (Workflow :> es) => Eff es (WorkflowName, WorkflowId)+currentWorkflow = send CurrentWorkflow++-- | The journal generation this run is operating on. Wake sources include it+-- in their durable identities so a generation opened by 'continueAsNew' never+-- collides with prior-generation rows.+currentRunGeneration :: (Workflow :> es) => Eff es Int+currentRunGeneration = send CurrentRunGeneration++-- | A per-run, per-namespace counter (starting at 0). Used by convenience+-- forms of wake sources (e.g. @sleep@ → @"sleep:0"@) to derive a deterministic,+-- replay-stable ordinal name. Note: ordinal names are only stable if the order+-- of @awaitStep@-style calls does not change across deploys; the named forms+-- are the stable primitives.+freshOrdinal :: (Workflow :> es) => Text -> Eff es Int+freshOrdinal namespace = send (FreshOrdinal namespace)++-- | Continue this workflow /as new/ (EP-48): snapshot the carried @seed@ onto a+-- fresh journal generation, journal a terminal rotation marker on the current+-- generation, and unwind this run. The next run or resume of the same logical+-- @('WorkflowName', 'WorkflowId')@ starts against the fresh generation, hydrated+-- from the seed, with an empty (bounded) journal.+--+-- This is how a workflow that runs an /unbounded/ number of steps — a poller, a+-- per-day rolling process — keeps its per-generation journal bounded so replay+-- and hydration stay fast forever. The result type is fully polymorphic (@a@)+-- because control never returns to the caller within /this/ run: the rotated+-- continuation runs in the next run/resume. Read the carried seed back at the top+-- of the workflow body with 'restoreSeed'.+continueAsNew :: (Workflow :> es, Aeson.ToJSON s) => s -> Eff es a+continueAsNew seed = send (ContinueAsNew seed)++-- | Restore the seed carried by the previous generation's 'continueAsNew', or+-- return @def@ on the first generation (EP-48). Implemented as an ordinary+-- journaled @step@ under the reserved 'continueSeedStepName': on a generation that+-- was rotated into, the seed step was journaled (and snapshotted) by the rotation,+-- so this @step@ hits it and returns the carried value without re-running; on the+-- very first generation it misses and records @def@. Call it once at the top of a+-- workflow body that uses 'continueAsNew'.+restoreSeed :: (Workflow :> es, Aeson.ToJSON s, Aeson.FromJSON s) => s -> Eff es s+restoreSeed def = step (StepName continueSeedStepName) (pure def)++-- | Decide a cross-cutting branch for an in-flight-vs-fresh code change, and+-- journal the decision so every later replay observes the same branch (EP-49).+--+-- @patch (PatchId "fraud-check-v2")@ returns 'True' only when that id was present+-- in 'activePatches' when this workflow generation first started. The generation+-- records its active set under 'patchSetStepName' exactly once; on the first+-- encounter each individual patch decision is journaled under @patch:\<patchId\>@,+-- and every replay returns the recorded 'Bool'. Add a patch id to 'activePatches'+-- in the deploy that introduces the corresponding 'patch' call; remove it only+-- after deleting that call from the workflow body.+--+-- This is an /escape hatch/ for changes that cross-cut multiple steps. For the+-- common case — one step changed — do __not__ use 'patch': rename the step's+-- 'StepName' instead. A renamed step has no journaled history under its new name,+-- so its action runs fresh on the next replay, which is exactly the right+-- behaviour for a single-step change. Reach for 'patch' only when an in-flight+-- instance would be left incoherent by the new code (e.g. the change adds, removes,+-- or reorders steps, or changes the meaning of an already-journaled step result).+patch :: (Workflow :> es) => PatchId -> Eff es Bool+patch pid = send (Patch pid)++-- ---------------------------------------------------------------------------+-- Per-run options+-- ---------------------------------------------------------------------------++-- | Lease renewal coordinates for a resume-worker-owned workflow run.+--+-- The runtime renews this lease immediately before each fresh step action and+-- unresolved await arm. Direct 'runWorkflow' calls leave it 'Nothing'.+data LeaseHeartbeat = LeaseHeartbeat+  { owner :: !Text,+    ttl :: !NominalDiffTime+  }+  deriving stock (Generic, Eq, Show)++-- | Options for a single workflow run. This is the canonical home for+-- per-run options across the v2 initiative — EP-41 adds the snapshot policy,+-- EP-44 adds metrics/tracer fields, all additive. Extend it additively; never+-- break the field set EP-38/EP-41 established.+data WorkflowRunOptions = WorkflowRunOptions+  { -- | When to persist a snapshot of the accumulated step-result map after a+    --     step append (and at completion, for 'OnTerminal'). Default 'Never'+    --     (EP-38 behaviour: every run/resume does a full version-0 replay).+    snapshotPolicy :: !(SnapshotPolicy WorkflowState),+    -- | Page size for the journal pre-load read.+    pageSize :: !Int32,+    -- | EP-44: when 'Just', the runtime records the @keiro.workflow.*@ instruments+    --     (steps executed/replayed, active count, journal length). 'Nothing' is the+    --     no-op default, so a run with 'defaultWorkflowRunOptions' records nothing.+    metrics :: !(Maybe KeiroMetrics),+    -- | EP-44: when 'Just', the runtime opens a @workflow \<name\>@ 'Internal' span+    --     around the run. 'Nothing' runs the body unwrapped.+    tracer :: !(Maybe Tracer),+    -- | Patch ids currently active in this deployed workflow code. A fresh+    --     workflow generation records this set once under 'patchSetStepName', and+    --     each 'patch' call returns 'True' iff its id was in that recorded set.+    activePatches :: !(Set PatchId),+    -- | Resume-worker lease coordinates. When present, fresh workflow+    --     boundaries renew the lease and throw 'WorkflowLeaseLost' if another owner+    --     has taken it. 'Nothing' keeps direct runs free of lease traffic.+    leaseHeartbeat :: !(Maybe LeaseHeartbeat)+  }+  deriving stock (Generic)++-- | Sensible defaults: no snapshotting, a journal pre-load page size of 100,+-- and no telemetry (metrics/tracer 'Nothing'). A default-options run replays+-- and behaves exactly as EP-38 did.+defaultWorkflowRunOptions :: WorkflowRunOptions+defaultWorkflowRunOptions =+  WorkflowRunOptions+    { snapshotPolicy = Never,+      pageSize = 100,+      metrics = Nothing,+      tracer = Nothing,+      activePatches = Set.empty,+      leaseHeartbeat = Nothing+    }++-- ---------------------------------------------------------------------------+-- Errors and the suspension sentinel+-- ---------------------------------------------------------------------------++-- | Errors the workflow runtime raises (via 'throwIO', so they surface+-- through the surrounding store/IO error channel).+data WorkflowError+  = -- | A journaled step result could not be decoded into the type the+    --       replaying @step@/@awaitStep@ expects (step name, decode message). The+    --       result type changed incompatibly — a programmer error.+    WorkflowStepDecodeError !Text !Text+  | -- | A journal event could not be decoded during pre-load.+    WorkflowJournalDecodeError !Text+  | -- | A journal event could not be encoded for append.+    WorkflowJournalEncodeError !Text+  | -- | Appending a journal entry failed for a non-conflict reason.+    WorkflowJournalAppendError !Text+  deriving stock (Eq, Show)++instance Exception WorkflowError++-- | The resume worker no longer owns the workflow instance lease.+--+-- Thrown before a fresh step action or unresolved await arm, so the run stops+-- before performing further side effects. Resume workers classify this as a+-- lease skip rather than a workflow crash.+data WorkflowLeaseLost = WorkflowLeaseLost+  deriving stock (Eq, Show)++instance Exception WorkflowLeaseLost++-- | Internal sentinel thrown to unwind a suspended run up to 'runWorkflowWith'.+data WorkflowSuspend = WorkflowSuspend+  deriving stock (Show)++instance Exception WorkflowSuspend++-- | Internal sentinel thrown when a cancellation marker appears mid-run.+data WorkflowCancelPending = WorkflowCancelPending+  deriving stock (Show)++instance Exception WorkflowCancelPending++-- | Internal sentinel thrown by the 'ContinueAsNew' handler to unwind a+-- rotating run up to 'runWorkflowWith' (EP-48), carrying the JSON-encoded seed for+-- the next generation. Mirrors 'WorkflowSuspend': a non-returning unwind the run+-- entry point catches and turns into an outcome ('ContinuedAsNew').+newtype WorkflowRotate = WorkflowRotate Aeson.Value+  deriving stock (Show)++instance Exception WorkflowRotate++-- ---------------------------------------------------------------------------+-- Running+-- ---------------------------------------------------------------------------++-- | Process-wide count of workflow runs currently in flight, backing the+-- @keiro.workflow.active@ gauge (EP-44). 'runWorkflowWith' brackets each run with+-- @+1@/@-1@ and samples the gauge on both edges, so the exported last-value+-- reflects the true live count whether a run is mid-flight or finished. A+-- process-global 'IORef' is the lightest faithful implementation (the gauge is a+-- last-value-wins level, not a per-run delta), mirroring how the other keiro+-- backlog/level gauges are recorded with a value the runtime already holds.+{-# NOINLINE activeCountRef #-}+activeCountRef :: IORef Int64+activeCountRef = unsafePerformIO (newIORef 0)++-- | Run a workflow computation, journaling each 'step' and replaying any+-- already-journaled steps. Returns 'Completed' when the computation finishes+-- (a 'WorkflowCompleted' marker is journaled) or 'Suspended' when it pauses at+-- an unresolved 'awaitStep'.+--+-- Equivalent to @'runWorkflowWith' 'defaultWorkflowRunOptions'@.+runWorkflow ::+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowName ->+  WorkflowId ->+  Eff (Workflow : es) a ->+  Eff es (WorkflowOutcome a)+runWorkflow = runWorkflowWith defaultWorkflowRunOptions++-- | 'runWorkflow' with explicit 'WorkflowRunOptions'. This is the single+-- canonical run entry point; EP-42's resume worker re-invokes through it so+-- resumed runs honor the same options.+--+-- If the workflow's journal already carries a 'WorkflowCancelled' marker (a child+-- cancelled by its parent, EP-43), the run short-circuits immediately and returns+-- 'Cancelled' without executing any step. The handler also re-checks that marker+-- on step/await/patch miss paths and after a fresh step action returns, so a+-- mid-run cancellation stops at the next workflow boundary. A cancellation that+-- lands after the check but before/during the user action may still let that one+-- action run; durable workflow steps remain at-least-once at boundaries. If the+-- journal carries a 'WorkflowFailed' marker, the run likewise short-circuits to+-- 'Failed'. To /propagate/ a finished child's result to its parent, drive the+-- child through 'Keiro.Workflow.Child.runChildWorkflow' rather than this function+-- directly.+runWorkflowWith ::+  forall a es.+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowRunOptions ->+  WorkflowName ->+  WorkflowId ->+  Eff (Workflow : es) a ->+  Eff es (WorkflowOutcome a)+runWorkflowWith options name wid action = do+  -- EP-48: resolve the CURRENT (highest) generation once per run and operate+  -- only on it. A never-rotating workflow stays at generation 0, so naming,+  -- load, and append are byte-for-byte as before. A rotated workflow resolves+  -- to its newest generation, so discovery/resume transparently continue there.+  gen <- currentGeneration name wid+  -- Cancellation short-circuit (EP-43): a workflow whose journal carries a+  -- WorkflowCancelled marker makes no further progress. The index row for that+  -- marker is keyed under 'cancelledStepName' on the current generation, so a+  -- single existence check is enough and we never run the user action.+  cancelled <- stepExists name wid gen cancelledStepName+  failed <- stepExists name wid gen failedStepName+  case (cancelled, failed) of+    (True, _) -> pure Cancelled+    (_, True) -> pure Failed+    _ -> runActive gen+  where+    -- EP-44 telemetry handles, pulled from the run options once. Both default+    -- to 'Nothing' (see 'defaultWorkflowRunOptions'), so a default-options run+    -- records nothing and opens no span — the no-op idiom holds end to end.+    mMetrics = options ^. #metrics+    mTracer = options ^. #tracer+    runActive :: Int -> Eff es (WorkflowOutcome a)+    runActive gen =+      -- EP-44: maintain the process-wide live-run count and sample the+      -- @keiro.workflow.active@ gauge on both entry and exit, and open the+      -- whole-run @workflow \<name\>@ span (step 'Nothing'). The body is+      -- unchanged from EP-41 except for the journal-length recording below.+      bracket_+        (liftIO (atomicModifyIORef' activeCountRef (\n -> (n + 1, ()))) >> sampleActive)+        (liftIO (atomicModifyIORef' activeCountRef (\n -> (n - 1, ()))) >> sampleActive)+        (withWorkflowSpan mTracer name wid Nothing (\_sp -> interpreted))+      where+        sampleActive = liftIO (readIORef activeCountRef) >>= recordWorkflowActive mMetrics+        interpreted = do+          initial <- loadJournal options name wid gen+          initial' <- recordPatchSetIfFresh gen initial+          journalRef <- liftIO (newIORef initial')+          ordinalRef <- liftIO (newIORef Map.empty)+          let runHandler = interpret (handler gen journalRef ordinalRef) action+          outcome <-+            (Completed <$> runHandler)+              `catch` (\WorkflowSuspend -> pure Suspended)+              `catch` (\WorkflowCancelPending -> pure Cancelled)+              `catch` ( \(WorkflowRotate seedJson) ->+                          rotateGeneration+                            mMetrics+                            (options ^. #activePatches)+                            name+                            wid+                            gen+                            seedJson+                      )+          case outcome of+            Completed result -> do+              now <- liftIO getCurrentTime+              finalMap <- liftIO (readIORef journalRef)+              -- Idempotent: only appends (and so only snapshots) when the completion+              -- marker is not already journaled. On a replay of an already-completed+              -- workflow this is 'Nothing' and no terminal snapshot is taken (one was+              -- already taken on the original completing run, if the policy fired).+              mAppend <- appendCompletion name wid gen now+              for_ mAppend $ \appendResult ->+                when+                  ( shouldSnapshot+                      (options ^. #snapshotPolicy)+                      Terminal+                      finalMap+                      (appendResult ^. #streamVersion)+                  )+                  (writeWorkflowSnapshotAdvisory mMetrics (appendResult ^. #streamId) (appendResult ^. #streamVersion) finalMap)+              -- EP-44: record one @keiro.workflow.journal.length@ observation per+              -- completing run (the 'Completed' path only, never 'Suspended'),+              -- including a replay that completes again. Length is the recorded+              -- step map plus the WorkflowCompleted marker.+              recordWorkflowJournalLength mMetrics (fromIntegral (Map.size finalMap + 1))+              pure (Completed result)+            Suspended -> markInstanceSuspended name wid >> pure Suspended+            Cancelled -> pure Cancelled+            Failed -> pure Failed+            -- EP-48: the run unwound via 'WorkflowRotate'; 'rotateGeneration'+            -- already journaled the seed step on the next generation and the+            -- rotation marker on this one, so there is nothing more to do here.+            ContinuedAsNew -> pure ContinuedAsNew+        -- Generation 0 has no rotation moment at which to record the patch set,+        -- so it retains the fresh-journal path. Rotated generations receive the+        -- set atomically with their seed in 'rotateGeneration'; this fallback+        -- also keeps generations produced by a pre-change worker compatible.+        recordPatchSetIfFresh runGen initial = do+          let patches = options ^. #activePatches+              freshStart = Map.keysSet initial `Set.isSubsetOf` Set.singleton continueSeedStepName+          if freshStart && not (Set.null patches)+            then do+              let encoded = Aeson.toJSON (map unPatchId (Set.toList patches))+              now <- liftIO getCurrentTime+              appendJournal name wid runGen (StepRecorded patchSetStepName encoded now) >>= \case+                JournalAppended {} -> pure (Map.insert patchSetStepName encoded initial)+                JournalAlreadyPresent stored -> pure (Map.insert patchSetStepName stored initial)+                JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+            else pure initial+    handler ::+      Int ->+      IORef (Map Text Aeson.Value) ->+      IORef (Map Text Int) ->+      EffectHandler Workflow es+    handler gen journalRef ordinalRef env operation = case operation of+      Step (StepName key) act -> do+        journal <- liftIO (readIORef journalRef)+        case Map.lookup key journal of+          Just stored -> do+            -- Hit: the step is already journaled, so its recorded result is+            -- returned without re-running @act@ — a replay.+            recordWorkflowStepReplayed mMetrics 1+            decodeStored key stored+          Nothing -> do+            renewLease+            checkCancellationPending name wid gen+            a <- localSeqUnlift env (\unlift -> unlift act)+            checkCancellationPending name wid gen+            let encoded = Aeson.toJSON a+            now <- liftIO getCurrentTime+            appendOutcome <- appendJournal name wid gen (StepRecorded key encoded now)+            case appendOutcome of+              JournalAppended appendResult -> do+                -- Miss: @act@ ran and was journaled — a fresh execution.+                recordWorkflowStepExecuted mMetrics 1+                newMap <-+                  liftIO+                    ( atomicModifyIORef' journalRef $ \m ->+                        let m' = Map.insert key encoded m in (m', m')+                    )+                -- Evaluate the snapshot policy on the post-append map and version;+                -- a step is never the terminal marker, hence @False@.+                when+                  ( shouldSnapshot+                      (options ^. #snapshotPolicy)+                      NotTerminal+                      newMap+                      (appendResult ^. #streamVersion)+                  )+                  (writeWorkflowSnapshotAdvisory mMetrics (appendResult ^. #streamId) (appendResult ^. #streamVersion) newMap)+                decodeStored key encoded+              JournalAlreadyPresent stored -> do+                liftIO+                  ( atomicModifyIORef' journalRef $ \m ->+                      (Map.insert key stored m, ())+                  )+                decodeStored key stored+              JournalAppendConflict err ->+                throwIO (WorkflowJournalAppendError (Text.pack (show err)))+      Await (StepName key) arm -> do+        journal <- liftIO (readIORef journalRef)+        case Map.lookup key journal of+          Just stored -> do+            -- An awaitStep hit means the wake source already resolved this step;+            -- the recorded result is returned without arming — a replay. An+            -- awaitStep miss arms and suspends: no user @action@ ran, so it is+            -- not a step execution and records nothing here.+            recordWorkflowStepReplayed mMetrics 1+            decodeStored key stored+          Nothing ->+            -- The in-memory map can omit a wake completion journaled+            -- while a snapshotting run was mid-flight. The step index+            -- is written transactionally with every append, so consult+            -- it before arming and suspending.+            lookupStepResult name wid gen key >>= \case+              Just stored -> do+                liftIO+                  ( atomicModifyIORef' journalRef $ \m ->+                      (Map.insert key stored m, ())+                  )+                recordWorkflowStepReplayed mMetrics 1+                decodeStored key stored+              Nothing -> do+                renewLease+                checkCancellationPending name wid gen+                localSeqUnlift env (\unlift -> unlift arm)+                throwIO WorkflowSuspend+      CurrentWorkflow -> pure (name, wid)+      CurrentRunGeneration -> pure gen+      FreshOrdinal namespace ->+        liftIO . atomicModifyIORef' ordinalRef $ \counters ->+          let n = Map.findWithDefault 0 namespace counters+           in (Map.insert namespace (n + 1) counters, n)+      -- EP-48: encode the carried seed and throw the rotation sentinel, which+      -- 'runWorkflowWith' catches and turns into 'rotateGeneration'. Never+      -- returns to the caller within this run (result type is polymorphic).+      ContinueAsNew seed -> throwIO (WorkflowRotate (Aeson.toJSON seed))+      -- EP-49: decide and journal a cross-cutting branch. Mirrors the 'Step'+      -- hit/miss shape, but the miss path computes the decision from the+      -- patch set recorded when this workflow generation first started.+      Patch pid -> do+        let key = patchStepName pid+        journal <- liftIO (readIORef journalRef)+        case Map.lookup key journal of+          Just stored ->+            -- Hit: the decision was made on an earlier run; replay it verbatim.+            decodeStored key stored+          Nothing -> do+            checkCancellationPending name wid gen+            recordedSet <- case Map.lookup patchSetStepName journal of+              Nothing -> pure []+              Just stored -> decodeStored patchSetStepName stored+            let decision = unPatchId pid `elem` (recordedSet :: [Text])+                encoded = Aeson.toJSON decision+            now <- liftIO getCurrentTime+            appendOutcome <- appendJournal name wid gen (StepRecorded key encoded now)+            case appendOutcome of+              JournalAppended {} -> do+                liftIO+                  ( atomicModifyIORef' journalRef $ \m ->+                      (Map.insert key encoded m, ())+                  )+                pure decision+              JournalAlreadyPresent stored -> do+                liftIO+                  ( atomicModifyIORef' journalRef $ \m ->+                      (Map.insert key stored m, ())+                  )+                decodeStored key stored+              JournalAppendConflict err ->+                throwIO (WorkflowJournalAppendError (Text.pack (show err)))+      where+        renewLease =+          for_ (options ^. #leaseHeartbeat) $ \heartbeat -> do+            renewed <-+              renewInstanceLease+                (heartbeat ^. #owner)+                (heartbeat ^. #ttl)+                name+                wid+            unless renewed (throwIO WorkflowLeaseLost)++-- | Decode a stored journal result into the type the caller expects.+decodeStored :: (Aeson.FromJSON a) => Text -> Aeson.Value -> Eff es a+decodeStored key stored = case Aeson.fromJSON stored of+  Aeson.Success a -> pure a+  Aeson.Error message -> throwIO (WorkflowStepDecodeError key (Text.pack message))++checkCancellationPending :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Eff es ()+checkCancellationPending name wid gen = do+  cancelled <- stepExists name wid gen cancelledStepName+  when cancelled (throwIO WorkflowCancelPending)++-- | Pre-load a workflow's journal stream into a @step name -> result@ map.+--+-- If a compatible snapshot exists ('loadWorkflowSnapshot'), seed the map from it+-- and read only the journal events /after/ the snapshot's version ("tail+-- replay"). The reconstructed map is the journal state as the snapshotting run+-- saw it. A wake completion journaled concurrently with that run can fall at or+-- before the snapshot version yet be absent from the seed, so the map may+-- under-approximate the journal. The @Await@ handler compensates by consulting+-- the authoritative @keiro_workflow_steps@ index on a map miss; that index is+-- written transactionally with every journal append.+--+-- A missing, mismatched, or undecodable snapshot is recorded as a miss (and, for+-- undecodable bytes, a decode failure) before the read falls back to a full+-- replay from version 0.+-- 'WorkflowCompleted' contributes nothing to the map.+loadJournal ::+  (IOE :> es, Store :> es) =>+  WorkflowRunOptions ->+  WorkflowName ->+  WorkflowId ->+  Int ->+  Eff es (Map Text Aeson.Value)+loadJournal options name wid gen = do+  let journalName = workflowGenerationStreamName name wid gen+  snapshot <- lookupWorkflowSnapshot journalName+  (seedMap, fromVersion) <- case snapshot of+    Right (m, v) -> do+      recordSnapshotReadHits (options ^. #metrics) 1+      pure (m, v)+    Left reason -> do+      recordSnapshotReadMisses (options ^. #metrics) 1+      case reason of+        SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1+        _ -> pure ()+      pure (Map.empty, StreamVersion 0)+  let events = readStreamForwardStream journalName fromVersion (options ^. #pageSize)+  Streamly.fold (Fold.foldlM' accumulate (pure seedMap)) events+  where+    accumulate journal recorded =+      case decodeRecorded workflowJournalCodec recorded of+        Right (StepRecorded key value _) -> pure (Map.insert key value journal)+        Right (WorkflowCompleted _) -> pure journal+        Right (WorkflowCancelled _) -> pure journal+        Right (WorkflowFailed _ _) -> pure journal+        Right (WorkflowContinuedAsNew _ _) -> pure journal -- a rotation marker carries no step result+        Left err -> throwIO (WorkflowJournalDecodeError (Text.pack (show err)))++-- ---------------------------------------------------------------------------+-- Journal append helpers+-- ---------------------------------------------------------------------------++data JournalAppendOutcome+  = JournalAppended !AppendResult+  | JournalAlreadyPresent !Aeson.Value+  | JournalAppendConflict !AppendConflict+  deriving stock (Eq, Show)++prepareJournalAppend ::+  (IOE :> es) =>+  WorkflowName ->+  WorkflowId ->+  Int ->+  WorkflowJournalEvent ->+  Eff es (Tx.Transaction JournalAppendOutcome)+prepareJournalAppend name wid gen event = do+  let key = journalKey event+      entryId = deterministicJournalId name wid gen key+      requestedEntryId = case event of+        -- Resurrection removes the derived failure-marker index row while+        -- retaining append-only journal history. A later failure on the+        -- same generation therefore needs a fresh UUIDv7; step locking and+        -- the in-transaction index check still deduplicate concurrent+        -- failure writers.+        WorkflowFailed {} -> Nothing+        _ -> Just entryId+      row = journalRow name wid gen event+      (status, mLastError) = instanceStatusForEvent event+      journalName = workflowGenerationStreamName name wid gen+      lockKey =+        Text.intercalate+          "/"+          [unWorkflowId wid, unWorkflowName name, Text.pack (show gen), key]+  base <- case encodeForAppendWithMetadata workflowJournalCodec Nothing event of+    Right encoded -> pure encoded+    Left err -> throwIO (WorkflowJournalEncodeError (Text.pack (show err)))+  let entry = base & #eventId .~ requestedEntryId :: EventData+  prepared <- prepareEventsIO [entry]+  now <- liftIO getCurrentTime+  pure $ do+    lockWorkflowStepTx lockKey+    lookupStepResultTx (unWorkflowId wid) (unWorkflowName name) gen key >>= \case+      Just stored -> pure (JournalAlreadyPresent stored)+      Nothing ->+        appendToStreamTx journalName AnyVersion prepared now >>= \case+          Left err -> pure (JournalAppendConflict err)+          Right appendResult ->+            JournalAppended appendResult+              <$ recordStepTx row+              <* upsertInstanceTx+                (unWorkflowId wid)+                (unWorkflowName name)+                (fromIntegral gen)+                status+                mLastError++appendJournal :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Int -> WorkflowJournalEvent -> Eff es JournalAppendOutcome+appendJournal name wid gen event =+  prepareJournalAppend name wid gen event >>= runTransaction++-- | Append a journal event to a workflow's journal stream (and keep its+-- index row consistent), idempotently. If the entry already exists this is a+-- no-op returning the would-be event id.+--+-- This is the integration helper a wake source's external-completion path uses+-- to record an awaited step's resolution. The append uses a deterministic event+-- id derived from @("keiro" : "workflow" : name : id : stepName)@ so concurrent+-- or retried writes collapse to one row.+appendJournalEntry :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> WorkflowJournalEvent -> Eff es ()+appendJournalEntry name wid event = void (appendJournalEntryReturningId name wid event)++-- | Like 'appendJournalEntry' but returns the (deterministic) 'EventId' of+-- the entry. EP-39's fired timer needs this for @markTimerFired@.+appendJournalEntryReturningId :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> WorkflowJournalEvent -> Eff es EventId+appendJournalEntryReturningId name wid event = do+  -- EP-48: a wake source (timer fired, signalAwakeable, child completion)+  -- resolves the awaited step on whichever generation the suspended run is+  -- parked on — always the current (highest) one, since runs only ever operate+  -- on the current generation. Resolve it here so the append and its+  -- deterministic id are namespaced by that generation.+  gen <- currentGeneration name wid+  let key = journalKey event+      entryId = deterministicJournalId name wid gen key+  appendJournal name wid gen event >>= \case+    JournalAppended {} -> pure entryId+    JournalAlreadyPresent {} -> pure entryId+    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))++-- | Append a journal entry only if it is not already journaled, returning the+-- 'AppendResult' of the fresh append (or 'Nothing' if it already existed). Used+-- on the completion path so a terminal ('OnTerminal') snapshot can be taken from+-- the completing run's 'AppendResult', while a replay of an already-completed+-- workflow is a no-op.+appendCompletion :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Int -> UTCTime -> Eff es (Maybe AppendResult)+appendCompletion name wid gen now = do+  appendJournal name wid gen (WorkflowCompleted now) >>= \case+    JournalAppended appendResult -> pure (Just appendResult)+    JournalAlreadyPresent {} -> pure Nothing+    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))++-- | Perform a continue-as-new rotation (EP-48): close generation @gen@ and open+-- generation @gen + 1@, seeded with @seedJson@ and the deployed patch set. Returns+-- 'ContinuedAsNew'.+--+-- The next generation's seed and non-empty patch set are appended in one+-- transaction before the old generation's rotation marker. The seed advances+-- @MAX(generation)@ — and therefore 'currentGeneration' — to @gen + 1@, while the+-- same commit makes patch decisions available before any asynchronous wake writer+-- can append to the new generation. After that commit, any re-run resolves the+-- current generation to @gen + 1@, hydrates from the seed and patch set, and never+-- re-enters generation @gen@. We then append the terminal+-- 'WorkflowContinuedAsNew' marker on generation @gen@. Every append is guarded by+-- an existence check and uses a deterministic, generation-namespaced id, so the+-- whole rotation is idempotent.+--+-- The seed carries state forward and the patch-set entry freezes code-evolution+-- decisions. We snapshot their map at the newest fresh append's version so the+-- next generation hydrates in O(1). The snapshot is advisory, so it is written+-- unconditionally on rotation regardless of the run's 'snapshotPolicy' — rotation+-- is exactly when a fresh snapshot earns its keep.+rotateGeneration ::+  forall a es.+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  Maybe KeiroMetrics ->+  Set PatchId ->+  WorkflowName ->+  WorkflowId ->+  Int ->+  Aeson.Value ->+  Eff es (WorkflowOutcome a)+rotateGeneration mMetrics patches name wid gen seedJson = do+  let nextGen = gen + 1+      encodedPatches = Aeson.toJSON (map unPatchId (Set.toList patches))+      patchEvent =+        StepRecorded patchSetStepName encodedPatches+  now <- liftIO getCurrentTime+  seedTx <-+    prepareJournalAppend+      name+      wid+      nextGen+      (StepRecorded continueSeedStepName seedJson now)+  patchTx <-+    if Set.null patches+      then pure Nothing+      else+        Just+          <$> prepareJournalAppend+            name+            wid+            nextGen+            (patchEvent now)+  -- 1. Seed and patch set on the NEXT generation in one transaction. A+  -- conflict condemns the whole transaction before it returns its diagnostic+  -- outcome, so no seed-without-patch intermediate state can commit.+  (seedOutcome, patchOutcome) <-+    runTransaction $ do+      seedResult <- seedTx+      condemnOnConflict seedResult+      patchResult <- traverse id patchTx+      traverse_ condemnOnConflict patchResult+      pure (seedResult, patchResult)+  throwOnConflict seedOutcome+  traverse_ throwOnConflict patchOutcome+  let seedValue = recordedValue seedJson seedOutcome+      snapshotState =+        maybe+          (Map.singleton continueSeedStepName seedValue)+          ( \outcome ->+              Map.fromList+                [ (continueSeedStepName, seedValue),+                  (patchSetStepName, recordedValue encodedPatches outcome)+                ]+          )+          patchOutcome+      snapshotAppend =+        case patchOutcome of+          Just (JournalAppended appendResult) -> Just appendResult+          _ -> case seedOutcome of+            JournalAppended appendResult -> Just appendResult+            _ -> Nothing+  for_ snapshotAppend $ \appendResult ->+    writeWorkflowSnapshotAdvisory+      mMetrics+      (appendResult ^. #streamId)+      (appendResult ^. #streamVersion)+      snapshotState+  -- 2. Terminal rotation marker on the CURRENT generation (audit + closes it).+  appendJournal name wid gen (WorkflowContinuedAsNew nextGen now) >>= \case+    JournalAppended {} -> pure ()+    JournalAlreadyPresent {} -> pure ()+    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  pure ContinuedAsNew+  where+    condemnOnConflict = \case+      JournalAppendConflict {} -> Tx.condemn+      _ -> pure ()+    throwOnConflict = \case+      JournalAppendConflict err ->+        throwIO (WorkflowJournalAppendError (Text.pack (show err)))+      _ -> pure ()+    recordedValue fallback = \case+      JournalAlreadyPresent stored -> stored+      _ -> fallback++-- | Snapshot a workflow state after its journal append has committed. The+-- snapshot is advisory: a store failure is counted and cannot turn the+-- already-durable workflow transition into a failed run.+writeWorkflowSnapshotAdvisory ::+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  Maybe KeiroMetrics ->+  StreamId ->+  StreamVersion ->+  WorkflowState ->+  Eff es ()+writeWorkflowSnapshotAdvisory mMetrics streamId version state = do+  -- WorkflowState is already a Map Text Value assembled from journaled step+  -- results, so this path has no aggregate RegFile/uninit encode to guard.+  outcome <- tryError @StoreError (writeWorkflowSnapshot streamId version state)+  case outcome of+    Right () -> pure ()+    Left _ -> recordSnapshotWriteFailures mMetrics 1++instanceStatusForEvent :: WorkflowJournalEvent -> (WorkflowStatus, Maybe Text)+instanceStatusForEvent = \case+  StepRecorded {} -> (WfRunning, Nothing)+  WorkflowCompleted {} -> (WfCompleted, Nothing)+  WorkflowCancelled {} -> (WfCancelled, Nothing)+  WorkflowFailed reason _ -> (WfFailed, Just reason)+  WorkflowContinuedAsNew {} -> (WfRunning, Nothing)++-- | The reserved step-name key a journal event indexes under.+journalKey :: WorkflowJournalEvent -> Text+journalKey = \case+  StepRecorded {stepName = key} -> key+  WorkflowCompleted {} -> completedStepName+  WorkflowCancelled {} -> cancelledStepName+  WorkflowFailed {} -> failedStepName+  WorkflowContinuedAsNew {} -> continuedAsNewStepName++-- | The index row corresponding to a journal event, on the given generation.+journalRow :: WorkflowName -> WorkflowId -> Int -> WorkflowJournalEvent -> WorkflowStepRow+journalRow name wid gen = \case+  StepRecorded key value t ->+    WorkflowStepRow+      { workflowId = unWorkflowId wid,+        workflowName = unWorkflowName name,+        generation = gen,+        stepName = key,+        result = value,+        recordedAt = t+      }+  WorkflowCompleted t ->+    WorkflowStepRow+      { workflowId = unWorkflowId wid,+        workflowName = unWorkflowName name,+        generation = gen,+        stepName = completedStepName,+        result = Aeson.Null,+        recordedAt = t+      }+  WorkflowCancelled t ->+    WorkflowStepRow+      { workflowId = unWorkflowId wid,+        workflowName = unWorkflowName name,+        generation = gen,+        stepName = cancelledStepName,+        result = Aeson.Null,+        recordedAt = t+      }+  WorkflowFailed r t ->+    WorkflowStepRow+      { workflowId = unWorkflowId wid,+        workflowName = unWorkflowName name,+        generation = gen,+        stepName = failedStepName,+        result = Aeson.toJSON r,+        recordedAt = t+      }+  WorkflowContinuedAsNew g t ->+    WorkflowStepRow+      { workflowId = unWorkflowId wid,+        workflowName = unWorkflowName name,+        generation = gen,+        stepName = continuedAsNewStepName,+        result = Aeson.toJSON g, -- the NEXT generation this rotation opens+        recordedAt = t+      }++-- | A stable, collision-resistant journal-event id from+-- @("keiro" : "workflow" : name : id : generation : stepName)@ via a v5 UUID.+-- Mirrors 'Keiro.ProcessManager.deterministicCommandId': the same inputs always+-- yield the same id, so a re-append of the same step collapses to the same row.+--+-- The /generation/ (EP-48) is part of the id so a step named @"s1"@ in+-- generation 0 and the same name in generation 1 produce __different__ kiroku+-- event ids — they live on different physical streams, but the event id is+-- global, so namespacing it by generation keeps rotated generations from+-- colliding on the deterministic id.+deterministicJournalId :: WorkflowName -> WorkflowId -> Int -> Text -> EventId+deterministicJournalId (WorkflowName name) (WorkflowId wid) gen key =+  EventId $+    UUID.V5.generateNamed UUID.V5.namespaceURL $+      fmap (fromIntegral . fromEnum) $+        Text.unpack $+          Text.intercalate ":" ["keiro", "workflow", name, wid, Text.pack (show gen), key]
src/Keiro/Workflow/Awakeable.hs view
@@ -1,57 +1,56 @@-{- | Awakeables: durable promises an external system resolves.--== What this gives you--A workflow allocates an opaque 'AwakeableId', hands it to some external system-(a webhook handler, a human approver, an LLM tool call), and suspends until-that system /signals/ the id with a result — no polling, no bespoke-"wait for event X then react in a process manager" wiring.--@-approvalFlow :: ('Workflow' ':>' es, 'Store' ':>' es, 'IOE' ':>' es) => Eff es Text-approvalFlow = do-  (aid, await) <- 'awakeableNamed' (StepName \"approval\")  -- allocate the promise-  -- (hand @aid@ to a webhook handler / human / LLM tool here)-  decision <- await                                       -- SUSPEND until signalled-  'Keiro.Workflow.step' (StepName \"use\") (pure (decision <> \"!\"))-@--The @pending@ row is committed as part of the journaled allocation step,-before the id can be returned or handed to an external system. On the __first__-'Keiro.Workflow.runWorkflow' this returns 'Suspended' (the run parked on-@await@). An external caller later runs-@'signalAwakeable' aid \"ok\"@, which flips the row to @completed@ /and/-appends a @StepRecorded \"awk:\<uuid\>\"@ to the workflow's journal. The-__next__ run replays past the now-resolved @await@ and 'Completed's.--== Contract recap for downstream plans (the v2 MasterPlan)--* 'AwakeableId' is journaled randomness: new allocations generate an opaque v4-  UUID and record it under @awkid:\<label\>@ before awaiting @awk:\<uuid\>@.-  Replay reads the journaled id, so a resumed workflow allocates the same id it-  already handed out without making that id guessable from public coordinates.-* 'awakeableNamed' (caller-supplied label) is the __stable primitive__;-  'awakeable' is an ordinal convenience whose label is positional (a fragile-  derivation across code edits — see its Haddock).-* Awakeables journal their completion as ordinary 'StepRecorded' events under-  the reserved @awk:@ prefix ('Keiro.Workflow.awakeableStepPrefix'), never a-  new event type, so EP-38's replay loop stays uniform.-* 'signalAwakeable' is idempotent /and/ crash-safe: it commits the row update-  and journal append in one transaction for new signals, a double signal-  returns 'False' and does not change the recorded value, and a signal of an-  already-@completed@ awakeable re-appends the journal entry from the stored-  payload to repair historical wedges. A signal that loses a row race to-  cancellation returns 'False' without appending.-* 'cancelAwakeable' abandons a still-@pending@ promise; a workflow that-  re-enters its @await@ then throws 'WorkflowAwakeableCancelled', which the-  author can @catch@ for compensation. If uncaught, EP-42's resume worker-  records an attempt, backs off, and eventually appends 'WorkflowFailed' at the-  configured failure ceiling.-* @countPendingAwakeables@ (in "Keiro.Workflow.Awakeable.Schema") backs EP-44's-  @keiro.workflow.awakeables.pending@ gauge.--}-module Keiro.Workflow.Awakeable (-    -- * Awakeable ids+-- | Awakeables: durable promises an external system resolves.+--+-- == What this gives you+--+-- A workflow allocates an opaque 'AwakeableId', hands it to some external system+-- (a webhook handler, a human approver, an LLM tool call), and suspends until+-- that system /signals/ the id with a result — no polling, no bespoke+-- "wait for event X then react in a process manager" wiring.+--+-- @+-- approvalFlow :: ('Workflow' ':>' es, 'Store' ':>' es, 'IOE' ':>' es) => Eff es Text+-- approvalFlow = do+--   (aid, await) <- 'awakeableNamed' (StepName \"approval\")  -- allocate the promise+--   -- (hand @aid@ to a webhook handler / human / LLM tool here)+--   decision <- await                                       -- SUSPEND until signalled+--   'Keiro.Workflow.step' (StepName \"use\") (pure (decision <> \"!\"))+-- @+--+-- The @pending@ row is committed as part of the journaled allocation step,+-- before the id can be returned or handed to an external system. On the __first__+-- 'Keiro.Workflow.runWorkflow' this returns 'Suspended' (the run parked on+-- @await@). An external caller later runs+-- @'signalAwakeable' aid \"ok\"@, which flips the row to @completed@ /and/+-- appends a @StepRecorded \"awk:\<uuid\>\"@ to the workflow's journal. The+-- __next__ run replays past the now-resolved @await@ and 'Completed's.+--+-- == Contract recap for downstream plans (the v2 MasterPlan)+--+-- * 'AwakeableId' is journaled randomness: new allocations generate an opaque v4+--   UUID and record it under @awkid:\<label\>@ before awaiting @awk:\<uuid\>@.+--   Replay reads the journaled id, so a resumed workflow allocates the same id it+--   already handed out without making that id guessable from public coordinates.+-- * 'awakeableNamed' (caller-supplied label) is the __stable primitive__;+--   'awakeable' is an ordinal convenience whose label is positional (a fragile+--   derivation across code edits — see its Haddock).+-- * Awakeables journal their completion as ordinary 'StepRecorded' events under+--   the reserved @awk:@ prefix ('Keiro.Workflow.awakeableStepPrefix'), never a+--   new event type, so EP-38's replay loop stays uniform.+-- * 'signalAwakeable' is idempotent /and/ crash-safe: it commits the row update+--   and journal append in one transaction for new signals, a double signal+--   returns 'False' and does not change the recorded value, and a signal of an+--   already-@completed@ awakeable re-appends the journal entry from the stored+--   payload to repair historical wedges. A signal that loses a row race to+--   cancellation returns 'False' without appending.+-- * 'cancelAwakeable' abandons a still-@pending@ promise; a workflow that+--   re-enters its @await@ then throws 'WorkflowAwakeableCancelled', which the+--   author can @catch@ for compensation. If uncaught, EP-42's resume worker+--   records an attempt, backs off, and eventually appends 'WorkflowFailed' at the+--   configured failure ceiling.+-- * @countPendingAwakeables@ (in "Keiro.Workflow.Awakeable.Schema") backs EP-44's+--   @keiro.workflow.awakeables.pending@ gauge.+module Keiro.Workflow.Awakeable+  ( -- * Awakeable ids     AwakeableId (..),     awakeableIdToUuid,     awakeableIdText,@@ -68,7 +67,7 @@      -- * Errors     WorkflowAwakeableCancelled (..),-)+  ) where  import Control.Exception (Exception)@@ -80,8 +79,8 @@ import Effectful (Eff, IOE, (:>)) import Effectful.Exception (throwIO) import Keiro.Prelude-import Keiro.Workflow (-    JournalAppendOutcome (..),+import Keiro.Workflow+  ( JournalAppendOutcome (..),     StepName (..),     Workflow,     WorkflowError (..),@@ -98,16 +97,16 @@     freshOrdinal,     prepareJournalAppend,     step,- )-import Keiro.Workflow.Awakeable.Schema (-    AwakeableRow,+  )+import Keiro.Workflow.Awakeable.Schema+  ( AwakeableRow,     AwakeableStatus (..),     cancelAwakeableTx,     completeAwakeableTx,     lookupAwakeable,     lookupAwakeableStatusTx,     registerAwakeableTx,- )+  ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx@@ -116,55 +115,51 @@ -- Awakeable ids -- --------------------------------------------------------------------------- -{- | The opaque id of an awakeable. New allocations are random and journaled by-'awakeableNamed'; 'deterministicAwakeableId' is retained only as a legacy-generation-0 adoption helper. The @ToJSON@\/@FromJSON@ instances are over the-inner UUID, so the workflow journal can replay the id and webhook payloads may-carry it.--}+-- | The opaque id of an awakeable. New allocations are random and journaled by+-- 'awakeableNamed'; 'deterministicAwakeableId' is retained only as a legacy+-- generation-0 adoption helper. The @ToJSON@\/@FromJSON@ instances are over the+-- inner UUID, so the workflow journal can replay the id and webhook payloads may+-- carry it. newtype AwakeableId = AwakeableId UUID-    deriving stock (Eq, Show, Generic)-    deriving newtype (ToJSON, FromJSON)+  deriving stock (Eq, Show, Generic)+  deriving newtype (ToJSON, FromJSON)  -- | The raw UUID inside an 'AwakeableId'. awakeableIdToUuid :: AwakeableId -> UUID awakeableIdToUuid (AwakeableId u) = u -{- | The 'AwakeableId' rendered as text — the suffix of the @awk:\<uuid\>@-journal step name an awakeable's completion is recorded under.--}+-- | The 'AwakeableId' rendered as text — the suffix of the @awk:\<uuid\>@+-- journal step name an awakeable's completion is recorded under. awakeableIdText :: AwakeableId -> Text awakeableIdText = UUID.toText . awakeableIdToUuid -{- | The legacy deterministic 'AwakeableId' for a @(workflow name, workflow id,-label)@: a v5 UUID over @(\"keiro\":\"awakeable\":name:id:label)@.--This is predictable from public coordinates, so new code must not hand-derive-ids with it. It remains exported for operators and for generation-0 adoption:-if a pre-change workflow already registered a row under this id, the first-post-change allocation adopts that row so the in-flight promise keeps working.--}+-- | The legacy deterministic 'AwakeableId' for a @(workflow name, workflow id,+-- label)@: a v5 UUID over @(\"keiro\":\"awakeable\":name:id:label)@.+--+-- This is predictable from public coordinates, so new code must not hand-derive+-- ids with it. It remains exported for operators and for generation-0 adoption:+-- if a pre-change workflow already registered a row under this id, the first+-- post-change allocation adopts that row so the in-flight promise keeps working. deterministicAwakeableId :: WorkflowName -> WorkflowId -> Text -> AwakeableId deterministicAwakeableId (WorkflowName name) (WorkflowId wid) label =-    AwakeableId $-        UUID.V5.generateNamed UUID.V5.namespaceURL $-            fmap (fromIntegral . fromEnum) $-                Text.unpack $-                    Text.intercalate ":" ["keiro", "awakeable", name, wid, label]+  AwakeableId $+    UUID.V5.generateNamed UUID.V5.namespaceURL $+      fmap (fromIntegral . fromEnum) $+        Text.unpack $+          Text.intercalate ":" ["keiro", "awakeable", name, wid, label]  -- --------------------------------------------------------------------------- -- Errors -- --------------------------------------------------------------------------- -{- | Thrown out of 'Keiro.Workflow.runWorkflow' when a workflow re-enters the-@await@ of an awakeable that was 'cancelAwakeable'd. A cancelled awakeable will-never be signalled, so suspending forever would be wrong and silently-completing would fabricate a result; the workflow author can @catch@ this to-run compensation. If uncaught, the resume worker records the attempt and-eventually marks the workflow failed at its configured ceiling.--}+-- | Thrown out of 'Keiro.Workflow.runWorkflow' when a workflow re-enters the+-- @await@ of an awakeable that was 'cancelAwakeable'd. A cancelled awakeable will+-- never be signalled, so suspending forever would be wrong and silently+-- completing would fabricate a result; the workflow author can @catch@ this to+-- run compensation. If uncaught, the resume worker records the attempt and+-- eventually marks the workflow failed at its configured ceiling. newtype WorkflowAwakeableCancelled = WorkflowAwakeableCancelled AwakeableId-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  instance Exception WorkflowAwakeableCancelled @@ -172,210 +167,203 @@ -- Authoring surface -- --------------------------------------------------------------------------- -{- | Allocate an awakeable under the stable, caller-supplied @label@. Returns-the 'AwakeableId' (hand it to the external system) and an @await@ action that-'Suspended's the workflow until the awakeable is signalled, then yields the-decoded payload on a later run.--The @label@ is the only fully-deterministic option: it survives code edits that-insert or remove awakeables elsewhere in the workflow (the same robustness-argument EP-38 makes for named steps over positional history). Prefer this over-'awakeable' for anything that may outlive a code change mid-flight.--}+-- | Allocate an awakeable under the stable, caller-supplied @label@. Returns+-- the 'AwakeableId' (hand it to the external system) and an @await@ action that+-- 'Suspended's the workflow until the awakeable is signalled, then yields the+-- decoded payload on a later run.+--+-- The @label@ is the only fully-deterministic option: it survives code edits that+-- insert or remove awakeables elsewhere in the workflow (the same robustness+-- argument EP-38 makes for named steps over positional history). Prefer this over+-- 'awakeable' for anything that may outlive a code change mid-flight. awakeableNamed ::-    (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>-    StepName ->-    Eff es (AwakeableId, Eff es a)+  (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>+  StepName ->+  Eff es (AwakeableId, Eff es a) awakeableNamed (StepName label) = do-    (name, wid) <- currentWorkflow-    gen <- currentRunGeneration-    aid <--        step (StepName (awakeableAllocStepPrefix <> label)) $ do-            allocated <- allocateAwakeableId name wid gen label-            runTransaction $-                registerAwakeableTx-                    (awakeableIdToUuid allocated)-                    (unWorkflowName name)-                    (unWorkflowId wid)-            pure allocated-    let-        stepNm = StepName (awakeableStepPrefix <> awakeableIdText aid)-        await = awaitCancellable name wid aid stepNm-    pure (aid, await)+  (name, wid) <- currentWorkflow+  gen <- currentRunGeneration+  aid <-+    step (StepName (awakeableAllocStepPrefix <> label)) $ do+      allocated <- allocateAwakeableId name wid gen label+      runTransaction $+        registerAwakeableTx+          (awakeableIdToUuid allocated)+          (unWorkflowName name)+          (unWorkflowId wid)+      pure allocated+  let stepNm = StepName (awakeableStepPrefix <> awakeableIdText aid)+      await = awaitCancellable name wid aid stepNm+  pure (aid, await)  allocateAwakeableId ::-    (Store :> es, IOE :> es) =>-    WorkflowName ->-    WorkflowId ->-    Int ->-    Text ->-    Eff es AwakeableId+  (Store :> es, IOE :> es) =>+  WorkflowName ->+  WorkflowId ->+  Int ->+  Text ->+  Eff es AwakeableId allocateAwakeableId name wid gen label-    | gen <= 0 = do-        let legacy = deterministicAwakeableId name wid label-        existing <- lookupAwakeable (awakeableIdToUuid legacy)-        case existing of-            Just _ -> pure legacy-            Nothing -> AwakeableId <$> liftIO UUID.V4.nextRandom-    | otherwise = AwakeableId <$> liftIO UUID.V4.nextRandom+  | gen <= 0 = do+      let legacy = deterministicAwakeableId name wid label+      existing <- lookupAwakeable (awakeableIdToUuid legacy)+      case existing of+        Just _ -> pure legacy+        Nothing -> AwakeableId <$> liftIO UUID.V4.nextRandom+  | otherwise = AwakeableId <$> liftIO UUID.V4.nextRandom -{- | Allocate an awakeable under an ordinal label (the @N@th awakeable in a run-becomes @ord:N@). Convenient, but its determinism is __conditional__: adding or-removing an 'awakeable' call earlier in the workflow shifts every later ordinal-and so changes their derived ids, which corrupts an in-flight workflow exactly-the way EP-38 warns positional history does. Prefer 'awakeableNamed' for-anything that may outlive a code edit.--}+-- | Allocate an awakeable under an ordinal label (the @N@th awakeable in a run+-- becomes @ord:N@). Convenient, but its determinism is __conditional__: adding or+-- removing an 'awakeable' call earlier in the workflow shifts every later ordinal+-- and so changes their derived ids, which corrupts an in-flight workflow exactly+-- the way EP-38 warns positional history does. Prefer 'awakeableNamed' for+-- anything that may outlive a code edit. awakeable ::-    (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>-    Eff es (AwakeableId, Eff es a)+  (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>+  Eff es (AwakeableId, Eff es a) awakeable = do-    n <- freshOrdinal awakeableStepPrefix-    awakeableNamed (StepName ("ord:" <> Text.pack (show n)))+  n <- freshOrdinal awakeableStepPrefix+  awakeableNamed (StepName ("ord:" <> Text.pack (show n))) -{- | EP-38's 'awaitStep', wrapped so that a re-entered @await@ on a-'Cancelled' awakeable throws 'WorkflowAwakeableCancelled' instead of suspending-forever. The check lives /inside/ the arming action because 'awaitStep' runs-@arm@ only on the miss path (the awakeable not yet journaled) and re-runs it on-every resume until it resolves: on a miss we either notice the cancel and throw,-or (re-)register the idempotent @pending@ row and suspend. On the hit path-('signalAwakeable' already journaled the result) @arm@ is never run, so a-signalled-then-cancelled race still returns the signalled value (signal wins — a-resolved promise cannot be un-resolved).--}+-- | EP-38's 'awaitStep', wrapped so that a re-entered @await@ on a+-- 'Cancelled' awakeable throws 'WorkflowAwakeableCancelled' instead of suspending+-- forever. The check lives /inside/ the arming action because 'awaitStep' runs+-- @arm@ only on the miss path (the awakeable not yet journaled) and re-runs it on+-- every resume until it resolves: on a miss we either notice the cancel and throw,+-- or (re-)register the idempotent @pending@ row and suspend. On the hit path+-- ('signalAwakeable' already journaled the result) @arm@ is never run, so a+-- signalled-then-cancelled race still returns the signalled value (signal wins — a+-- resolved promise cannot be un-resolved). awaitCancellable ::-    (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>-    WorkflowName -> WorkflowId -> AwakeableId -> StepName -> Eff es a+  (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>+  WorkflowName -> WorkflowId -> AwakeableId -> StepName -> Eff es a awaitCancellable name wid aid stepNm =-    awaitStep stepNm $ do-        existing <- lookupAwakeable (awakeableIdToUuid aid)-        case existing of-            Just row-                | row ^. #status == Cancelled ->-                    throwIO (WorkflowAwakeableCancelled aid)-                | row ^. #status == Completed-                , Just payload <- row ^. #payload -> do-                    now <- liftIO getCurrentTime-                    appendJournalEntry-                        name-                        wid-                        StepRecorded-                            { stepName = unStepName stepNm-                            , result = payload-                            , recordedAt = now-                            }-            _ ->-                runTransaction $-                    registerAwakeableTx (awakeableIdToUuid aid) (unWorkflowName name) (unWorkflowId wid)+  awaitStep stepNm $ do+    existing <- lookupAwakeable (awakeableIdToUuid aid)+    case existing of+      Just row+        | row ^. #status == Cancelled ->+            throwIO (WorkflowAwakeableCancelled aid)+        | row ^. #status == Completed,+          Just payload <- row ^. #payload -> do+            now <- liftIO getCurrentTime+            appendJournalEntry+              name+              wid+              StepRecorded+                { stepName = unStepName stepNm,+                  result = payload,+                  recordedAt = now+                }+      _ ->+        runTransaction $+          registerAwakeableTx (awakeableIdToUuid aid) (unWorkflowName name) (unWorkflowId wid)  -- --------------------------------------------------------------------------- -- External completion -- --------------------------------------------------------------------------- -{- | Resolve an awakeable from outside the workflow: store @result@ in the-@keiro_awakeables@ row /and/ append a @StepRecorded@ to the owning workflow's-journal so the next run replays past the @await@.--Idempotent and crash-safe:--* Returns 'True' only when /this/ call transitioned the row @pending@ ->-  @completed@; a second signal (or a signal of a @cancelled@ row) returns-  'False' and leaves the stored payload unchanged.-* For a @pending@ row, the row transition and journal append happen in one-  transaction. For an already-@completed@ row, the journal entry is re-appended-  from the stored payload to repair rows wedged before that atomic path existed.-  The append path is idempotent (deterministic event id plus step-index check),-  so a re-append collapses to a no-op once the entry is present.-* If a cancellation wins after this function's initial row read but before its-  guarded completion, the transaction re-reads the status and appends nothing.-  The signal returns 'False', so cancellation cannot both trigger compensation-  and leak a completion value into the workflow journal.--A 'False' return therefore does not mean "nothing happened": the journal may-still have been repaired. Returns 'False' for an unknown id.--}+-- | Resolve an awakeable from outside the workflow: store @result@ in the+-- @keiro_awakeables@ row /and/ append a @StepRecorded@ to the owning workflow's+-- journal so the next run replays past the @await@.+--+-- Idempotent and crash-safe:+--+-- * Returns 'True' only when /this/ call transitioned the row @pending@ ->+--   @completed@; a second signal (or a signal of a @cancelled@ row) returns+--   'False' and leaves the stored payload unchanged.+-- * For a @pending@ row, the row transition and journal append happen in one+--   transaction. For an already-@completed@ row, the journal entry is re-appended+--   from the stored payload to repair rows wedged before that atomic path existed.+--   The append path is idempotent (deterministic event id plus step-index check),+--   so a re-append collapses to a no-op once the entry is present.+-- * If a cancellation wins after this function's initial row read but before its+--   guarded completion, the transaction re-reads the status and appends nothing.+--   The signal returns 'False', so cancellation cannot both trigger compensation+--   and leak a completion value into the workflow journal.+--+-- A 'False' return therefore does not mean "nothing happened": the journal may+-- still have been repaired. Returns 'False' for an unknown id. signalAwakeable :: (IOE :> es, Store :> es, ToJSON r) => AwakeableId -> r -> Eff es Bool signalAwakeable aid result =-    lookupAwakeable (awakeableIdToUuid aid) >>= \case-        Nothing -> pure False-        Just row -> signalAwakeableFrom row result--{- | The transaction-decision core of 'signalAwakeable', exposed so race-contracts can deterministically interpose between the initial row read and the-guarded completion. Normal callers should use 'signalAwakeable'.+  lookupAwakeable (awakeableIdToUuid aid) >>= \case+    Nothing -> pure False+    Just row -> signalAwakeableFrom row result -The supplied row may be stale. This function therefore trusts it only for the-owner coordinates and candidate payload; when a pending-to-completed UPDATE-loses, it re-reads status inside the same transaction and appends only if-another signal completed the row. A winning cancellation gets no append.--}+-- | The transaction-decision core of 'signalAwakeable', exposed so race+-- contracts can deterministically interpose between the initial row read and the+-- guarded completion. Normal callers should use 'signalAwakeable'.+--+-- The supplied row may be stale. This function therefore trusts it only for the+-- owner coordinates and candidate payload; when a pending-to-completed UPDATE+-- loses, it re-reads status inside the same transaction and appends only if+-- another signal completed the row. A winning cancellation gets no append. signalAwakeableFrom ::-    (IOE :> es, Store :> es, ToJSON r) =>-    AwakeableRow ->-    r ->-    Eff es Bool+  (IOE :> es, Store :> es, ToJSON r) =>+  AwakeableRow ->+  r ->+  Eff es Bool signalAwakeableFrom row result-    | row ^. #status == Cancelled = pure False-    | otherwise = do-        now <- liftIO getCurrentTime-        let aid = AwakeableId (row ^. #awakeableId)-            payload =-                if row ^. #status == Completed-                    then row ^. #payload-                    else Just (toJSON result)-        case payload of-            Nothing -> pure False-            Just payloadValue -> do-                let ownerName = WorkflowName (row ^. #ownerWorkflowName)-                    ownerId = WorkflowId (row ^. #ownerWorkflowId)-                gen <- currentGeneration ownerName ownerId-                appendTx <--                    prepareJournalAppend-                        ownerName-                        ownerId-                        gen-                        StepRecorded-                            { stepName = awakeableStepPrefix <> awakeableIdText aid-                            , result = payloadValue-                            , recordedAt = now-                            }-                (transitioned, appendOutcome) <--                    runTransaction $ do-                        transitioned <--                            if row ^. #status == Pending-                                then completeAwakeableTx (awakeableIdToUuid aid) (toJSON result) now-                                else pure False-                        if transitioned || row ^. #status == Completed-                            then do-                                outcome <- appendTx-                                condemnOnAppendConflict outcome-                                pure (transitioned, Just outcome)-                            else-                                lookupAwakeableStatusTx (awakeableIdToUuid aid) >>= \case-                                    Just Completed -> do-                                        outcome <- appendTx-                                        condemnOnAppendConflict outcome-                                        pure (False, Just outcome)-                                    _ -> pure (False, Nothing)-                for_ appendOutcome throwOnAppendConflict-                pure transitioned+  | row ^. #status == Cancelled = pure False+  | otherwise = do+      now <- liftIO getCurrentTime+      let aid = AwakeableId (row ^. #awakeableId)+          payload =+            if row ^. #status == Completed+              then row ^. #payload+              else Just (toJSON result)+      case payload of+        Nothing -> pure False+        Just payloadValue -> do+          let ownerName = WorkflowName (row ^. #ownerWorkflowName)+              ownerId = WorkflowId (row ^. #ownerWorkflowId)+          gen <- currentGeneration ownerName ownerId+          appendTx <-+            prepareJournalAppend+              ownerName+              ownerId+              gen+              StepRecorded+                { stepName = awakeableStepPrefix <> awakeableIdText aid,+                  result = payloadValue,+                  recordedAt = now+                }+          (transitioned, appendOutcome) <-+            runTransaction $ do+              transitioned <-+                if row ^. #status == Pending+                  then completeAwakeableTx (awakeableIdToUuid aid) (toJSON result) now+                  else pure False+              if transitioned || row ^. #status == Completed+                then do+                  outcome <- appendTx+                  condemnOnAppendConflict outcome+                  pure (transitioned, Just outcome)+                else+                  lookupAwakeableStatusTx (awakeableIdToUuid aid) >>= \case+                    Just Completed -> do+                      outcome <- appendTx+                      condemnOnAppendConflict outcome+                      pure (False, Just outcome)+                    _ -> pure (False, Nothing)+          for_ appendOutcome throwOnAppendConflict+          pure transitioned  condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction () condemnOnAppendConflict = \case-    JournalAppendConflict{} -> Tx.condemn-    _ -> pure ()+  JournalAppendConflict {} -> Tx.condemn+  _ -> pure ()  throwOnAppendConflict :: JournalAppendOutcome -> Eff es () throwOnAppendConflict = \case-    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))-    _ -> pure ()+  JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  _ -> pure () -{- | Abandon a still-@pending@ awakeable: flips its row to @cancelled@ and-writes __no__ journal entry (there is no result value to record). Returns 'True'-when it transitioned a @pending@ row, 'False' otherwise (already completed,-already cancelled, or unknown). A workflow that later re-enters the awakeable's-@await@ then throws 'WorkflowAwakeableCancelled'.--}+-- | Abandon a still-@pending@ awakeable: flips its row to @cancelled@ and+-- writes __no__ journal entry (there is no result value to record). Returns 'True'+-- when it transitioned a @pending@ row, 'False' otherwise (already completed,+-- already cancelled, or unknown). A workflow that later re-enters the awakeable's+-- @await@ then throws 'WorkflowAwakeableCancelled'. cancelAwakeable :: (Store :> es) => AwakeableId -> Eff es Bool cancelAwakeable aid =-    runTransaction $ cancelAwakeableTx (awakeableIdToUuid aid)+  runTransaction $ cancelAwakeableTx (awakeableIdToUuid aid)
src/Keiro/Workflow/Awakeable/Schema.hs view
@@ -1,25 +1,24 @@-{- | The @keiro_awakeables@ table: durable storage for awakeables (external-promises a workflow suspends on).--Mirrors the @Keiro.Timer@ \/ @Keiro.Timer.Schema@ split: this module owns the-row type, the 'AwakeableStatus' lifecycle, and the hasql statements;-"Keiro.Workflow.Awakeable" owns the effectful authoring\/completion surface.--* 'registerAwakeableTx' inserts a @pending@ row idempotently (the-  @ON CONFLICT DO NOTHING@ EP-38's @awaitStep@ arming contract requires).-* 'lookupAwakeable' reads a row back.-* 'completeAwakeableTx' transitions a @pending@ row to @completed@ (guarded so-  a double signal is a no-op), and 'cancelAwakeableTx' transitions it to-  @cancelled@.-* 'countPendingAwakeables' counts outstanding promises — the seam EP-44 reads-  for its @keiro.workflow.awakeables.pending@ gauge.--Callers normally use the re-exports / surface from "Keiro.Workflow.Awakeable"-rather than this module directly; EP-44 imports 'countPendingAwakeables' here-without pulling in the effect surface.--}-module Keiro.Workflow.Awakeable.Schema (-    -- * Rows and status+-- | The @keiro_awakeables@ table: durable storage for awakeables (external+-- promises a workflow suspends on).+--+-- Mirrors the @Keiro.Timer@ \/ @Keiro.Timer.Schema@ split: this module owns the+-- row type, the 'AwakeableStatus' lifecycle, and the hasql statements;+-- "Keiro.Workflow.Awakeable" owns the effectful authoring\/completion surface.+--+-- * 'registerAwakeableTx' inserts a @pending@ row idempotently (the+--   @ON CONFLICT DO NOTHING@ EP-38's @awaitStep@ arming contract requires).+-- * 'lookupAwakeable' reads a row back.+-- * 'completeAwakeableTx' transitions a @pending@ row to @completed@ (guarded so+--   a double signal is a no-op), and 'cancelAwakeableTx' transitions it to+--   @cancelled@.+-- * 'countPendingAwakeables' counts outstanding promises — the seam EP-44 reads+--   for its @keiro.workflow.awakeables.pending@ gauge.+--+-- Callers normally use the re-exports / surface from "Keiro.Workflow.Awakeable"+-- rather than this module directly; EP-44 imports 'countPendingAwakeables' here+-- without pulling in the effect surface.+module Keiro.Workflow.Awakeable.Schema+  ( -- * Rows and status     AwakeableStatus (..),     AwakeableRow (..),     statusToText,@@ -34,7 +33,7 @@     -- * Read-only lookups     lookupAwakeable,     countPendingAwakeables,-)+  ) where  import Contravariant.Extras (contrazip3)@@ -48,187 +47,180 @@ import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | An awakeable's lifecycle state.--* 'Pending' — allocated and waiting for an external signal; the workflow is-  suspended on it.-* 'Completed' — signalled with a payload; terminal.-* 'Cancelled' — abandoned before it was signalled; terminal; also the decode-  fallback for an unrecognized stored value (the same defensive choice-  "Keiro.Timer.Schema" makes).--}+-- | An awakeable's lifecycle state.+--+-- * 'Pending' — allocated and waiting for an external signal; the workflow is+--   suspended on it.+-- * 'Completed' — signalled with a payload; terminal.+-- * 'Cancelled' — abandoned before it was signalled; terminal; also the decode+--   fallback for an unrecognized stored value (the same defensive choice+--   "Keiro.Timer.Schema" makes). data AwakeableStatus-    = Pending-    | Completed-    | Cancelled-    deriving stock (Generic, Eq, Show)+  = Pending+  | Completed+  | Cancelled+  deriving stock (Generic, Eq, Show) -{- | An awakeable row as stored: the deterministic id, the owning workflow's-name and instance id, the live 'status', the signalled 'payload' (JSON, set-only once 'Completed'), and the timestamps.--}+-- | An awakeable row as stored: the deterministic id, the owning workflow's+-- name and instance id, the live 'status', the signalled 'payload' (JSON, set+-- only once 'Completed'), and the timestamps. data AwakeableRow = AwakeableRow-    { awakeableId :: !UUID-    , ownerWorkflowName :: !Text-    , ownerWorkflowId :: !Text-    , status :: !AwakeableStatus-    , payload :: !(Maybe Value)-    , createdAt :: !UTCTime-    , updatedAt :: !UTCTime-    , completedAt :: !(Maybe UTCTime)-    }-    deriving stock (Generic, Eq, Show)+  { awakeableId :: !UUID,+    ownerWorkflowName :: !Text,+    ownerWorkflowId :: !Text,+    status :: !AwakeableStatus,+    payload :: !(Maybe Value),+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime,+    completedAt :: !(Maybe UTCTime)+  }+  deriving stock (Generic, Eq, Show) -{- | Insert a @pending@ awakeable row inside the caller's transaction.-Idempotent by @ON CONFLICT (awakeable_id) DO NOTHING@ — exactly what EP-38's-"arm must be idempotent" contract needs, since a resumed workflow re-runs the-arming action on every resume until the awakeable resolves.--}+-- | Insert a @pending@ awakeable row inside the caller's transaction.+-- Idempotent by @ON CONFLICT (awakeable_id) DO NOTHING@ — exactly what EP-38's+-- "arm must be idempotent" contract needs, since a resumed workflow re-runs the+-- arming action on every resume until the awakeable resolves. registerAwakeableTx :: UUID -> Text -> Text -> Tx.Transaction () registerAwakeableTx aid name wid =-    Tx.statement (aid, name, wid) registerAwakeableStmt+  Tx.statement (aid, name, wid) registerAwakeableStmt -{- | Transition a @pending@ awakeable to @completed@, storing @payload@ and the-completion time, inside the caller's transaction. The @status = 'pending'@-guard makes a double-signal a no-op; returns 'True' only when this call-performed the transition (so the caller knows whether it was the one that-resolved the promise).--}+-- | Transition a @pending@ awakeable to @completed@, storing @payload@ and the+-- completion time, inside the caller's transaction. The @status = 'pending'@+-- guard makes a double-signal a no-op; returns 'True' only when this call+-- performed the transition (so the caller knows whether it was the one that+-- resolved the promise). completeAwakeableTx :: UUID -> Value -> UTCTime -> Tx.Transaction Bool completeAwakeableTx aid result now =-    Tx.statement (aid, result, now) completeAwakeableStmt+  Tx.statement (aid, result, now) completeAwakeableStmt -{- | Transition a @pending@ awakeable to @cancelled@ inside the caller's-transaction. Only @pending@ rows match, so an already-completed (or-already-cancelled) awakeable is left untouched and the call returns 'False'.--}+-- | Transition a @pending@ awakeable to @cancelled@ inside the caller's+-- transaction. Only @pending@ rows match, so an already-completed (or+-- already-cancelled) awakeable is left untouched and the call returns 'False'. cancelAwakeableTx :: UUID -> Tx.Transaction Bool cancelAwakeableTx aid =-    Tx.statement aid cancelAwakeableStmt+  Tx.statement aid cancelAwakeableStmt -{- | Read an awakeable's current status inside the caller's transaction.-Used after a guarded completion loses a row race, so the caller can distinguish-a winning cancel (no journal append) from a winning signal (idempotent repair).--}+-- | Read an awakeable's current status inside the caller's transaction.+-- Used after a guarded completion loses a row race, so the caller can distinguish+-- a winning cancel (no journal append) from a winning signal (idempotent repair). lookupAwakeableStatusTx :: UUID -> Tx.Transaction (Maybe AwakeableStatus) lookupAwakeableStatusTx aid =-    Tx.statement aid lookupAwakeableStatusStmt+  Tx.statement aid lookupAwakeableStatusStmt  -- | Read an awakeable row by id. 'Nothing' if no such awakeable exists. lookupAwakeable :: (Store :> es) => UUID -> Eff es (Maybe AwakeableRow) lookupAwakeable aid =-    runTransaction (Tx.statement aid lookupAwakeableStmt)+  runTransaction (Tx.statement aid lookupAwakeableStmt) -{- | Count awakeables currently @pending@. Read-only. EP-44 backs the-@keiro.workflow.awakeables.pending@ gauge with this.--}+-- | Count awakeables currently @pending@. Read-only. EP-44 backs the+-- @keiro.workflow.awakeables.pending@ gauge with this. countPendingAwakeables :: (Store :> es) => Eff es Int countPendingAwakeables =-    runTransaction (Tx.statement () countPendingAwakeablesStmt)+  runTransaction (Tx.statement () countPendingAwakeablesStmt)  registerAwakeableStmt :: Statement (UUID, Text, Text) () registerAwakeableStmt =-    preparable-        """-        INSERT INTO keiro.keiro_awakeables-          (awakeable_id, owner_workflow_name, owner_workflow_id, status)-        VALUES ($1, $2, $3, 'pending')-        ON CONFLICT (awakeable_id) DO NOTHING-        """-        ( contrazip3-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_awakeables+      (awakeable_id, owner_workflow_name, owner_workflow_id, status)+    VALUES ($1, $2, $3, 'pending')+    ON CONFLICT (awakeable_id) DO NOTHING+    """+    ( contrazip3+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  completeAwakeableStmt :: Statement (UUID, Value, UTCTime) Bool completeAwakeableStmt =-    preparable-        """-        UPDATE keiro.keiro_awakeables-        SET status = 'completed',-            payload = $2,-            completed_at = $3,-            updated_at = now()-        WHERE awakeable_id = $1-          AND status = 'pending'-        """-        ( contrazip3-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.timestamptz))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_awakeables+    SET status = 'completed',+        payload = $2,+        completed_at = $3,+        updated_at = now()+    WHERE awakeable_id = $1+      AND status = 'pending'+    """+    ( contrazip3+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.timestamptz))+    )+    ((> 0) <$> D.rowsAffected)  cancelAwakeableStmt :: Statement UUID Bool cancelAwakeableStmt =-    preparable-        """-        UPDATE keiro.keiro_awakeables-        SET status = 'cancelled',-            updated_at = now()-        WHERE awakeable_id = $1-          AND status = 'pending'-        """-        (E.param (E.nonNullable E.uuid))-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_awakeables+    SET status = 'cancelled',+        updated_at = now()+    WHERE awakeable_id = $1+      AND status = 'pending'+    """+    (E.param (E.nonNullable E.uuid))+    ((> 0) <$> D.rowsAffected)  lookupAwakeableStatusStmt :: Statement UUID (Maybe AwakeableStatus) lookupAwakeableStatusStmt =-    preparable-        """-        SELECT status-        FROM keiro.keiro_awakeables-        WHERE awakeable_id = $1-        """-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe (statusFromText <$> D.column (D.nonNullable D.text)))+  preparable+    """+    SELECT status+    FROM keiro.keiro_awakeables+    WHERE awakeable_id = $1+    """+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe (statusFromText <$> D.column (D.nonNullable D.text)))  lookupAwakeableStmt :: Statement UUID (Maybe AwakeableRow) lookupAwakeableStmt =-    preparable-        """-        SELECT awakeable_id, owner_workflow_name, owner_workflow_id, status,-          payload, created_at, updated_at, completed_at-        FROM keiro.keiro_awakeables-        WHERE awakeable_id = $1-        """-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe awakeableRowDecoder)+  preparable+    """+    SELECT awakeable_id, owner_workflow_name, owner_workflow_id, status,+      payload, created_at, updated_at, completed_at+    FROM keiro.keiro_awakeables+    WHERE awakeable_id = $1+    """+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe awakeableRowDecoder)  countPendingAwakeablesStmt :: Statement () Int countPendingAwakeablesStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_awakeables-        WHERE status = 'pending'-        """-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_awakeables+    WHERE status = 'pending'+    """+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))  awakeableRowDecoder :: D.Row AwakeableRow awakeableRowDecoder =-    AwakeableRow-        <$> D.column (D.nonNullable D.uuid)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> (statusFromText <$> D.column (D.nonNullable D.text))-        <*> D.column (D.nullable D.jsonb)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)+  AwakeableRow+    <$> D.column (D.nonNullable D.uuid)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> (statusFromText <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nullable D.jsonb)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)  statusToText :: AwakeableStatus -> Text statusToText = \case-    Pending -> "pending"-    Completed -> "completed"-    Cancelled -> "cancelled"+  Pending -> "pending"+  Completed -> "completed"+  Cancelled -> "cancelled"  statusFromText :: Text -> AwakeableStatus statusFromText = \case-    "pending" -> Pending-    "completed" -> Completed-    "cancelled" -> Cancelled-    _ -> Cancelled+  "pending" -> Pending+  "completed" -> Completed+  "cancelled" -> Cancelled+  _ -> Cancelled
src/Keiro/Workflow/Child.hs view
@@ -1,64 +1,63 @@-{- | Child workflows: spawn, wait on, and cancel a workflow from inside another.--== What this gives you--Fan-out\/fan-in composition of durable workflows. A /parent/ workflow spawns a-/child/ (a second workflow with its own journal stream), waits for the child's-result, and may cancel a child it no longer needs — with the whole relationship-surviving a crash, because the spawn is recorded in the parent's journal and the-link is stored in @keiro_workflow_children@.--@-parent :: ('Workflow' ':>' es, 'Store' ':>' es, 'IOE' ':>' es) => Eff es Text-parent = do-  h      <- 'spawnChild' (WorkflowName \"ship-order\") (WorkflowId \"ord-7\") shipWorkflow-  result <- 'awaitChild' h                  -- SUSPEND until the child completes-  _      <- 'Keiro.Workflow.step' (StepName \"notify\") (pure (\"shipped: \" <> result))-  pure result-@--== How it works (contract recap for downstream plans)--* __'spawnChild' is a journaled step.__ It records a @StepRecorded-  \"child:\<childId\>\"@ in the /parent/ journal (so a replay short-circuits the-  spawn and never re-spawns) and inserts a @running@ row in-  @keiro_workflow_children@ linking the child back to the parent plus the-  parent-journal step the parent awaits (@child:\<childId\>:result@). It does-  __not__ run the child inline — the EP-42 resume worker drives the child to-  completion from the application's @WorkflowRegistry@, so __a child's-  'WorkflowName' must be registered there__, exactly like any resumable-  workflow. (The @childDef@ argument is taken for authoring ergonomics; the-  registry, keyed by 'WorkflowName', is the source of truth for the child's-  body.)-* __'awaitChild' reuses EP-38's suspension primitive__ — it is-  @'awaitStep' (StepName \"child:\<childId\>:result\") arm@. On the miss path it-  re-delivers a completed child's stored result onto the current parent-  generation (attach semantics), throws 'WorkflowChildCancelled' if the child-  was cancelled meanwhile, throws 'WorkflowChildFailed' from the durable child-  row if it failed, and otherwise re-asserts nothing new (the spawn already-  registered the link). On the hit path-  it decodes a tagged parent-journal envelope: @{"ok": result}@ returns the-  child result, @{"cancelled": true}@ throws 'WorkflowChildCancelled',-  @{"failed": reason}@ throws 'WorkflowChildFailed', and legacy raw values are-  still decoded as pre-envelope successful results.-* __'runChildWorkflow'__ is what the resume worker selects (instead of bare-  'Keiro.Workflow.runWorkflowWith') for any workflow that is some parent's child:-  on 'Completed' it runs 'childCompletionHook', which flips the child row to-  @completed@ and appends @{"ok": result}@ to the parent's-  @child:\<childId\>:result@ step. If it finds a historically-  @cancelled@-but-unmarked row, it ensures the child cancellation marker and-  parent sentinel before returning 'Cancelled'.-* __'cancelChild'__ flips the child row to @cancelled@ and, in the same-  transaction, writes a 'Keiro.Workflow.WorkflowCancelled' marker to the-  /child/ journal plus a @{"cancelled": true}@ sentinel as the parent's-  await-step result. Retrying after a historical row-only cancel repairs both-  markers even though the return value remains 'False' ("this call did not-  transition the row").-* @'Keiro.Workflow.Child.Schema.countActiveChildren'@ backs a potential-  @keiro.workflow.children.active@ gauge for EP-44.--}-module Keiro.Workflow.Child (-    -- * Child handles and reserved step names+-- | Child workflows: spawn, wait on, and cancel a workflow from inside another.+--+-- == What this gives you+--+-- Fan-out\/fan-in composition of durable workflows. A /parent/ workflow spawns a+-- /child/ (a second workflow with its own journal stream), waits for the child's+-- result, and may cancel a child it no longer needs — with the whole relationship+-- surviving a crash, because the spawn is recorded in the parent's journal and the+-- link is stored in @keiro_workflow_children@.+--+-- @+-- parent :: ('Workflow' ':>' es, 'Store' ':>' es, 'IOE' ':>' es) => Eff es Text+-- parent = do+--   h      <- 'spawnChild' (WorkflowName \"ship-order\") (WorkflowId \"ord-7\") shipWorkflow+--   result <- 'awaitChild' h                  -- SUSPEND until the child completes+--   _      <- 'Keiro.Workflow.step' (StepName \"notify\") (pure (\"shipped: \" <> result))+--   pure result+-- @+--+-- == How it works (contract recap for downstream plans)+--+-- * __'spawnChild' is a journaled step.__ It records a @StepRecorded+--   \"child:\<childId\>\"@ in the /parent/ journal (so a replay short-circuits the+--   spawn and never re-spawns) and inserts a @running@ row in+--   @keiro_workflow_children@ linking the child back to the parent plus the+--   parent-journal step the parent awaits (@child:\<childId\>:result@). It does+--   __not__ run the child inline — the EP-42 resume worker drives the child to+--   completion from the application's @WorkflowRegistry@, so __a child's+--   'WorkflowName' must be registered there__, exactly like any resumable+--   workflow. (The @childDef@ argument is taken for authoring ergonomics; the+--   registry, keyed by 'WorkflowName', is the source of truth for the child's+--   body.)+-- * __'awaitChild' reuses EP-38's suspension primitive__ — it is+--   @'awaitStep' (StepName \"child:\<childId\>:result\") arm@. On the miss path it+--   re-delivers a completed child's stored result onto the current parent+--   generation (attach semantics), throws 'WorkflowChildCancelled' if the child+--   was cancelled meanwhile, throws 'WorkflowChildFailed' from the durable child+--   row if it failed, and otherwise re-asserts nothing new (the spawn already+--   registered the link). On the hit path+--   it decodes a tagged parent-journal envelope: @{"ok": result}@ returns the+--   child result, @{"cancelled": true}@ throws 'WorkflowChildCancelled',+--   @{"failed": reason}@ throws 'WorkflowChildFailed', and legacy raw values are+--   still decoded as pre-envelope successful results.+-- * __'runChildWorkflow'__ is what the resume worker selects (instead of bare+--   'Keiro.Workflow.runWorkflowWith') for any workflow that is some parent's child:+--   on 'Completed' it runs 'childCompletionHook', which flips the child row to+--   @completed@ and appends @{"ok": result}@ to the parent's+--   @child:\<childId\>:result@ step. If it finds a historically+--   @cancelled@-but-unmarked row, it ensures the child cancellation marker and+--   parent sentinel before returning 'Cancelled'.+-- * __'cancelChild'__ flips the child row to @cancelled@ and, in the same+--   transaction, writes a 'Keiro.Workflow.WorkflowCancelled' marker to the+--   /child/ journal plus a @{"cancelled": true}@ sentinel as the parent's+--   await-step result. Retrying after a historical row-only cancel repairs both+--   markers even though the return value remains 'False' ("this call did not+--   transition the row").+-- * @'Keiro.Workflow.Child.Schema.countActiveChildren'@ backs a potential+--   @keiro.workflow.children.active@ gauge for EP-44.+module Keiro.Workflow.Child+  ( -- * Child handles and reserved step names     ChildHandle (..),     childSpawnStepName,     childResultStepName,@@ -77,7 +76,7 @@     -- * Errors     WorkflowChildCancelled (..),     WorkflowChildFailed (..),-)+  ) where  import Control.Exception (Exception)@@ -88,8 +87,8 @@ import Effectful.Error.Static (Error) import Effectful.Exception (throwIO) import Keiro.Prelude-import Keiro.Workflow (-    JournalAppendOutcome (..),+import Keiro.Workflow+  ( JournalAppendOutcome (..),     StepName (..),     Workflow,     WorkflowError (..),@@ -106,15 +105,15 @@     prepareJournalAppend,     runWorkflowWith,     step,- )-import Keiro.Workflow.Child.Schema (-    ChildRow,+  )+import Keiro.Workflow.Child.Schema+  ( ChildRow,     ChildStatus (..),     lookupChild,     markChildCancelledTx,     markChildResultTx,     registerChildTx,- )+  ) import Keiro.Workflow.Instance (WorkflowStatus (..), lookupInstance, upsertInstanceTx) import Kiroku.Store.Effect (Store) import Kiroku.Store.Error (StoreError)@@ -125,27 +124,24 @@ -- Handles and reserved step names -- --------------------------------------------------------------------------- -{- | An in-memory handle to a spawned child, returned by 'spawnChild' and taken-by 'awaitChild' \/ 'cancelChild'. The phantom type parameter @a@ records the-child's result type so 'awaitChild' decodes without an extra annotation; the-handle itself carries only the child's name and id (the child journal is-@wf:\<childName\>-\<childWfId\>@).--}+-- | An in-memory handle to a spawned child, returned by 'spawnChild' and taken+-- by 'awaitChild' \/ 'cancelChild'. The phantom type parameter @a@ records the+-- child's result type so 'awaitChild' decodes without an extra annotation; the+-- handle itself carries only the child's name and id (the child journal is+-- @wf:\<childName\>-\<childWfId\>@). data ChildHandle a = ChildHandle-    { childName :: !WorkflowName-    , childWfId :: !WorkflowId-    }-    deriving stock (Eq, Show)+  { childName :: !WorkflowName,+    childWfId :: !WorkflowId+  }+  deriving stock (Eq, Show) -{- | The reserved /spawn/ step name a child is recorded under in its parent's-journal: @child:\<childId\>@ (uses EP-38's 'childStepPrefix').--}+-- | The reserved /spawn/ step name a child is recorded under in its parent's+-- journal: @child:\<childId\>@ (uses EP-38's 'childStepPrefix'). childSpawnStepName :: WorkflowId -> Text childSpawnStepName (WorkflowId wid) = childStepPrefix <> wid -{- | The reserved /result/ step name the parent awaits and the child's-completion is propagated under: @child:\<childId\>:result@.--}+-- | The reserved /result/ step name the parent awaits and the child's+-- completion is propagated under: @child:\<childId\>:result@. childResultStepName :: WorkflowId -> Text childResultStepName (WorkflowId wid) = childStepPrefix <> wid <> ":result" @@ -153,23 +149,21 @@ -- Errors -- --------------------------------------------------------------------------- -{- | Thrown out of 'awaitChild' when the awaited child was 'cancelChild'led. A-cancelled child never produces a result, so suspending forever would be wrong-and fabricating a result would be wrong; the parent author can @catch@ this to-run compensation. If uncaught, the resume worker records the parent attempt,-backs it off, and eventually marks the parent failed at its configured ceiling.-Mirrors EP-40's 'Keiro.Workflow.Awakeable.WorkflowAwakeableCancelled'.--}+-- | Thrown out of 'awaitChild' when the awaited child was 'cancelChild'led. A+-- cancelled child never produces a result, so suspending forever would be wrong+-- and fabricating a result would be wrong; the parent author can @catch@ this to+-- run compensation. If uncaught, the resume worker records the parent attempt,+-- backs it off, and eventually marks the parent failed at its configured ceiling.+-- Mirrors EP-40's 'Keiro.Workflow.Awakeable.WorkflowAwakeableCancelled'. data WorkflowChildCancelled = WorkflowChildCancelled WorkflowName WorkflowId-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  instance Exception WorkflowChildCancelled -{- | Thrown out of 'awaitChild' when the child was terminally failed by the-resume worker. Carries the child's identity plus the persisted failure reason.--}+-- | Thrown out of 'awaitChild' when the child was terminally failed by the+-- resume worker. Carries the child's identity plus the persisted failure reason. data WorkflowChildFailed = WorkflowChildFailed WorkflowName WorkflowId Text-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  instance Exception WorkflowChildFailed @@ -177,272 +171,266 @@ -- Authoring surface -- --------------------------------------------------------------------------- -{- | Spawn a child workflow. Records a @StepRecorded \"child:\<childId\>\"@ in-the /parent/ journal (so a replay short-circuits and never re-spawns) and-inserts an idempotent @running@ link row, then returns a 'ChildHandle'. The-child is driven to completion by the resume worker from the registry, so-@childNm@ __must be registered there__; @childDef@ is accepted for authoring-ergonomics but is not run here.--A child id names one execution globally. Spawning an id whose child row already-completed attaches to that execution: 'awaitChild' re-delivers the stored-result onto the parent's current generation. To run a fresh child after-'Keiro.Workflow.continueAsNew', derive a fresh child id from the carried seed.--}+-- | Spawn a child workflow. Records a @StepRecorded \"child:\<childId\>\"@ in+-- the /parent/ journal (so a replay short-circuits and never re-spawns) and+-- inserts an idempotent @running@ link row, then returns a 'ChildHandle'. The+-- child is driven to completion by the resume worker from the registry, so+-- @childNm@ __must be registered there__; @childDef@ is accepted for authoring+-- ergonomics but is not run here.+--+-- A child id names one execution globally. Spawning an id whose child row already+-- completed attaches to that execution: 'awaitChild' re-delivers the stored+-- result onto the parent's current generation. To run a fresh child after+-- 'Keiro.Workflow.continueAsNew', derive a fresh child id from the carried seed. spawnChild ::-    (Workflow :> es, Store :> es) =>-    -- | The child's name (must be in the resume worker's registry).-    WorkflowName ->-    -- | The child's id (names the child journal).-    WorkflowId ->-    -- | The child workflow definition (for authoring; the registry actually runs it).-    Eff (Workflow : es) a ->-    Eff es (ChildHandle a)+  (Workflow :> es, Store :> es) =>+  -- | The child's name (must be in the resume worker's registry).+  WorkflowName ->+  -- | The child's id (names the child journal).+  WorkflowId ->+  -- | The child workflow definition (for authoring; the registry actually runs it).+  Eff (Workflow : es) a ->+  Eff es (ChildHandle a) spawnChild childNm childWid _childDef = do-    (parentNm, parentWid) <- currentWorkflow-    let spawnStep = StepName (childSpawnStepName childWid)-        resultStep = childResultStepName childWid-    -- Journaled as a step in the PARENT journal: a parent replay short-circuits-    -- this body, and the ON CONFLICT DO NOTHING register collapses on re-run.-    _ <--        step spawnStep $ do-            runTransaction $-                registerChildTx-                    (unWorkflowId childWid)-                    (unWorkflowName childNm)-                    (unWorkflowId parentWid)-                    (unWorkflowName parentNm)-                    resultStep-                    *> upsertInstanceTx-                        (unWorkflowId childWid)-                        (unWorkflowName childNm)-                        0-                        WfRunning-                        Nothing-            pure ()-    pure (ChildHandle childNm childWid)--{- | Suspend the parent until the child completes, then return the child's-result. This is EP-38's 'awaitStep' on the @child:\<childId\>:result@ step:-'childCompletionHook' journals that step into the parent when the child-finishes, so the next parent run replays past the wait and decodes the result.+  (parentNm, parentWid) <- currentWorkflow+  let spawnStep = StepName (childSpawnStepName childWid)+      resultStep = childResultStepName childWid+  -- Journaled as a step in the PARENT journal: a parent replay short-circuits+  -- this body, and the ON CONFLICT DO NOTHING register collapses on re-run.+  _ <-+    step spawnStep $ do+      runTransaction $+        registerChildTx+          (unWorkflowId childWid)+          (unWorkflowName childNm)+          (unWorkflowId parentWid)+          (unWorkflowName parentNm)+          resultStep+          *> upsertInstanceTx+            (unWorkflowId childWid)+            (unWorkflowName childNm)+            0+            WfRunning+            Nothing+      pure ()+  pure (ChildHandle childNm childWid) -If the child was cancelled or failed, throws 'WorkflowChildCancelled' or-'WorkflowChildFailed'. Parent-journal values are tagged envelopes-(@{"ok": ...}@, @{"cancelled": true}@, @{"failed": reason}@) with a legacy raw-success fallback; a decode mismatch throws 'WorkflowStepDecodeError'.--}+-- | Suspend the parent until the child completes, then return the child's+-- result. This is EP-38's 'awaitStep' on the @child:\<childId\>:result@ step:+-- 'childCompletionHook' journals that step into the parent when the child+-- finishes, so the next parent run replays past the wait and decodes the result.+--+-- If the child was cancelled or failed, throws 'WorkflowChildCancelled' or+-- 'WorkflowChildFailed'. Parent-journal values are tagged envelopes+-- (@{"ok": ...}@, @{"cancelled": true}@, @{"failed": reason}@) with a legacy raw+-- success fallback; a decode mismatch throws 'WorkflowStepDecodeError'. awaitChild ::-    (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>-    ChildHandle a ->-    Eff es a+  (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>+  ChildHandle a ->+  Eff es a awaitChild (ChildHandle childNm childWid) = do-    let resultStep = StepName (childResultStepName childWid)-        arm = do-            mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)-            case mrow of-                Just row-                    | (row ^. #status) == ChildCancelled ->-                        throwIO (WorkflowChildCancelled childNm childWid)-                    | (row ^. #status) == ChildFailed -> do-                        mInstance <- lookupInstance childNm childWid-                        let reason =-                                case row ^. #failureReason of-                                    Just recorded -> recorded-                                    Nothing ->-                                        fromMaybe-                                            "child workflow failed (reason not recorded)"-                                            (mInstance >>= (^. #lastError))-                        throwIO (WorkflowChildFailed childNm childWid reason)-                    | (row ^. #status) == ChildCompleted-                    , Just resultValue <- row ^. #result ->-                        appendJournalEntry-                            (WorkflowName (row ^. #parentName))-                            (WorkflowId (row ^. #parentId))-                            StepRecorded-                                { stepName = row ^. #awaitStep-                                , result = childOkEnvelope resultValue-                                , recordedAt = fromMaybe (row ^. #updatedAt) (row ^. #completedAt)-                                }-                -- The spawn already registered the link; nothing more to (re-)arm.-                _ -> pure ()-    raw <- awaitStep resultStep arm-    decodeChildResult childNm childWid (unStepName resultStep) raw+  let resultStep = StepName (childResultStepName childWid)+      arm = do+        mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)+        case mrow of+          Just row+            | (row ^. #status) == ChildCancelled ->+                throwIO (WorkflowChildCancelled childNm childWid)+            | (row ^. #status) == ChildFailed -> do+                mInstance <- lookupInstance childNm childWid+                let reason =+                      case row ^. #failureReason of+                        Just recorded -> recorded+                        Nothing ->+                          fromMaybe+                            "child workflow failed (reason not recorded)"+                            (mInstance >>= (^. #lastError))+                throwIO (WorkflowChildFailed childNm childWid reason)+            | (row ^. #status) == ChildCompleted,+              Just resultValue <- row ^. #result ->+                appendJournalEntry+                  (WorkflowName (row ^. #parentName))+                  (WorkflowId (row ^. #parentId))+                  StepRecorded+                    { stepName = row ^. #awaitStep,+                      result = childOkEnvelope resultValue,+                      recordedAt = fromMaybe (row ^. #updatedAt) (row ^. #completedAt)+                    }+          -- The spawn already registered the link; nothing more to (re-)arm.+          _ -> pure ()+  raw <- awaitStep resultStep arm+  decodeChildResult childNm childWid (unStepName resultStep) raw  -- --------------------------------------------------------------------------- -- External control -- --------------------------------------------------------------------------- -{- | Cancel a child. For a @running@ child, flips its-@keiro_workflow_children@ row to @cancelled@ and, in the same transaction,-writes both the child's 'WorkflowCancelled' marker and the parent's-@{"cancelled": true}@ await-step sentinel. Returns 'True' only when this call-performed the row transition. For an already-@cancelled@ row, returns 'False'-but still ensures both markers, repairing historical crashes that committed the-row flip before either journal append. Already completed/failed/unknown-children return 'False' and no marker is fabricated.--}+-- | Cancel a child. For a @running@ child, flips its+-- @keiro_workflow_children@ row to @cancelled@ and, in the same transaction,+-- writes both the child's 'WorkflowCancelled' marker and the parent's+-- @{"cancelled": true}@ await-step sentinel. Returns 'True' only when this call+-- performed the row transition. For an already-@cancelled@ row, returns 'False'+-- but still ensures both markers, repairing historical crashes that committed the+-- row flip before either journal append. Already completed/failed/unknown+-- children return 'False' and no marker is fabricated. cancelChild ::-    (IOE :> es, Store :> es) =>-    ChildHandle a ->-    Eff es Bool+  (IOE :> es, Store :> es) =>+  ChildHandle a ->+  Eff es Bool cancelChild (ChildHandle childNm childWid) = do-    mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)-    case mrow of-        Nothing -> pure False-        Just row -> do-            (transitioned, childOutcome, parentOutcome) <- ensureChildCancelled row-            throwOnAppendConflict childOutcome-            throwOnAppendConflict parentOutcome-            pure transitioned+  mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)+  case mrow of+    Nothing -> pure False+    Just row -> do+      (transitioned, childOutcome, parentOutcome) <- ensureChildCancelled row+      throwOnAppendConflict childOutcome+      throwOnAppendConflict parentOutcome+      pure transitioned  -- --------------------------------------------------------------------------- -- Completion propagation -- --------------------------------------------------------------------------- -{- | Drive a child workflow to completion, propagating its result to the parent.-This is 'runWorkflowWith' followed by 'childCompletionHook' on 'Completed': the-resume worker selects this (instead of bare 'runWorkflowWith') for any workflow-that is some parent's child, so a finished child wakes its waiting parent. A-'Suspended' child propagates nothing. A child row already marked 'ChildCancelled'-is repaired by ensuring the child cancellation marker and parent sentinel, then-returns 'Cancelled'; a 'ChildFailed' row returns 'Failed'.--}+-- | Drive a child workflow to completion, propagating its result to the parent.+-- This is 'runWorkflowWith' followed by 'childCompletionHook' on 'Completed': the+-- resume worker selects this (instead of bare 'runWorkflowWith') for any workflow+-- that is some parent's child, so a finished child wakes its waiting parent. A+-- 'Suspended' child propagates nothing. A child row already marked 'ChildCancelled'+-- is repaired by ensuring the child cancellation marker and parent sentinel, then+-- returns 'Cancelled'; a 'ChildFailed' row returns 'Failed'. runChildWorkflow ::-    (IOE :> es, Store :> es, Error StoreError :> es, ToJSON a) =>-    WorkflowRunOptions ->-    WorkflowName ->-    WorkflowId ->-    Eff (Workflow : es) a ->-    Eff es (WorkflowOutcome a)+  (IOE :> es, Store :> es, Error StoreError :> es, ToJSON a) =>+  WorkflowRunOptions ->+  WorkflowName ->+  WorkflowId ->+  Eff (Workflow : es) a ->+  Eff es (WorkflowOutcome a) runChildWorkflow opts childNm childWid action = do-    mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)-    case fmap (^. #status) mrow of-        Just ChildCancelled -> do-            for_ mrow $ \row -> do-                (_, childOutcome, parentOutcome) <- ensureChildCancelled row-                throwOnAppendConflict childOutcome-                throwOnAppendConflict parentOutcome-            pure Cancelled-        Just ChildFailed -> pure Failed-        _ -> do-            outcome <- runWorkflowWith opts childNm childWid action-            case outcome of-                Completed result -> do-                    childCompletionHook childNm childWid (toJSON result)-                    pure (Completed result)-                other -> pure other+  mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)+  case fmap (^. #status) mrow of+    Just ChildCancelled -> do+      for_ mrow $ \row -> do+        (_, childOutcome, parentOutcome) <- ensureChildCancelled row+        throwOnAppendConflict childOutcome+        throwOnAppendConflict parentOutcome+      pure Cancelled+    Just ChildFailed -> pure Failed+    _ -> do+      outcome <- runWorkflowWith opts childNm childWid action+      case outcome of+        Completed result -> do+          childCompletionHook childNm childWid (toJSON result)+          pure (Completed result)+        other -> pure other -{- | Propagate a finished child's result to its parent: flip the child row to-@completed@ (storing the raw result in the child row) and append an-@{"ok": result}@ @child:\<childId\>:result@ 'StepRecorded' to the /parent/-journal — exactly the wake source the parent's 'awaitChild' resolves on. The-running-row transition and parent append happen in one transaction. An-already-@completed@ row re-appends from the stored result, repairing historical-wedges; cancelled/failed children do not fabricate a success. Normally invoked-via 'runChildWorkflow'.--}+-- | Propagate a finished child's result to its parent: flip the child row to+-- @completed@ (storing the raw result in the child row) and append an+-- @{"ok": result}@ @child:\<childId\>:result@ 'StepRecorded' to the /parent/+-- journal — exactly the wake source the parent's 'awaitChild' resolves on. The+-- running-row transition and parent append happen in one transaction. An+-- already-@completed@ row re-appends from the stored result, repairing historical+-- wedges; cancelled/failed children do not fabricate a success. Normally invoked+-- via 'runChildWorkflow'. childCompletionHook ::-    (IOE :> es, Store :> es) =>-    WorkflowName ->-    WorkflowId ->-    Aeson.Value ->-    Eff es ()+  (IOE :> es, Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  Aeson.Value ->+  Eff es () childCompletionHook childNm childWid resultValue = do-    mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)-    for_ mrow $ \row -> case row ^. #status of-        Running -> do-            now <- liftIO getCurrentTime-            let parentName = WorkflowName (row ^. #parentName)-                parentId = WorkflowId (row ^. #parentId)-            gen <- currentGeneration parentName parentId-            appendTx <--                prepareJournalAppend-                    parentName-                    parentId-                    gen-                    StepRecorded-                        { stepName = row ^. #awaitStep-                        , result = childOkEnvelope resultValue-                        , recordedAt = now-                        }-            appendOutcome <--                runTransaction $ do-                    transitioned <- markChildResultTx (unWorkflowId childWid) (unWorkflowName childNm) resultValue now-                    if transitioned-                        then do-                            appendOutcome <- appendTx-                            condemnOnAppendConflict appendOutcome-                            pure appendOutcome-                        else pure (JournalAlreadyPresent resultValue)-            throwOnAppendConflict appendOutcome-        ChildCompleted ->-            for_ (row ^. #result) $ \stored ->-                appendJournalEntry-                    (WorkflowName (row ^. #parentName))-                    (WorkflowId (row ^. #parentId))-                    StepRecorded-                        { stepName = row ^. #awaitStep-                        , result = childOkEnvelope stored-                        , recordedAt = fromMaybe (row ^. #updatedAt) (row ^. #completedAt)-                        }-        ChildCancelled -> pure ()-        ChildFailed -> pure ()+  mrow <- lookupChild (unWorkflowId childWid) (unWorkflowName childNm)+  for_ mrow $ \row -> case row ^. #status of+    Running -> do+      now <- liftIO getCurrentTime+      let parentName = WorkflowName (row ^. #parentName)+          parentId = WorkflowId (row ^. #parentId)+      gen <- currentGeneration parentName parentId+      appendTx <-+        prepareJournalAppend+          parentName+          parentId+          gen+          StepRecorded+            { stepName = row ^. #awaitStep,+              result = childOkEnvelope resultValue,+              recordedAt = now+            }+      appendOutcome <-+        runTransaction $ do+          transitioned <- markChildResultTx (unWorkflowId childWid) (unWorkflowName childNm) resultValue now+          if transitioned+            then do+              appendOutcome <- appendTx+              condemnOnAppendConflict appendOutcome+              pure appendOutcome+            else pure (JournalAlreadyPresent resultValue)+      throwOnAppendConflict appendOutcome+    ChildCompleted ->+      for_ (row ^. #result) $ \stored ->+        appendJournalEntry+          (WorkflowName (row ^. #parentName))+          (WorkflowId (row ^. #parentId))+          StepRecorded+            { stepName = row ^. #awaitStep,+              result = childOkEnvelope stored,+              recordedAt = fromMaybe (row ^. #updatedAt) (row ^. #completedAt)+            }+    ChildCancelled -> pure ()+    ChildFailed -> pure ()  condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction () condemnOnAppendConflict = \case-    JournalAppendConflict{} -> Tx.condemn-    _ -> pure ()+  JournalAppendConflict {} -> Tx.condemn+  _ -> pure ()  throwOnAppendConflict :: JournalAppendOutcome -> Eff es () throwOnAppendConflict = \case-    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))-    _ -> pure ()+  JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  _ -> pure ()  ensureChildCancelled ::-    (IOE :> es, Store :> es) =>-    ChildRow ->-    Eff es (Bool, JournalAppendOutcome, JournalAppendOutcome)+  (IOE :> es, Store :> es) =>+  ChildRow ->+  Eff es (Bool, JournalAppendOutcome, JournalAppendOutcome) ensureChildCancelled row = do-    now <- liftIO getCurrentTime-    let childNm = WorkflowName (row ^. #childName)-        childWid = WorkflowId (row ^. #childId)-        parentNm = WorkflowName (row ^. #parentName)-        parentWid = WorkflowId (row ^. #parentId)-    childGen <- currentGeneration childNm childWid-    parentGen <- currentGeneration parentNm parentWid-    childAppendTx <- prepareJournalAppend childNm childWid childGen WorkflowCancelled{recordedAt = now}-    parentAppendTx <--        prepareJournalAppend-            parentNm-            parentWid-            parentGen-            StepRecorded-                { stepName = row ^. #awaitStep-                , result = cancelledSentinel-                , recordedAt = now-                }-    runTransaction $ do-        transitioned <--            if row ^. #status == Running-                then markChildCancelledTx (row ^. #childId) (row ^. #childName)-                else pure False-        if transitioned || row ^. #status == ChildCancelled-            then do-                childOutcome <- childAppendTx-                parentOutcome <- parentAppendTx-                condemnOnAppendConflict childOutcome-                condemnOnAppendConflict parentOutcome-                pure (transitioned, childOutcome, parentOutcome)-            else pure (False, JournalAlreadyPresent Aeson.Null, JournalAlreadyPresent Aeson.Null)+  now <- liftIO getCurrentTime+  let childNm = WorkflowName (row ^. #childName)+      childWid = WorkflowId (row ^. #childId)+      parentNm = WorkflowName (row ^. #parentName)+      parentWid = WorkflowId (row ^. #parentId)+  childGen <- currentGeneration childNm childWid+  parentGen <- currentGeneration parentNm parentWid+  childAppendTx <- prepareJournalAppend childNm childWid childGen WorkflowCancelled {recordedAt = now}+  parentAppendTx <-+    prepareJournalAppend+      parentNm+      parentWid+      parentGen+      StepRecorded+        { stepName = row ^. #awaitStep,+          result = cancelledSentinel,+          recordedAt = now+        }+  runTransaction $ do+    transitioned <-+      if row ^. #status == Running+        then markChildCancelledTx (row ^. #childId) (row ^. #childName)+        else pure False+    if transitioned || row ^. #status == ChildCancelled+      then do+        childOutcome <- childAppendTx+        parentOutcome <- parentAppendTx+        condemnOnAppendConflict childOutcome+        condemnOnAppendConflict parentOutcome+        pure (transitioned, childOutcome, parentOutcome)+      else pure (False, JournalAlreadyPresent Aeson.Null, JournalAlreadyPresent Aeson.Null)  -- --------------------------------------------------------------------------- -- The cancellation sentinel -- --------------------------------------------------------------------------- -{- | The @{"cancelled": true}@ value 'cancelChild' writes as a cancelled child's-await-step result so 'awaitChild' can detect it and throw.--}+-- | The @{"cancelled": true}@ value 'cancelChild' writes as a cancelled child's+-- await-step result so 'awaitChild' can detect it and throw. cancelledSentinel :: Aeson.Value cancelledSentinel = Aeson.object ["cancelled" Aeson..= True] @@ -450,24 +438,24 @@ childOkEnvelope value = Aeson.object ["ok" Aeson..= value]  decodeChildResult ::-    (FromJSON a) =>-    WorkflowName ->-    WorkflowId ->-    Text ->-    Aeson.Value ->-    Eff es a+  (FromJSON a) =>+  WorkflowName ->+  WorkflowId ->+  Text ->+  Aeson.Value ->+  Eff es a decodeChildResult childNm childWid key raw =-    case raw of-        Aeson.Object obj-            | Just okValue <- KeyMap.lookup "ok" obj ->-                decodeOrThrow okValue-            | Just (Aeson.Bool True) <- KeyMap.lookup "cancelled" obj ->-                throwIO (WorkflowChildCancelled childNm childWid)-            | Just (Aeson.String reason) <- KeyMap.lookup "failed" obj ->-                throwIO (WorkflowChildFailed childNm childWid reason)-        _ -> decodeOrThrow raw+  case raw of+    Aeson.Object obj+      | Just okValue <- KeyMap.lookup "ok" obj ->+          decodeOrThrow okValue+      | Just (Aeson.Bool True) <- KeyMap.lookup "cancelled" obj ->+          throwIO (WorkflowChildCancelled childNm childWid)+      | Just (Aeson.String reason) <- KeyMap.lookup "failed" obj ->+          throwIO (WorkflowChildFailed childNm childWid reason)+    _ -> decodeOrThrow raw   where     decodeOrThrow value =-        case Aeson.fromJSON value of-            Aeson.Success a -> pure a-            Aeson.Error e -> throwIO (WorkflowStepDecodeError key (Text.pack e))+      case Aeson.fromJSON value of+        Aeson.Success a -> pure a+        Aeson.Error e -> throwIO (WorkflowStepDecodeError key (Text.pack e))
src/Keiro/Workflow/Child/Schema.hs view
@@ -1,30 +1,29 @@-{- | The @keiro_workflow_children@ table: durable parent↔child workflow links.--Mirrors the @Keiro.Timer@ \/ @Keiro.Timer.Schema@ and-@Keiro.Workflow.Awakeable.Schema@ split: this module owns the row type, the-'ChildStatus' lifecycle, and the hasql statements; "Keiro.Workflow.Child" owns-the effectful spawn\/await\/cancel surface.--* 'registerChildTx' inserts a @running@ row idempotently (the-  @ON CONFLICT (child_id, child_name) DO NOTHING@ EP-38's @awaitStep@ arming-  contract requires — a resumed parent re-runs the arm on every resume).-* 'lookupChild' \/ 'lookupChildrenOfParent' read rows back (operator-  inspection and the @awaitChild@ arm's cancellation check).-* 'markChildResultTx' transitions a @running@ row to @completed@ (storing the-  child's result), 'markChildCancelledTx' transitions it to @cancelled@, and-  'markChildFailedTx' transitions it to @failed@ while preserving the reason;-  all guard on @status = 'running'@ so a double-resolve is a no-op.-* 'findRunningChildIds' is the resume worker's discovery seed for a zero-step-  child (one that has been spawned but not yet driven, so has no-  @keiro_workflow_steps@ rows for 'findUnfinishedWorkflowIds' to find).-* 'countActiveChildren' counts outstanding children — the seam EP-44 may read-  for a @keiro.workflow.children.active@ gauge.--Callers normally use the surface from "Keiro.Workflow.Child" rather than this-module directly.--}-module Keiro.Workflow.Child.Schema (-    -- * Rows and status+-- | The @keiro_workflow_children@ table: durable parent↔child workflow links.+--+-- Mirrors the @Keiro.Timer@ \/ @Keiro.Timer.Schema@ and+-- @Keiro.Workflow.Awakeable.Schema@ split: this module owns the row type, the+-- 'ChildStatus' lifecycle, and the hasql statements; "Keiro.Workflow.Child" owns+-- the effectful spawn\/await\/cancel surface.+--+-- * 'registerChildTx' inserts a @running@ row idempotently (the+--   @ON CONFLICT (child_id, child_name) DO NOTHING@ EP-38's @awaitStep@ arming+--   contract requires — a resumed parent re-runs the arm on every resume).+-- * 'lookupChild' \/ 'lookupChildrenOfParent' read rows back (operator+--   inspection and the @awaitChild@ arm's cancellation check).+-- * 'markChildResultTx' transitions a @running@ row to @completed@ (storing the+--   child's result), 'markChildCancelledTx' transitions it to @cancelled@, and+--   'markChildFailedTx' transitions it to @failed@ while preserving the reason;+--   all guard on @status = 'running'@ so a double-resolve is a no-op.+-- * 'findRunningChildIds' is the resume worker's discovery seed for a zero-step+--   child (one that has been spawned but not yet driven, so has no+--   @keiro_workflow_steps@ rows for 'findUnfinishedWorkflowIds' to find).+-- * 'countActiveChildren' counts outstanding children — the seam EP-44 may read+--   for a @keiro.workflow.children.active@ gauge.+--+-- Callers normally use the surface from "Keiro.Workflow.Child" rather than this+-- module directly.+module Keiro.Workflow.Child.Schema+  ( -- * Rows and status     ChildStatus (..),     ChildRow (..),     statusToText,@@ -43,7 +42,7 @@     childStatus,     countActiveChildren,     findRunningChildIds,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)@@ -56,299 +55,290 @@ import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | A child workflow's lifecycle, as seen from its parent.--* 'Running' — spawned and not yet finished; the resume worker drives it.-* 'ChildCompleted' — the child reached its own 'Keiro.Workflow.WorkflowCompleted'-  and its result was propagated to the parent journal; terminal.-* 'ChildCancelled' — the parent 'Keiro.Workflow.Child.cancelChild'led it;-  terminal; also the decode fallback for an unrecognized stored value (the same-  defensive choice "Keiro.Timer.Schema" makes).--The constructors are prefixed @Child@ to avoid clashing with-'Keiro.Workflow.WorkflowOutcome''s @Completed@ and the awakeable\/timer-@Cancelled@ constructors.--}+-- | A child workflow's lifecycle, as seen from its parent.+--+-- * 'Running' — spawned and not yet finished; the resume worker drives it.+-- * 'ChildCompleted' — the child reached its own 'Keiro.Workflow.WorkflowCompleted'+--   and its result was propagated to the parent journal; terminal.+-- * 'ChildCancelled' — the parent 'Keiro.Workflow.Child.cancelChild'led it;+--   terminal; also the decode fallback for an unrecognized stored value (the same+--   defensive choice "Keiro.Timer.Schema" makes).+--+-- The constructors are prefixed @Child@ to avoid clashing with+-- 'Keiro.Workflow.WorkflowOutcome''s @Completed@ and the awakeable\/timer+-- @Cancelled@ constructors. data ChildStatus-    = Running-    | ChildCompleted-    | ChildCancelled-    | ChildFailed-    deriving stock (Generic, Eq, Show)+  = Running+  | ChildCompleted+  | ChildCancelled+  | ChildFailed+  deriving stock (Generic, Eq, Show) -{- | A child link row as stored: the child's (id, name), the parent's (id,-name), the parent-journal step the parent awaits ('awaitStep' =-@child:\<childId\>:result@), the live 'status', the child's 'result' (set only-once 'ChildCompleted'), the terminal 'failureReason' (set only once-'ChildFailed'), and the timestamps.--}+-- | A child link row as stored: the child's (id, name), the parent's (id,+-- name), the parent-journal step the parent awaits ('awaitStep' =+-- @child:\<childId\>:result@), the live 'status', the child's 'result' (set only+-- once 'ChildCompleted'), the terminal 'failureReason' (set only once+-- 'ChildFailed'), and the timestamps. data ChildRow = ChildRow-    { childId :: !Text-    , childName :: !Text-    , parentId :: !Text-    , parentName :: !Text-    , awaitStep :: !Text-    , status :: !ChildStatus-    , result :: !(Maybe Value)-    , failureReason :: !(Maybe Text)-    , createdAt :: !UTCTime-    , updatedAt :: !UTCTime-    , completedAt :: !(Maybe UTCTime)-    }-    deriving stock (Generic, Eq, Show)+  { childId :: !Text,+    childName :: !Text,+    parentId :: !Text,+    parentName :: !Text,+    awaitStep :: !Text,+    status :: !ChildStatus,+    result :: !(Maybe Value),+    failureReason :: !(Maybe Text),+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime,+    completedAt :: !(Maybe UTCTime)+  }+  deriving stock (Generic, Eq, Show) -{- | Insert a @running@ child link row inside the caller's transaction, given-the child's @(id, name)@, the parent's @(id, name)@, and the parent-journal-step the parent awaits (@await_step@). Idempotent by-@ON CONFLICT (child_id, child_name) DO NOTHING@ — exactly what EP-38's "arm-must be idempotent" contract needs, since the spawn step and every resume's arm-re-run it. @status@, @result@, and the timestamps take their table defaults, so-no clock read is needed at the spawn site (mirrors-'Keiro.Workflow.Awakeable.Schema.registerAwakeableTx').--}+-- | Insert a @running@ child link row inside the caller's transaction, given+-- the child's @(id, name)@, the parent's @(id, name)@, and the parent-journal+-- step the parent awaits (@await_step@). Idempotent by+-- @ON CONFLICT (child_id, child_name) DO NOTHING@ — exactly what EP-38's "arm+-- must be idempotent" contract needs, since the spawn step and every resume's arm+-- re-run it. @status@, @result@, and the timestamps take their table defaults, so+-- no clock read is needed at the spawn site (mirrors+-- 'Keiro.Workflow.Awakeable.Schema.registerAwakeableTx'). registerChildTx :: Text -> Text -> Text -> Text -> Text -> Tx.Transaction () registerChildTx cid cname pid pname awaitStep =-    Tx.statement (cid, cname, pid, pname, awaitStep) registerChildStmt+  Tx.statement (cid, cname, pid, pname, awaitStep) registerChildStmt -{- | Transition a @running@ child to @completed@, storing @result@ and the-completion time, inside the caller's transaction. The @status = 'running'@-guard makes a double-complete a no-op; returns 'True' only when this call-performed the transition.--}+-- | Transition a @running@ child to @completed@, storing @result@ and the+-- completion time, inside the caller's transaction. The @status = 'running'@+-- guard makes a double-complete a no-op; returns 'True' only when this call+-- performed the transition. markChildResultTx :: Text -> Text -> Value -> UTCTime -> Tx.Transaction Bool markChildResultTx cid cname result now =-    Tx.statement (cid, cname, result, now) markChildResultStmt+  Tx.statement (cid, cname, result, now) markChildResultStmt -{- | Transition a @running@ child to @cancelled@ inside the caller's-transaction. Only @running@ rows match, so an already-completed (or-already-cancelled) child is left untouched and the call returns 'False'.--}+-- | Transition a @running@ child to @cancelled@ inside the caller's+-- transaction. Only @running@ rows match, so an already-completed (or+-- already-cancelled) child is left untouched and the call returns 'False'. markChildCancelledTx :: Text -> Text -> Tx.Transaction Bool markChildCancelledTx cid cname =-    Tx.statement (cid, cname) markChildCancelledStmt+  Tx.statement (cid, cname) markChildCancelledStmt -{- | Transition a @running@ child to @failed@, preserving the terminal reason.-The guarded transition and the parent's failure journal append are performed-in one caller-owned transaction by the resume worker.--}+-- | Transition a @running@ child to @failed@, preserving the terminal reason.+-- The guarded transition and the parent's failure journal append are performed+-- in one caller-owned transaction by the resume worker. markChildFailedTx :: Text -> Text -> Text -> Tx.Transaction Bool markChildFailedTx cid cname reason =-    Tx.statement (cid, cname, reason) markChildFailedStmt+  Tx.statement (cid, cname, reason) markChildFailedStmt -{- | Revive a @failed@ child link so the resume worker can drive the child-again. Clears terminal result metadata; the parent journal's already-delivered-failure sentinel remains immutable history.--}+-- | Revive a @failed@ child link so the resume worker can drive the child+-- again. Clears terminal result metadata; the parent journal's already-delivered+-- failure sentinel remains immutable history. reviveFailedChildTx :: Text -> Text -> Tx.Transaction Bool reviveFailedChildTx cid cname =-    Tx.statement (cid, cname) reviveFailedChildStmt+  Tx.statement (cid, cname) reviveFailedChildStmt  -- | Read a child link row by @(child_id, child_name)@. 'Nothing' if absent. lookupChild :: (Store :> es) => Text -> Text -> Eff es (Maybe ChildRow) lookupChild cid cname =-    runTransaction (Tx.statement (cid, cname) lookupChildStmt)+  runTransaction (Tx.statement (cid, cname) lookupChildStmt)  -- | Read every child link of a parent @(parent_id, parent_name)@. lookupChildrenOfParent :: (Store :> es) => Text -> Text -> Eff es [ChildRow] lookupChildrenOfParent pid pname =-    runTransaction (Tx.statement (pid, pname) lookupChildrenOfParentStmt)+  runTransaction (Tx.statement (pid, pname) lookupChildrenOfParentStmt)  -- | The 'ChildStatus' of a child by @(child_id, child_name)@, if it exists. childStatus :: (Store :> es) => Text -> Text -> Eff es (Maybe ChildStatus) childStatus cid cname = fmap (^. #status) <$> lookupChild cid cname -{- | Count children currently @running@. Read-only. EP-44 may back a-@keiro.workflow.children.active@ gauge with this.--}+-- | Count children currently @running@. Read-only. EP-44 may back a+-- @keiro.workflow.children.active@ gauge with this. countActiveChildren :: (Store :> es) => Eff es Int countActiveChildren =-    runTransaction (Tx.statement () countActiveChildrenStmt)+  runTransaction (Tx.statement () countActiveChildrenStmt) -{- | The @(child_id, child_name)@ of every @running@ child. The resume worker-unions this with 'Keiro.Workflow.findUnfinishedWorkflowIds' so a freshly-spawned child that has no @keiro_workflow_steps@ rows yet is still discovered-and driven. The tuple order matches 'findUnfinishedWorkflowIds' — @(id, name)@.--}+-- | The @(child_id, child_name)@ of every @running@ child. The resume worker+-- unions this with 'Keiro.Workflow.findUnfinishedWorkflowIds' so a freshly+-- spawned child that has no @keiro_workflow_steps@ rows yet is still discovered+-- and driven. The tuple order matches 'findUnfinishedWorkflowIds' — @(id, name)@. findRunningChildIds :: (Store :> es) => Eff es [(Text, Text)] findRunningChildIds =-    runTransaction (Tx.statement () findRunningChildIdsStmt)+  runTransaction (Tx.statement () findRunningChildIdsStmt)  registerChildStmt :: Statement (Text, Text, Text, Text, Text) () registerChildStmt =-    preparable-        """-        INSERT INTO keiro.keiro_workflow_children-          (child_id, child_name, parent_id, parent_name, await_step, status)-        VALUES ($1, $2, $3, $4, $5, 'running')-        ON CONFLICT (child_id, child_name) DO NOTHING-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_workflow_children+      (child_id, child_name, parent_id, parent_name, await_step, status)+    VALUES ($1, $2, $3, $4, $5, 'running')+    ON CONFLICT (child_id, child_name) DO NOTHING+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  markChildResultStmt :: Statement (Text, Text, Value, UTCTime) Bool markChildResultStmt =-    preparable-        """-        UPDATE keiro.keiro_workflow_children-        SET status = 'completed',-            result = $3,-            completed_at = $4,-            updated_at = now()-        WHERE child_id = $1-          AND child_name = $2-          AND status = 'running'-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.timestamptz))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_workflow_children+    SET status = 'completed',+        result = $3,+        completed_at = $4,+        updated_at = now()+    WHERE child_id = $1+      AND child_name = $2+      AND status = 'running'+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.timestamptz))+    )+    ((> 0) <$> D.rowsAffected)  markChildCancelledStmt :: Statement (Text, Text) Bool markChildCancelledStmt =-    preparable-        """-        UPDATE keiro.keiro_workflow_children-        SET status = 'cancelled',-            updated_at = now()-        WHERE child_id = $1-          AND child_name = $2-          AND status = 'running'-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_workflow_children+    SET status = 'cancelled',+        updated_at = now()+    WHERE child_id = $1+      AND child_name = $2+      AND status = 'running'+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)  markChildFailedStmt :: Statement (Text, Text, Text) Bool markChildFailedStmt =-    preparable-        """-        UPDATE keiro.keiro_workflow_children-        SET status = 'failed',-            failure_reason = $3,-            updated_at = now()-        WHERE child_id = $1-          AND child_name = $2-          AND status = 'running'-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_workflow_children+    SET status = 'failed',+        failure_reason = $3,+        updated_at = now()+    WHERE child_id = $1+      AND child_name = $2+      AND status = 'running'+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)  reviveFailedChildStmt :: Statement (Text, Text) Bool reviveFailedChildStmt =-    preparable-        """-        UPDATE keiro.keiro_workflow_children-        SET status = 'running',-            result = NULL,-            failure_reason = NULL,-            completed_at = NULL,-            updated_at = now()-        WHERE child_id = $1-          AND child_name = $2-          AND status = 'failed'-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_workflow_children+    SET status = 'running',+        result = NULL,+        failure_reason = NULL,+        completed_at = NULL,+        updated_at = now()+    WHERE child_id = $1+      AND child_name = $2+      AND status = 'failed'+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)  lookupChildStmt :: Statement (Text, Text) (Maybe ChildRow) lookupChildStmt =-    preparable-        """-        SELECT child_id, child_name, parent_id, parent_name, await_step,-          status, result, failure_reason, created_at, updated_at, completed_at-        FROM keiro.keiro_workflow_children-        WHERE child_id = $1-          AND child_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.rowMaybe childRowDecoder)+  preparable+    """+    SELECT child_id, child_name, parent_id, parent_name, await_step,+      status, result, failure_reason, created_at, updated_at, completed_at+    FROM keiro.keiro_workflow_children+    WHERE child_id = $1+      AND child_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe childRowDecoder)  lookupChildrenOfParentStmt :: Statement (Text, Text) [ChildRow] lookupChildrenOfParentStmt =-    preparable-        """-        SELECT child_id, child_name, parent_id, parent_name, await_step,-          status, result, failure_reason, created_at, updated_at, completed_at-        FROM keiro.keiro_workflow_children-        WHERE parent_id = $1-          AND parent_name = $2-        ORDER BY created_at, child_id-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.rowList childRowDecoder)+  preparable+    """+    SELECT child_id, child_name, parent_id, parent_name, await_step,+      status, result, failure_reason, created_at, updated_at, completed_at+    FROM keiro.keiro_workflow_children+    WHERE parent_id = $1+      AND parent_name = $2+    ORDER BY created_at, child_id+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowList childRowDecoder)  countActiveChildrenStmt :: Statement () Int countActiveChildrenStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_workflow_children-        WHERE status = 'running'-        """-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_workflow_children+    WHERE status = 'running'+    """+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))  findRunningChildIdsStmt :: Statement () [(Text, Text)] findRunningChildIdsStmt =-    preparable-        """-        SELECT child_id, child_name-        FROM keiro.keiro_workflow_children-        WHERE status = 'running'-        """-        E.noParams-        (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))+  preparable+    """+    SELECT child_id, child_name+    FROM keiro.keiro_workflow_children+    WHERE status = 'running'+    """+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))  childRowDecoder :: D.Row ChildRow childRowDecoder =-    ChildRow-        <$> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> (statusFromText <$> D.column (D.nonNullable D.text))-        <*> D.column (D.nullable D.jsonb)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)+  ChildRow+    <$> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> (statusFromText <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nullable D.jsonb)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)  statusToText :: ChildStatus -> Text statusToText = \case-    Running -> "running"-    ChildCompleted -> "completed"-    ChildCancelled -> "cancelled"-    ChildFailed -> "failed"+  Running -> "running"+  ChildCompleted -> "completed"+  ChildCancelled -> "cancelled"+  ChildFailed -> "failed"  statusFromText :: Text -> ChildStatus statusFromText = \case-    "running" -> Running-    "completed" -> ChildCompleted-    "cancelled" -> ChildCancelled-    "failed" -> ChildFailed-    _ -> ChildCancelled+  "running" -> Running+  "completed" -> ChildCompleted+  "cancelled" -> ChildCancelled+  "failed" -> ChildFailed+  _ -> ChildCancelled
src/Keiro/Workflow/Gc.hs view
@@ -1,19 +1,18 @@-{- | Optional garbage collection for terminal workflow instances.--The hot-path schema modules keep lifecycle writes and lookup statements. This-module owns only cleanup statements used by an operator-scheduled GC pass.-Eligibility is based on the derived @keiro_workflows@ row: terminal instances-older than the retention cutoff are deleted, except completed children whose-parent is still non-terminal and may still attach to their result. Cleanup-removes workflow-sleep timers in every lifecycle state so no scheduled timer-can later recreate a collected workflow.--}-module Keiro.Workflow.Gc (-    WorkflowGcPolicy (..),+-- | Optional garbage collection for terminal workflow instances.+--+-- The hot-path schema modules keep lifecycle writes and lookup statements. This+-- module owns only cleanup statements used by an operator-scheduled GC pass.+-- Eligibility is based on the derived @keiro_workflows@ row: terminal instances+-- older than the retention cutoff are deleted, except completed children whose+-- parent is still non-terminal and may still attach to their result. Cleanup+-- removes workflow-sleep timers in every lifecycle state so no scheduled timer+-- can later recreate a collected workflow.+module Keiro.Workflow.Gc+  ( WorkflowGcPolicy (..),     WorkflowGcSummary (..),     gcWorkflowsOnce,     runWorkflowGcWorker,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4)@@ -36,161 +35,161 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx  data WorkflowGcPolicy = WorkflowGcPolicy-    { retention :: !NominalDiffTime-    , batchSize :: !Int-    }-    deriving stock (Generic, Eq, Show)+  { retention :: !NominalDiffTime,+    batchSize :: !Int+  }+  deriving stock (Generic, Eq, Show)  data WorkflowGcSummary = WorkflowGcSummary-    { scanned :: !Int-    , deleted :: !Int-    }-    deriving stock (Generic, Eq, Show)+  { scanned :: !Int,+    deleted :: !Int+  }+  deriving stock (Generic, Eq, Show)  gcWorkflowsOnce :: (Store :> es) => UTCTime -> WorkflowGcPolicy -> Eff es WorkflowGcSummary gcWorkflowsOnce now policy = do-    let cutoff = addUTCTime (negate (policy ^. #retention)) now-        limit = max 0 (policy ^. #batchSize)-    eligible <- runTransaction (Tx.statement (cutoff, fromIntegral limit :: Int32) eligibleWorkflowsStmt)-    deletedCount <- length <$> traverse deleteWorkflow eligible-    pure WorkflowGcSummary{scanned = length eligible, deleted = deletedCount}+  let cutoff = addUTCTime (negate (policy ^. #retention)) now+      limit = max 0 (policy ^. #batchSize)+  eligible <- runTransaction (Tx.statement (cutoff, fromIntegral limit :: Int32) eligibleWorkflowsStmt)+  deletedCount <- length <$> traverse deleteWorkflow eligible+  pure WorkflowGcSummary {scanned = length eligible, deleted = deletedCount}  runWorkflowGcWorker :: (IOE :> es, Store :> es) => WorkflowGcPolicy -> Int -> Eff es () runWorkflowGcWorker policy pollMicros =-    forever $ do-        now <- liftIO getCurrentTime-        void (gcWorkflowsOnce now policy)-        liftIO (threadDelay pollMicros)+  forever $ do+    now <- liftIO getCurrentTime+    void (gcWorkflowsOnce now policy)+    liftIO (threadDelay pollMicros)  deleteWorkflow :: (Store :> es) => (Text, Text) -> Eff es () deleteWorkflow (widText, nameText) = do-    let name = WorkflowName nameText-        wid = WorkflowId widText-    gen <- currentGeneration name wid-    for_ [0 .. gen] $ \generation -> do-        let streamName = workflowGenerationStreamName name wid generation-        mStreamId <- lookupStreamId streamName-        for_ mStreamId $ \(StreamId sid) ->-            runTransaction (Tx.statement sid deleteSnapshotStmt)-        void (hardDeleteStream streamName)-    runTransaction $ do-        Tx.statement (widText, nameText) deleteStepsStmt-        Tx.statement (nameText, widText) deleteAwakeablesStmt-        Tx.statement (widText, nameText, widText, nameText) deleteChildrenStmt-        -- Eligibility is already terminal, so remove every owned sleep timer:-        -- a scheduled survivor could otherwise recreate this workflow later.-        -- Keep this literal in sync with Keiro.Workflow.Sleep.workflowSleepKind.-        Tx.statement (widText, nameText, workflowSleepKindLiteral) deleteSleepTimersStmt-        Tx.statement (widText, nameText) deleteWorkflowStmt+  let name = WorkflowName nameText+      wid = WorkflowId widText+  gen <- currentGeneration name wid+  for_ [0 .. gen] $ \generation -> do+    let streamName = workflowGenerationStreamName name wid generation+    mStreamId <- lookupStreamId streamName+    for_ mStreamId $ \(StreamId sid) ->+      runTransaction (Tx.statement sid deleteSnapshotStmt)+    void (hardDeleteStream streamName)+  runTransaction $ do+    Tx.statement (widText, nameText) deleteStepsStmt+    Tx.statement (nameText, widText) deleteAwakeablesStmt+    Tx.statement (widText, nameText, widText, nameText) deleteChildrenStmt+    -- Eligibility is already terminal, so remove every owned sleep timer:+    -- a scheduled survivor could otherwise recreate this workflow later.+    -- Keep this literal in sync with Keiro.Workflow.Sleep.workflowSleepKind.+    Tx.statement (widText, nameText, workflowSleepKindLiteral) deleteSleepTimersStmt+    Tx.statement (widText, nameText) deleteWorkflowStmt  workflowSleepKindLiteral :: Text workflowSleepKindLiteral = "keiro.workflow.sleep"  eligibleWorkflowsStmt :: Statement (UTCTime, Int32) [(Text, Text)] eligibleWorkflowsStmt =-    preparable-        """-        SELECT w.workflow_id, w.workflow_name-        FROM keiro.keiro_workflows w-        WHERE w.status IN ('completed', 'cancelled', 'failed')-          AND w.completed_at IS NOT NULL-          AND w.completed_at <= $1-          AND NOT EXISTS (-            SELECT 1-            FROM keiro.keiro_workflow_children c-            JOIN keiro.keiro_workflows p-              ON p.workflow_id = c.parent_id-             AND p.workflow_name = c.parent_name-            WHERE c.child_id = w.workflow_id-              AND c.child_name = w.workflow_name-              AND p.status NOT IN ('completed', 'cancelled', 'failed')-          )-        ORDER BY w.completed_at-        LIMIT $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.int4))-        )-        (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))+  preparable+    """+    SELECT w.workflow_id, w.workflow_name+    FROM keiro.keiro_workflows w+    WHERE w.status IN ('completed', 'cancelled', 'failed')+      AND w.completed_at IS NOT NULL+      AND w.completed_at <= $1+      AND NOT EXISTS (+        SELECT 1+        FROM keiro.keiro_workflow_children c+        JOIN keiro.keiro_workflows p+          ON p.workflow_id = c.parent_id+         AND p.workflow_name = c.parent_name+        WHERE c.child_id = w.workflow_id+          AND c.child_name = w.workflow_name+          AND p.status NOT IN ('completed', 'cancelled', 'failed')+      )+    ORDER BY w.completed_at+    LIMIT $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))  deleteSnapshotStmt :: Statement Int64 () deleteSnapshotStmt =-    preparable-        """-        DELETE FROM keiro.keiro_snapshots-        WHERE stream_id = $1-        """-        (E.param (E.nonNullable E.int8))-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_snapshots+    WHERE stream_id = $1+    """+    (E.param (E.nonNullable E.int8))+    D.noResult  deleteStepsStmt :: Statement (Text, Text) () deleteStepsStmt =-    preparable-        """-        DELETE FROM keiro.keiro_workflow_steps-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  deleteAwakeablesStmt :: Statement (Text, Text) () deleteAwakeablesStmt =-    preparable-        """-        DELETE FROM keiro.keiro_awakeables-        WHERE owner_workflow_name = $1 AND owner_workflow_id = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_awakeables+    WHERE owner_workflow_name = $1 AND owner_workflow_id = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  deleteChildrenStmt :: Statement (Text, Text, Text, Text) () deleteChildrenStmt =-    preparable-        """-        DELETE FROM keiro.keiro_workflow_children-        WHERE (parent_id = $1 AND parent_name = $2)-           OR (child_id = $3 AND child_name = $4)-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_workflow_children+    WHERE (parent_id = $1 AND parent_name = $2)+       OR (child_id = $3 AND child_name = $4)+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  deleteSleepTimersStmt :: Statement (Text, Text, Text) () deleteSleepTimersStmt =-    preparable-        """-        DELETE FROM keiro.keiro_timers-        WHERE correlation_id = $1-          AND process_manager_name = $2-          AND payload->>'kind' = $3-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_timers+    WHERE correlation_id = $1+      AND process_manager_name = $2+      AND payload->>'kind' = $3+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  deleteWorkflowStmt :: Statement (Text, Text) () deleteWorkflowStmt =-    preparable-        """-        DELETE FROM keiro.keiro_workflows-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_workflows+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult
src/Keiro/Workflow/Instance.hs view
@@ -1,12 +1,11 @@-{- | Durable workflow instance summaries.--The journal stream and @keiro_workflow_steps@ index remain the source of truth-for replay. This module maintains one @keiro_workflows@ row per logical-workflow instance so the resume worker can track lifecycle, attempts, and-leases without scanning journal history.--}-module Keiro.Workflow.Instance (-    WorkflowStatus (..),+-- | Durable workflow instance summaries.+--+-- The journal stream and @keiro_workflow_steps@ index remain the source of truth+-- for replay. This module maintains one @keiro_workflows@ row per logical+-- workflow instance so the resume worker can track lifecycle, attempts, and+-- leases without scanning journal history.+module Keiro.Workflow.Instance+  ( WorkflowStatus (..),     WorkflowInstanceRow (..),     ResurrectOutcome (..),     statusToText,@@ -22,7 +21,7 @@     resetInstanceAttempts,     reviveFailedInstanceTx,     resurrectFailedWorkflow,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)@@ -41,357 +40,355 @@ import "hasql-transaction" Hasql.Transaction qualified as Tx  data WorkflowStatus-    = WfRunning-    | WfSuspended-    | WfCompleted-    | WfCancelled-    | WfFailed-    deriving stock (Generic, Eq, Show)+  = WfRunning+  | WfSuspended+  | WfCompleted+  | WfCancelled+  | WfFailed+  deriving stock (Generic, Eq, Show)  data WorkflowInstanceRow = WorkflowInstanceRow-    { workflowId :: !Text-    , workflowName :: !Text-    , generation :: !Int32-    , status :: !WorkflowStatus-    , attempts :: !Int32-    , lastError :: !(Maybe Text)-    , nextAttemptAt :: !(Maybe UTCTime)-    , leasedBy :: !(Maybe Text)-    , leaseExpiresAt :: !(Maybe UTCTime)-    , createdAt :: !UTCTime-    , updatedAt :: !UTCTime-    , completedAt :: !(Maybe UTCTime)-    }-    deriving stock (Generic, Eq, Show)+  { workflowId :: !Text,+    workflowName :: !Text,+    generation :: !Int32,+    status :: !WorkflowStatus,+    attempts :: !Int32,+    lastError :: !(Maybe Text),+    nextAttemptAt :: !(Maybe UTCTime),+    leasedBy :: !(Maybe Text),+    leaseExpiresAt :: !(Maybe UTCTime),+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime,+    completedAt :: !(Maybe UTCTime)+  }+  deriving stock (Generic, Eq, Show)  data ResurrectOutcome-    = WorkflowResurrected-    | WorkflowNotFailed-    | WorkflowNotFound-    deriving stock (Generic, Eq, Show)+  = WorkflowResurrected+  | WorkflowNotFailed+  | WorkflowNotFound+  deriving stock (Generic, Eq, Show)  upsertInstanceTx :: Text -> Text -> Int32 -> WorkflowStatus -> Maybe Text -> Tx.Transaction () upsertInstanceTx wid name gen status mLastError =-    Tx.statement (wid, name, gen, statusToText status, mLastError) upsertInstanceStmt+  Tx.statement (wid, name, gen, statusToText status, mLastError) upsertInstanceStmt  markInstanceSuspended :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es () markInstanceSuspended name@(WorkflowName nameText) wid@(WorkflowId widText) = do-    gen <- currentGeneration name wid-    runTransaction $-        upsertInstanceTx widText nameText (fromIntegral gen) WfSuspended Nothing+  gen <- currentGeneration name wid+  runTransaction $+    upsertInstanceTx widText nameText (fromIntegral gen) WfSuspended Nothing  lookupInstance :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es (Maybe WorkflowInstanceRow) lookupInstance (WorkflowName name) (WorkflowId wid) =-    runTransaction (Tx.statement (wid, name) lookupInstanceStmt)+  runTransaction (Tx.statement (wid, name) lookupInstanceStmt)  claimInstance :: (IOE :> es, Store :> es) => Text -> NominalDiffTime -> WorkflowName -> WorkflowId -> Eff es Bool claimInstance owner ttl name@(WorkflowName nameText) wid@(WorkflowId widText) = do-    now <- liftIO getCurrentTime-    gen <- currentGeneration name wid-    runTransaction $ do-        Tx.statement (widText, nameText, fromIntegral gen :: Int32) ensureInstanceStmt-        fromMaybe False-            <$> Tx.statement-                (widText, nameText, owner, now, addUTCTime ttl now)-                claimInstanceStmt--{- | Extend an instance lease only when @owner@ still holds it.+  now <- liftIO getCurrentTime+  gen <- currentGeneration name wid+  runTransaction $ do+    Tx.statement (widText, nameText, fromIntegral gen :: Int32) ensureInstanceStmt+    fromMaybe False+      <$> Tx.statement+        (widText, nameText, owner, now, addUTCTime ttl now)+        claimInstanceStmt -The caller supplies one clock reading so @updated_at@ and the new expiry share-the same boundary. Returns 'False' after ownership is lost or the row vanishes.--}+-- | Extend an instance lease only when @owner@ still holds it.+--+-- The caller supplies one clock reading so @updated_at@ and the new expiry share+-- the same boundary. Returns 'False' after ownership is lost or the row vanishes. renewInstanceLeaseTx ::-    Text ->-    NominalDiffTime ->-    UTCTime ->-    Text ->-    Text ->-    Tx.Transaction Bool+  Text ->+  NominalDiffTime ->+  UTCTime ->+  Text ->+  Text ->+  Tx.Transaction Bool renewInstanceLeaseTx owner ttl now wid name =-    Tx.statement-        (wid, name, owner, now, addUTCTime ttl now)-        renewInstanceLeaseStmt+  Tx.statement+    (wid, name, owner, now, addUTCTime ttl now)+    renewInstanceLeaseStmt  -- | Effect-level wrapper around 'renewInstanceLeaseTx' using the current time. renewInstanceLease ::-    (IOE :> es, Store :> es) =>-    Text ->-    NominalDiffTime ->-    WorkflowName ->-    WorkflowId ->-    Eff es Bool+  (IOE :> es, Store :> es) =>+  Text ->+  NominalDiffTime ->+  WorkflowName ->+  WorkflowId ->+  Eff es Bool renewInstanceLease owner ttl (WorkflowName name) (WorkflowId wid) = do-    now <- liftIO getCurrentTime-    runTransaction (renewInstanceLeaseTx owner ttl now wid name)+  now <- liftIO getCurrentTime+  runTransaction (renewInstanceLeaseTx owner ttl now wid name)  releaseInstance :: (Store :> es) => Text -> Bool -> WorkflowName -> WorkflowId -> Eff es () releaseInstance owner progressed (WorkflowName name) (WorkflowId wid) =-    runTransaction $-        Tx.statement (wid, name, owner, progressed) releaseInstanceStmt+  runTransaction $+    Tx.statement (wid, name, owner, progressed) releaseInstanceStmt  recordCrashTx :: Text -> Text -> Text -> Tx.Transaction Int32 recordCrashTx wid name err =-    Tx.statement (wid, name, err) recordCrashStmt+  Tx.statement (wid, name, err) recordCrashStmt  resetInstanceAttempts :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es () resetInstanceAttempts (WorkflowName name) (WorkflowId wid) =-    runTransaction (Tx.statement (wid, name) resetInstanceAttemptsStmt)+  runTransaction (Tx.statement (wid, name) resetInstanceAttemptsStmt)  reviveFailedInstanceTx :: Text -> Text -> Tx.Transaction Bool reviveFailedInstanceTx wid name =-    Tx.statement (wid, name) reviveFailedInstanceStmt--{- | Return a terminally failed workflow to the runnable pool.+  Tx.statement (wid, name) reviveFailedInstanceStmt -The operation removes only the current generation's derived failed-marker index-row; the append-only 'Keiro.Workflow.WorkflowFailed' journal event remains as-history. A failed child link is revived in the same transaction. Parent failure-sentinels already delivered to another journal are not retracted.--}+-- | Return a terminally failed workflow to the runnable pool.+--+-- The operation removes only the current generation's derived failed-marker index+-- row; the append-only 'Keiro.Workflow.WorkflowFailed' journal event remains as+-- history. A failed child link is revived in the same transaction. Parent failure+-- sentinels already delivered to another journal are not retracted. resurrectFailedWorkflow ::-    (Store :> es) =>-    WorkflowName ->-    WorkflowId ->-    Eff es ResurrectOutcome+  (Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  Eff es ResurrectOutcome resurrectFailedWorkflow name@(WorkflowName nameText) wid@(WorkflowId widText) =-    lookupInstance name wid >>= \case-        Nothing -> pure WorkflowNotFound-        Just row-            | row ^. #status /= WfFailed -> pure WorkflowNotFailed-            | otherwise -> do-                gen <- currentGeneration name wid-                revived <--                    runTransaction $ do-                        instanceRevived <- reviveFailedInstanceTx widText nameText-                        when instanceRevived $ do-                            deleteStepRowTx widText nameText gen failedStepName-                            void (reviveFailedChildTx widText nameText)-                        pure instanceRevived-                pure $-                    if revived-                        then WorkflowResurrected-                        else WorkflowNotFailed+  lookupInstance name wid >>= \case+    Nothing -> pure WorkflowNotFound+    Just row+      | row ^. #status /= WfFailed -> pure WorkflowNotFailed+      | otherwise -> do+          gen <- currentGeneration name wid+          revived <-+            runTransaction $ do+              instanceRevived <- reviveFailedInstanceTx widText nameText+              when instanceRevived $ do+                deleteStepRowTx widText nameText gen failedStepName+                void (reviveFailedChildTx widText nameText)+              pure instanceRevived+          pure $+            if revived+              then WorkflowResurrected+              else WorkflowNotFailed  statusToText :: WorkflowStatus -> Text statusToText = \case-    WfRunning -> "running"-    WfSuspended -> "suspended"-    WfCompleted -> "completed"-    WfCancelled -> "cancelled"-    WfFailed -> "failed"+  WfRunning -> "running"+  WfSuspended -> "suspended"+  WfCompleted -> "completed"+  WfCancelled -> "cancelled"+  WfFailed -> "failed"  statusFromText :: Text -> WorkflowStatus statusFromText = \case-    "running" -> WfRunning-    "suspended" -> WfSuspended-    "completed" -> WfCompleted-    "cancelled" -> WfCancelled-    "failed" -> WfFailed-    _ -> WfFailed+  "running" -> WfRunning+  "suspended" -> WfSuspended+  "completed" -> WfCompleted+  "cancelled" -> WfCancelled+  "failed" -> WfFailed+  _ -> WfFailed  upsertInstanceStmt :: Statement (Text, Text, Int32, Text, Maybe Text) () upsertInstanceStmt =-    preparable-        """-        INSERT INTO keiro.keiro_workflows-          (workflow_id, workflow_name, generation, status, last_error, completed_at)-        VALUES ($1, $2, $3, $4, $5,-                CASE WHEN $4 IN ('completed', 'cancelled', 'failed') THEN now() ELSE NULL END)-        ON CONFLICT (workflow_id, workflow_name) DO UPDATE-        SET generation = GREATEST(keiro_workflows.generation, EXCLUDED.generation),-            status = EXCLUDED.status,-            last_error = EXCLUDED.last_error,-            updated_at = now(),-            completed_at = CASE-              WHEN EXCLUDED.status IN ('completed', 'cancelled', 'failed')-                THEN COALESCE(keiro_workflows.completed_at, now())-              ELSE keiro_workflows.completed_at-            END-        WHERE keiro_workflows.status NOT IN ('completed', 'cancelled', 'failed')-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-            (E.param (E.nullable E.text))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_workflows+      (workflow_id, workflow_name, generation, status, last_error, completed_at)+    VALUES ($1, $2, $3, $4, $5,+            CASE WHEN $4 IN ('completed', 'cancelled', 'failed') THEN now() ELSE NULL END)+    ON CONFLICT (workflow_id, workflow_name) DO UPDATE+    SET generation = GREATEST(keiro_workflows.generation, EXCLUDED.generation),+        status = EXCLUDED.status,+        last_error = EXCLUDED.last_error,+        updated_at = now(),+        completed_at = CASE+          WHEN EXCLUDED.status IN ('completed', 'cancelled', 'failed')+            THEN COALESCE(keiro_workflows.completed_at, now())+          ELSE keiro_workflows.completed_at+        END+    WHERE keiro_workflows.status NOT IN ('completed', 'cancelled', 'failed')+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+    )+    D.noResult  lookupInstanceStmt :: Statement (Text, Text) (Maybe WorkflowInstanceRow) lookupInstanceStmt =-    preparable-        """-        SELECT workflow_id, workflow_name, generation, status, attempts,-               last_error, next_attempt_at, leased_by, lease_expires_at,-               created_at, updated_at, completed_at-        FROM keiro.keiro_workflows-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.rowMaybe instanceRowDecoder)+  preparable+    """+    SELECT workflow_id, workflow_name, generation, status, attempts,+           last_error, next_attempt_at, leased_by, lease_expires_at,+           created_at, updated_at, completed_at+    FROM keiro.keiro_workflows+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe instanceRowDecoder)  ensureInstanceStmt :: Statement (Text, Text, Int32) () ensureInstanceStmt =-    preparable-        """-        INSERT INTO keiro.keiro_workflows-          (workflow_id, workflow_name, generation, status)-        VALUES ($1, $2, $3, 'running')-        ON CONFLICT (workflow_id, workflow_name) DO NOTHING-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_workflows+      (workflow_id, workflow_name, generation, status)+    VALUES ($1, $2, $3, 'running')+    ON CONFLICT (workflow_id, workflow_name) DO NOTHING+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    D.noResult  claimInstanceStmt :: Statement (Text, Text, Text, UTCTime, UTCTime) (Maybe Bool) claimInstanceStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET leased_by = $3,-            lease_expires_at = $5,-            updated_at = $4-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND status IN ('running', 'suspended')-          AND (lease_expires_at IS NULL OR lease_expires_at < $4)-          AND (next_attempt_at IS NULL OR next_attempt_at <= $4)-        RETURNING TRUE-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.timestamptz))-        )-        (D.rowMaybe (D.column (D.nonNullable D.bool)))+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET leased_by = $3,+        lease_expires_at = $5,+        updated_at = $4+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND status IN ('running', 'suspended')+      AND (lease_expires_at IS NULL OR lease_expires_at < $4)+      AND (next_attempt_at IS NULL OR next_attempt_at <= $4)+    RETURNING TRUE+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.timestamptz))+    )+    (D.rowMaybe (D.column (D.nonNullable D.bool)))  renewInstanceLeaseStmt :: Statement (Text, Text, Text, UTCTime, UTCTime) Bool renewInstanceLeaseStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET lease_expires_at = $5,-            updated_at = $4-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND leased_by = $3-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.timestamptz))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET lease_expires_at = $5,+        updated_at = $4+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND leased_by = $3+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.timestamptz))+    )+    ((> 0) <$> D.rowsAffected)  releaseInstanceStmt :: Statement (Text, Text, Text, Bool) () releaseInstanceStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET leased_by = NULL,-            lease_expires_at = NULL,-            attempts = CASE WHEN $4 THEN 0 ELSE attempts END,-            last_error = CASE WHEN $4 THEN NULL ELSE last_error END,-            next_attempt_at = CASE WHEN $4 THEN NULL ELSE next_attempt_at END,-            updated_at = now()-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND leased_by = $3-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.bool))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET leased_by = NULL,+        lease_expires_at = NULL,+        attempts = CASE WHEN $4 THEN 0 ELSE attempts END,+        last_error = CASE WHEN $4 THEN NULL ELSE last_error END,+        next_attempt_at = CASE WHEN $4 THEN NULL ELSE next_attempt_at END,+        updated_at = now()+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND leased_by = $3+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.bool))+    )+    D.noResult  recordCrashStmt :: Statement (Text, Text, Text) Int32 recordCrashStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET attempts = attempts + 1,-            last_error = $3,-            next_attempt_at = now() + (LEAST(power(2, attempts + 1), 64) * interval '1 second'),-            updated_at = now()-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND status NOT IN ('completed', 'cancelled', 'failed')-        RETURNING attempts-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.singleRow (D.column (D.nonNullable D.int4)))+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET attempts = attempts + 1,+        last_error = $3,+        next_attempt_at = now() + (LEAST(power(2, attempts + 1), 64) * interval '1 second'),+        updated_at = now()+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND status NOT IN ('completed', 'cancelled', 'failed')+    RETURNING attempts+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.singleRow (D.column (D.nonNullable D.int4)))  resetInstanceAttemptsStmt :: Statement (Text, Text) () resetInstanceAttemptsStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET attempts = 0,-            last_error = NULL,-            next_attempt_at = NULL,-            updated_at = now()-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND status NOT IN ('completed', 'cancelled', 'failed')-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET attempts = 0,+        last_error = NULL,+        next_attempt_at = NULL,+        updated_at = now()+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND status NOT IN ('completed', 'cancelled', 'failed')+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  reviveFailedInstanceStmt :: Statement (Text, Text) Bool reviveFailedInstanceStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET status = 'running',-            attempts = 0,-            last_error = NULL,-            next_attempt_at = NULL,-            leased_by = NULL,-            lease_expires_at = NULL,-            completed_at = NULL,-            updated_at = now()-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND status = 'failed'-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        ((> 0) <$> D.rowsAffected)+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET status = 'running',+        attempts = 0,+        last_error = NULL,+        next_attempt_at = NULL,+        leased_by = NULL,+        lease_expires_at = NULL,+        completed_at = NULL,+        updated_at = now()+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND status = 'failed'+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)  instanceRowDecoder :: D.Row WorkflowInstanceRow instanceRowDecoder =-    WorkflowInstanceRow-        <$> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.int4)-        <*> (statusFromText <$> D.column (D.nonNullable D.text))-        <*> D.column (D.nonNullable D.int4)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.timestamptz)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)+  WorkflowInstanceRow+    <$> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int4)+    <*> (statusFromText <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.int4)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)
src/Keiro/Workflow/Resume.hs view
@@ -1,66 +1,65 @@-{- | The workflow resume / crash-recovery worker.--EP-38 makes a workflow replay-on-re-invocation: call 'runWorkflow' (or-'runWorkflowWith') with the same id and each already-journaled step-short-circuits, so only the un-journaled tail runs. But nothing in the runtime-/notices/ that a workflow exists, has steps, and lacks a terminal-'WorkflowCompleted' — i.e. that it crashed mid-run, or is parked on a-@sleep@\/@awakeable@ whose wake source has since resolved. This module is what-notices: a background worker that, on each pass, asks the database "which-workflows have steps but no completion?" ('findUnfinishedWorkflowIds') and-re-invokes each so it proceeds.--Synchronous exceptions are retried with database-backed exponential backoff.-Once 'maxAttempts' is reached, the worker appends 'WorkflowFailed' and stops-discovering that instance. The operator-facing counterpart lives in-"Keiro.Workflow.Instance": 'Keiro.Workflow.Instance.resurrectFailedWorkflow'-transactionally returns a terminally failed instance to the runnable pool-without deleting its append-only failure history.--== Why a registry--A workflow's body is application Haskell code — only its /journal/ (the-recorded step results) lives in the database. To re-invoke a workflow the-worker must turn its stored name into a function-@'WorkflowId' -> 'Eff' ('Workflow' : es) a@. There is no way to materialize a-closure from a string, so the application supplies a 'WorkflowRegistry' mapping-each 'WorkflowName' to its 'WorkflowDef'. This is the resume-worker analogue of-the caller-supplied @fire@ action 'Keiro.Timer.runTimerWorker' takes: the-worker owns the discovery loop and the database access; the application owns-the domain behaviour.--== Contract recap for downstream plans (the v2 MasterPlan)--* __'WorkflowRegistry' \/ 'WorkflowDef'__ — the application-supplied-  name → definition map. EP-43 (child workflows) relies on this worker to wake-  a /parent/ once a child finishes: the child's completion journals the-  parent's awaited @child:\<id\>@ 'StepRecorded', and the next resume pass-  re-invokes the parent (registered here) so it proceeds past its child-wait.-* __'ResumeSummary'__ ('discovered', 'resumed', 'completed', 'stillSuspended',-  'unknownName', 'failed', 'transientErrors', 'leaseSkipped') — the per-pass-  observability record. EP-44 reads it for the @keiro.workflow.resumed@-  instrument (and may thread a @Maybe KeiroMetrics@ into-  'WorkflowResumeOptions' \/ 'resumeWorkflowsOnce' following the-  no-op-under-@Nothing@ idiom the timer and outbox workers use).-* __'resumeWorkflowsOnce'__ is the single-pass, testable unit (like-  'Keiro.Outbox.publishClaimedOutbox'); __'runWorkflowResumeWorker'__ \/-  __'runWorkflowResumeWorkerWith'__ are the poll-loop drivers (like the-  @runTimerWorker@ pair). Re-invocation goes through EP-41's 'runWorkflowWith'-  carrying 'runOptions', so a resumed run honours the same snapshot/telemetry-  options as its first run.--Discovery is the 'findUnfinishedWorkflowIds' index query plus the child-row-seed query. Each candidate is claimed through an expiry-based row lease in-@keiro_workflows@ before it is advanced. A live foreign lease skips only that-instance and increments 'leaseSkipped'; a dead worker's lease becomes claimable-after 'leaseTtl'. Each fresh workflow boundary renews the lease before running-side effects. The lease prevents duplicate steady-state work, while the journal-append path still serializes same-step writers so lease expiry races converge-on one recorded result. There is __no kiroku @wf:@ prefix subscription__ and no-session-level advisory lock.--}-module Keiro.Workflow.Resume (-    -- * Registry+-- | The workflow resume / crash-recovery worker.+--+-- EP-38 makes a workflow replay-on-re-invocation: call 'runWorkflow' (or+-- 'runWorkflowWith') with the same id and each already-journaled step+-- short-circuits, so only the un-journaled tail runs. But nothing in the runtime+-- /notices/ that a workflow exists, has steps, and lacks a terminal+-- 'WorkflowCompleted' — i.e. that it crashed mid-run, or is parked on a+-- @sleep@\/@awakeable@ whose wake source has since resolved. This module is what+-- notices: a background worker that, on each pass, asks the database "which+-- workflows have steps but no completion?" ('findUnfinishedWorkflowIds') and+-- re-invokes each so it proceeds.+--+-- Synchronous exceptions are retried with database-backed exponential backoff.+-- Once 'maxAttempts' is reached, the worker appends 'WorkflowFailed' and stops+-- discovering that instance. The operator-facing counterpart lives in+-- "Keiro.Workflow.Instance": 'Keiro.Workflow.Instance.resurrectFailedWorkflow'+-- transactionally returns a terminally failed instance to the runnable pool+-- without deleting its append-only failure history.+--+-- == Why a registry+--+-- A workflow's body is application Haskell code — only its /journal/ (the+-- recorded step results) lives in the database. To re-invoke a workflow the+-- worker must turn its stored name into a function+-- @'WorkflowId' -> 'Eff' ('Workflow' : es) a@. There is no way to materialize a+-- closure from a string, so the application supplies a 'WorkflowRegistry' mapping+-- each 'WorkflowName' to its 'WorkflowDef'. This is the resume-worker analogue of+-- the caller-supplied @fire@ action 'Keiro.Timer.runTimerWorker' takes: the+-- worker owns the discovery loop and the database access; the application owns+-- the domain behaviour.+--+-- == Contract recap for downstream plans (the v2 MasterPlan)+--+-- * __'WorkflowRegistry' \/ 'WorkflowDef'__ — the application-supplied+--   name → definition map. EP-43 (child workflows) relies on this worker to wake+--   a /parent/ once a child finishes: the child's completion journals the+--   parent's awaited @child:\<id\>@ 'StepRecorded', and the next resume pass+--   re-invokes the parent (registered here) so it proceeds past its child-wait.+-- * __'ResumeSummary'__ ('discovered', 'resumed', 'completed', 'stillSuspended',+--   'unknownName', 'failed', 'transientErrors', 'leaseSkipped') — the per-pass+--   observability record. EP-44 reads it for the @keiro.workflow.resumed@+--   instrument (and may thread a @Maybe KeiroMetrics@ into+--   'WorkflowResumeOptions' \/ 'resumeWorkflowsOnce' following the+--   no-op-under-@Nothing@ idiom the timer and outbox workers use).+-- * __'resumeWorkflowsOnce'__ is the single-pass, testable unit (like+--   'Keiro.Outbox.publishClaimedOutbox'); __'runWorkflowResumeWorker'__ \/+--   __'runWorkflowResumeWorkerWith'__ are the poll-loop drivers (like the+--   @runTimerWorker@ pair). Re-invocation goes through EP-41's 'runWorkflowWith'+--   carrying 'runOptions', so a resumed run honours the same snapshot/telemetry+--   options as its first run.+--+-- Discovery is the 'findUnfinishedWorkflowIds' index query plus the child-row+-- seed query. Each candidate is claimed through an expiry-based row lease in+-- @keiro_workflows@ before it is advanced. A live foreign lease skips only that+-- instance and increments 'leaseSkipped'; a dead worker's lease becomes claimable+-- after 'leaseTtl'. Each fresh workflow boundary renews the lease before running+-- side effects. The lease prevents duplicate steady-state work, while the journal+-- append path still serializes same-step writers so lease expiry races converge+-- on one recorded result. There is __no kiroku @wf:@ prefix subscription__ and no+-- session-level advisory lock.+module Keiro.Workflow.Resume+  ( -- * Registry     WorkflowDef (..),     WorkflowRegistry, @@ -81,7 +80,7 @@     -- * Running (push-aware, EP-50)     runPollLoopWith,     runWorkflowResumeWorkerPush,-)+  ) where  import Control.Concurrent (threadDelay)@@ -101,16 +100,16 @@ import Effectful.Error.Static qualified as Error import Effectful.Exception (catch, catchSync, finally, throwIO) import Keiro.Prelude-import Keiro.Telemetry (-    recordWorkflowAwakeablesPending,+import Keiro.Telemetry+  ( recordWorkflowAwakeablesPending,     recordWorkflowFailed,     recordWorkflowLeaseSkipped,     recordWorkflowResumeErrors,     recordWorkflowResumed,- )+  ) import Keiro.Wake (WakeSignal (..), wakeSignalFromStore)-import Keiro.Workflow (-    JournalAppendOutcome (..),+import Keiro.Workflow+  ( JournalAppendOutcome (..),     LeaseHeartbeat (..),     Workflow,     WorkflowError (..),@@ -126,7 +125,7 @@     findUnfinishedWorkflowIds,     prepareJournalAppend,     runWorkflowWith,- )+  ) import Keiro.Workflow.Awakeable.Schema (countPendingAwakeables) import Keiro.Workflow.Child (runChildWorkflow) import Keiro.Workflow.Child.Schema (ChildRow, findRunningChildIds, lookupChild, markChildFailedTx)@@ -142,133 +141,127 @@ -- Registry -- --------------------------------------------------------------------------- -{- | How to re-build a workflow's body from its id, for one workflow name.--The result type @a@ is existential: the worker discards it (it cares only-whether a re-invocation reached 'Completed' or 'Suspended'), so one registry-can hold workflows of different return types.--}+-- | How to re-build a workflow's body from its id, for one workflow name.+--+-- The result type @a@ is existential: the worker discards it (it cares only+-- whether a re-invocation reached 'Completed' or 'Suspended'), so one registry+-- can hold workflows of different return types. data WorkflowDef es = forall a. (Aeson.ToJSON a) => WorkflowDef-    { runDef :: WorkflowId -> Eff (Workflow : es) a-    }+  { runDef :: WorkflowId -> Eff (Workflow : es) a+  } -{- | The application-supplied map from workflow name to its definition. The-worker looks up each discovered workflow's name here; an absent name is-skipped and counted as 'unknownName' (a deploy that dropped a workflow while-instances were still in flight — surfaced, not silently lost).--}+-- | The application-supplied map from workflow name to its definition. The+-- worker looks up each discovered workflow's name here; an absent name is+-- skipped and counted as 'unknownName' (a deploy that dropped a workflow while+-- instances were still in flight — surfaced, not silently lost). type WorkflowRegistry es = Map WorkflowName (WorkflowDef es)  -- --------------------------------------------------------------------------- -- Options -- --------------------------------------------------------------------------- -{- | Options for the resume worker. Mirrors the @TimerWorkerOptions@ shape:-'runOptions' threads EP-41's snapshot/telemetry options into 'runWorkflowWith',-and 'pollInterval' is the loop driver's gap between passes.--}+-- | Options for the resume worker. Mirrors the @TimerWorkerOptions@ shape:+-- 'runOptions' threads EP-41's snapshot/telemetry options into 'runWorkflowWith',+-- and 'pollInterval' is the loop driver's gap between passes. data WorkflowResumeOptions = WorkflowResumeOptions-    { runOptions :: !WorkflowRunOptions-    {- ^ Threaded into 'runWorkflowWith' (or 'runChildWorkflow' for a child) so a-    resumed run honours the same snapshot (EP-41) and telemetry (EP-44)-    options as its first run.-    -}-    , pollInterval :: !Int-    -- ^ Microseconds the loop driver sleeps between passes.-    , maxAttempts :: !Int-    -- ^ Workflow-level synchronous exceptions before terminal failure.-    , leaseTtl :: !NominalDiffTime-    {- ^ How long a claimed workflow instance stays leased without reaching-    another fresh workflow boundary. It bounds dead-worker recovery time and-    must exceed the longest single step action or await arm.-    -}-    , logEvent :: !(ResumeLogEvent -> IO ())-    -- ^ Per-worker logging hook. Defaults to a compact stderr renderer.-    }-    deriving stock (Generic)+  { -- | Threaded into 'runWorkflowWith' (or 'runChildWorkflow' for a child) so a+    --     resumed run honours the same snapshot (EP-41) and telemetry (EP-44)+    --     options as its first run.+    runOptions :: !WorkflowRunOptions,+    -- | Microseconds the loop driver sleeps between passes.+    pollInterval :: !Int,+    -- | Workflow-level synchronous exceptions before terminal failure.+    maxAttempts :: !Int,+    -- | How long a claimed workflow instance stays leased without reaching+    --     another fresh workflow boundary. It bounds dead-worker recovery time and+    --     must exceed the longest single step action or await arm.+    leaseTtl :: !NominalDiffTime,+    -- | Per-worker logging hook. Defaults to a compact stderr renderer.+    logEvent :: !(ResumeLogEvent -> IO ())+  }+  deriving stock (Generic)  data ResumeLogEvent-    = ResumeUnknownName !Text !Text-    | ResumeTransientError !Text !Text !Text-    | ResumeWorkflowCrashed !Text !Text !Int !Int !Text-    | ResumeWorkflowMarkedFailed !Text !Text !Text-    | ResumePassFailed !Text-    deriving stock (Eq, Show)+  = ResumeUnknownName !Text !Text+  | ResumeTransientError !Text !Text !Text+  | ResumeWorkflowCrashed !Text !Text !Int !Int !Text+  | ResumeWorkflowMarkedFailed !Text !Text !Text+  | ResumePassFailed !Text+  deriving stock (Eq, Show)  -- | Defaults: EP-41's 'defaultWorkflowRunOptions', a 1-second poll, and a 60-second lease. defaultWorkflowResumeOptions :: WorkflowResumeOptions defaultWorkflowResumeOptions =-    WorkflowResumeOptions-        { runOptions = defaultWorkflowRunOptions-        , pollInterval = 1_000_000-        , maxAttempts = 5-        , leaseTtl = 60-        , logEvent = defaultResumeLogEvent-        }+  WorkflowResumeOptions+    { runOptions = defaultWorkflowRunOptions,+      pollInterval = 1_000_000,+      maxAttempts = 5,+      leaseTtl = 60,+      logEvent = defaultResumeLogEvent+    }  defaultResumeLogEvent :: ResumeLogEvent -> IO () defaultResumeLogEvent event =-    hPutStrLn stderr $ case event of-        ResumeUnknownName name wid ->-            "keiro resume worker: no registry entry for workflow "-                <> Text.unpack name-                <> " (id "-                <> Text.unpack wid-                <> "); skipping"-        ResumeTransientError name wid err ->-            "keiro resume worker: transient store error while advancing "-                <> Text.unpack name-                <> " (id "-                <> Text.unpack wid-                <> "): "-                <> Text.unpack err-        ResumeWorkflowCrashed name wid attempt maxAttempt err ->-            "keiro resume worker: workflow "-                <> Text.unpack name-                <> " (id "-                <> Text.unpack wid-                <> ") crashed on attempt "-                <> show attempt-                <> "/"-                <> show maxAttempt-                <> ": "-                <> Text.unpack err-        ResumeWorkflowMarkedFailed name wid err ->-            "keiro resume worker: marked workflow "-                <> Text.unpack name-                <> " (id "-                <> Text.unpack wid-                <> ") failed: "-                <> Text.unpack err-        ResumePassFailed err ->-            "keiro resume worker: pass failed: " <> Text.unpack err+  hPutStrLn stderr $ case event of+    ResumeUnknownName name wid ->+      "keiro resume worker: no registry entry for workflow "+        <> Text.unpack name+        <> " (id "+        <> Text.unpack wid+        <> "); skipping"+    ResumeTransientError name wid err ->+      "keiro resume worker: transient store error while advancing "+        <> Text.unpack name+        <> " (id "+        <> Text.unpack wid+        <> "): "+        <> Text.unpack err+    ResumeWorkflowCrashed name wid attempt maxAttempt err ->+      "keiro resume worker: workflow "+        <> Text.unpack name+        <> " (id "+        <> Text.unpack wid+        <> ") crashed on attempt "+        <> show attempt+        <> "/"+        <> show maxAttempt+        <> ": "+        <> Text.unpack err+    ResumeWorkflowMarkedFailed name wid err ->+      "keiro resume worker: marked workflow "+        <> Text.unpack name+        <> " (id "+        <> Text.unpack wid+        <> ") failed: "+        <> Text.unpack err+    ResumePassFailed err ->+      "keiro resume worker: pass failed: " <> Text.unpack err  -- --------------------------------------------------------------------------- -- Per-pass summary -- --------------------------------------------------------------------------- -{- | What one 'resumeWorkflowsOnce' pass did. EP-44 instruments this for-@keiro.workflow.resumed@.--}+-- | What one 'resumeWorkflowsOnce' pass did. EP-44 instruments this for+-- @keiro.workflow.resumed@. data ResumeSummary = ResumeSummary-    { discovered :: !Int-    -- ^ Unfinished workflows 'findUnfinishedWorkflowIds' returned this pass.-    , resumed :: !Int-    -- ^ Workflows re-invoked (found in the registry and run).-    , completed :: !Int-    -- ^ Re-invocations that reached 'Completed' this pass.-    , stillSuspended :: !Int-    -- ^ Re-invocations that returned 'Suspended' (wake source not yet resolved).-    , unknownName :: !Int-    -- ^ Discovered workflows whose name was absent from the registry (skipped + logged).-    , failed :: !Int-    -- ^ Workflows marked terminally failed this pass.-    , transientErrors :: !Int-    -- ^ Store errors observed while advancing individual workflows.-    , leaseSkipped :: !Int-    -- ^ Candidates skipped because another worker holds a live lease.-    }-    deriving stock (Generic, Eq, Show)+  { -- | Unfinished workflows 'findUnfinishedWorkflowIds' returned this pass.+    discovered :: !Int,+    -- | Workflows re-invoked (found in the registry and run).+    resumed :: !Int,+    -- | Re-invocations that reached 'Completed' this pass.+    completed :: !Int,+    -- | Re-invocations that returned 'Suspended' (wake source not yet resolved).+    stillSuspended :: !Int,+    -- | Discovered workflows whose name was absent from the registry (skipped + logged).+    unknownName :: !Int,+    -- | Workflows marked terminally failed this pass.+    failed :: !Int,+    -- | Store errors observed while advancing individual workflows.+    transientErrors :: !Int,+    -- | Candidates skipped because another worker holds a live lease.+    leaseSkipped :: !Int+  }+  deriving stock (Generic, Eq, Show)  -- | A zeroed 'ResumeSummary'. emptyResumeSummary :: ResumeSummary@@ -278,45 +271,44 @@ -- Running -- --------------------------------------------------------------------------- -{- | Run one discover-and-reinvoke pass.--Discovers every unfinished workflow via 'findUnfinishedWorkflowIds', and for-each looks its name up in @registry@:--* __present__ — re-invoke through 'runWorkflowWith' (the journal pre-load-  short-circuits already-journaled steps, so only the un-journaled tail runs);-  the outcome bumps 'completed' or 'stillSuspended'.-* __absent__ — log a warning and bump 'unknownName' (a workflow whose code was-  removed while instances were in flight must be visible, not silently lost).--Idempotent: a completed workflow has a @__workflow_completed__@ index row and-so drops out of discovery; re-invoking an unfinished one twice converges to the-same journal (EP-38 deterministic ids + step short-circuit).--}+-- | Run one discover-and-reinvoke pass.+--+-- Discovers every unfinished workflow via 'findUnfinishedWorkflowIds', and for+-- each looks its name up in @registry@:+--+-- * __present__ — re-invoke through 'runWorkflowWith' (the journal pre-load+--   short-circuits already-journaled steps, so only the un-journaled tail runs);+--   the outcome bumps 'completed' or 'stillSuspended'.+-- * __absent__ — log a warning and bump 'unknownName' (a workflow whose code was+--   removed while instances were in flight must be visible, not silently lost).+--+-- Idempotent: a completed workflow has a @__workflow_completed__@ index row and+-- so drops out of discovery; re-invoking an unfinished one twice converges to the+-- same journal (EP-38 deterministic ids + step short-circuit). resumeWorkflowsOnce ::-    forall es.-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    WorkflowResumeOptions ->-    WorkflowRegistry es ->-    Eff es ResumeSummary+  forall es.+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowResumeOptions ->+  WorkflowRegistry es ->+  Eff es ResumeSummary resumeWorkflowsOnce opts registry = do-    -- EP-44: sample the @keiro.workflow.awakeables.pending@ gauge once per pass,-    -- on the same Store the discovery query uses. The metrics handle rides on the-    -- run options (EP-44 threads telemetry through 'WorkflowRunOptions'), so it is-    -- already forwarded into 'runWorkflowWith' for every re-invocation.-    pending <- countPendingAwakeables-    recordWorkflowAwakeablesPending mMetrics (fromIntegral pending)-    -- Discovery unions two sources: workflows with steps but no terminal marker-    -- ('findUnfinishedWorkflowIds') and freshly-spawned children that have no-    -- step rows yet ('findRunningChildIds', EP-43) — so a zero-step child is-    -- still driven. The dedup collapses a child that appears in both.-    now <- liftIO getCurrentTime-    unfinished <- findUnfinishedWorkflowIds now-    runningChildren <- findRunningChildIds-    let pairs = dedupeFirstSeen (unfinished <> runningChildren)-        seed = emptyResumeSummary{discovered = length pairs}-    owner <- UUID.toText <$> liftIO UUIDv4.nextRandom-    foldM (advance owner) seed pairs+  -- EP-44: sample the @keiro.workflow.awakeables.pending@ gauge once per pass,+  -- on the same Store the discovery query uses. The metrics handle rides on the+  -- run options (EP-44 threads telemetry through 'WorkflowRunOptions'), so it is+  -- already forwarded into 'runWorkflowWith' for every re-invocation.+  pending <- countPendingAwakeables+  recordWorkflowAwakeablesPending mMetrics (fromIntegral pending)+  -- Discovery unions two sources: workflows with steps but no terminal marker+  -- ('findUnfinishedWorkflowIds') and freshly-spawned children that have no+  -- step rows yet ('findRunningChildIds', EP-43) — so a zero-step child is+  -- still driven. The dedup collapses a child that appears in both.+  now <- liftIO getCurrentTime+  unfinished <- findUnfinishedWorkflowIds now+  runningChildren <- findRunningChildIds+  let pairs = dedupeFirstSeen (unfinished <> runningChildren)+      seed = emptyResumeSummary {discovered = length pairs}+  owner <- UUID.toText <$> liftIO UUIDv4.nextRandom+  foldM (advance owner) seed pairs   where     mMetrics = runOptions opts ^. #metrics     dedupeFirstSeen :: [(Text, Text)] -> [(Text, Text)]@@ -324,241 +316,237 @@       where         go !_ [] = []         go !seen (pair : rest)-            | pair `Set.member` seen = go seen rest-            | otherwise = pair : go (Set.insert pair seen) rest+          | pair `Set.member` seen = go seen rest+          | otherwise = pair : go (Set.insert pair seen) rest      advance :: Text -> ResumeSummary -> (Text, Text) -> Eff es ResumeSummary     advance owner acc (widText, wnameText) =-        case Map.lookup (WorkflowName wnameText) registry of-            Nothing -> do-                liftIO $ logEvent opts (ResumeUnknownName wnameText widText)-                pure acc{unknownName = unknownName acc + 1}-            Just (WorkflowDef runDef) -> do-                let wid = WorkflowId widText-                    name = WorkflowName wnameText-                claimed <- claimInstance owner (leaseTtl opts) name wid-                if not claimed-                    then do-                        recordWorkflowLeaseSkipped mMetrics 1-                        pure acc{leaseSkipped = leaseSkipped acc + 1}-                    else do-                        progressedRef <- liftIO (newIORef False)-                        ( do-                                attempt <--                                    Error.catchError-                                        @StoreError-                                        (AdvOk <$> driveInstance owner name wid runDef)-                                        (\_ e -> pure (AdvTransient e))-                                        `catch` (\WorkflowLeaseLost -> pure AdvLeaseLost)-                                        `catchSync` (pure . AdvCrashed)-                                recordWorkflowResumed mMetrics 1-                                (acc', progressed) <- handleAttempt acc name wid attempt-                                liftIO (writeIORef progressedRef progressed)-                                pure acc'-                            )-                            `finally` do-                                progressed <- liftIO (readIORef progressedRef)-                                releaseInstance owner progressed name wid+      case Map.lookup (WorkflowName wnameText) registry of+        Nothing -> do+          liftIO $ logEvent opts (ResumeUnknownName wnameText widText)+          pure acc {unknownName = unknownName acc + 1}+        Just (WorkflowDef runDef) -> do+          let wid = WorkflowId widText+              name = WorkflowName wnameText+          claimed <- claimInstance owner (leaseTtl opts) name wid+          if not claimed+            then do+              recordWorkflowLeaseSkipped mMetrics 1+              pure acc {leaseSkipped = leaseSkipped acc + 1}+            else do+              progressedRef <- liftIO (newIORef False)+              ( do+                  attempt <-+                    Error.catchError+                      @StoreError+                      (AdvOk <$> driveInstance owner name wid runDef)+                      (\_ e -> pure (AdvTransient e))+                      `catch` (\WorkflowLeaseLost -> pure AdvLeaseLost)+                      `catchSync` (pure . AdvCrashed)+                  recordWorkflowResumed mMetrics 1+                  (acc', progressed) <- handleAttempt acc name wid attempt+                  liftIO (writeIORef progressedRef progressed)+                  pure acc'+                )+                `finally` do+                  progressed <- liftIO (readIORef progressedRef)+                  releaseInstance owner progressed name wid     driveInstance :: (Aeson.ToJSON a) => Text -> WorkflowName -> WorkflowId -> (WorkflowId -> Eff (Workflow : es) a) -> Eff es (WorkflowOutcome a)     driveInstance owner name@(WorkflowName wnameText) wid@(WorkflowId widText) runDef = do-        mChild <- lookupChild widText wnameText-        let runOpts =-                runOptions opts-                    & #leaseHeartbeat-                    .~ Just LeaseHeartbeat{owner, ttl = leaseTtl opts}-        case mChild of-            Just _ -> runChildWorkflow runOpts name wid (runDef wid)-            Nothing -> runWorkflowWith runOpts name wid (runDef wid)+      mChild <- lookupChild widText wnameText+      let runOpts =+            runOptions opts+              & #leaseHeartbeat+              .~ Just LeaseHeartbeat {owner, ttl = leaseTtl opts}+      case mChild of+        Just _ -> runChildWorkflow runOpts name wid (runDef wid)+        Nothing -> runWorkflowWith runOpts name wid (runDef wid)     handleAttempt :: ResumeSummary -> WorkflowName -> WorkflowId -> AdvanceResult a -> Eff es (ResumeSummary, Bool)     handleAttempt acc name@(WorkflowName wnameText) wid@(WorkflowId widText) = \case-        AdvOk outcome -> do-            pure (bumpForOutcome outcome acc, True)-        AdvTransient err -> do-            let rendered = Text.pack (show err)-            liftIO $ logEvent opts (ResumeTransientError wnameText widText rendered)-            recordWorkflowResumeErrors mMetrics 1-            pure (acc{resumed = resumed acc + 1, transientErrors = transientErrors acc + 1}, False)-        AdvLeaseLost -> do-            recordWorkflowLeaseSkipped mMetrics 1-            pure (acc{leaseSkipped = leaseSkipped acc + 1}, False)-        AdvCrashed err -> do-            let rendered = Text.pack (show err)-            attempt <- runTransaction (recordCrashTx widText wnameText rendered)-            liftIO $ logEvent opts (ResumeWorkflowCrashed wnameText widText (fromIntegral attempt) (maxAttempts opts) rendered)-            if attempt >= fromIntegral (maxAttempts opts :: Int)-                then do-                    now <- liftIO getCurrentTime-                    mChild <- lookupChild widText wnameText-                    case mChild of-                        Nothing ->-                            appendJournalEntry name wid (WorkflowFailed rendered now)-                        Just childRow ->-                            appendFailedChildAndWakeParent name wid rendered now childRow-                    liftIO $ logEvent opts (ResumeWorkflowMarkedFailed wnameText widText rendered)-                    recordWorkflowFailed mMetrics 1-                    pure (acc{resumed = resumed acc + 1, failed = failed acc + 1}, False)-                else pure (acc{resumed = resumed acc + 1}, False)+      AdvOk outcome -> do+        pure (bumpForOutcome outcome acc, True)+      AdvTransient err -> do+        let rendered = Text.pack (show err)+        liftIO $ logEvent opts (ResumeTransientError wnameText widText rendered)+        recordWorkflowResumeErrors mMetrics 1+        pure (acc {resumed = resumed acc + 1, transientErrors = transientErrors acc + 1}, False)+      AdvLeaseLost -> do+        recordWorkflowLeaseSkipped mMetrics 1+        pure (acc {leaseSkipped = leaseSkipped acc + 1}, False)+      AdvCrashed err -> do+        let rendered = Text.pack (show err)+        attempt <- runTransaction (recordCrashTx widText wnameText rendered)+        liftIO $ logEvent opts (ResumeWorkflowCrashed wnameText widText (fromIntegral attempt) (maxAttempts opts) rendered)+        if attempt >= fromIntegral (maxAttempts opts :: Int)+          then do+            now <- liftIO getCurrentTime+            mChild <- lookupChild widText wnameText+            case mChild of+              Nothing ->+                appendJournalEntry name wid (WorkflowFailed rendered now)+              Just childRow ->+                appendFailedChildAndWakeParent name wid rendered now childRow+            liftIO $ logEvent opts (ResumeWorkflowMarkedFailed wnameText widText rendered)+            recordWorkflowFailed mMetrics 1+            pure (acc {resumed = resumed acc + 1, failed = failed acc + 1}, False)+          else pure (acc {resumed = resumed acc + 1}, False)  data AdvanceResult a-    = AdvOk !(WorkflowOutcome a)-    | AdvTransient !StoreError-    | AdvLeaseLost-    | AdvCrashed !Exception.SomeException+  = AdvOk !(WorkflowOutcome a)+  | AdvTransient !StoreError+  | AdvLeaseLost+  | AdvCrashed !Exception.SomeException  appendFailedChildAndWakeParent ::-    (IOE :> es, Store :> es) =>-    WorkflowName ->-    WorkflowId ->-    Text ->-    UTCTime ->-    ChildRow ->-    Eff es ()+  (IOE :> es, Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  Text ->+  UTCTime ->+  ChildRow ->+  Eff es () appendFailedChildAndWakeParent childNm childWid reason now childRow = do-    childGen <- currentGeneration childNm childWid-    let parentNm = WorkflowName (childRow ^. #parentName)-        parentWid = WorkflowId (childRow ^. #parentId)-    parentGen <- currentGeneration parentNm parentWid-    childFailTx <- prepareJournalAppend childNm childWid childGen (WorkflowFailed reason now)-    parentWakeTx <--        prepareJournalAppend-            parentNm-            parentWid-            parentGen-            StepRecorded-                { stepName = childRow ^. #awaitStep-                , result = Aeson.object ["failed" Aeson..= reason]-                , recordedAt = now-                }-    (childOutcome, parentOutcome) <--        runTransaction $ do-            childOutcome <- childFailTx-            _transitioned <- markChildFailedTx (unWorkflowId childWid) (unWorkflowName childNm) reason-            parentOutcome <- parentWakeTx-            condemnOnAppendConflict childOutcome-            condemnOnAppendConflict parentOutcome-            pure (childOutcome, parentOutcome)-    throwOnAppendConflict childOutcome-    throwOnAppendConflict parentOutcome+  childGen <- currentGeneration childNm childWid+  let parentNm = WorkflowName (childRow ^. #parentName)+      parentWid = WorkflowId (childRow ^. #parentId)+  parentGen <- currentGeneration parentNm parentWid+  childFailTx <- prepareJournalAppend childNm childWid childGen (WorkflowFailed reason now)+  parentWakeTx <-+    prepareJournalAppend+      parentNm+      parentWid+      parentGen+      StepRecorded+        { stepName = childRow ^. #awaitStep,+          result = Aeson.object ["failed" Aeson..= reason],+          recordedAt = now+        }+  (childOutcome, parentOutcome) <-+    runTransaction $ do+      childOutcome <- childFailTx+      _transitioned <- markChildFailedTx (unWorkflowId childWid) (unWorkflowName childNm) reason+      parentOutcome <- parentWakeTx+      condemnOnAppendConflict childOutcome+      condemnOnAppendConflict parentOutcome+      pure (childOutcome, parentOutcome)+  throwOnAppendConflict childOutcome+  throwOnAppendConflict parentOutcome  condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction () condemnOnAppendConflict = \case-    JournalAppendConflict{} -> Tx.condemn-    _ -> pure ()+  JournalAppendConflict {} -> Tx.condemn+  _ -> pure ()  throwOnAppendConflict :: JournalAppendOutcome -> Eff es () throwOnAppendConflict = \case-    JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))-    _ -> pure ()+  JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  _ -> pure () -{- | Fold one re-invocation's outcome into the running summary. The existential-result @a@ is discarded here, so it never escapes the registry.--}+-- | Fold one re-invocation's outcome into the running summary. The existential+-- result @a@ is discarded here, so it never escapes the registry. bumpForOutcome :: WorkflowOutcome a -> ResumeSummary -> ResumeSummary bumpForOutcome outcome acc = case outcome of-    Completed _ -> acc{resumed = resumed acc + 1, completed = completed acc + 1}-    Suspended -> acc{resumed = resumed acc + 1, stillSuspended = stillSuspended acc + 1}-    -- A workflow cancelled between discovery and re-invocation short-circuits to-    -- 'Cancelled' (EP-43); count it as re-invoked but neither completed nor-    -- suspended. (A cancelled workflow also drops out of discovery, so this is a-    -- rare race, not the steady state.)-    Cancelled -> acc{resumed = resumed acc + 1}-    Failed -> acc{resumed = resumed acc + 1}-    -- A workflow that rotated via continueAsNew (EP-48) returns 'ContinuedAsNew':-    -- it is re-invoked but neither completed nor suspended this pass. Its new-    -- generation has no terminal marker, so 'findUnfinishedWorkflowIds' still-    -- reports it and the next pass drives the rotated generation forward.-    ContinuedAsNew -> acc{resumed = resumed acc + 1}+  Completed _ -> acc {resumed = resumed acc + 1, completed = completed acc + 1}+  Suspended -> acc {resumed = resumed acc + 1, stillSuspended = stillSuspended acc + 1}+  -- A workflow cancelled between discovery and re-invocation short-circuits to+  -- 'Cancelled' (EP-43); count it as re-invoked but neither completed nor+  -- suspended. (A cancelled workflow also drops out of discovery, so this is a+  -- rare race, not the steady state.)+  Cancelled -> acc {resumed = resumed acc + 1}+  Failed -> acc {resumed = resumed acc + 1}+  -- A workflow that rotated via continueAsNew (EP-48) returns 'ContinuedAsNew':+  -- it is re-invoked but neither completed nor suspended this pass. Its new+  -- generation has no terminal marker, so 'findUnfinishedWorkflowIds' still+  -- reports it and the next pass drives the rotated generation forward.+  ContinuedAsNew -> acc {resumed = resumed acc + 1} -{- | Poll-and-resume loop: run 'resumeWorkflowsOnce' on the configured-'pollInterval' forever. Mirrors how an application schedules-'Keiro.Outbox.publishClaimedOutbox' \/ @runTimerWorker@ per tick; the-single-pass 'resumeWorkflowsOnce' remains the testable unit.--}+-- | Poll-and-resume loop: run 'resumeWorkflowsOnce' on the configured+-- 'pollInterval' forever. Mirrors how an application schedules+-- 'Keiro.Outbox.publishClaimedOutbox' \/ @runTimerWorker@ per tick; the+-- single-pass 'resumeWorkflowsOnce' remains the testable unit. runWorkflowResumeWorkerWith ::-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    WorkflowResumeOptions ->-    WorkflowRegistry es ->-    Eff es ()+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowResumeOptions ->+  WorkflowRegistry es ->+  Eff es () runWorkflowResumeWorkerWith opts registry = forever $ do-    _summary <--        (Just <$> resumeWorkflowsOnce opts registry)-            `Error.catchError` (\_ (e :: StoreError) -> logPass (Text.pack (show e)))-            `catchSync` (logPass . Text.pack . show)-    liftIO (threadDelay (pollInterval opts))+  _summary <-+    (Just <$> resumeWorkflowsOnce opts registry)+      `Error.catchError` (\_ (e :: StoreError) -> logPass (Text.pack (show e)))+      `catchSync` (logPass . Text.pack . show)+  liftIO (threadDelay (pollInterval opts))   where     logPass msg = do-        liftIO $ logEvent opts (ResumePassFailed msg)-        pure Nothing+      liftIO $ logEvent opts (ResumePassFailed msg)+      pure Nothing  -- | 'runWorkflowResumeWorkerWith' with 'defaultWorkflowResumeOptions'. runWorkflowResumeWorker ::-    (IOE :> es, Store :> es, Error StoreError :> es) =>-    WorkflowRegistry es ->-    Eff es ()+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowRegistry es ->+  Eff es () runWorkflowResumeWorker = runWorkflowResumeWorkerWith defaultWorkflowResumeOptions  -- --------------------------------------------------------------------------- -- Push-aware loop (EP-50) -- --------------------------------------------------------------------------- -{- | Generic push-aware poll loop: run one pass, then block on the 'WakeSignal'-with the given fallback timeout (microseconds), forever. The pass is the durable-unit; the wake only shortens the gap between passes. Both wake reasons (a-notification or the fallback elapsing) mean "run another pass", so the returned-'Keiro.Wake.WakeReason' is ignored for control flow. A missed @NOTIFY@ costs at-most one fallback interval of latency, never lost work — correctness rests on the-pass (e.g. 'resumeWorkflowsOnce'), which is idempotent, not on a notification-arriving. The same pattern applies mechanically to 'Keiro.Timer.runTimerWorker'-and 'Keiro.Outbox.publishClaimedOutbox' (documented, not implemented here; the-resume worker carries the acceptance via parent/child cascades).--}+-- | Generic push-aware poll loop: run one pass, then block on the 'WakeSignal'+-- with the given fallback timeout (microseconds), forever. The pass is the durable+-- unit; the wake only shortens the gap between passes. Both wake reasons (a+-- notification or the fallback elapsing) mean "run another pass", so the returned+-- 'Keiro.Wake.WakeReason' is ignored for control flow. A missed @NOTIFY@ costs at+-- most one fallback interval of latency, never lost work — correctness rests on the+-- pass (e.g. 'resumeWorkflowsOnce'), which is idempotent, not on a notification+-- arriving. The same pattern applies mechanically to 'Keiro.Timer.runTimerWorker'+-- and 'Keiro.Outbox.publishClaimedOutbox' (documented, not implemented here; the+-- resume worker carries the acceptance via parent/child cascades). runPollLoopWith ::-    -- | the wake signal to block on between passes-    WakeSignal ->-    -- | fallback timeout in microseconds (the maximum gap when no notification arrives)-    Int ->-    -- | one pass, already wrapped to run in 'IO'-    IO () ->-    IO ()+  -- | the wake signal to block on between passes+  WakeSignal ->+  -- | fallback timeout in microseconds (the maximum gap when no notification arrives)+  Int ->+  -- | one pass, already wrapped to run in 'IO'+  IO () ->+  IO () runPollLoopWith wake fallbackMicros pass =-    forever (pass >> void (waitForWake wake fallbackMicros))--{- | The workflow resume worker, push-aware (EP-50). Runs 'resumeWorkflowsOnce'-on each pass; between passes it waits on the store's notifier (sub-second wake on-any append) and falls back to 'pollInterval' so a dropped notification still-drains the backlog.--This is the push-aware sibling of 'runWorkflowResumeWorker' /-'runWorkflowResumeWorkerWith', which remain unchanged as the durable fixed-poll-baseline. The 'pollInterval' field is __repurposed__ as the /fallback/ timeout:-its meaning shifts from "fixed gap between passes" to "maximum gap when no-notification arrives" — strictly better for latency, identical in the-no-notification worst case.+  forever (pass >> void (waitForWake wake fallbackMicros)) -It opens __no__ new database connection (the 'WakeSignal' rides kiroku's existing-single per-store listener; see "Keiro.Wake"). It takes the 'KirokuStore' handle-directly to reach that notifier, and runs each pass through-'Kiroku.Store.Effect.runStoreIO', which pins the registry's effect row to the-concrete @'[Store, Error StoreError, IOE]@ that @runStoreIO@ eliminates. A caller-needing a richer effect row can use 'runPollLoopWith' directly with their own-@runStoreIO@-equivalent pass.--}+-- | The workflow resume worker, push-aware (EP-50). Runs 'resumeWorkflowsOnce'+-- on each pass; between passes it waits on the store's notifier (sub-second wake on+-- any append) and falls back to 'pollInterval' so a dropped notification still+-- drains the backlog.+--+-- This is the push-aware sibling of 'runWorkflowResumeWorker' /+-- 'runWorkflowResumeWorkerWith', which remain unchanged as the durable fixed-poll+-- baseline. The 'pollInterval' field is __repurposed__ as the /fallback/ timeout:+-- its meaning shifts from "fixed gap between passes" to "maximum gap when no+-- notification arrives" — strictly better for latency, identical in the+-- no-notification worst case.+--+-- It opens __no__ new database connection (the 'WakeSignal' rides kiroku's existing+-- single per-store listener; see "Keiro.Wake"). It takes the 'KirokuStore' handle+-- directly to reach that notifier, and runs each pass through+-- 'Kiroku.Store.Effect.runStoreIO', which pins the registry's effect row to the+-- concrete @'[Store, Error StoreError, IOE]@ that @runStoreIO@ eliminates. A caller+-- needing a richer effect row can use 'runPollLoopWith' directly with their own+-- @runStoreIO@-equivalent pass. runWorkflowResumeWorkerPush ::-    KirokuStore ->-    WorkflowResumeOptions ->-    WorkflowRegistry '[Store, Error StoreError, IOE] ->-    IO ()+  KirokuStore ->+  WorkflowResumeOptions ->+  WorkflowRegistry '[Store, Error StoreError, IOE] ->+  IO () runWorkflowResumeWorkerPush store opts registry = do-    wake <- wakeSignalFromStore store-    runPollLoopWith wake (pollInterval opts) onePass+  wake <- wakeSignalFromStore store+  runPollLoopWith wake (pollInterval opts) onePass   where     onePass =-        handleSyncIO $-            runStoreIO store (resumeWorkflowsOnce opts registry) >>= \case-                Left err -> logEvent opts (ResumePassFailed (Text.pack (show err)))-                Right _ -> pure ()+      handleSyncIO $+        runStoreIO store (resumeWorkflowsOnce opts registry) >>= \case+          Left err -> logEvent opts (ResumePassFailed (Text.pack (show err)))+          Right _ -> pure ()     handleSyncIO action =-        action `Exception.catch` \err ->-            case Exception.fromException err of-                Just (async :: Exception.SomeAsyncException) -> Exception.throwIO async-                Nothing -> logEvent opts (ResumePassFailed (Text.pack (show (err :: Exception.SomeException))))+      action `Exception.catch` \err ->+        case Exception.fromException err of+          Just (async :: Exception.SomeAsyncException) -> Exception.throwIO async+          Nothing -> logEvent opts (ResumePassFailed (Text.pack (show (err :: Exception.SomeException))))
src/Keiro/Workflow/Schema.hs view
@@ -1,18 +1,17 @@-{- | The @keiro_workflow_steps@ table: the derived index of journaled-workflow steps.--The journal stream (@wf:\<name\>-\<id\>@) is the source of truth for replay;-this table is a fast-lookup view kept in sync inside the same transaction as-each journal append (see "Keiro.Workflow"). 'recordStepTx' upserts a row;-'loadStepIndex' reads an instance's recorded steps; 'stepExists' checks for-one step; 'findUnfinishedWorkflowIds' discovers resumable rows from-@keiro_workflows@ — the seam EP-42's resume worker builds on.--Callers normally use the re-exports from "Keiro.Workflow" rather than this-module directly.--}-module Keiro.Workflow.Schema (-    -- * Rows+-- | The @keiro_workflow_steps@ table: the derived index of journaled+-- workflow steps.+--+-- The journal stream (@wf:\<name\>-\<id\>@) is the source of truth for replay;+-- this table is a fast-lookup view kept in sync inside the same transaction as+-- each journal append (see "Keiro.Workflow"). 'recordStepTx' upserts a row;+-- 'loadStepIndex' reads an instance's recorded steps; 'stepExists' checks for+-- one step; 'findUnfinishedWorkflowIds' discovers resumable rows from+-- @keiro_workflows@ — the seam EP-42's resume worker builds on.+--+-- Callers normally use the re-exports from "Keiro.Workflow" rather than this+-- module directly.+module Keiro.Workflow.Schema+  ( -- * Rows     WorkflowStepRow (..),      -- * Storage@@ -29,7 +28,7 @@     stepExists,     currentGeneration,     findUnfinishedWorkflowIds,-)+  ) where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip6)@@ -46,221 +45,213 @@ import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx -{- | A row of the @keiro_workflow_steps@ index: the workflow instance and-name, the step name, the step's JSON result, and when it was recorded. The-terminal completion marker is stored as a row whose 'stepName' is-'Keiro.Workflow.Types.completedStepName' and whose 'result' is JSON @null@.--}+-- | A row of the @keiro_workflow_steps@ index: the workflow instance and+-- name, the step name, the step's JSON result, and when it was recorded. The+-- terminal completion marker is stored as a row whose 'stepName' is+-- 'Keiro.Workflow.Types.completedStepName' and whose 'result' is JSON @null@. data WorkflowStepRow = WorkflowStepRow-    { workflowId :: !Text-    , workflowName :: !Text-    , generation :: !Int-    {- ^ EP-48: the journal /generation/ this step belongs to. Generation 0 is-    the pre-rotation default; @continueAsNew@ rotates onto higher generations.-    -}-    , stepName :: !Text-    , result :: !Value-    , recordedAt :: !UTCTime-    }-    deriving stock (Generic, Eq, Show)+  { workflowId :: !Text,+    workflowName :: !Text,+    -- | EP-48: the journal /generation/ this step belongs to. Generation 0 is+    --     the pre-rotation default; @continueAsNew@ rotates onto higher generations.+    generation :: !Int,+    stepName :: !Text,+    result :: !Value,+    recordedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show) -{- | Upsert a step row inside the caller's transaction — an-@INSERT ... ON CONFLICT (workflow_id, step_name) DO NOTHING@, so a replayed-or raced write is a no-op. Called in the same transaction as the journal-append so the index and the journal stay consistent.--}+-- | Upsert a step row inside the caller's transaction — an+-- @INSERT ... ON CONFLICT (workflow_id, step_name) DO NOTHING@, so a replayed+-- or raced write is a no-op. Called in the same transaction as the journal+-- append so the index and the journal stay consistent. recordStepTx :: WorkflowStepRow -> Tx.Transaction () recordStepTx row =-    Tx.statement-        ( row ^. #workflowId-        , row ^. #workflowName-        , fromIntegral (row ^. #generation) :: Int32-        , row ^. #stepName-        , row ^. #result-        , row ^. #recordedAt-        )-        recordStepStmt+  Tx.statement+    ( row ^. #workflowId,+      row ^. #workflowName,+      fromIntegral (row ^. #generation) :: Int32,+      row ^. #stepName,+      row ^. #result,+      row ^. #recordedAt+    )+    recordStepStmt  lookupStepResultTx :: Text -> Text -> Int -> Text -> Tx.Transaction (Maybe Value) lookupStepResultTx wid name gen key =-    Tx.statement (wid, name, fromIntegral gen :: Int32, key) lookupStepResultStmt+  Tx.statement (wid, name, fromIntegral gen :: Int32, key) lookupStepResultStmt  lockWorkflowStepTx :: Text -> Tx.Transaction () lockWorkflowStepTx key =-    void (Tx.statement key lockWorkflowStepStmt)+  void (Tx.statement key lockWorkflowStepStmt)  deleteStepRowTx :: Text -> Text -> Int -> Text -> Tx.Transaction () deleteStepRowTx wid name gen key =-    Tx.statement-        (wid, name, fromIntegral gen :: Int32, key)-        deleteStepRowStmt+  Tx.statement+    (wid, name, fromIntegral gen :: Int32, key)+    deleteStepRowStmt  setWorkflowWakeAfterTx :: WorkflowName -> WorkflowId -> UTCTime -> Tx.Transaction () setWorkflowWakeAfterTx (WorkflowName name) (WorkflowId wid) wakeAfter =-    Tx.statement (wid, name, wakeAfter) setWorkflowWakeAfterStmt+  Tx.statement (wid, name, wakeAfter) setWorkflowWakeAfterStmt  clearWorkflowWakeAfterTx :: WorkflowName -> WorkflowId -> Tx.Transaction () clearWorkflowWakeAfterTx (WorkflowName name) (WorkflowId wid) =-    Tx.statement (wid, name) clearWorkflowWakeAfterStmt+  Tx.statement (wid, name) clearWorkflowWakeAfterStmt -{- | Load every recorded step for a workflow instance as a @step name ->-result@ map (includes the terminal completion marker row if present). Exposed-for EP-42's resume worker; the replay handler in "Keiro.Workflow" pre-loads-from the journal stream instead.--}+-- | Load every recorded step for a workflow instance as a @step name ->+-- result@ map (includes the terminal completion marker row if present). Exposed+-- for EP-42's resume worker; the replay handler in "Keiro.Workflow" pre-loads+-- from the journal stream instead. loadStepIndex :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Eff es (Map Text Value) loadStepIndex (WorkflowName name) (WorkflowId wid) gen =-    Map.fromList <$> runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32) loadStepIndexStmt)+  Map.fromList <$> runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32) loadStepIndexStmt) -{- | Point-lookup one recorded step result for a workflow instance and-generation, directly from the authoritative @keiro_workflow_steps@ index.-Used by the replay handler's @Await@ miss path as the safety net for a stale-in-memory map: the index is written in the same transaction as every journal-append, so it is complete even when the snapshot-seeded map is not.--}+-- | Point-lookup one recorded step result for a workflow instance and+-- generation, directly from the authoritative @keiro_workflow_steps@ index.+-- Used by the replay handler's @Await@ miss path as the safety net for a stale+-- in-memory map: the index is written in the same transaction as every journal+-- append, so it is complete even when the snapshot-seeded map is not. lookupStepResult :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Text -> Eff es (Maybe Value) lookupStepResult (WorkflowName name) (WorkflowId wid) gen key =-    runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32, key) lookupStepResultStmt)+  runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32, key) lookupStepResultStmt) -{- | Whether a workflow instance already has an index row for the given step-name. Used to make journal re-appends idempotent without relying on the event-store's duplicate-id rejection.--}+-- | Whether a workflow instance already has an index row for the given step+-- name. Used to make journal re-appends idempotent without relying on the event+-- store's duplicate-id rejection. stepExists :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Text -> Eff es Bool stepExists (WorkflowName name) (WorkflowId wid) gen key =-    runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32, key) stepExistsStmt)+  runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32, key) stepExistsStmt) -{- | The current (highest) generation recorded for a logical workflow, or 0 if-it has no step rows yet (EP-48). Index-supported by the-@(workflow_id, workflow_name, generation)@ lookup index. A workflow that never-rotates stays at generation 0 and behaves byte-for-byte as it did before EP-48.-A rotation commits the next generation's seed step under generation @g+1@ in-the same logical-id key space, so @MAX(generation)@ is unambiguously the-current generation.--}+-- | The current (highest) generation recorded for a logical workflow, or 0 if+-- it has no step rows yet (EP-48). Index-supported by the+-- @(workflow_id, workflow_name, generation)@ lookup index. A workflow that never+-- rotates stays at generation 0 and behaves byte-for-byte as it did before EP-48.+-- A rotation commits the next generation's seed step under generation @g+1@ in+-- the same logical-id key space, so @MAX(generation)@ is unambiguously the+-- current generation. currentGeneration :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es Int currentGeneration (WorkflowName name) (WorkflowId wid) =-    fromIntegral <$> runTransaction (Tx.statement (wid, name) currentGenerationStmt)+  fromIntegral <$> runTransaction (Tx.statement (wid, name) currentGenerationStmt) -{- | Return the @(workflow_id, workflow_name)@ of every non-terminal workflow-instance. Terminal statuses are @completed@, @cancelled@, and @failed@, matching-'Keiro.Workflow.Instance.WorkflowStatus'. The explicit time parameter is-reserved for wake-time filtering; today it keeps the call shape stable for that-addition.--}+-- | Return the @(workflow_id, workflow_name)@ of every non-terminal workflow+-- instance. Terminal statuses are @completed@, @cancelled@, and @failed@, matching+-- 'Keiro.Workflow.Instance.WorkflowStatus'. The explicit time parameter is+-- reserved for wake-time filtering; today it keeps the call shape stable for that+-- addition. findUnfinishedWorkflowIds :: (Store :> es) => UTCTime -> Eff es [(Text, Text)] findUnfinishedWorkflowIds now =-    runTransaction (Tx.statement now findUnfinishedWorkflowIdsStmt)+  runTransaction (Tx.statement now findUnfinishedWorkflowIdsStmt)  recordStepStmt :: Statement (Text, Text, Int32, Text, Value, UTCTime) () recordStepStmt =-    preparable-        """-        INSERT INTO keiro.keiro_workflow_steps-          (workflow_id, workflow_name, generation, step_name, result, recorded_at)-        VALUES ($1, $2, $3, $4, $5, $6)-        ON CONFLICT (workflow_id, workflow_name, generation, step_name) DO NOTHING-        """-        ( contrazip6-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult+  preparable+    """+    INSERT INTO keiro.keiro_workflow_steps+      (workflow_id, workflow_name, generation, step_name, result, recorded_at)+    VALUES ($1, $2, $3, $4, $5, $6)+    ON CONFLICT (workflow_id, workflow_name, generation, step_name) DO NOTHING+    """+    ( contrazip6+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult  lookupStepResultStmt :: Statement (Text, Text, Int32, Text) (Maybe Value) lookupStepResultStmt =-    preparable-        """-        SELECT result-        FROM keiro.keiro_workflow_steps-        WHERE workflow_id = $1 AND workflow_name = $2 AND generation = $3 AND step_name = $4-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-        )-        (D.rowMaybe (D.column (D.nonNullable D.jsonb)))+  preparable+    """+    SELECT result+    FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1 AND workflow_name = $2 AND generation = $3 AND step_name = $4+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe (D.column (D.nonNullable D.jsonb)))  lockWorkflowStepStmt :: Statement Text Int32 lockWorkflowStepStmt =-    preparable-        """-        SELECT 1::int4 FROM pg_advisory_xact_lock(hashtextextended($1, 0))-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (D.column (D.nonNullable D.int4)))+  preparable+    """+    SELECT 1::int4 FROM pg_advisory_xact_lock(hashtextextended($1, 0))+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.int4)))  deleteStepRowStmt :: Statement (Text, Text, Int32, Text) () deleteStepRowStmt =-    preparable-        """-        DELETE FROM keiro.keiro_workflow_steps-        WHERE workflow_id = $1-          AND workflow_name = $2-          AND generation = $3-          AND step_name = $4-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    DELETE FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND generation = $3+      AND step_name = $4+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  loadStepIndexStmt :: Statement (Text, Text, Int32) [(Text, Value)] loadStepIndexStmt =-    preparable-        """-        SELECT step_name, result-        FROM keiro.keiro_workflow_steps-        WHERE workflow_id = $1 AND workflow_name = $2 AND generation = $3-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.jsonb)))+  preparable+    """+    SELECT step_name, result+    FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1 AND workflow_name = $2 AND generation = $3+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.jsonb)))  stepExistsStmt :: Statement (Text, Text, Int32, Text) Bool stepExistsStmt =-    preparable-        """-        SELECT EXISTS (-          SELECT 1 FROM keiro.keiro_workflow_steps-          WHERE workflow_id = $1 AND workflow_name = $2 AND generation = $3 AND step_name = $4-        )-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-        )-        (D.singleRow (D.column (D.nonNullable D.bool)))+  preparable+    """+    SELECT EXISTS (+      SELECT 1 FROM keiro.keiro_workflow_steps+      WHERE workflow_id = $1 AND workflow_name = $2 AND generation = $3 AND step_name = $4+    )+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+    )+    (D.singleRow (D.column (D.nonNullable D.bool)))  -- The current generation is MAX(generation) for the logical id+name, or 0 when -- the workflow has no rows. Index-supported by keiro_workflow_steps_workflow_idx. currentGenerationStmt :: Statement (Text, Text) Int32 currentGenerationStmt =-    preparable-        """-        SELECT COALESCE(MAX(generation), 0)::int4-        FROM keiro.keiro_workflow_steps-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.singleRow (D.column (D.nonNullable D.int4)))+  preparable+    """+    SELECT COALESCE(MAX(generation), 0)::int4+    FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.singleRow (D.column (D.nonNullable D.int4)))  -- The terminal-status literals must match 'Keiro.Workflow.Instance.statusToText' -- for completed, cancelled, and failed. The timestamp parameter makes@@ -268,44 +259,44 @@ -- until their timer is due. findUnfinishedWorkflowIdsStmt :: Statement UTCTime [(Text, Text)] findUnfinishedWorkflowIdsStmt =-    preparable-        """-        SELECT workflow_id, workflow_name-        FROM keiro.keiro_workflows-        WHERE status NOT IN ('completed', 'cancelled', 'failed')-          AND (wake_after IS NULL OR wake_after <= $1)-        ORDER BY workflow_name, workflow_id-        """-        (E.param (E.nonNullable E.timestamptz))-        (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))+  preparable+    """+    SELECT workflow_id, workflow_name+    FROM keiro.keiro_workflows+    WHERE status NOT IN ('completed', 'cancelled', 'failed')+      AND (wake_after IS NULL OR wake_after <= $1)+    ORDER BY workflow_name, workflow_id+    """+    (E.param (E.nonNullable E.timestamptz))+    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))  setWorkflowWakeAfterStmt :: Statement (Text, Text, UTCTime) () setWorkflowWakeAfterStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET wake_after = $3,-            updated_at = now()-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET wake_after = $3,+        updated_at = now()+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult  clearWorkflowWakeAfterStmt :: Statement (Text, Text) () clearWorkflowWakeAfterStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET wake_after = NULL,-            updated_at = now()-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET wake_after = NULL,+        updated_at = now()+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult
src/Keiro/Workflow/Sleep.hs view
@@ -1,81 +1,80 @@-{- | Durable @sleep@ for workflows, backed by the existing @keiro_timers@ table.--== What this gives you--A workflow author can insert a durable pause between steps:--@-demo :: ('Workflow' ':>' es, 'Store' ':>' es, IOE ':>' es) => Eff es (Int, Int)-demo = do-  a <- 'step' (StepName \"a\") (liftIO incr)   -- side effect #1-  'sleepNamed' (StepName \"cool\") 300          -- durable wait, survives a restart-  b <- 'step' (StepName \"b\") (liftIO incr)   -- side effect #2-  pure (a, b)-@--On the __first__ run, @demo@ executes @a@, journals it, arms a Postgres timer-for the sleep, and __suspends__ — 'Keiro.Workflow.runWorkflow' returns-'Suspended' and the journal @wf:demo-\<id\>@ holds only the @StepRecorded \"a\"@-event. The @b@ side effect has not run. The process can now crash, be-redeployed, or sit idle for the full delay: the only durable state of the pause-is a single row in 'keiro_timers', so the wait survives a restart with __no-external scheduler and no in-memory timer thread__.--When the timer becomes due, the existing timer worker-('Keiro.Timer.runTimerWorker') fires it through 'workflowSleepFireAction',-which recognises the row as a workflow sleep (by the JSON payload-discriminator), reconstructs the journal stream, and appends a-@StepRecorded \"sleep:cool\"@ completion. A later run replays: @step \"a\"@-short-circuits to its recorded result, @sleepNamed \"cool\"@ sees its completion-already journaled and returns immediately, and only @step \"b\"@ runs for real.--== 'sleepNamed' vs. 'sleep'--* 'sleepNamed' is the __stable primitive__. Replay matches the sleep on its-  (prefixed) 'StepName', so the name must be deterministic across replays. A-  user-supplied name is unconditionally stable: the same source always produces-  the same name regardless of how surrounding code is reordered between deploys.--* 'sleep' is an ordinal convenience built on 'sleepNamed' via-  'Keiro.Workflow.freshOrdinal' (the @N@th sleep in a run gets @sleep:N@). Its-  determinism is __conditional__: reordering or inserting sleeps between deploys-  shifts the ordinals and can make a resumed in-flight workflow re-arm a-  /different/ timer. Prefer 'sleepNamed' for anything that must survive a code-  change mid-flight.--== Operational contract--* __Payload discriminator.__ A workflow-sleep timer row carries-  @{\"kind\":\"keiro.workflow.sleep\",\"step\":\"sleep:\<suffix\>\",\"gen\":0}@-  in its @payload@ (see 'sleepTimerPayload' / 'parseSleepPayload'). This is how-  a single timer worker distinguishes workflow sleeps from ordinary-  process-manager timers, routes each correctly, and pins a fire to the-  generation that armed it. Legacy payloads without @gen@ remain supported.--* __Deterministic timer id.__ The timer id is a v5 UUID over-  @(\"keiro\":\"workflow-sleep\":name:id:generation:sleepStepName)@ for-  generation 1 and later, while generation 0 keeps the legacy-  @(\"keiro\":\"workflow-sleep\":name:id:sleepStepName)@ shape. The workflow-  sleep arm uses 'Keiro.Timer.scheduleTimerOnceTx', so the first arm's-  @fire_at@ wins and every resume that re-enters the not-yet-resolved sleep-  leaves the row untouched. The sleep duration is measured from the first arm,-  not from the latest resume pass.--* __No @keiro_timers@ schema change.__ Routing is entirely a function of the-  caller-supplied fire action and the JSON payload; this module owns no-  migration.--* __A worker must drain the timers.__ A sleep only ever fires if some timer-  worker runs 'workflowSleepFireAction' (via 'runWorkflowTimerWorker', or by-  passing 'workflowSleepFireAction' directly to 'Keiro.Timer.runTimerWorker').-  A sleep whose timer is never drained — or one whose timer an operator-  'Keiro.Timer.cancelTimer's — stays suspended forever until an operator-  intervenes. Workflow sleeps otherwise inherit the timer subsystem's recovery-  surface ('Keiro.Timer.findStuckTimers' / 'Keiro.Timer.requeueStuckTimer' /-  'Keiro.Timer.deadLetterTimer') for free.--}-module Keiro.Workflow.Sleep (-    -- * Authoring surface+-- | Durable @sleep@ for workflows, backed by the existing @keiro_timers@ table.+--+-- == What this gives you+--+-- A workflow author can insert a durable pause between steps:+--+-- @+-- demo :: ('Workflow' ':>' es, 'Store' ':>' es, IOE ':>' es) => Eff es (Int, Int)+-- demo = do+--   a <- 'step' (StepName \"a\") (liftIO incr)   -- side effect #1+--   'sleepNamed' (StepName \"cool\") 300          -- durable wait, survives a restart+--   b <- 'step' (StepName \"b\") (liftIO incr)   -- side effect #2+--   pure (a, b)+-- @+--+-- On the __first__ run, @demo@ executes @a@, journals it, arms a Postgres timer+-- for the sleep, and __suspends__ — 'Keiro.Workflow.runWorkflow' returns+-- 'Suspended' and the journal @wf:demo-\<id\>@ holds only the @StepRecorded \"a\"@+-- event. The @b@ side effect has not run. The process can now crash, be+-- redeployed, or sit idle for the full delay: the only durable state of the pause+-- is a single row in 'keiro_timers', so the wait survives a restart with __no+-- external scheduler and no in-memory timer thread__.+--+-- When the timer becomes due, the existing timer worker+-- ('Keiro.Timer.runTimerWorker') fires it through 'workflowSleepFireAction',+-- which recognises the row as a workflow sleep (by the JSON payload+-- discriminator), reconstructs the journal stream, and appends a+-- @StepRecorded \"sleep:cool\"@ completion. A later run replays: @step \"a\"@+-- short-circuits to its recorded result, @sleepNamed \"cool\"@ sees its completion+-- already journaled and returns immediately, and only @step \"b\"@ runs for real.+--+-- == 'sleepNamed' vs. 'sleep'+--+-- * 'sleepNamed' is the __stable primitive__. Replay matches the sleep on its+--   (prefixed) 'StepName', so the name must be deterministic across replays. A+--   user-supplied name is unconditionally stable: the same source always produces+--   the same name regardless of how surrounding code is reordered between deploys.+--+-- * 'sleep' is an ordinal convenience built on 'sleepNamed' via+--   'Keiro.Workflow.freshOrdinal' (the @N@th sleep in a run gets @sleep:N@). Its+--   determinism is __conditional__: reordering or inserting sleeps between deploys+--   shifts the ordinals and can make a resumed in-flight workflow re-arm a+--   /different/ timer. Prefer 'sleepNamed' for anything that must survive a code+--   change mid-flight.+--+-- == Operational contract+--+-- * __Payload discriminator.__ A workflow-sleep timer row carries+--   @{\"kind\":\"keiro.workflow.sleep\",\"step\":\"sleep:\<suffix\>\",\"gen\":0}@+--   in its @payload@ (see 'sleepTimerPayload' / 'parseSleepPayload'). This is how+--   a single timer worker distinguishes workflow sleeps from ordinary+--   process-manager timers, routes each correctly, and pins a fire to the+--   generation that armed it. Legacy payloads without @gen@ remain supported.+--+-- * __Deterministic timer id.__ The timer id is a v5 UUID over+--   @(\"keiro\":\"workflow-sleep\":name:id:generation:sleepStepName)@ for+--   generation 1 and later, while generation 0 keeps the legacy+--   @(\"keiro\":\"workflow-sleep\":name:id:sleepStepName)@ shape. The workflow+--   sleep arm uses 'Keiro.Timer.scheduleTimerOnceTx', so the first arm's+--   @fire_at@ wins and every resume that re-enters the not-yet-resolved sleep+--   leaves the row untouched. The sleep duration is measured from the first arm,+--   not from the latest resume pass.+--+-- * __No @keiro_timers@ schema change.__ Routing is entirely a function of the+--   caller-supplied fire action and the JSON payload; this module owns no+--   migration.+--+-- * __A worker must drain the timers.__ A sleep only ever fires if some timer+--   worker runs 'workflowSleepFireAction' (via 'runWorkflowTimerWorker', or by+--   passing 'workflowSleepFireAction' directly to 'Keiro.Timer.runTimerWorker').+--   A sleep whose timer is never drained — or one whose timer an operator+--   'Keiro.Timer.cancelTimer's — stays suspended forever until an operator+--   intervenes. Workflow sleeps otherwise inherit the timer subsystem's recovery+--   surface ('Keiro.Timer.findStuckTimers' / 'Keiro.Timer.requeueStuckTimer' /+--   'Keiro.Timer.deadLetterTimer') for free.+module Keiro.Workflow.Sleep+  ( -- * Authoring surface     sleepNamed,     sleep, @@ -90,7 +89,7 @@     parseSleepPayload,     matchSleepTimerGeneration,     workflowSleepKind,-)+  ) where  import Data.Aeson (Value (..), object)@@ -104,16 +103,16 @@ import Effectful.Exception (throwIO) import Keiro.Prelude import Keiro.Telemetry (KeiroMetrics)-import Keiro.Timer (-    TimerId (..),+import Keiro.Timer+  ( TimerId (..),     TimerRequest (..),     TimerRow,     cancelTimer,     runTimerWorker,     scheduleTimerOnceTx,- )-import Keiro.Workflow (-    JournalAppendOutcome (..),+  )+import Keiro.Workflow+  ( JournalAppendOutcome (..),     StepName (..),     Workflow,     WorkflowError (..),@@ -130,7 +129,7 @@     prepareJournalAppend,     setWorkflowWakeAfterTx,     sleepStepPrefix,- )+  ) import Keiro.Workflow.Instance qualified as Instance import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction)@@ -140,106 +139,100 @@ -- Discriminator and payload -- --------------------------------------------------------------------------- -{- | The payload @"kind"@ tag that marks a 'keiro_timers' row as a workflow-sleep, distinguishing it from an ordinary process-manager timer so a single-timer worker can route each correctly.--}+-- | The payload @"kind"@ tag that marks a 'keiro_timers' row as a workflow+-- sleep, distinguishing it from an ordinary process-manager timer so a single+-- timer worker can route each correctly. workflowSleepKind :: Text workflowSleepKind = "keiro.workflow.sleep" -{- | Build the JSON payload carried on a workflow-sleep timer row. The first-argument is the generation that armed the timer; the second is the full-@"sleep:\<suffix\>"@ journal step name the firing will record.--}+-- | Build the JSON payload carried on a workflow-sleep timer row. The first+-- argument is the generation that armed the timer; the second is the full+-- @"sleep:\<suffix\>"@ journal step name the firing will record. sleepTimerPayload :: Int -> Text -> Value sleepTimerPayload gen fullStep =-    object-        [ "kind" Aeson..= workflowSleepKind-        , "step" Aeson..= fullStep-        , "gen" Aeson..= gen-        ]+  object+    [ "kind" Aeson..= workflowSleepKind,+      "step" Aeson..= fullStep,+      "gen" Aeson..= gen+    ] -{- | Recognise and extract a workflow-sleep payload. The result contains the-full step name and the generation when the payload records one. 'Nothing' in-the generation slot denotes a legacy workflow-sleep payload written before-generation pinning; an overall 'Nothing' denotes any other timer (for example,-a process manager's).--}+-- | Recognise and extract a workflow-sleep payload. The result contains the+-- full step name and the generation when the payload records one. 'Nothing' in+-- the generation slot denotes a legacy workflow-sleep payload written before+-- generation pinning; an overall 'Nothing' denotes any other timer (for example,+-- a process manager's). parseSleepPayload :: Value -> Maybe (Text, Maybe Int) parseSleepPayload = \case-    Object o-        | KeyMap.lookup "kind" o == Just (String workflowSleepKind) ->-            case KeyMap.lookup "step" o of-                Just (String s) ->-                    case KeyMap.lookup "gen" o of-                        Nothing -> Just (s, Nothing)-                        Just value ->-                            case Aeson.fromJSON value of-                                Aeson.Success gen -> Just (s, Just gen)-                                Aeson.Error{} -> Nothing-                _ -> Nothing-    _ -> Nothing+  Object o+    | KeyMap.lookup "kind" o == Just (String workflowSleepKind) ->+        case KeyMap.lookup "step" o of+          Just (String s) ->+            case KeyMap.lookup "gen" o of+              Nothing -> Just (s, Nothing)+              Just value ->+                case Aeson.fromJSON value of+                  Aeson.Success gen -> Just (s, Just gen)+                  Aeson.Error {} -> Nothing+          _ -> Nothing+  _ -> Nothing  -- --------------------------------------------------------------------------- -- Deterministic ids and step names -- --------------------------------------------------------------------------- -{- | The deterministic timer id for a sleep. Generation 0 keeps the legacy v5-UUID over @(\"keiro\":\"workflow-sleep\":name:id:fullStep)@ so in-flight-pre-change timers remain signalable. Generations 1 and later include the-generation component so a sleep after 'Keiro.Workflow.continueAsNew' never-collides with a prior generation's terminal timer row.--}+-- | The deterministic timer id for a sleep. Generation 0 keeps the legacy v5+-- UUID over @(\"keiro\":\"workflow-sleep\":name:id:fullStep)@ so in-flight+-- pre-change timers remain signalable. Generations 1 and later include the+-- generation component so a sleep after 'Keiro.Workflow.continueAsNew' never+-- collides with a prior generation's terminal timer row. sleepTimerId :: WorkflowName -> WorkflowId -> Int -> Text -> TimerId sleepTimerId name wid gen fullStep =-    TimerId $-        UUID.V5.generateNamed UUID.V5.namespaceURL $-            fmap (fromIntegral . fromEnum) $-                Text.unpack $-                    Text.intercalate-                        ":"-                        components+  TimerId $+    UUID.V5.generateNamed UUID.V5.namespaceURL $+      fmap (fromIntegral . fromEnum) $+        Text.unpack $+          Text.intercalate+            ":"+            components   where     components-        | gen <= 0 =-            [ "keiro"-            , "workflow-sleep"-            , unWorkflowName name-            , unWorkflowId wid-            , fullStep-            ]-        | otherwise =-            [ "keiro"-            , "workflow-sleep"-            , unWorkflowName name-            , unWorkflowId wid-            , Text.pack (show gen)-            , fullStep-            ]+      | gen <= 0 =+          [ "keiro",+            "workflow-sleep",+            unWorkflowName name,+            unWorkflowId wid,+            fullStep+          ]+      | otherwise =+          [ "keiro",+            "workflow-sleep",+            unWorkflowName name,+            unWorkflowId wid,+            Text.pack (show gen),+            fullStep+          ] -{- | Recover the generation represented by a deterministic workflow-sleep-timer id. Candidate generations from @currentGen@ down to zero are tested-against 'sleepTimerId'; this lets a new worker pin legacy payloads that do not-carry an explicit generation. Returns 'Nothing' only for an operator-crafted-or otherwise non-matching timer id.--}+-- | Recover the generation represented by a deterministic workflow-sleep+-- timer id. Candidate generations from @currentGen@ down to zero are tested+-- against 'sleepTimerId'; this lets a new worker pin legacy payloads that do not+-- carry an explicit generation. Returns 'Nothing' only for an operator-crafted+-- or otherwise non-matching timer id. matchSleepTimerGeneration ::-    WorkflowName ->-    WorkflowId ->-    Int ->-    Text ->-    TimerId ->-    Maybe Int+  WorkflowName ->+  WorkflowId ->+  Int ->+  Text ->+  TimerId ->+  Maybe Int matchSleepTimerGeneration name wid currentGen fullStep timerId =-    find-        (\gen -> sleepTimerId name wid gen fullStep == timerId)-        (reverse [0 .. max 0 currentGen])+  find+    (\gen -> sleepTimerId name wid gen fullStep == timerId)+    (reverse [0 .. max 0 currentGen]) -{- | The durable journal step name for a sleep: the user's suffix prefixed with-'sleepStepPrefix'. @'sleepStepName' (StepName \"cool\") == \"sleep:cool\"@. The-prefix keeps the journal self-describing — an operator scanning it sees-@sleep:@ and knows the entry is a durable wait, not an ordinary step.--}+-- | The durable journal step name for a sleep: the user's suffix prefixed with+-- 'sleepStepPrefix'. @'sleepStepName' (StepName \"cool\") == \"sleep:cool\"@. The+-- prefix keeps the journal self-describing — an operator scanning it sees+-- @sleep:@ and knows the entry is a durable wait, not an ordinary step. sleepStepName :: StepName -> Text sleepStepName (StepName suffix) = sleepStepPrefix <> suffix @@ -247,139 +240,134 @@ -- Authoring surface -- --------------------------------------------------------------------------- -{- | 'awaitStep' specialised to a 'Value' result. The recorded sleep completion-carries JSON @null@; 'Value''s 'FromJSON' is the identity, so any recorded-result decodes, which decouples the sleep from a @FromJSON ()@ instance.--}+-- | 'awaitStep' specialised to a 'Value' result. The recorded sleep completion+-- carries JSON @null@; 'Value''s 'FromJSON' is the identity, so any recorded+-- result decodes, which decouples the sleep from a @FromJSON ()@ instance. awaitValue :: (Workflow :> es) => StepName -> Eff es () -> Eff es Value awaitValue = awaitStep -{- | Durably pause the workflow for @delta@ under the stable name @userStep@.-On the first encounter this arms a deterministic 'keiro_timers' row and-suspends the run; the suspension resolves when a timer worker fires the row-(see 'workflowSleepFireAction') and journals the sleep's completion, after which-a later run replays past the sleep without re-arming.--The arming action is idempotent (a resumed workflow re-runs it until the sleep-resolves): it inserts with a deterministic 'sleepTimerId' only when the row is-absent, so the first @fire_at@ persists across every resume pass.--}+-- | Durably pause the workflow for @delta@ under the stable name @userStep@.+-- On the first encounter this arms a deterministic 'keiro_timers' row and+-- suspends the run; the suspension resolves when a timer worker fires the row+-- (see 'workflowSleepFireAction') and journals the sleep's completion, after which+-- a later run replays past the sleep without re-arming.+--+-- The arming action is idempotent (a resumed workflow re-runs it until the sleep+-- resolves): it inserts with a deterministic 'sleepTimerId' only when the row is+-- absent, so the first @fire_at@ persists across every resume pass. sleepNamed ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    StepName ->-    NominalDiffTime ->-    Eff es ()+  (Workflow :> es, Store :> es, IOE :> es) =>+  StepName ->+  NominalDiffTime ->+  Eff es () sleepNamed userStep delta = do-    (name, wid) <- currentWorkflow-    gen <- currentRunGeneration-    let full = sleepStepName userStep-        armedStep = StepName full-    void . awaitValue armedStep $ do-        now <- liftIO getCurrentTime-        let request =-                TimerRequest-                    { timerId = sleepTimerId name wid gen full-                    , processManagerName = unWorkflowName name-                    , correlationId = unWorkflowId wid-                    , fireAt = addUTCTime delta now-                    , payload = sleepTimerPayload gen full-                    }-        -- Only the arm that created the timer writes the wake hint. Replays-        -- leave both fire_at and wake_after untouched, so re-entering a due or-        -- already-fired sleep cannot postpone discovery. Firing clears the-        -- hint in the same transaction as its journal append.-        runTransaction $ do-            inserted <- scheduleTimerOnceTx request-            when inserted (setWorkflowWakeAfterTx name wid (request ^. #fireAt))+  (name, wid) <- currentWorkflow+  gen <- currentRunGeneration+  let full = sleepStepName userStep+      armedStep = StepName full+  void . awaitValue armedStep $ do+    now <- liftIO getCurrentTime+    let request =+          TimerRequest+            { timerId = sleepTimerId name wid gen full,+              processManagerName = unWorkflowName name,+              correlationId = unWorkflowId wid,+              fireAt = addUTCTime delta now,+              payload = sleepTimerPayload gen full+            }+    -- Only the arm that created the timer writes the wake hint. Replays+    -- leave both fire_at and wake_after untouched, so re-entering a due or+    -- already-fired sleep cannot postpone discovery. Firing clears the+    -- hint in the same transaction as its journal append.+    runTransaction $ do+      inserted <- scheduleTimerOnceTx request+      when inserted (setWorkflowWakeAfterTx name wid (request ^. #fireAt)) -{- | Durably pause the workflow for @delta@ under an ordinal name (the @N@th-sleep in a run becomes @sleep:N@). Convenient but its determinism is-conditional — see the module header. Prefer 'sleepNamed' for anything that must-survive a code change mid-flight.--}+-- | Durably pause the workflow for @delta@ under an ordinal name (the @N@th+-- sleep in a run becomes @sleep:N@). Convenient but its determinism is+-- conditional — see the module header. Prefer 'sleepNamed' for anything that must+-- survive a code change mid-flight. sleep :: (Workflow :> es, Store :> es, IOE :> es) => NominalDiffTime -> Eff es () sleep delta = do-    n <- freshOrdinal "sleep"-    sleepNamed (StepName (Text.pack (show n))) delta+  n <- freshOrdinal "sleep"+  sleepNamed (StepName (Text.pack (show n))) delta  -- --------------------------------------------------------------------------- -- Firing and worker wiring -- --------------------------------------------------------------------------- -{- | The fire action for workflow-sleep timers. For a 'TimerRow' whose payload-is a workflow-sleep discriminator, reconstruct the workflow identity from the-row's @processManagerName@ (= workflow name) and @correlationId@ (= workflow-id), resolve the generation that armed the timer, append a @StepRecorded@-completion (@result = null@) to that generation's journal, and return the-deterministic 'EventId' so the worker marks the timer @Fired@. Returns-'Nothing' for a row whose payload is __not__ a workflow sleep, or for a sleep-owned by a terminal workflow (whose timer is cancelled), so a mixed worker can-delegate that row to its process-manager fire action.--Idempotent: 'prepareJournalAppend' pre-checks the generation-scoped step and-the event id is deterministic, so at-least-once timer firing yields-exactly-once journaling even after the workflow has rotated.--}+-- | The fire action for workflow-sleep timers. For a 'TimerRow' whose payload+-- is a workflow-sleep discriminator, reconstruct the workflow identity from the+-- row's @processManagerName@ (= workflow name) and @correlationId@ (= workflow+-- id), resolve the generation that armed the timer, append a @StepRecorded@+-- completion (@result = null@) to that generation's journal, and return the+-- deterministic 'EventId' so the worker marks the timer @Fired@. Returns+-- 'Nothing' for a row whose payload is __not__ a workflow sleep, or for a sleep+-- owned by a terminal workflow (whose timer is cancelled), so a mixed worker can+-- delegate that row to its process-manager fire action.+--+-- Idempotent: 'prepareJournalAppend' pre-checks the generation-scoped step and+-- the event id is deterministic, so at-least-once timer firing yields+-- exactly-once journaling even after the workflow has rotated. workflowSleepFireAction ::-    (Store :> es, IOE :> es) => TimerRow -> Eff es (Maybe EventId)+  (Store :> es, IOE :> es) => TimerRow -> Eff es (Maybe EventId) workflowSleepFireAction row =-    case parseSleepPayload (row ^. #payload) of-        Nothing -> pure Nothing-        Just (full, payloadGen) -> do-            let name = WorkflowName (row ^. #processManagerName)-                wid = WorkflowId (row ^. #correlationId)-            Instance.lookupInstance name wid >>= \case-                Just instanceRow-                    | instanceRow ^. #status-                        `elem` [Instance.WfCompleted, Instance.WfCancelled, Instance.WfFailed] -> do-                        void (cancelTimer (row ^. #timerId))-                        pure Nothing-                _ -> do-                    targetGen <--                        case payloadGen of-                            Just gen -> pure gen-                            Nothing -> do-                                currentGen <- currentGeneration name wid-                                pure $-                                    fromMaybe-                                        currentGen-                                        (matchSleepTimerGeneration name wid currentGen full (row ^. #timerId))-                    now <- liftIO getCurrentTime-                    appendTx <--                        prepareJournalAppend-                            name-                            wid-                            targetGen-                            (StepRecorded{stepName = full, result = Null, recordedAt = now})-                    runTransaction (appendTx <* clearWorkflowWakeAfterTx name wid) >>= \case-                        JournalAppended{} ->-                            pure (Just (deterministicJournalId name wid targetGen full))-                        JournalAlreadyPresent{} ->-                            pure (Just (deterministicJournalId name wid targetGen full))-                        JournalAppendConflict err ->-                            throwIO (WorkflowJournalAppendError (Text.pack (show err)))--{- | A timer worker pass that handles __both__ workflow-sleep timers and-ordinary process-manager timers. For each claimed timer: if its payload is a-workflow sleep, wake the workflow; otherwise delegate to the supplied-process-manager fire action.+  case parseSleepPayload (row ^. #payload) of+    Nothing -> pure Nothing+    Just (full, payloadGen) -> do+      let name = WorkflowName (row ^. #processManagerName)+          wid = WorkflowId (row ^. #correlationId)+      Instance.lookupInstance name wid >>= \case+        Just instanceRow+          | instanceRow ^. #status+              `elem` [Instance.WfCompleted, Instance.WfCancelled, Instance.WfFailed] -> do+              void (cancelTimer (row ^. #timerId))+              pure Nothing+        _ -> do+          targetGen <-+            case payloadGen of+              Just gen -> pure gen+              Nothing -> do+                currentGen <- currentGeneration name wid+                pure $+                  fromMaybe+                    currentGen+                    (matchSleepTimerGeneration name wid currentGen full (row ^. #timerId))+          now <- liftIO getCurrentTime+          appendTx <-+            prepareJournalAppend+              name+              wid+              targetGen+              (StepRecorded {stepName = full, result = Null, recordedAt = now})+          runTransaction (appendTx <* clearWorkflowWakeAfterTx name wid) >>= \case+            JournalAppended {} ->+              pure (Just (deterministicJournalId name wid targetGen full))+            JournalAlreadyPresent {} ->+              pure (Just (deterministicJournalId name wid targetGen full))+            JournalAppendConflict err ->+              throwIO (WorkflowJournalAppendError (Text.pack (show err))) -A deployment that runs only workflows can pass @\\_ -> pure Nothing@ as the-fallback (or use 'workflowSleepFireAction' directly with-'Keiro.Timer.runTimerWorker'). A deployment that mixes process-manager timers-and workflow sleeps passes its existing PM fire action so one worker drains-both kinds.--}+-- | A timer worker pass that handles __both__ workflow-sleep timers and+-- ordinary process-manager timers. For each claimed timer: if its payload is a+-- workflow sleep, wake the workflow; otherwise delegate to the supplied+-- process-manager fire action.+--+-- A deployment that runs only workflows can pass @\\_ -> pure Nothing@ as the+-- fallback (or use 'workflowSleepFireAction' directly with+-- 'Keiro.Timer.runTimerWorker'). A deployment that mixes process-manager timers+-- and workflow sleeps passes its existing PM fire action so one worker drains+-- both kinds. runWorkflowTimerWorker ::-    (IOE :> es, Store :> es) =>-    Maybe KeiroMetrics ->-    UTCTime ->-    -- | Fallback fire action for non-sleep (process-manager) timers.-    (TimerRow -> Eff es (Maybe EventId)) ->-    Eff es (Maybe TimerRow)+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  UTCTime ->+  -- | Fallback fire action for non-sleep (process-manager) timers.+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es (Maybe TimerRow) runWorkflowTimerWorker metrics now pmFire =-    runTimerWorker metrics now $ \row -> do-        handled <- workflowSleepFireAction row-        case handled of-            Just eid -> pure (Just eid)-            Nothing -> pmFire row+  runTimerWorker metrics now $ \row -> do+    handled <- workflowSleepFireAction row+    case handled of+      Just eid -> pure (Just eid)+      Nothing -> pmFire row
src/Keiro/Workflow/Snapshot.hs view
@@ -1,52 +1,51 @@-{- | Workflow journal snapshots: the workflow-specific 'StateCodec' and the-read\/write helpers the runtime uses to skip a full version-0 replay.--A durable workflow folds its journal stream into a 'WorkflowState' — a-@'Map' 'Text' 'Value'@ of step-name to encoded result. For a long-lived-workflow, re-reading the whole journal on every run and resume is ruinous.-This module supplies the EP-4 snapshot machinery the runtime reuses to-persist that folded map and, on the next run, seed from it and replay only-the tail.--== Why a workflow-specific codec--EP-4's 'Keiro.Snapshot.Codec.defaultStateCodec' derives its 'shapeHash' from-a /statically-known/ keiki register-file slot list-('Keiki.Shape.regFileShapeHash'). A workflow's step names are dynamic runtime-strings, so there is no static slot list. The accumulated state is instead a-self-describing JSON object, so it round-trips through Aeson trivially and its-/shape/ never varies with which steps ran. Hence the fixed sentinel-'workflowStateShapeHash'; per-step result-type evolution is each step's own-'Aeson.ToJSON'\/'Aeson.FromJSON' concern, surfaced at the @step@ decode in-"Keiro.Workflow", not here.--== Advisory semantics--A snapshot is an optimization, never a source of truth. 'loadWorkflowSnapshot'-returns 'Nothing' — meaning "replay from version 0" — when the stream has no-id yet, no matching snapshot, or an undecodable snapshot. So a stale or corrupt-snapshot degrades performance at worst, never correctness.--Runtime snapshot writes are advisory too. After a workflow journal append has-committed, "Keiro.Workflow" swallows any snapshot-store failure and increments-@keiro.snapshot.write.failures@; the committed step, completion, or rotation-therefore still determines a successful run. This low-level module continues-to expose the store primitive, while the runtime owns that error handling.--Snapshot writes store the full accumulated step map each time the selected-policy fires. With an every-n policy that means rewriting the complete map at-each boundary; the intended way to bound that cost for forever-running-workflows is 'Keiro.Workflow.continueAsNew', which starts a fresh generation-with a small carried seed.--}-module Keiro.Workflow.Snapshot (-    workflowStateCodec,+-- | Workflow journal snapshots: the workflow-specific 'StateCodec' and the+-- read\/write helpers the runtime uses to skip a full version-0 replay.+--+-- A durable workflow folds its journal stream into a 'WorkflowState' — a+-- @'Map' 'Text' 'Value'@ of step-name to encoded result. For a long-lived+-- workflow, re-reading the whole journal on every run and resume is ruinous.+-- This module supplies the EP-4 snapshot machinery the runtime reuses to+-- persist that folded map and, on the next run, seed from it and replay only+-- the tail.+--+-- == Why a workflow-specific codec+--+-- EP-4's 'Keiro.Snapshot.Codec.defaultStateCodec' derives its 'shapeHash' from+-- a /statically-known/ keiki register-file slot list+-- ('Keiki.Shape.regFileShapeHash'). A workflow's step names are dynamic runtime+-- strings, so there is no static slot list. The accumulated state is instead a+-- self-describing JSON object, so it round-trips through Aeson trivially and its+-- /shape/ never varies with which steps ran. Hence the fixed sentinel+-- 'workflowStateShapeHash'; per-step result-type evolution is each step's own+-- 'Aeson.ToJSON'\/'Aeson.FromJSON' concern, surfaced at the @step@ decode in+-- "Keiro.Workflow", not here.+--+-- == Advisory semantics+--+-- A snapshot is an optimization, never a source of truth. 'loadWorkflowSnapshot'+-- returns 'Nothing' — meaning "replay from version 0" — when the stream has no+-- id yet, no matching snapshot, or an undecodable snapshot. So a stale or corrupt+-- snapshot degrades performance at worst, never correctness.+--+-- Runtime snapshot writes are advisory too. After a workflow journal append has+-- committed, "Keiro.Workflow" swallows any snapshot-store failure and increments+-- @keiro.snapshot.write.failures@; the committed step, completion, or rotation+-- therefore still determines a successful run. This low-level module continues+-- to expose the store primitive, while the runtime owns that error handling.+--+-- Snapshot writes store the full accumulated step map each time the selected+-- policy fires. With an every-n policy that means rewriting the complete map at+-- each boundary; the intended way to bound that cost for forever-running+-- workflows is 'Keiro.Workflow.continueAsNew', which starts a fresh generation+-- with a small carried seed.+module Keiro.Workflow.Snapshot+  ( workflowStateCodec,     workflowStateCodecVersion,     workflowStateShapeHash,     lookupWorkflowSnapshot,     loadWorkflowSnapshot,     writeWorkflowSnapshot,-)+  ) where  import Data.Aeson (Result (..))@@ -61,77 +60,71 @@ import Kiroku.Store.Read (lookupStreamId) import Kiroku.Store.Types (StreamId, StreamName, StreamVersion) -{- | The codec version of the workflow snapshot envelope. Bumped only if the-map /envelope/ encoding itself changes (e.g. a switch from a bare JSON-object to a tagged container), never for a per-step result-type change.--}+-- | The codec version of the workflow snapshot envelope. Bumped only if the+-- map /envelope/ encoding itself changes (e.g. a switch from a bare JSON+-- object to a tagged container), never for a per-step result-type change. workflowStateCodecVersion :: Int workflowStateCodecVersion = 1 -{- | A FIXED sentinel shape hash. The accumulated state is always "a JSON-object of step-name strings to self-describing JSON values"; its /shape/-never varies with which steps ran, so the shape hash is constant. Per-step-result-type evolution is the step's own 'Aeson.ToJSON'\/'Aeson.FromJSON'-concern, surfaced at the step decode in "Keiro.Workflow", not here.--}+-- | A FIXED sentinel shape hash. The accumulated state is always "a JSON+-- object of step-name strings to self-describing JSON values"; its /shape/+-- never varies with which steps ran, so the shape hash is constant. Per-step+-- result-type evolution is the step's own 'Aeson.ToJSON'\/'Aeson.FromJSON'+-- concern, surfaced at the step decode in "Keiro.Workflow", not here. workflowStateShapeHash :: Text workflowStateShapeHash = "keiro.workflow.stepmap.v1" -{- | The workflow-specific 'StateCodec'. Encodes the accumulated-'WorkflowState' map straight to JSON and decodes it back, with a fixed-discriminant ('workflowStateCodecVersion', 'workflowStateShapeHash').--}+-- | The workflow-specific 'StateCodec'. Encodes the accumulated+-- 'WorkflowState' map straight to JSON and decodes it back, with a fixed+-- discriminant ('workflowStateCodecVersion', 'workflowStateShapeHash'). workflowStateCodec :: StateCodec WorkflowState workflowStateCodec =-    StateCodec-        { stateCodecVersion = workflowStateCodecVersion-        , shapeHash = workflowStateShapeHash-        , stateShapeHash = workflowStateShapeHash-        , encode = toJSON-        , decode = \value -> case fromJSON value of-            Success m -> Right m-            Error msg -> Left (Text.pack msg)-        }+  StateCodec+    { stateCodecVersion = workflowStateCodecVersion,+      shapeHash = workflowStateShapeHash,+      stateShapeHash = workflowStateShapeHash,+      encode = toJSON,+      decode = \value -> case fromJSON value of+        Success m -> Right m+        Error msg -> Left (Text.pack msg)+    } -{- | Encode the accumulated map and upsert @keiro_snapshots@ for the journal-stream. Called from the @step@ miss-path (and the completion site) with the-'Kiroku.Store.Types.AppendResult'\'s @streamId@ and post-append @streamVersion@.-The underlying upsert keeps only the highest version per stream, so a re-fire-at an already-snapshotted version is a harmless no-op.--}+-- | Encode the accumulated map and upsert @keiro_snapshots@ for the journal+-- stream. Called from the @step@ miss-path (and the completion site) with the+-- 'Kiroku.Store.Types.AppendResult'\'s @streamId@ and post-append @streamVersion@.+-- The underlying upsert keeps only the highest version per stream, so a re-fire+-- at an already-snapshotted version is a harmless no-op. writeWorkflowSnapshot :: (Store :> es) => StreamId -> StreamVersion -> WorkflowState -> Eff es () writeWorkflowSnapshot streamId version state =-    writeSnapshot streamId version workflowStateCodec state+  writeSnapshot streamId version workflowStateCodec state -{- | Resolve the journal stream id, look up the latest matching snapshot row,-and decode it to a @('WorkflowState', 'StreamVersion')@ seed. Returns-'Nothing' — meaning "replay from 0" — when the stream has no id yet, no-matching snapshot, or an undecodable snapshot (advisory semantics). Mirrors-'Keiro.Snapshot.hydrateWithSnapshot''s miss-is-benign contract.--}+-- | Resolve the journal stream id, look up the latest matching snapshot row,+-- and decode it to a @('WorkflowState', 'StreamVersion')@ seed. Returns+-- 'Nothing' — meaning "replay from 0" — when the stream has no id yet, no+-- matching snapshot, or an undecodable snapshot (advisory semantics). Mirrors+-- 'Keiro.Snapshot.hydrateWithSnapshot''s miss-is-benign contract. loadWorkflowSnapshot :: (Store :> es) => StreamName -> Eff es (Maybe (WorkflowState, StreamVersion)) loadWorkflowSnapshot journalName =-    lookupWorkflowSnapshot journalName <&> either (const Nothing) Just+  lookupWorkflowSnapshot journalName <&> either (const Nothing) Just -{- | Resolve and decode a workflow snapshot while retaining the reason a-usable seed was unavailable. This is the observable counterpart to-'loadWorkflowSnapshot'.--}+-- | Resolve and decode a workflow snapshot while retaining the reason a+-- usable seed was unavailable. This is the observable counterpart to+-- 'loadWorkflowSnapshot'. lookupWorkflowSnapshot :: (Store :> es) => StreamName -> Eff es (Either SnapshotMissReason (WorkflowState, StreamVersion)) lookupWorkflowSnapshot journalName = do-    mStreamId <- lookupStreamId journalName-    case mStreamId of-        Nothing -> pure (Left SnapshotNoStream)-        Just streamId -> do-            mRow <--                lookupSnapshot-                    streamId-                    workflowStateCodecVersion-                    workflowStateShapeHash-                    workflowStateShapeHash-            pure $ case mRow of-                Nothing -> Left SnapshotNotFound-                Just row ->-                    case (workflowStateCodec ^. #decode) (row ^. #state) of-                        Left message -> Left (SnapshotDecodeFailed message)-                        Right state -> Right (state, row ^. #streamVersion)+  mStreamId <- lookupStreamId journalName+  case mStreamId of+    Nothing -> pure (Left SnapshotNoStream)+    Just streamId -> do+      mRow <-+        lookupSnapshot+          streamId+          workflowStateCodecVersion+          workflowStateShapeHash+          workflowStateShapeHash+      pure $ case mRow of+        Nothing -> Left SnapshotNotFound+        Just row ->+          case (workflowStateCodec ^. #decode) (row ^. #state) of+            Left message -> Left (SnapshotDecodeFailed message)+            Right state -> Right (state, row ^. #streamVersion)
src/Keiro/Workflow/Types.hs view
@@ -1,25 +1,24 @@ {-# OPTIONS_GHC -Wno-partial-fields #-} -{- | Core types and the journal codec for the durable workflow runtime.--A /durable workflow/ is an ordinary @effectful@ computation whose side-effects are recorded ("journaled") at named checkpoints so the computation-can be paused and resumed across crashes without re-running work that-already happened. The journal is a kiroku stream named-@wf:\<workflow-name\>-\<workflow-id\>@ ('workflowStreamName') holding one-'StepRecorded' event per executed step and a terminal 'WorkflowCompleted'-event.--This module owns the contracts every sibling plan of the v2 MasterPlan-builds on: the identity newtypes ('WorkflowName', 'WorkflowId',-'StepName'), the journal event sum ('WorkflowJournalEvent') and its-'Codec' ('workflowJournalCodec'), the accumulated-state alias-('WorkflowState'), the run outcome ('WorkflowOutcome'), and the-reserved step-name conventions ('completedStepName' plus the-@sleep:@\/@awk:@\/@child:@ prefixes).--}-module Keiro.Workflow.Types (-    -- * Identity+-- | Core types and the journal codec for the durable workflow runtime.+--+-- A /durable workflow/ is an ordinary @effectful@ computation whose side+-- effects are recorded ("journaled") at named checkpoints so the computation+-- can be paused and resumed across crashes without re-running work that+-- already happened. The journal is a kiroku stream named+-- @wf:\<workflow-name\>-\<workflow-id\>@ ('workflowStreamName') holding one+-- 'StepRecorded' event per executed step and a terminal 'WorkflowCompleted'+-- event.+--+-- This module owns the contracts every sibling plan of the v2 MasterPlan+-- builds on: the identity newtypes ('WorkflowName', 'WorkflowId',+-- 'StepName'), the journal event sum ('WorkflowJournalEvent') and its+-- 'Codec' ('workflowJournalCodec'), the accumulated-state alias+-- ('WorkflowState'), the run outcome ('WorkflowOutcome'), and the+-- reserved step-name conventions ('completedStepName' plus the+-- @sleep:@\/@awk:@\/@child:@ prefixes).+module Keiro.Workflow.Types+  ( -- * Identity     WorkflowName (..),     WorkflowId (..),     WorkflowIdentityError (..),@@ -53,7 +52,7 @@     patchStepPrefix,     patchSetStepName,     patchStepName,-)+  ) where  import Data.Aeson qualified as Aeson@@ -65,340 +64,315 @@ import Keiro.Prelude import Kiroku.Store.Types (EventType (..), StreamName (..)) -{- | The stable name of a workflow /definition/ (for example-@"orderFulfillment"@). Part of the journal stream name and of every-deterministic journal-event id, so it must not change for a given definition-across deploys. Prefer 'mkWorkflowName' for new code; the raw constructor is-kept for compatibility and can create ambiguous journal stream names if the-text contains structural separators such as @-@, @:@, or @#@.--}+-- | The stable name of a workflow /definition/ (for example+-- @"orderFulfillment"@). Part of the journal stream name and of every+-- deterministic journal-event id, so it must not change for a given definition+-- across deploys. Prefer 'mkWorkflowName' for new code; the raw constructor is+-- kept for compatibility and can create ambiguous journal stream names if the+-- text contains structural separators such as @-@, @:@, or @#@. newtype WorkflowName = WorkflowName {unWorkflowName :: Text}-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic) -{- | The id of a single workflow /instance/ (a UUID-as-text or any stable-caller-supplied string). Combined with the 'WorkflowName' it identifies the-instance's journal stream. Prefer 'mkWorkflowId' for new code; the raw-constructor is kept for compatibility and can create ambiguous journal stream-names if the text contains structural separators such as @:@ or @#@.--}+-- | The id of a single workflow /instance/ (a UUID-as-text or any stable+-- caller-supplied string). Combined with the 'WorkflowName' it identifies the+-- instance's journal stream. Prefer 'mkWorkflowId' for new code; the raw+-- constructor is kept for compatibility and can create ambiguous journal stream+-- names if the text contains structural separators such as @:@ or @#@. newtype WorkflowId = WorkflowId {unWorkflowId :: Text}-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  data WorkflowIdentityError-    = WorkflowNameEmpty-    | WorkflowNameInvalidChar !Char !Text-    | WorkflowIdEmpty-    | WorkflowIdInvalidChar !Char !Text-    deriving stock (Eq, Show, Generic)--{- | Validated constructor for workflow names.+  = WorkflowNameEmpty+  | WorkflowNameInvalidChar !Char !Text+  | WorkflowIdEmpty+  | WorkflowIdInvalidChar !Char !Text+  deriving stock (Eq, Show, Generic) -Rejects empty names and the structural separators @:@ (stream prefix), @-@-(name/id boundary), and @#@ (generation suffix). Prefer camelCase compound-names such as @"orderFulfillment"@; the raw 'WorkflowName' constructor remains-available for compatibility.--}+-- | Validated constructor for workflow names.+--+-- Rejects empty names and the structural separators @:@ (stream prefix), @-@+-- (name/id boundary), and @#@ (generation suffix). Prefer camelCase compound+-- names such as @"orderFulfillment"@; the raw 'WorkflowName' constructor remains+-- available for compatibility. mkWorkflowName :: Text -> Either WorkflowIdentityError WorkflowName mkWorkflowName raw-    | Text.null raw = Left WorkflowNameEmpty-    | Just c <- Text.find (`elem` ([':', '-', '#'] :: [Char])) raw =-        Left (WorkflowNameInvalidChar c raw)-    | otherwise = Right (WorkflowName raw)--{- | Validated constructor for workflow ids.+  | Text.null raw = Left WorkflowNameEmpty+  | Just c <- Text.find (`elem` ([':', '-', '#'] :: [Char])) raw =+      Left (WorkflowNameInvalidChar c raw)+  | otherwise = Right (WorkflowName raw) -Rejects empty ids and the structural separators @:@ and @#@. The @-@ character-is permitted so UUID-style ids remain valid; with a validated, hyphen-free-workflow name, the @wf:\<name\>-\<id\>@ boundary is the first @-@.--}+-- | Validated constructor for workflow ids.+--+-- Rejects empty ids and the structural separators @:@ and @#@. The @-@ character+-- is permitted so UUID-style ids remain valid; with a validated, hyphen-free+-- workflow name, the @wf:\<name\>-\<id\>@ boundary is the first @-@. mkWorkflowId :: Text -> Either WorkflowIdentityError WorkflowId mkWorkflowId raw-    | Text.null raw = Left WorkflowIdEmpty-    | Just c <- Text.find (`elem` ([':', '#'] :: [Char])) raw =-        Left (WorkflowIdInvalidChar c raw)-    | otherwise = Right (WorkflowId raw)+  | Text.null raw = Left WorkflowIdEmpty+  | Just c <- Text.find (`elem` ([':', '#'] :: [Char])) raw =+      Left (WorkflowIdInvalidChar c raw)+  | otherwise = Right (WorkflowId raw) -{- | The label identifying a step within a workflow. Replay matches on this-label, not on source position, so reordering code between deploys does not-corrupt an in-flight workflow.--}+-- | The label identifying a step within a workflow. Replay matches on this+-- label, not on source position, so reordering code between deploys does not+-- corrupt an in-flight workflow. newtype StepName = StepName {unStepName :: Text}-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic) -{- | The stable identifier of a /patch/ (EP-49) — a guarded, cross-cutting change-to a running workflow's logic. The author chooses an opaque, never-reused string-(for example @"fraud-check-v2"@). A patch decision is journaled under the key-@patch:\<patchId\>@, so the id must not contain the structural @:@ in a way that-makes the prefix boundary ambiguous, mirroring the 'sleepStepPrefix' caveat.--}+-- | The stable identifier of a /patch/ (EP-49) — a guarded, cross-cutting change+-- to a running workflow's logic. The author chooses an opaque, never-reused string+-- (for example @"fraud-check-v2"@). A patch decision is journaled under the key+-- @patch:\<patchId\>@, so the id must not contain the structural @:@ in a way that+-- makes the prefix boundary ambiguous, mirroring the 'sleepStepPrefix' caveat. newtype PatchId = PatchId {unPatchId :: Text}-    deriving stock (Eq, Ord, Show, Generic)--{- | The journal stream name for a workflow instance:-@wf:\<name\>-\<id\>@.+  deriving stock (Eq, Ord, Show, Generic) -The @:@ and @-@ characters are structural, and generation streams reserve @#@.-Use 'mkWorkflowName' and 'mkWorkflowId' for separator-safe identities. Raw-unvalidated names containing @-@ can make two distinct @(name, id)@ pairs share-one journal stream (the v1 process-manager @pm:\<name\>-\<correlationId\>@-convention carries the same caveat).--}+-- | The journal stream name for a workflow instance:+-- @wf:\<name\>-\<id\>@.+--+-- The @:@ and @-@ characters are structural, and generation streams reserve @#@.+-- Use 'mkWorkflowName' and 'mkWorkflowId' for separator-safe identities. Raw+-- unvalidated names containing @-@ can make two distinct @(name, id)@ pairs share+-- one journal stream (the v1 process-manager @pm:\<name\>-\<correlationId\>@+-- convention carries the same caveat). workflowStreamName :: WorkflowName -> WorkflowId -> StreamName workflowStreamName (WorkflowName name) (WorkflowId wid) =-    StreamName ("wf:" <> name <> "-" <> wid)--{- | The PHYSICAL journal stream for a given /generation/ of a logical-workflow (EP-48 continue-as-new).+  StreamName ("wf:" <> name <> "-" <> wid) -Generation 0 keeps the legacy name @wf:\<name\>-\<id\>@ — so already-running,-never-rotated workflows are byte-for-byte unchanged and need zero data-migration — while generation @g > 0@ appends a @#\<g\>@ suffix. The @#@ is a-new structural separator, distinct from the @:@ and @-@ that-'workflowStreamName' already reserves, so it cannot collide with an existing-boundary. The /logical/ identity @('WorkflowName', 'WorkflowId')@ the author-and the resume registry see is stable across rotations; only the physical-stream the journal lives on rotates underneath it.--}+-- | The PHYSICAL journal stream for a given /generation/ of a logical+-- workflow (EP-48 continue-as-new).+--+-- Generation 0 keeps the legacy name @wf:\<name\>-\<id\>@ — so already-running,+-- never-rotated workflows are byte-for-byte unchanged and need zero data+-- migration — while generation @g > 0@ appends a @#\<g\>@ suffix. The @#@ is a+-- new structural separator, distinct from the @:@ and @-@ that+-- 'workflowStreamName' already reserves, so it cannot collide with an existing+-- boundary. The /logical/ identity @('WorkflowName', 'WorkflowId')@ the author+-- and the resume registry see is stable across rotations; only the physical+-- stream the journal lives on rotates underneath it. workflowGenerationStreamName :: WorkflowName -> WorkflowId -> Int -> StreamName workflowGenerationStreamName name wid gen-    | gen <= 0 = workflowStreamName name wid-    | otherwise =-        let StreamName base = workflowStreamName name wid-         in StreamName (base <> "#" <> Text.pack (show gen))--{- | The events written to a workflow journal.--* 'StepRecorded' — a step (identified by 'stepName') ran and produced-  'result' (the step's value encoded as JSON) at 'recordedAt'. The-  suspension primitives journal their completions as ordinary-  'StepRecorded' events whose 'stepName' carries a reserved prefix-  ('sleepStepPrefix', 'awakeableStepPrefix', 'childStepPrefix'); the-  replay loop stays uniform because there is no separate event type.-* 'WorkflowCompleted' — the terminal marker appended once the whole-  computation has returned.-* 'WorkflowCancelled' — a terminal marker (EP-43) written to a /child/-  workflow's journal by @cancelChild@; a run whose journal carries it-  short-circuits without executing further steps and reports-  'Keiro.Workflow.Types.Cancelled'.-* 'WorkflowFailed' — a terminal failure marker (carries a 'reason'),-  available for a worker to record a permanently-failed run.+  | gen <= 0 = workflowStreamName name wid+  | otherwise =+      let StreamName base = workflowStreamName name wid+       in StreamName (base <> "#" <> Text.pack (show gen)) -These last two are purely additive within @schemaVersion = 1@ (a new wire-tag added to 'workflowJournalCodec''s 'eventTypes'; old journals never carry-it, so no upcaster is needed). The codec is deliberately the single place to-extend.--}+-- | The events written to a workflow journal.+--+-- * 'StepRecorded' — a step (identified by 'stepName') ran and produced+--   'result' (the step's value encoded as JSON) at 'recordedAt'. The+--   suspension primitives journal their completions as ordinary+--   'StepRecorded' events whose 'stepName' carries a reserved prefix+--   ('sleepStepPrefix', 'awakeableStepPrefix', 'childStepPrefix'); the+--   replay loop stays uniform because there is no separate event type.+-- * 'WorkflowCompleted' — the terminal marker appended once the whole+--   computation has returned.+-- * 'WorkflowCancelled' — a terminal marker (EP-43) written to a /child/+--   workflow's journal by @cancelChild@; a run whose journal carries it+--   short-circuits without executing further steps and reports+--   'Keiro.Workflow.Types.Cancelled'.+-- * 'WorkflowFailed' — a terminal failure marker (carries a 'reason'),+--   available for a worker to record a permanently-failed run.+--+-- These last two are purely additive within @schemaVersion = 1@ (a new wire+-- tag added to 'workflowJournalCodec''s 'eventTypes'; old journals never carry+-- it, so no upcaster is needed). The codec is deliberately the single place to+-- extend. data WorkflowJournalEvent-    = StepRecorded {stepName :: !Text, result :: !Aeson.Value, recordedAt :: !UTCTime}-    | WorkflowCompleted {recordedAt :: !UTCTime}-    | WorkflowCancelled {recordedAt :: !UTCTime}-    | WorkflowFailed {reason :: !Text, recordedAt :: !UTCTime}-    | {- | Terminal-for-this-generation rotation marker (EP-48). 'generation'-      is the NEXT generation this rotation opens. Additive within-      @schemaVersion = 1@: old journals never carry the-      @"WorkflowContinuedAsNew"@ tag, so no upcaster is needed.-      -}-      WorkflowContinuedAsNew {generation :: !Int, recordedAt :: !UTCTime}-    deriving stock (Eq, Show, Generic)+  = StepRecorded {stepName :: !Text, result :: !Aeson.Value, recordedAt :: !UTCTime}+  | WorkflowCompleted {recordedAt :: !UTCTime}+  | WorkflowCancelled {recordedAt :: !UTCTime}+  | WorkflowFailed {reason :: !Text, recordedAt :: !UTCTime}+  | -- | Terminal-for-this-generation rotation marker (EP-48). 'generation'+    --       is the NEXT generation this rotation opens. Additive within+    --       @schemaVersion = 1@: old journals never carry the+    --       @"WorkflowContinuedAsNew"@ tag, so no upcaster is needed.+    WorkflowContinuedAsNew {generation :: !Int, recordedAt :: !UTCTime}+  deriving stock (Eq, Show, Generic) -{- | The 'Codec' that serializes 'WorkflowJournalEvent' to and from the-JSON payloads stored on the journal stream. Schema version 1; no-upcasters. Each payload is self-describing (it carries a @"kind"@-discriminator) so 'decode' can reconstruct the constructor from the-payload alone.--}+-- | The 'Codec' that serializes 'WorkflowJournalEvent' to and from the+-- JSON payloads stored on the journal stream. Schema version 1; no+-- upcasters. Each payload is self-describing (it carries a @"kind"@+-- discriminator) so 'decode' can reconstruct the constructor from the+-- payload alone. workflowJournalCodec :: Codec WorkflowJournalEvent workflowJournalCodec =-    Codec-        { eventTypes = EventType "StepRecorded" :| [EventType "WorkflowCompleted", EventType "WorkflowCancelled", EventType "WorkflowFailed", EventType "WorkflowContinuedAsNew"]-        , eventType = \case-            StepRecorded{} -> EventType "StepRecorded"-            WorkflowCompleted{} -> EventType "WorkflowCompleted"-            WorkflowCancelled{} -> EventType "WorkflowCancelled"-            WorkflowFailed{} -> EventType "WorkflowFailed"-            WorkflowContinuedAsNew{} -> EventType "WorkflowContinuedAsNew"-        , schemaVersion = 1-        , encode = encodeJournalEvent-        , decode = decodeJournalEvent-        , upcasters = []-        }+  Codec+    { eventTypes = EventType "StepRecorded" :| [EventType "WorkflowCompleted", EventType "WorkflowCancelled", EventType "WorkflowFailed", EventType "WorkflowContinuedAsNew"],+      eventType = \case+        StepRecorded {} -> EventType "StepRecorded"+        WorkflowCompleted {} -> EventType "WorkflowCompleted"+        WorkflowCancelled {} -> EventType "WorkflowCancelled"+        WorkflowFailed {} -> EventType "WorkflowFailed"+        WorkflowContinuedAsNew {} -> EventType "WorkflowContinuedAsNew",+      schemaVersion = 1,+      encode = encodeJournalEvent,+      decode = decodeJournalEvent,+      upcasters = []+    }  encodeJournalEvent :: WorkflowJournalEvent -> Aeson.Value encodeJournalEvent = \case-    StepRecorded name r t ->-        Aeson.object-            [ "kind" Aeson..= ("StepRecorded" :: Text)-            , "stepName" Aeson..= name-            , "result" Aeson..= r-            , "recordedAt" Aeson..= t-            ]-    WorkflowCompleted t ->-        Aeson.object-            [ "kind" Aeson..= ("WorkflowCompleted" :: Text)-            , "recordedAt" Aeson..= t-            ]-    WorkflowCancelled t ->-        Aeson.object-            [ "kind" Aeson..= ("WorkflowCancelled" :: Text)-            , "recordedAt" Aeson..= t-            ]-    WorkflowFailed r t ->-        Aeson.object-            [ "kind" Aeson..= ("WorkflowFailed" :: Text)-            , "reason" Aeson..= r-            , "recordedAt" Aeson..= t-            ]-    WorkflowContinuedAsNew g t ->-        Aeson.object-            [ "kind" Aeson..= ("WorkflowContinuedAsNew" :: Text)-            , "generation" Aeson..= g-            , "recordedAt" Aeson..= t-            ]+  StepRecorded name r t ->+    Aeson.object+      [ "kind" Aeson..= ("StepRecorded" :: Text),+        "stepName" Aeson..= name,+        "result" Aeson..= r,+        "recordedAt" Aeson..= t+      ]+  WorkflowCompleted t ->+    Aeson.object+      [ "kind" Aeson..= ("WorkflowCompleted" :: Text),+        "recordedAt" Aeson..= t+      ]+  WorkflowCancelled t ->+    Aeson.object+      [ "kind" Aeson..= ("WorkflowCancelled" :: Text),+        "recordedAt" Aeson..= t+      ]+  WorkflowFailed r t ->+    Aeson.object+      [ "kind" Aeson..= ("WorkflowFailed" :: Text),+        "reason" Aeson..= r,+        "recordedAt" Aeson..= t+      ]+  WorkflowContinuedAsNew g t ->+    Aeson.object+      [ "kind" Aeson..= ("WorkflowContinuedAsNew" :: Text),+        "generation" Aeson..= g,+        "recordedAt" Aeson..= t+      ]  decodeJournalEvent :: EventType -> Aeson.Value -> Either Text WorkflowJournalEvent decodeJournalEvent (EventType tag) value = first Text.pack (parseEither parser value)   where     parser = Aeson.withObject "WorkflowJournalEvent" $ \o -> do-        case tag of-            "StepRecorded" ->-                StepRecorded <$> o Aeson..: "stepName" <*> o Aeson..: "result" <*> o Aeson..: "recordedAt"-            "WorkflowCompleted" ->-                WorkflowCompleted <$> o Aeson..: "recordedAt"-            "WorkflowCancelled" ->-                WorkflowCancelled <$> o Aeson..: "recordedAt"-            "WorkflowFailed" ->-                WorkflowFailed <$> o Aeson..: "reason" <*> o Aeson..: "recordedAt"-            "WorkflowContinuedAsNew" ->-                WorkflowContinuedAsNew <$> o Aeson..: "generation" <*> o Aeson..: "recordedAt"-            other ->-                fail ("unknown workflow journal event type: " <> Text.unpack other)--{- | The accumulated step state a running workflow holds in memory: a map-from step name to that step's recorded JSON result. This is exactly the-value the journal carries, folded into a map.+      case tag of+        "StepRecorded" ->+          StepRecorded <$> o Aeson..: "stepName" <*> o Aeson..: "result" <*> o Aeson..: "recordedAt"+        "WorkflowCompleted" ->+          WorkflowCompleted <$> o Aeson..: "recordedAt"+        "WorkflowCancelled" ->+          WorkflowCancelled <$> o Aeson..: "recordedAt"+        "WorkflowFailed" ->+          WorkflowFailed <$> o Aeson..: "reason" <*> o Aeson..: "recordedAt"+        "WorkflowContinuedAsNew" ->+          WorkflowContinuedAsNew <$> o Aeson..: "generation" <*> o Aeson..: "recordedAt"+        other ->+          fail ("unknown workflow journal event type: " <> Text.unpack other) -This alias is the integration contract EP-41 (snapshots) consumes; it must-remain a @Map Text Value@ whose keys are dynamic step-name strings.--}+-- | The accumulated step state a running workflow holds in memory: a map+-- from step name to that step's recorded JSON result. This is exactly the+-- value the journal carries, folded into a map.+--+-- This alias is the integration contract EP-41 (snapshots) consumes; it must+-- remain a @Map Text Value@ whose keys are dynamic step-name strings. type WorkflowState = Map Text Aeson.Value -{- | The result of running a workflow.--* 'Completed' — the computation ran to its end and a 'WorkflowCompleted'-  event was journaled.-* 'Suspended' — the computation paused at an unresolved @awaitStep@; a-  wake source was armed and the run will be resumed later (by EP-42's-  resume worker) once the awaited result is journaled.--* 'Cancelled' — the run's journal carried a 'WorkflowCancelled' marker (a-  child cancelled by its parent, EP-43), so the handler short-circuited and-  executed nothing further. Distinct from 'Suspended' (which will resume) and-  'Completed' (which finished its work).-* 'Failed' — the run's journal carried a 'WorkflowFailed' marker, so the-  handler short-circuited and executed nothing further.--}+-- | The result of running a workflow.+--+-- * 'Completed' — the computation ran to its end and a 'WorkflowCompleted'+--   event was journaled.+-- * 'Suspended' — the computation paused at an unresolved @awaitStep@; a+--   wake source was armed and the run will be resumed later (by EP-42's+--   resume worker) once the awaited result is journaled.+--+-- * 'Cancelled' — the run's journal carried a 'WorkflowCancelled' marker (a+--   child cancelled by its parent, EP-43), so the handler short-circuited and+--   executed nothing further. Distinct from 'Suspended' (which will resume) and+--   'Completed' (which finished its work).+-- * 'Failed' — the run's journal carried a 'WorkflowFailed' marker, so the+--   handler short-circuited and executed nothing further. data WorkflowOutcome a-    = Completed a-    | Suspended-    | Cancelled-    | Failed-    | {- | EP-48: the run rotated onto a fresh journal generation via-      @continueAsNew@; a subsequent run/resume of the same logical id-      continues from the carried seed. Distinct from 'Suspended' (a wake-      source is pending) and 'Completed' (the workflow is done): a rotated-      workflow is still unfinished, so the resume worker re-invokes it and it-      proceeds on the new generation.-      -}-      ContinuedAsNew-    deriving stock (Eq, Show, Functor)+  = Completed a+  | Suspended+  | Cancelled+  | Failed+  | -- | EP-48: the run rotated onto a fresh journal generation via+    --       @continueAsNew@; a subsequent run/resume of the same logical id+    --       continues from the carried seed. Distinct from 'Suspended' (a wake+    --       source is pending) and 'Completed' (the workflow is done): a rotated+    --       workflow is still unfinished, so the resume worker re-invokes it and it+    --       proceeds on the new generation.+    ContinuedAsNew+  deriving stock (Eq, Show, Functor) -{- | The reserved step name written (as a 'WorkflowCompleted' journal event-and an index row) when a workflow finishes. The discovery query in-"Keiro.Workflow.Schema" ('Keiro.Workflow.Schema.findUnfinishedWorkflowIds')-treats a workflow lacking a row with this step name as unfinished, so the-literal here must match the literal in that SQL.--}+-- | The reserved step name written (as a 'WorkflowCompleted' journal event+-- and an index row) when a workflow finishes. The discovery query in+-- "Keiro.Workflow.Schema" ('Keiro.Workflow.Schema.findUnfinishedWorkflowIds')+-- treats a workflow lacking a row with this step name as unfinished, so the+-- literal here must match the literal in that SQL. completedStepName :: Text completedStepName = "__workflow_completed__" -{- | The reserved step name written (as a 'WorkflowCancelled' journal event and-an index row) when a workflow is cancelled. The replay handler short-circuits a-run that has a row with this step name, and-'Keiro.Workflow.Schema.findUnfinishedWorkflowIds' treats it (like-'completedStepName') as a terminal marker, so a cancelled workflow drops out of-resume discovery. The literal must match the literal in that SQL.--}+-- | The reserved step name written (as a 'WorkflowCancelled' journal event and+-- an index row) when a workflow is cancelled. The replay handler short-circuits a+-- run that has a row with this step name, and+-- 'Keiro.Workflow.Schema.findUnfinishedWorkflowIds' treats it (like+-- 'completedStepName') as a terminal marker, so a cancelled workflow drops out of+-- resume discovery. The literal must match the literal in that SQL. cancelledStepName :: Text cancelledStepName = "__workflow_cancelled__" -{- | The reserved step name written (as a 'WorkflowFailed' journal event and an-index row) when a workflow is recorded as permanently failed.--}+-- | The reserved step name written (as a 'WorkflowFailed' journal event and an+-- index row) when a workflow is recorded as permanently failed. failedStepName :: Text failedStepName = "__workflow_failed__" -{- | The reserved step name written (as a 'WorkflowContinuedAsNew' journal event-and an index row) when a workflow's generation rotates via @continueAsNew@-(EP-48). Its presence on a generation's index row makes that generation terminal-/for itself/ (a rotated-away generation drops out of any per-generation-"unfinished" check); the /current/ generation is the one with no terminal marker-row. Distinct from 'completedStepName'/'cancelledStepName': a rotated workflow is-NOT finished — its work continues on the next generation —-so 'Keiro.Workflow.Schema.findUnfinishedWorkflowIds' deliberately scopes its-terminal-marker check to the current (MAX) generation and does not treat-'continuedAsNewStepName' as terminal for the logical workflow.--}+-- | The reserved step name written (as a 'WorkflowContinuedAsNew' journal event+-- and an index row) when a workflow's generation rotates via @continueAsNew@+-- (EP-48). Its presence on a generation's index row makes that generation terminal+-- /for itself/ (a rotated-away generation drops out of any per-generation+-- "unfinished" check); the /current/ generation is the one with no terminal marker+-- row. Distinct from 'completedStepName'/'cancelledStepName': a rotated workflow is+-- NOT finished — its work continues on the next generation —+-- so 'Keiro.Workflow.Schema.findUnfinishedWorkflowIds' deliberately scopes its+-- terminal-marker check to the current (MAX) generation and does not treat+-- 'continuedAsNewStepName' as terminal for the logical workflow. continuedAsNewStepName :: Text continuedAsNewStepName = "__workflow_continued_as_new__" -{- | The reserved step name under which @continueAsNew@ (EP-48) carries the-author's seed value into the next generation. On rotation the runtime appends a-single @StepRecorded continueSeedStepName seedJson@ to the next generation's-journal (and snapshots it); the next run's body reads it back via @restoreSeed@-— an ordinary journaled @step@ — so the carried state is restored without-re-running anything. The leading/trailing @__@ marks it reserved, like the-other @__workflow_*__@ names.--}+-- | The reserved step name under which @continueAsNew@ (EP-48) carries the+-- author's seed value into the next generation. On rotation the runtime appends a+-- single @StepRecorded continueSeedStepName seedJson@ to the next generation's+-- journal (and snapshots it); the next run's body reads it back via @restoreSeed@+-- — an ordinary journaled @step@ — so the carried state is restored without+-- re-running anything. The leading/trailing @__@ marks it reserved, like the+-- other @__workflow_*__@ names. continueSeedStepName :: Text continueSeedStepName = "__workflow_seed__" -{- | Reserved step-name prefix EP-39 uses to journal a durable @sleep@'s-completion. Integration contract: EP-39 must use exactly this string.--}+-- | Reserved step-name prefix EP-39 uses to journal a durable @sleep@'s+-- completion. Integration contract: EP-39 must use exactly this string. sleepStepPrefix :: Text sleepStepPrefix = "sleep:" -{- | Reserved step-name prefix EP-40 uses to journal an awakeable's-completion. Integration contract: EP-40 must use exactly this string.--}+-- | Reserved step-name prefix EP-40 uses to journal an awakeable's+-- completion. Integration contract: EP-40 must use exactly this string. awakeableStepPrefix :: Text awakeableStepPrefix = "awk:" -{- | Reserved step-name prefix used to journal the random id allocated for an-awakeable. The id is random for new allocations but replay-stable because it is-recorded under @awkid:\<label\>@ before the workflow awaits @awk:\<uuid\>@.--}+-- | Reserved step-name prefix used to journal the random id allocated for an+-- awakeable. The id is random for new allocations but replay-stable because it is+-- recorded under @awkid:\<label\>@ before the workflow awaits @awk:\<uuid\>@. awakeableAllocStepPrefix :: Text awakeableAllocStepPrefix = "awkid:" -{- | Reserved step-name prefix EP-43 uses to journal a child workflow's-completion. Integration contract: EP-43 must use exactly this string.--}+-- | Reserved step-name prefix EP-43 uses to journal a child workflow's+-- completion. Integration contract: EP-43 must use exactly this string. childStepPrefix :: Text childStepPrefix = "child:" -{- | Reserved step-name prefix EP-49 uses to journal a 'patch' decision. A patch-decision is journaled as an ordinary 'StepRecorded' event whose 'stepName' is-@'patchStepPrefix' <> 'unPatchId' pid@ and whose 'result' is the JSON 'Bool'-branch decision, so the replay loop and the step index stay uniform — no new-journal-event constructor is added.--}+-- | Reserved step-name prefix EP-49 uses to journal a 'patch' decision. A patch+-- decision is journaled as an ordinary 'StepRecorded' event whose 'stepName' is+-- @'patchStepPrefix' <> 'unPatchId' pid@ and whose 'result' is the JSON 'Bool'+-- branch decision, so the replay loop and the step index stay uniform — no new+-- journal-event constructor is added. patchStepPrefix :: Text patchStepPrefix = "patch:" -{- | Reserved step name under which a workflow generation records the patch ids-that were active when that generation first started.--}+-- | Reserved step name under which a workflow generation records the patch ids+-- that were active when that generation first started. patchSetStepName :: Text patchSetStepName = "__workflow_patches__" 
test/Main.hs view
@@ -1,12807 +1,12808 @@-module Main (-    main,-)-where--import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)-import Control.Concurrent (forkIO, killThread, threadDelay)-import Control.Concurrent.MVar (MVar, modifyMVar, newEmptyMVar, newMVar, putMVar, readMVar, takeMVar, tryPutMVar)-import Control.Concurrent.STM (atomically, putTMVar)-import Control.Exception (ErrorCall, Exception, SomeException, displayException, evaluate, finally, throwIO, try)-import Data.Aeson (object, withObject, (.:), (.:?))-import Data.Aeson qualified as Aeson-import Data.Aeson.KeyMap qualified as KeyMap-import Data.Aeson.Types (parseEither)-import Data.ByteString (ByteString)-import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)-import Data.Int (Int32)-import Data.List (isInfixOf)-import Data.Map.Strict qualified as Map-import Data.Monoid (mempty)-import Data.Set qualified as Set-import Data.Text qualified as Text-import Data.Text.Encoding qualified as TE-import Data.Time (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, secondsToDiffTime)-import Data.Time.Calendar (Day (ModifiedJulianDay))-import Data.UUID (UUID, fromString, fromWords64)-import Data.UUID qualified as UUID-import Data.Vector qualified as Vector-import Data.Word (Word64)-import Effectful (Eff, IOE, (:>))-import Effectful.Error.Static (Error, throwError)-import Effectful.Exception qualified as EffException-import GHC.Conc (ThreadStatus (..), threadStatus)-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Statement (Statement, preparable)-import Keiki.Core (-    Edge (..),-    HsPred (..),-    InCtor (..),-    IndexN,-    RegFile (..),-    SymTransducer (..),-    Update (..),-    WireCtor (..),-    inpCtor,-    lit,-    matchInCtor,-    oNil,-    pack,-    proj,-    (*:),-    (.==),- )-import Keiki.Core qualified as Keiki-import Keiki.Generics (emptyRegFile)-import Keiki.Operators qualified as K-import Keiki.Shape (CanonicalStateShape)-import Keiro-import Keiro qualified as KeiroRoot-import Keiro.Codec.Structural (-    StructuralBinding (..),-    bindingDomainRoundTrip,-    bindingShapeRoundTrip,-    decodeViaBinding,-    encodeViaBinding,- )-import Keiro.Connection (ensureProjectionSchema, qualifyTable, withProjectionSchema)-import Keiro.DeadLetter (-    DispatchDeadLetter (..),-    DispatcherKind (..),-    listDispatchDeadLetters,-    recordDispatchDeadLetter,- )-import Keiro.DeadLetter.Replay (-    ReplayOutcome (..),-    ReplayResult (..),-    listSubscriptionDeadLetters,-    replaySubscriptionDeadLetters,- )-import Keiro.EventStream (Terminality (..))-import Keiro.EventStream.Validate (-    EventStreamWarning (..),-    ValidatedEventStream,-    mkEventStream,-    mkEventStreamOrThrow,-    mkEventStreamUnchecked,-    mkEventStreamWith,-    validateEventStream,- )-import Keiro.Inbox (-    InboxDedupePolicy (..),-    InboxError (..),-    InboxPersistence (..),-    InboxResult (..),-    InboxStatus (..),-    KafkaDeliveryRef (..),-    garbageCollectCompleted,-    listInbox,-    lookupInbox,-    markFailedTx,-    runInboxTransaction,-    runInboxTransactionBatch,-    runInboxTransactionWith,-    runInboxTransactionWithRetries,-    runInboxTransactionWithRetriesWith,-    sampleInboxBacklog,- )-import Keiro.Inbox.Kafka qualified as InboxKafka-import Keiro.Integration.Event (-    IntegrationContentType (..),-    IntegrationEvent (..),-    SchemaReference (..),-    TraceContext (..),-    decodeJsonIntegrationEvent,-    encodeJsonIntegrationEvent,-    headerContentType,-    headerMessageId,-    headerSchemaSubject,-    headerSchemaVersion,-    headerSourceEventId,-    headerSourceGlobalPosition,-    headerTraceParent,-    integrationHeaders,-    integrationPayload,-    parseContentType,- )-import Keiro.Integration.Event qualified as IntegrationEvent-import Keiro.Outbox (-    BackoffSchedule (..),-    ExponentialBackoffOptions (..),-    IntegrationEventDraft (..),-    IntegrationProducer (..),-    IntegrationProducerConfigError (..),-    OrderingPolicy (..),-    OutboxId (..),-    OutboxPublishConfigError (..),-    OutboxRow (..),-    OutboxStatus (..),-    PublishOutcome (..),-    claimOutboxBatch,-    defaultMaintenanceOptions,-    defaultPublishOptions,-    draftToEvent,-    enqueueIntegrationEventTx,-    freshOutboxId,-    garbageCollectSent,-    lookupOutbox,-    markOutboxSent,-    mintIntegrationEvent,-    mkIntegrationProducer,-    mkOutboxPublishOptions,-    outboxMaintenancePass,-    publishClaimedOutbox,-    sampleOutboxBacklog,- )-import Keiro.Outbox.Kafka qualified as OutboxKafka-import Keiro.Outbox.Schema (markOutboxFailedTx)-import Keiro.Prelude-import Keiro.ProcessManager-import Keiro.Projection-import Keiro.ReadModel-import Keiro.ReadModel.Rebuild qualified as Rebuild-import Keiro.ReplayAudit qualified as ReplayAudit-import Keiro.Snapshot.Policy (shouldSnapshot, shouldSnapshotSpan)-import Keiro.Stream qualified as Stream-import Keiro.Subscription.Shard (-    ShardCountMismatch (..),-    ShardLease (..),-    WorkerId (..),-    ensureShards,-    fairShareTarget,- )-import Keiro.Subscription.Shard.Schema (-    claimShardsTx,-    ensureShardRows,-    listShardOwnership,-    releaseShardsTx,-    renewLeaseTx,- )-import Keiro.Subscription.Shard.Worker (-    ShardAck (..),-    ShardWorkerError (..),-    ShardedWorkerConfigError (..),-    ShardedWorkerOptions (..),-    acquireOutcome,-    defaultShardedWorkerOptions,-    mkShardedWorkerOptions,-    reconcileShardsOnce,-    runShardedSubscriptionGroup,-    runShardedSubscriptionGroupAck,- )-import Keiro.Telemetry qualified as Telemetry-import Keiro.Test.Postgres (-    StoreRunner (..),-    withFreshResourceStore,-    withFreshResourceStoreWith,-    withFreshStore,-    withFreshStoreWith,-    withFreshStores2,-    withMigratedSuite,- )-import Keiro.Timer-import Keiro.Wake (-    WakeReason (..),-    WakeSignal (..),-    neverWake,-    wakeSignalFromStore,- )-import Keiro.Workflow (-    LeaseHeartbeat (..),-    PatchId (..),-    StepName (..),-    Workflow,-    WorkflowError (..),-    WorkflowId (..),-    WorkflowIdentityError (..),-    WorkflowJournalEvent (StepRecorded, WorkflowCancelled, WorkflowCompleted, WorkflowContinuedAsNew, WorkflowFailed),-    WorkflowLeaseLost (..),-    WorkflowName (..),-    WorkflowOutcome (..),-    appendJournalEntry,-    appendJournalEntryReturningId,-    awaitStep,-    awakeableAllocStepPrefix,-    awakeableStepPrefix,-    continueAsNew,-    currentGeneration,-    defaultWorkflowRunOptions,-    findUnfinishedWorkflowIds,-    loadStepIndex,-    mkWorkflowId,-    mkWorkflowName,-    patch,-    patchSetStepName,-    patchStepName,-    restoreSeed,-    runWorkflow,-    runWorkflowWith,-    step,-    stepExists,-    workflowGenerationStreamName,-    workflowJournalCodec,- )-import Keiro.Workflow.Awakeable (-    AwakeableId (..),-    WorkflowAwakeableCancelled (..),-    awakeableIdText,-    awakeableIdToUuid,-    awakeableNamed,-    cancelAwakeable,-    deterministicAwakeableId,-    signalAwakeable,-    signalAwakeableFrom,- )-import Keiro.Workflow.Awakeable.Schema qualified as Awk-import Keiro.Workflow.Child (-    ChildHandle (..),-    WorkflowChildCancelled (..),-    WorkflowChildFailed (..),-    awaitChild,-    cancelChild,-    childCompletionHook,-    childResultStepName,-    childSpawnStepName,-    runChildWorkflow,-    spawnChild,- )-import Keiro.Workflow.Child.Schema qualified as Child-import Keiro.Workflow.Gc qualified as WorkflowGc-import Keiro.Workflow.Instance qualified as Instance-import Keiro.Workflow.Resume (-    ResumeLogEvent (..),-    ResumeSummary (..),-    WorkflowDef (..),-    defaultWorkflowResumeOptions,-    emptyResumeSummary,-    resumeWorkflowsOnce,-    runPollLoopWith,-    runWorkflowResumeWorkerPush,-    runWorkflowResumeWorkerWith,- )-import Keiro.Workflow.Sleep (-    matchSleepTimerGeneration,-    parseSleepPayload,-    runWorkflowTimerWorker,-    sleepNamed,-    sleepStepName,-    sleepTimerId,-    sleepTimerPayload,-    workflowSleepFireAction,- )-import Keiro.Workflow.Snapshot (-    loadWorkflowSnapshot,-    workflowStateCodec,- )-import Kiroku.Store qualified as Store-import Kiroku.Store.Effect (Store)-import Kiroku.Store.SQL qualified as KirokuSQL-import Kiroku.Store.Subscription.Stream (AckItem (..), subscriptionAckStream)-import Kiroku.Store.Subscription.Types (-    SubscriptionName (..),-    SubscriptionTarget (..),- )-import Kiroku.Store.Subscription.Types qualified as KirokuSub-import Kiroku.Store.Types (-    CategoryName (..),-    EventData (..),-    EventId (..),-    EventType (..),-    ExpectedVersion (..),-    GlobalPosition (..),-    RecordedEvent (..),-    StreamId (..),-    StreamName (..),-    StreamVersion (..),- )-import OpenTelemetry.Attributes (Attribute (..), Attributes, PrimitiveAttribute (..), lookupAttribute)-import OpenTelemetry.Attributes.Key (AttributeKey, unkey)-import OpenTelemetry.Exporter.InMemory.Metric (inMemoryMetricExporter)-import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)-import OpenTelemetry.Exporter.Metric (-    GaugeDataPoint (..),-    HistogramDataPoint (..),-    MetricExport (..),-    NumberValue (..),-    ResourceMetricsExport (..),-    ScopeMetricsExport (..),-    SumDataPoint (..),- )-import OpenTelemetry.MeterProvider (-    SdkMeterProviderOptions (..),-    createMeterProvider,-    defaultSdkMeterProviderOptions,- )-import OpenTelemetry.Metric.Core (-    forceFlushMeterProvider,-    getMeter,- )-import OpenTelemetry.Resource (emptyMaterializedResources)-import OpenTelemetry.Trace (-    SpanStatus (..),-    createTracerProvider,-    emptyTracerProviderOptions,-    makeTracer,-    shutdownTracerProvider,-    tracerOptions,- )-import OpenTelemetry.Trace.Core (-    ImmutableSpan (..),-    Span,-    SpanContext (..),-    SpanHot (..),-    SpanKind,-    getSpanContext,- )-import Shibuya.Adapter (Adapter (..))-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))-import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))-import Shibuya.Core.Types (Envelope (..))-import Streamly.Data.Stream qualified as Streamly-import System.Exit (ExitCode (..))-import System.Process (readProcessWithExitCode)-import System.Timeout (timeout)-import Test.Hspec-import "hasql-transaction" Hasql.Transaction qualified as Tx--main :: IO ()-main = withMigratedSuite $ \fixture -> hspec $ do-    describe "Keiro" $ do-        it "exposes the scaffold version" $-            KeiroRoot.version `shouldBe` ("0.4.0.0" :: Text)--    describe "Keiro.Telemetry metrics" $ do-        it "records instrument names and values through an SDK meter" $ do-            (exporter, ref) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            metrics <- Telemetry.newKeiroMetrics meter-            let h = Just metrics-            -- A counter (monotonic sum), a gauge (last value wins), a histogram.-            Telemetry.recordOutboxPublished h 3-            Telemetry.recordOutboxPublished h 2-            Telemetry.recordOutboxBacklog h 7-            Telemetry.recordInboxDuplicates h 1-            Telemetry.recordTimerFireLag h 12.5-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef ref-            let scalars = flattenScalarPoints exported-                hists = flattenHistogramPoints exported-            -- The counter accumulated 3 + 2 = 5.-            lookup "keiro.outbox.published" scalars `shouldBe` Just (IntNumber 5)-            -- The gauge holds its last recorded value.-            lookup "keiro.outbox.backlog" scalars `shouldBe` Just (IntNumber 7)-            -- The duplicate counter holds 1.-            lookup "keiro.inbox.duplicates" scalars `shouldBe` Just (IntNumber 1)-            -- The histogram saw one observation summing to 12.5.-            let lag = [(c, s) | (n, c, s) <- hists, n == "keiro.timer.fire.lag"]-            lag `shouldBe` [(1, 12.5)]-            -- Instruments we never recorded export no points.-            lookup "keiro.timer.stuck" scalars `shouldBe` Nothing--        it "records nothing through a Nothing handle" $ do-            (exporter, ref) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            -- A Nothing handle is the no-op path: helpers must short-circuit.-            let h = Nothing-            Telemetry.recordOutboxPublished h 99-            Telemetry.recordOutboxBacklog h 99-            Telemetry.recordTimerFireLag h 99.0-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef ref-            flattenScalarPoints exported `shouldBe` []-            flattenHistogramPoints exported `shouldBe` []--    describe "Kiroku retry exhaustion observability" $ do-        it "dead-letters after the configured delivery bound, emits the metric, and advances" $ do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            metrics <- Telemetry.newKeiroMetrics meter-            forwarded <- newIORef (0 :: Int)-            let observe _ = modifyIORef' forwarded (+ 1)-                installBridge settings =-                    settings-                        & #eventHandler-                        .~ Just (Telemetry.kirokuEventBridge (Just metrics) observe)-            withFreshStoreWith fixture installBridge $ \store -> do-                total <- seedOrders store 1 2-                total `shouldBe` 2-                let subName = SubscriptionName "orders-retry-exhaustion"-                    subConfig =-                        ( KirokuSub.defaultSubscriptionConfig-                            subName-                            (Category (CategoryName "orders"))-                            (\_ -> pure KirokuSub.Continue)-                        )-                            { KirokuSub.retryPolicy = KirokuSub.RetryPolicy 2-                            }-                    pull label source = do-                        result <- timeout 5_000_000 (Streamly.uncons source)-                        case result of-                            Just (Just itemAndRest) -> pure itemAndRest-                            Just Nothing -> fail (label <> ": subscription ended early")-                            Nothing -> fail (label <> ": timed out waiting for delivery")-                    number item =-                        parseEither-                            (withObject "OrderPlaced" (.: "n"))-                            (ackEvent item ^. #payload)-                (stream0, cancelStream) <- subscriptionAckStream store subConfig 4-                ( do-                        (first, stream1) <- pull "initial poison delivery" stream0-                        ackAttempt first `shouldBe` 0-                        number first `shouldBe` Right (0 :: Int)-                        atomically $-                            putTMVar-                                (ackReply first)-                                (KirokuSub.Retry (KirokuSub.RetryDelay 0))--                        (retry, stream2) <- pull "poison redelivery" stream1-                        ackAttempt retry `shouldBe` 1-                        ackEvent retry ^. #eventId `shouldBe` ackEvent first ^. #eventId-                        atomically $-                            putTMVar-                                (ackReply retry)-                                (KirokuSub.Retry (KirokuSub.RetryDelay 0))--                        (next, stream3) <- pull "event after exhausted poison" stream2-                        ackAttempt next `shouldBe` 0-                        number next `shouldBe` Right (1 :: Int)-                        ackEvent next ^. #eventId `shouldNotBe` ackEvent first ^. #eventId-                        atomically (putTMVar (ackReply next) KirokuSub.Stop)-                        ended <- timeout 5_000_000 (Streamly.uncons stream3)-                        case ended of-                            Just Nothing -> pure ()-                            Just (Just _) -> expectationFailure "subscription delivered after Stop"-                            Nothing -> expectationFailure "subscription did not stop after the final acknowledgement"-                    )-                    `finally` cancelStream--                Right rows <--                    Store.runStoreIO store $-                        Store.runTransaction $-                            Tx.statement-                                ("orders-retry-exhaustion", 0)-                                KirokuSQL.readDeadLettersStmt-                case Vector.toList rows of-                    [row] -> do-                        row ^. #deadLetterReason-                            `shouldBe` object-                                [ "kind" Aeson..= ("max_attempts_exceeded" :: Text)-                                , "attempts" Aeson..= (2 :: Int)-                                ]-                        row ^. #deadLetterReasonSummary `shouldBe` "max retry attempts exceeded (2)"-                        row ^. #deadLetterAttemptCount `shouldBe` 2-                    other -> expectationFailure ("expected one Kiroku dead letter, got " <> show (Vector.length rows) <> ": " <> show other)--                _ <- forceFlushMeterProvider provider Nothing-                exported <- readIORef metricsRef-                lookup "keiro.subscription.deadlettered" (flattenScalarPoints exported)-                    `shouldBe` Just (IntNumber 1)-                readIORef forwarded >>= (`shouldSatisfy` (> 1))--    describe "Keiro.Stream" $ do-        it "wraps and unwraps kiroku stream names" $ do-            let orderStream = stream "order-123" :: Stream OrderStream-            Stream.streamName orderStream `shouldBe` StreamName "order-123"-            Stream.streamName (mapStreamName (\(StreamName name) -> StreamName (name <> "-archived")) orderStream)-                `shouldBe` StreamName "order-123-archived"--        it "validates categories, rejecting the dash boundary and reserved names" $ do-            fmap Stream.categoryText (Stream.category "incident" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Right "incident"-            -- compound categories are camelCase; ':' (reserved for the wf: family) is also accepted-            fmap Stream.categoryText (Stream.category "hospitalSurge" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Right "hospitalSurge"-            fmap Stream.categoryText (Stream.category "wf:fulfillment" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Right "wf:fulfillment"-            (Stream.category "" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Left Stream.CategoryEmpty-            (Stream.category "hospital-surge" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Left (Stream.CategoryContainsSeparator "hospital-surge")-            (Stream.category "$all" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Left (Stream.CategoryReserved "$all")-            (Stream.category "ord ers" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Left (Stream.CategoryContainsIllegalChar ' ' "ord ers")-            (Stream.category "ord\ners" :: Either Stream.CategoryError (Stream.StreamCategory ()))-                `shouldBe` Left (Stream.CategoryContainsIllegalChar '\n' "ord\ners")--        it "builds entity streams that round-trip through kiroku's category rule" $ do-            let cat = Stream.categoryUnsafe "orders" :: Stream.StreamCategory OrderStream-            Stream.streamName (Stream.entityStream cat "1") `shouldBe` StreamName "orders-1"-            Stream.categoryName cat `shouldBe` CategoryName "orders"-            -- The category keiro reports equals kiroku's own parse of the produced-            -- name, even when the id segment itself contains a dash.-            Store.categoryName (Stream.streamName (Stream.entityStream cat "a-b-c"))-                `shouldBe` Stream.categoryName cat--        it "entityStreamId renders ids via StreamIdSegment (Text and String)" $ do-            let cat = Stream.categoryUnsafe "orders" :: Stream.StreamCategory OrderStream-            Stream.streamName (Stream.entityStreamId cat ("o-1" :: Text)) `shouldBe` StreamName "orders-o-1"-            Stream.streamName (Stream.entityStreamId cat ("o-1" :: String)) `shouldBe` StreamName "orders-o-1"--        it "rejects blank entity stream id segments" $ do-            let cat = Stream.categoryUnsafe "orders" :: Stream.StreamCategory OrderStream-            evaluate (Stream.streamName (Stream.entityStream cat "")) `shouldThrow` anyErrorCall-            evaluate (Stream.streamName (Stream.entityStream cat "   ")) `shouldThrow` anyErrorCall--    describe "Keiro.DeadLetter" $ around (withFreshStore fixture) $ do-        it "records a dispatch dead letter idempotently" $ \storeHandle -> do-            let deadLetter =-                    DispatchDeadLetter-                        { dispatcherKind = DispatcherProcessManager-                        , dispatcherName = "orders-pm"-                        , correlationId = "order-42"-                        , sourceEventId = EventId sampleUuid-                        , sourceGlobalPosition = GlobalPosition 17-                        , emitIndex = 0-                        , targetStreamName = StreamName "orders-42"-                        , errorClass = "command_rejected"-                        , errorDetail = Text.replicate 1100 "x"-                        , attemptCount = 2-                        }-            Right rows <--                Store.runStoreIO storeHandle $ do-                    recordDispatchDeadLetter deadLetter-                    recordDispatchDeadLetter deadLetter-                    listDispatchDeadLetters "orders-pm"-            case rows of-                [row] -> do-                    row ^. #dispatcherKind `shouldBe` DispatcherProcessManager-                    row ^. #dispatcherName `shouldBe` "orders-pm"-                    row ^. #correlationId `shouldBe` "order-42"-                    row ^. #sourceEventId `shouldBe` EventId sampleUuid-                    row ^. #sourceGlobalPosition `shouldBe` GlobalPosition 17-                    row ^. #emitIndex `shouldBe` 0-                    row ^. #targetStreamName `shouldBe` StreamName "orders-42"-                    row ^. #errorClass `shouldBe` "command_rejected"-                    Text.length (row ^. #errorDetail) `shouldBe` 1024-                    row ^. #attemptCount `shouldBe` 2-                other -> expectationFailure ("expected one idempotent dead-letter row, got " <> show other)--    describe "Keiro.Codec" $ do-        it "encodes current events with type tags and schema-version metadata" $ do-            encoded <- shouldBeRight (encodeForAppend orderCodec (OrderPlaced "order-123" 5))-            encoded ^. #eventType `shouldBe` EventType "OrderPlaced"-            encoded ^. #payload `shouldBe` object ["orderId" Aeson..= ("order-123" :: Text), "quantity" Aeson..= (5 :: Int)]-            extractSchemaVersion (recordedFrom encoded) `shouldBe` Right 2--        it "round-trips current events" $ do-            encoded <- shouldBeRight (encodeForAppend orderCodec (OrderPlaced "order-123" 5))-            decodeRecorded orderCodec (recordedFrom encoded) `shouldBe` Right (OrderPlaced "order-123" 5)--        it "decodes by the stored tag, not by payload shape (H1)" $ do-            let recorded =-                    recordedFrom-                        EventData-                            { eventId = Nothing-                            , eventType = EventType "CounterAudited"-                            , payload = object ["amount" Aeson..= (5 :: Int)]-                            , metadata = Just (metadataForOrDie 1 Nothing)-                            , causationId = Nothing-                            , correlationId = Nothing-                            }-            decodeRecorded counterCodec recorded `shouldBe` Right (CounterAudited 5)--        it "runs upcasters in source-version order" $-            decodeRaw orderCodec (EventType "OrderPlaced") 1 (object ["orderId" Aeson..= ("order-123" :: Text), "qty" Aeson..= (5 :: Int)])-                `shouldBe` Right (OrderPlaced "order-123" 5)--        it "rejects gaps in upcaster chains" $-            decodeRaw gappyCodec (EventType "OrderPlaced") 1 (object ["orderId" Aeson..= ("order-123" :: Text), "qty" Aeson..= (5 :: Int)])-                `shouldBe` Left (GapInUpcasterChain 2 3)--        it "validates codec construction invariants" $ do-            fmap (const ()) (mkCodec (orderCodec{schemaVersion = 0})) `shouldBe` Left (CodecSchemaVersionInvalid 0)-            fmap (const ()) (mkCodec (orderCodec{eventTypes = EventType "OrderPlaced" :| [EventType "OrderPlaced"]}))-                `shouldBe` Left (CodecDuplicateEventTypes [EventType "OrderPlaced"])-            fmap (const ()) (mkCodec (orderCodec{schemaVersion = 3, upcasters = [(1, const upcastOrderPlacedV1), (1, const upcastOrderPlacedV1)]}))-                `shouldBe` Left (CodecDuplicateUpcasterSources [1])-            fmap (const ()) (mkCodec (orderCodec{schemaVersion = 3, upcasters = [(1, const upcastOrderPlacedV1)]}))-                `shouldBe` Left (CodecUpcasterChainIncomplete [2] 3)-            case mkCodec orderCodec of-                Right _ -> pure ()-                Left err -> expectationFailure ("expected orderCodec to validate, got " <> show err)--        it "rejects future-version, malformed metadata, and incomplete upcaster chains" $ do-            let v1Payload = object ["orderId" Aeson..= ("order-123" :: Text), "qty" Aeson..= (5 :: Int)]-                earlyEndCodec =-                    orderCodec-                        { schemaVersion = 4-                        , upcasters = [(1, const upcastOrderPlacedV1), (2, const Right)]-                        }-            decodeRaw orderCodec (EventType "OrderPlaced") 3 v1Payload-                `shouldBe` Left (VersionAhead 3 2)-            decodeRaw earlyEndCodec (EventType "OrderPlaced") 1 v1Payload-                `shouldBe` Left (IncompleteUpcasterChain 3 4)--            let malformedStamp =-                    recordedFrom-                        EventData-                            { eventId = Nothing-                            , eventType = EventType "OrderPlaced"-                            , payload = object ["orderId" Aeson..= ("order-123" :: Text), "quantity" Aeson..= (5 :: Int)]-                            , metadata = Just (object ["schemaVersion" Aeson..= ("2" :: Text)])-                            , causationId = Nothing-                            , correlationId = Nothing-                            }-            extractSchemaVersion malformedStamp-                `shouldBe` Left (MalformedSchemaVersionStamp (Aeson.String "2"))-            fmap (const ()) (encodeForAppendWithMetadata orderCodec (Just (Aeson.String "x")) (OrderPlaced "order-123" 5))-                `shouldBe` Left (NonObjectCallerMetadata (Aeson.String "x"))--        it "rejects recorded events with unknown type tags" $ do-            let encoded =-                    recordedFrom-                        EventData-                            { eventId = Nothing-                            , eventType = EventType "OrderCancelled"-                            , payload = object ["orderId" Aeson..= ("order-123" :: Text)]-                            , metadata = Just (metadataForOrDie 2 Nothing)-                            , causationId = Nothing-                            , correlationId = Nothing-                            }-            decodeRecorded orderCodec encoded-                `shouldBe` Left (UnknownEventType (EventType "OrderCancelled") [EventType "OrderPlaced"])--    describe "Keiro.Codec.Structural" $ do-        let pairBinding :: StructuralBinding (Int, Bool) (Bool, Int)-            pairBinding =-                StructuralBinding-                    { bindingToShape = \(amount, enabled) -> (enabled, amount)-                    , bindingFromShape = \(enabled, amount) -> (amount, enabled)-                    }-            encodePairShape (enabled, amount) =-                object ["enabled" Aeson..= enabled, "amount" Aeson..= amount]-            decodePairShape value =-                case parseEither (withObject "PairShape" $ \objectValue -> (,) <$> objectValue .: "enabled" <*> objectValue .: "amount") value of-                    Left err -> Left (Text.pack err)-                    Right shape -> Right shape--        it "checks both total binding laws" $ do-            bindingDomainRoundTrip pairBinding (7, True) `shouldBe` True-            bindingShapeRoundTrip pairBinding (False, 9) `shouldBe` True--        it "delegates encoding to the generated shape codec" $-            encodeViaBinding pairBinding encodePairShape (7, True)-                `shouldBe` object ["enabled" Aeson..= True, "amount" Aeson..= (7 :: Int)]--        it "propagates only shape decode failures before total construction" $ do-            let encoded = object ["enabled" Aeson..= False, "amount" Aeson..= (9 :: Int)]-            decodeViaBinding pairBinding decodePairShape encoded `shouldBe` Right (9, False)-            decodeViaBinding pairBinding (const (Left "shape-error")) Aeson.Null-                `shouldBe` Left "shape-error"--    describe "Keiro.EventStream" $ do-        it "constructs an author-facing EventStream contract" $ do-            let contract =-                    EventStream-                        { transducer = emptyTransducer-                        , initialState = Idle-                        , initialRegisters = RNil-                        , eventCodec = orderCodec-                        , resolveStreamName = \s -> Stream.streamName s-                        , snapshotPolicy = Never-                        , stateCodec = Nothing-                        }-                typedStream = stream "order-123" :: Stream (EventStream () '[] OrderState OrderCommand OrderEvent)-            contract ^. #initialState `shouldBe` Idle-            (contract ^. #resolveStreamName) typedStream `shouldBe` StreamName "order-123"--        it "evaluates snapshot policies with explicit terminality" $ do-            shouldSnapshot (Every 2) NotTerminal () (StreamVersion 0) `shouldBe` False-            shouldSnapshot (Every 2) NotTerminal () (StreamVersion 2) `shouldBe` True-            shouldSnapshot OnTerminal Terminal () (StreamVersion 1) `shouldBe` True-            shouldSnapshot OnTerminal NotTerminal () (StreamVersion 1) `shouldBe` False-            shouldSnapshot (Custom (\terminality _ _ -> terminality == Terminal)) Terminal () (StreamVersion 1)-                `shouldBe` True-            shouldSnapshot (Custom (\terminality _ _ -> terminality == Terminal)) NotTerminal () (StreamVersion 1)-                `shouldBe` False-            shouldSnapshotSpan (Every 3) NotTerminal () (StreamVersion 2) (StreamVersion 4)-                `shouldBe` True-            shouldSnapshotSpan (Every 3) NotTerminal () (StreamVersion 4) (StreamVersion 5)-                `shouldBe` False--        it "rejects snapshot policies without a state codec" $ do-            let contract :: CounterEventStream-                contract = counterEventStreamDef{snapshotPolicy = Every 10, stateCodec = Nothing}-            fmap (const ()) (mkEventStream "snapshotless" contract)-                `shouldBe` Left [EventStreamWarning "snapshotless" "snapshotPolicy is set but stateCodec is Nothing; snapshots would never be written"]--    describe "EventStream replay-safety (validateEventStream)" $ do-        it "every production-intent stream validates clean" $-            concat-                [ validateEventStream "counter" counterEventStreamDef-                , validateEventStream "counter-no-op" noOpCounterEventStreamDef-                , validateEventStream "counter-multi" multiCounterEventStreamDef-                , validateEventStream "counter-ambiguous" ambiguousCounterEventStreamDef-                , validateEventStream "snapshot-counter" snapshotCounterEventStreamDef-                , validateEventStream "snapshot-counter-multi" multiSnapshotCounterEventStreamDef-                , validateEventStream "snapshot-counter-guarded" guardedSnapshotCounterEventStreamDef-                , validateEventStream "pm-snapshot-counter" pmSnapshotCounterEventStreamDef-                , validateEventStream "rejecting-counter" rejectingEventStreamDef-                ]-                `shouldBe` []--    describe "mkEventStream" $ do-        it "rejects duplicate upcaster sources at the stream boundary" $ do-            let duplicateCodec =-                    counterCodec-                        { schemaVersion = 3-                        , upcasters = [(1, const Right), (1, const Right)]-                        }-                duplicateStream = counterEventStreamDef{eventCodec = duplicateCodec}-            case mkEventStream "duplicate-codec" duplicateStream of-                Left warnings -> do-                    map eswStreamLabel warnings `shouldSatisfy` all (== "duplicate-codec")-                    map eswReason warnings `shouldSatisfy` any (Text.isInfixOf "duplicate upcaster source version(s): 1")-                Right _ -> expectationFailure "expected mkEventStream to reject duplicate upcaster sources"--        it "rejects a missing upcaster rung at the stream boundary" $ do-            let incompleteCodec =-                    counterCodec-                        { schemaVersion = 3-                        , upcasters = [(2, const Right)]-                        }-                incompleteStream = counterEventStreamDef{eventCodec = incompleteCodec}-            case mkEventStream "incomplete-codec" incompleteStream of-                Left warnings -> do-                    map eswStreamLabel warnings `shouldSatisfy` all (== "incomplete-codec")-                    map eswReason warnings `shouldSatisfy` any (Text.isInfixOf "missing upcaster source version(s): 1")-                Right _ -> expectationFailure "expected mkEventStream to reject an incomplete upcaster chain"--        it "includes the stream label when throwing for an invalid codec" $ do-            let incompleteCodec =-                    counterCodec-                        { schemaVersion = 3-                        , upcasters = [(2, const Right)]-                        }-                incompleteStream = counterEventStreamDef{eventCodec = incompleteCodec}-            result <- try @ErrorCall (evaluate (mkEventStreamOrThrow "throwing-incomplete-codec" incompleteStream))-            case result of-                Left err -> do-                    displayException err `shouldSatisfy` isInfixOf "throwing-incomplete-codec"-                    displayException err `shouldSatisfy` isInfixOf "missing upcaster source version(s): 1"-                Right _ -> expectationFailure "expected mkEventStreamOrThrow to reject an incomplete upcaster chain"--        it "keeps invalid codecs available through the unchecked escape hatch" $ do-            let duplicateCodec =-                    counterCodec-                        { schemaVersion = 3-                        , upcasters = [(1, const Right), (1, const Right)]-                        }-                incompleteCodec =-                    counterCodec-                        { schemaVersion = 3-                        , upcasters = [(2, const Right)]-                        }-            _ <- evaluate (mkEventStreamUnchecked counterEventStreamDef{eventCodec = duplicateCodec})-            _ <- evaluate (mkEventStreamUnchecked counterEventStreamDef{eventCodec = incompleteCodec})-            pure ()--        it "rejects a hidden-input stream by label" $ do-            let warns = validateEventStream "broken" brokenHiddenInputEventStream-            warns `shouldNotBe` []-            map eswStreamLabel warns `shouldSatisfy` all (== "broken")-            map eswReason warns `shouldSatisfy` any (Text.isInfixOf "hidden-input")-            case mkEventStream "broken" brokenHiddenInputEventStream of-                Left ws -> do-                    map eswStreamLabel ws `shouldSatisfy` all (== "broken")-                    map eswReason ws `shouldSatisfy` any (Text.isInfixOf "hidden-input")-                Right _ -> expectationFailure "expected mkEventStream to reject the hidden-input stream"--        it "rejects a head-unrecoverable multi-event stream" $-            expectValidationWarning-                "head-unrecoverable"-                "head-unrecoverable"-                headUnrecoverableEventStreamDef--        it "rejects replay inversion ambiguity" $-            expectValidationWarning-                "inversion-ambiguity"-                "inversion-ambiguity"-                inversionAmbiguousEventStreamDef--        it "rejects an unguarded command-field read" $-            expectValidationWarning-                "unguarded-input-read"-                "unguarded-input-read"-                unguardedInputReadEventStreamDef--        it "rejects a silent edge that writes registers" $ do-            Keiki.validateTransducer Keiki.defaultValidationOptions stateChangingEpsilonTransducer-                `shouldSatisfy` any isStateChangingEpsilon-            expectValidationWarning-                "state-changing-epsilon"-                "state-changing-epsilon"-                stateChangingEpsilonEventStreamDef--        it "rejects a silent edge that changes vertex" $ do-            Keiki.validateTransducer Keiki.defaultValidationOptions silentMoveTransducer-                `shouldSatisfy` any isStateChangingEpsilon-            expectValidationWarning-                "silent-move"-                "state-changing-epsilon"-                silentMoveEventStreamDef--        it "keeps replay-contract checks enabled when caller options weaken them" $ do-            case mkEventStreamWith-                Keiki.defaultValidationOptions{Keiki.checkStateChangingEpsilon = False}-                "silent-move-weakened"-                silentMoveEventStreamDef of-                Left warnings ->-                    map eswReason warnings-                        `shouldSatisfy` any (Text.isInfixOf "state-changing-epsilon")-                Right _ -> expectationFailure "expected the durable boundary to restore the state-changing-epsilon check"-            case mkEventStreamWith-                Keiki.defaultValidationOptions{Keiki.checkHeadRecoverability = False}-                "head-unrecoverable-weakened"-                headUnrecoverableEventStreamDef of-                Left warnings ->-                    map eswReason warnings-                        `shouldSatisfy` any (Text.isInfixOf "head-unrecoverable")-                Right _ -> expectationFailure "expected the durable boundary to restore the head-recoverability check"--        it "provides a loudly named unchecked escape hatch" $ do-            _ <- evaluate (mkEventStreamUnchecked silentMoveEventStreamDef)-            pure ()--        it "accepts every production-intent stream" $ do-            let expectAccepted label eventStream =-                    case mkEventStream label eventStream of-                        Right _ -> pure ()-                        Left ws -> expectationFailure ("expected mkEventStream to accept " <> Text.unpack label <> ", got " <> show ws)-            expectAccepted "counter" counterEventStreamDef-            expectAccepted "counter-no-op" noOpCounterEventStreamDef-            expectAccepted "counter-multi" multiCounterEventStreamDef-            expectAccepted "counter-ambiguous" ambiguousCounterEventStreamDef-            expectAccepted "snapshot-counter" snapshotCounterEventStreamDef-            expectAccepted "snapshot-counter-multi" multiSnapshotCounterEventStreamDef-            expectAccepted "snapshot-counter-guarded" guardedSnapshotCounterEventStreamDef-            expectAccepted "pm-snapshot-counter" pmSnapshotCounterEventStreamDef-            expectAccepted "rejecting-counter" rejectingEventStreamDef--        it "rejects a snapshot codec whose initial register file contains an uninitialized slot" $ do-            case mkEventStream "uninitialized-snapshot" uninitializedSnapshotEventStreamDef of-                Left warns -> do-                    map eswStreamLabel warns `shouldSatisfy` all (== "uninitialized-snapshot")-                    map eswReason warns `shouldSatisfy` any (Text.isInfixOf "cannot encode the initial state/registers")-                    map eswReason warns `shouldSatisfy` any (Text.isInfixOf "uninit: neverWritten")-                Right _ -> expectationFailure "expected mkEventStream to reject an uninitialized snapshot register"--        it "accepts the same snapshot stream when every initial register is initialized" $ do-            case mkEventStream "initialized-snapshot" initializedSnapshotEventStreamDef of-                Right _ -> pure ()-                Left warns -> expectationFailure ("expected initialized snapshot registers to validate, got " <> show warns)--        it "rejects a bare EventStream at runCommand (compile-time)" $ do-            (exitCode, _stdout, stderr) <--                readProcessWithExitCode-                    "cabal"-                    [ "exec"-                    , "ghc"-                    , "--"-                    , "-fno-code"-                    , "-package"-                    , "keiro"-                    , "test/ReplaySafetyTypeProbe.hs"-                    ]-                    ""-            exitCode `shouldSatisfy` (/= ExitSuccess)-            stderr `shouldSatisfy` ("ValidatedEventStream" `isInfixOf`)--    describe "Keiro.Command" $ around (withFreshStore fixture) $ do-        it "creates a stream and appends the first command event" $ \storeHandle -> do-            let target = stream "counter-command-create" :: Stream CounterEventStream-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 2)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 1-                    commandResult ^. #eventsAppended `shouldBe` 1-                    commandResult ^. #globalPosition `shouldSatisfy` isJust-                other -> expectationFailure ("expected successful command, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-create") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 1-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 2]--        it "reports no global position for a no-op after prior events" $ \storeHandle -> do-            let target = stream "skip-command-no-op-position" :: Stream SkipEventStream-            Right (Right appended) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions skipEventStream target (SAdd 2)-            appended ^. #globalPosition `shouldSatisfy` isJust-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions skipEventStream target SSkip-            case result of-                Right (Right noOp) -> do-                    noOp ^. #streamVersion `shouldBe` StreamVersion 1-                    noOp ^. #eventsAppended `shouldBe` 0-                    noOp ^. #globalPosition `shouldBe` Nothing-                other -> expectationFailure ("expected successful no-op command, got " <> show other)--        it "surfaces runtime edge ambiguity without appending" $ \storeHandle -> do-            (processor, spansRef) <- inMemoryListExporter-            provider <- createTracerProvider [processor] emptyTracerProviderOptions-            let tracer = makeTracer provider "keiro-test" tracerOptions-                target = stream "counter-command-ambiguous" :: Stream CounterEventStream-                options = defaultRunCommandOptions & #tracer ?~ tracer-            result <--                Store.runStoreIO storeHandle $-                    runCommand options ambiguousCounterEventStream target (Add 1)-            _ <- shutdownTracerProvider provider Nothing-            result `shouldBe` Right (Left (CommandAmbiguous [0, 1]))-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-ambiguous") (StreamVersion 0) 10-            recorded `shouldBe` Vector.empty-            spans <- traverse captureSpan =<< readIORef spansRef-            case spans of-                [sp] -> textAttr (csAttributes sp) "error.type" `shouldBe` Just "command_ambiguous"-                other -> expectationFailure ("expected one span, got " <> show (length other))--        it "rehydrates prior events before appending a second command event" $ \storeHandle -> do-            let target = stream "counter-command-update" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 2)-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 3)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                other -> expectationFailure ("expected successful second command, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-update") (StreamVersion 0) 10-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 2, CounterAdded 3]--        it "rejects hydration after truncation without a covering snapshot" $ \storeHandle -> do-            let target = stream "counter-truncated-uncovered" :: Stream CounterEventStream-                targetName = StreamName "counter-truncated-uncovered"-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 1)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 3)-            Right (Just _) <--                Store.runStoreIO storeHandle $-                    Store.setStreamTruncateBefore targetName (StreamVersion 3)-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 4)-            case result of-                Right (Left (HydrationGapDetected expected observed)) -> do-                    expected `shouldBe` StreamVersion 1-                    observed `shouldBe` StreamVersion 3-                other -> expectationFailure ("expected HydrationGapDetected, got " <> show other)--        it "rejects hydration when truncation lands inside a command batch" $ \storeHandle -> do-            let target = stream "counter-truncated-mid-batch" :: Stream CounterEventStream-                targetName = StreamName "counter-truncated-mid-batch"-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 1)-            Right (Just _) <--                Store.runStoreIO storeHandle $-                    Store.setStreamTruncateBefore targetName (StreamVersion 2)-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 2)-            case result of-                Right (Left (HydrationGapDetected expected observed)) -> do-                    expected `shouldBe` StreamVersion 1-                    observed `shouldBe` StreamVersion 2-                other -> expectationFailure ("expected HydrationGapDetected, got " <> show other)--        it "hydrates normally after truncation covered by a snapshot" $ \storeHandle -> do-            let target = stream "counter-truncated-covered" :: Stream SnapshotCounterEventStream-                targetName = StreamName "counter-truncated-covered"-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 1)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)-            Right (Just _) <--                Store.runStoreIO storeHandle $-                    Store.setStreamTruncateBefore targetName (StreamVersion 2)-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 3-                other -> expectationFailure ("expected snapshot-covered command success, got " <> show other)--        it "uses caller-supplied event ids for idempotent command batches" $ \storeHandle -> do-            let target = stream "counter-command-event-id" :: Stream CounterEventStream-                supplied = EventId sampleUuid2-                options = defaultRunCommandOptions & #eventIds .~ [supplied]-            result <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 7)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 1-                other -> expectationFailure ("expected successful command, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-event-id") (StreamVersion 0) 10-            fmap (^. #eventId) (Vector.toList recorded) `shouldBe` [supplied]--        it "retries an optimistic conflict after rehydrating the winning event" $ \storeHandle -> do-            conflictInserted <- newIORef False-            let target = stream "counter-command-conflict" :: Stream CounterEventStream-                conflictStreamName = StreamName "counter-command-conflict"-                insertConflict = do-                    shouldInsert <- atomicModifyIORef' conflictInserted $ \alreadyInserted ->-                        if alreadyInserted-                            then (True, False)-                            else (True, True)-                    when shouldInsert $ do-                        encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 10))-                        outcome <--                            Store.runStoreIO storeHandle $-                                Store.appendToStream conflictStreamName NoStream [encoded]-                        case outcome of-                            Right _ -> pure ()-                            Left err -> expectationFailure ("failed to insert conflict event: " <> show err)-                options = defaultRunCommandOptions & #beforeAppend .~ insertConflict-            result <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 2)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                    commandResult ^. #eventsAppended `shouldBe` 1-                other -> expectationFailure ("expected retry to succeed, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward conflictStreamName (StreamVersion 0) 10-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 10, CounterAdded 2]--        it "reports true retry attempts and command conflict metrics when the retry budget is exhausted" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "counter-command-exhausted-conflict" :: Stream CounterEventStream-                conflictStreamName = StreamName "counter-command-exhausted-conflict"-                insertConflict = do-                    encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 10))-                    outcome <--                        Store.runStoreIO storeHandle $-                            Store.appendToStream conflictStreamName AnyVersion [encoded]-                    case outcome of-                        Right _ -> pure ()-                        Left err -> expectationFailure ("failed to insert conflict event: " <> show err)-                options =-                    defaultRunCommandOptions-                        & #beforeAppend-                        .~ insertConflict-                        & #retryLimit-                        .~ 2-                        & #retryBackoffMicros-                        .~ 0-                        & #metrics-                        ?~ keiroMetrics-            result <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 2)-            case result of-                Right (Left (RetryExhausted attempts _)) ->-                    attempts `shouldBe` 3-                other -> expectationFailure ("expected exhausted retry budget, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.command.conflicts" scalars `shouldBe` Just (IntNumber 3)-            lookup "keiro.command.retries" scalars `shouldBe` Just (IntNumber 2)--        it "records the successful retry attempt on the command span" $ \storeHandle -> do-            (processor, spansRef) <- inMemoryListExporter-            provider <- createTracerProvider [processor] emptyTracerProviderOptions-            conflictInserted <- newIORef False-            let tracer = makeTracer provider "keiro-test" tracerOptions-                target = stream "counter-command-retry-span" :: Stream CounterEventStream-                conflictStreamName = StreamName "counter-command-retry-span"-                insertConflict = do-                    shouldInsert <- atomicModifyIORef' conflictInserted $ \alreadyInserted ->-                        if alreadyInserted-                            then (True, False)-                            else (True, True)-                    when shouldInsert $ do-                        encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 10))-                        outcome <--                            Store.runStoreIO storeHandle $-                                Store.appendToStream conflictStreamName NoStream [encoded]-                        case outcome of-                            Right _ -> pure ()-                            Left err -> expectationFailure ("failed to insert conflict event: " <> show err)-                options =-                    defaultRunCommandOptions-                        & #beforeAppend-                        .~ insertConflict-                        & #retryBackoffMicros-                        .~ 0-                        & #tracer-                        ?~ tracer-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 2)-            _ <- shutdownTracerProvider provider Nothing-            spans <- traverse captureSpan =<< readIORef spansRef-            case spans of-                [sp] ->-                    case lookupAttribute (csAttributes sp) "keiro.retry.attempt" of-                        Just (AttributeValue (IntAttribute n)) -> n `shouldBe` 2-                        other -> expectationFailure ("expected retry attempt attribute 2, got " <> show other)-                other -> expectationFailure ("expected one span, got " <> show (length other))--        it "counts duplicate deterministic command events" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let supplied = EventId sampleUuid3-                first = stream "counter-command-duplicate-a" :: Stream CounterEventStream-                second = stream "counter-command-duplicate-b" :: Stream CounterEventStream-                options =-                    defaultRunCommandOptions-                        & #eventIds-                        .~ [supplied]-                        & #metrics-                        ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream first (Add 1)-            result <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream second (Add 2)-            case result of-                Right (Left (StoreFailed Store.DuplicateEvent{})) -> pure ()-                other -> expectationFailure ("expected duplicate event failure, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.command.duplicates" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)--        it "fails fast when a soft-deleted stream causes a conflict fixpoint" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "counter-command-soft-deleted" :: Stream CounterEventStream-                options =-                    defaultRunCommandOptions-                        & #retryBackoffMicros-                        .~ 0-                        & #metrics-                        ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 1)-            Right (Just _) <--                Store.runStoreIO storeHandle $-                    Store.softDeleteStream (StreamName "counter-command-soft-deleted")-            result <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 2)-            case result of-                Right (Left (ConflictFixpoint (StreamVersion 0) Store.StreamAlreadyExists{})) -> pure ()-                other -> expectationFailure ("expected conflict fixpoint, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.command.conflicts" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)--        it "surfaces decode failure during hydration" $ \storeHandle -> do-            Right _ <--                Store.runStoreIO storeHandle $-                    Store.appendToStream-                        (StreamName "counter-command-decode-failure")-                        NoStream-                        [ EventData-                            { eventId = Nothing-                            , eventType = EventType "OtherEvent"-                            , payload = object []-                            , metadata = Just (metadataForOrDie 1 Nothing)-                            , causationId = Nothing-                            , correlationId = Nothing-                            }-                        ]-            let target = stream "counter-command-decode-failure" :: Stream CounterEventStream-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 1)-            result-                `shouldBe` Right-                    (Left (HydrationDecodeFailed (UnknownEventType (EventType "OtherEvent") [EventType "CounterAdded", EventType "CounterAudited"])))--        it "surfaces a typed no-inverting-edge hydration failure" $ \storeHandle -> do-            let targetStreamName = StreamName "counter-command-no-inverting-edge"-                target = stream "counter-command-no-inverting-edge" :: Stream CounterEventStream-            appendCounterEvents storeHandle targetStreamName [CounterAudited 7]-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 1)-            result-                `shouldBe` Right-                    (Left (HydrationReplayFailed (StreamVersion 1) HydrationNoInvertingEdge))--        it "fails hydration after guard tightening without a replay-only twin (plan 143 reproduction)" $ \storeHandle -> do-            let target = stream "divert-black-acuity-bad" :: Stream DivertEventStream-            Right (Right appended) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)-            appended ^. #streamVersion `shouldBe` StreamVersion 1-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions tightenedDivertEventStream target (ConfirmDivert False)-            result-                `shouldBe` Right-                    (Left (HydrationReplayFailed (StreamVersion 1) HydrationNoInvertingEdge))--        it "replays black-acuity history through the replay-only twin and keeps serving the live rule" $ \storeHandle -> do-            let target = stream "divert-black-acuity-good" :: Stream DivertEventStream-            Right (Right appended) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)-            appended ^. #streamVersion `shouldBe` StreamVersion 1-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions twinDivertEventStream target (ConfirmDivert False)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                    commandResult ^. #eventsAppended `shouldBe` 1-                other ->-                    expectationFailure ("expected hydration through the twin to succeed, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "divert-black-acuity-good") (StreamVersion 0) 10-            traverse (decodeRecorded divertCodec) (Vector.toList recorded)-                `shouldBe` Right [DivertConfirmed True, DivertConfirmed False]--        it "rejects a new command in the removed region under the twin-bearing machine" $ \storeHandle -> do-            let target = stream "divert-black-acuity-removed" :: Stream DivertEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions twinDivertEventStream target (ConfirmDivert True)-            result `shouldBe` Right (Left CommandRejected)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "divert-black-acuity-removed") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 1--        it "surfaces a typed queue-mismatch hydration failure with the failing version" $ \storeHandle -> do-            let targetStreamName = StreamName "counter-command-queue-mismatch"-                target = stream "counter-command-queue-mismatch" :: Stream CounterEventStream-            appendCounterEvents storeHandle targetStreamName [CounterAdded 5, CounterAudited 6]-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 1)-            result-                `shouldBe` Right-                    (Left (HydrationReplayFailed (StreamVersion 2) HydrationQueueMismatch))--        it "surfaces a truncated multi-event chain as HydrationTruncatedChain" $ \storeHandle -> do-            let targetStreamName = StreamName "counter-command-truncated-chain"-                target = stream "counter-command-truncated-chain" :: Stream CounterEventStream-            appendCounterEvents storeHandle targetStreamName [CounterAdded 5]-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 1)-            result-                `shouldBe` Right-                    (Left (HydrationReplayFailed (StreamVersion 1) HydrationTruncatedChain))--        it "surfaces ambiguous inversion during hydration" $ \storeHandle -> do-            let targetStreamName = StreamName "counter-command-ambiguous-inversion"-                target = stream "counter-command-ambiguous-inversion" :: Stream CounterEventStream-            appendCounterEvents storeHandle targetStreamName [CounterAdded 3]-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions inversionAmbiguousEventStream target (Add 1)-            result-                `shouldBe` Right-                    (Left (HydrationReplayFailed (StreamVersion 1) HydrationAmbiguousInversion))--        it "truncates command span error status descriptions" $ \storeHandle -> do-            (processor, spansRef) <- inMemoryListExporter-            provider <- createTracerProvider [processor] emptyTracerProviderOptions-            let tracer = makeTracer provider "keiro-test" tracerOptions-                longTag = Text.replicate 400 "x"-            Right _ <--                Store.runStoreIO storeHandle $-                    Store.appendToStream-                        (StreamName "counter-command-long-decode-failure")-                        NoStream-                        [ EventData-                            { eventId = Nothing-                            , eventType = EventType longTag-                            , payload = object []-                            , metadata = Just (metadataForOrDie 1 Nothing)-                            , causationId = Nothing-                            , correlationId = Nothing-                            }-                        ]-            let target = stream "counter-command-long-decode-failure" :: Stream CounterEventStream-                options = defaultRunCommandOptions & #tracer ?~ tracer-            _ <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 1)-            _ <- shutdownTracerProvider provider Nothing-            spans <- traverse captureSpan =<< readIORef spansRef-            case spans of-                [sp] ->-                    case csStatus sp of-                        Error description -> Text.length description `shouldSatisfy` (<= 256)-                        other -> expectationFailure ("expected error span status, got " <> show other)-                other -> expectationFailure ("expected one span, got " <> show (length other))--        it "rolls back the append when inline SQL condemns the transaction" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                let target = stream "counter-command-rollback" :: Stream CounterEventStream-                result <--                    runner $-                        runCommandWithSql-                            defaultRunCommandOptions-                            counterEventStream-                            target-                            (Add 1)-                            (\_ -> Tx.condemn >> pure ("rolled-back" :: Text))-                case result of-                    Right (Right (_, Just "rolled-back")) -> pure ()-                    other -> expectationFailure ("expected condemned transaction result, got " <> show other)-                Right recorded <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "counter-command-rollback") (StreamVersion 0) 10-                recorded `shouldBe` Vector.empty--        it "appends all events emitted by one accepted command" $ \storeHandle -> do-            let target = stream "counter-command-multi-create" :: Stream CounterEventStream-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 5)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                    commandResult ^. #eventsAppended `shouldBe` 2-                    commandResult ^. #globalPosition `shouldSatisfy` isJust-                other -> expectationFailure ("expected successful multi-event command, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-multi-create") (StreamVersion 0) 10-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 5, CounterAudited 5]--        it "counts and traces a just-appended batch that cannot replay" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (metricProvider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter metricProvider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            (processor, spansRef) <- inMemoryListExporter-            tracerProvider <- createTracerProvider [processor] emptyTracerProviderOptions-            let tracer = makeTracer tracerProvider "keiro-test" tracerOptions-                target = stream "counter-command-replay-divergence" :: Stream CounterEventStream-                options =-                    defaultRunCommandOptions-                        & #metrics-                        ?~ keiroMetrics-                        & #tracer-                        ?~ tracer-            Right (Right commandResult) <--                Store.runStoreIO storeHandle $-                    runCommand options headUnrecoverableEventStream target (Add 2)-            commandResult ^. #streamVersion `shouldBe` StreamVersion 2-            commandResult ^. #eventsAppended `shouldBe` 2-            _ <- forceFlushMeterProvider metricProvider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.snapshot.apply.divergence" (flattenScalarPoints exported)-                `shouldBe` Just (IntNumber 1)-            next <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions headUnrecoverableEventStream target (Add 3)-            case next of-                Right (Left HydrationReplayFailed{}) -> pure ()-                other -> expectationFailure ("expected the witnessed divergence to poison hydration, got " <> show other)-            _ <- shutdownTracerProvider tracerProvider Nothing-            spans <- traverse captureSpan =<< readIORef spansRef-            case spans of-                [sp] ->-                    textAttr (csAttributes sp) "keiro.replay.divergence"-                        `shouldBe` Just "event_index=0;reason=no_inverting_edge"-                other -> expectationFailure ("expected one divergence span, got " <> show (length other))--        it "skips replay verification for a snapshot-less stream when disabled" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "counter-command-replay-divergence-disabled" :: Stream CounterEventStream-                options =-                    defaultRunCommandOptions-                        & #metrics-                        ?~ keiroMetrics-                        & #verifyReplayOnAppend-                        .~ False-            Right (Right commandResult) <--                Store.runStoreIO storeHandle $-                    runCommand options headUnrecoverableEventStream target (Add 2)-            commandResult ^. #eventsAppended `shouldBe` 2-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.snapshot.apply.divergence" (flattenScalarPoints exported)-                `shouldBe` Nothing--        it "witnesses replay divergence on the transactional SQL append path" $ \_ ->-            withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do-                (exporter, metricsRef) <- inMemoryMetricExporter-                (provider, _env) <--                    createMeterProvider-                        emptyMaterializedResources-                        defaultSdkMeterProviderOptions{metricExporter = Just exporter}-                meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-                keiroMetrics <- Telemetry.newKeiroMetrics meter-                let target = stream "counter-command-replay-divergence-sql" :: Stream CounterEventStream-                    options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-                Right (Right (commandResult, Just ())) <--                    runner $-                        runCommandWithSqlEvents-                            options-                            headUnrecoverableEventStream-                            target-                            (Add 2)-                            (\_ _ -> pure ())-                commandResult ^. #eventsAppended `shouldBe` 2-                _ <- forceFlushMeterProvider provider Nothing-                exported <- readIORef metricsRef-                lookup "keiro.snapshot.apply.divergence" (flattenScalarPoints exported)-                    `shouldBe` Just (IntNumber 1)--        it "replays a prior multi-event command before appending the next batch" $ \storeHandle -> do-            let target = stream "counter-command-multi-replay" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 2)-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiCounterEventStream target (Add 3)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 4-                    commandResult ^. #eventsAppended `shouldBe` 2-                other -> expectationFailure ("expected successful second multi-event command, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-multi-replay") (StreamVersion 0) 10-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 2, CounterAudited 2, CounterAdded 3, CounterAudited 3]--        it "passes the complete multi-event batch to inline SQL in append order" $ \_ ->-            withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do-                let target = stream "counter-command-multi-sql-events" :: Stream CounterEventStream-                result <--                    runner $-                        runCommandWithSqlEvents-                            defaultRunCommandOptions-                            multiCounterEventStream-                            target-                            (Add 8)-                            (\pairs _ -> pure (Prelude.map Prelude.fst pairs))-                case result of-                    Right (Right (commandResult, Just observed)) -> do-                        commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                        commandResult ^. #eventsAppended `shouldBe` 2-                        observed `shouldBe` [CounterAdded 8, CounterAudited 8]-                    other -> expectationFailure ("expected successful SQL multi-event command, got " <> show other)--        it "command metadata is merged into stored event metadata" $ \storeHandle -> do-            let target = stream "counter-command-metadata" :: Stream CounterEventStream-                opts =-                    defaultRunCommandOptions-                        & #metadata-                        ?~ object ["actor" Aeson..= ("agent-7" :: Text)]-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand opts counterEventStream target (Add 4)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "counter-command-metadata") (StreamVersion 0) 10-            case Vector.toList recorded of-                [event] ->-                    event ^. #metadata-                        `shouldBe` Just (object ["actor" Aeson..= ("agent-7" :: Text), "schemaVersion" Aeson..= (1 :: Int)])-                other -> expectationFailure ("expected a single recorded event, got " <> show other)--        it "reconstructed RecordedEvents match the stored batch" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                let target = stream "counter-reconstruct-fidelity" :: Stream CounterEventStream-                    opts =-                        defaultRunCommandOptions-                            & #metadata-                            ?~ object ["actor" Aeson..= ("agent-7" :: Text)]-                Right (Right (_, Just pairs)) <--                    runner $-                        runCommandWithSqlEvents opts multiCounterEventStream target (Add 8) (\ps _ -> pure ps)-                let reconstructed = Prelude.map Prelude.snd pairs-                -- Read the stored events back from their source stream.-                Right storedVec <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "counter-reconstruct-fidelity") (StreamVersion 0) 10-                let stored = Vector.toList storedVec-                -- readStreamForward reports globalPosition 0 for stream reads, so take-                -- the true global positions from a category read (the DB is fresh per-                -- test, so category "counter" holds exactly this batch).-                Right catVec <--                    Store.runStoreIO storeHandle $-                        Store.readCategory (CategoryName "counter") (GlobalPosition 0) 10-                let catList = Vector.toList catVec-                Prelude.length reconstructed `shouldBe` 2-                Prelude.length stored `shouldBe` 2-                fmap (^. #eventId) reconstructed `shouldBe` fmap (^. #eventId) stored-                fmap (^. #eventType) reconstructed `shouldBe` fmap (^. #eventType) stored-                fmap (^. #streamVersion) reconstructed `shouldBe` fmap (^. #streamVersion) stored-                fmap (^. #originalVersion) reconstructed `shouldBe` fmap (^. #originalVersion) stored-                fmap (^. #originalStreamId) reconstructed `shouldBe` fmap (^. #originalStreamId) stored-                fmap (^. #payload) reconstructed `shouldBe` fmap (^. #payload) stored-                fmap (^. #metadata) reconstructed `shouldBe` fmap (^. #metadata) stored-                fmap (^. #globalPosition) reconstructed `shouldBe` fmap (^. #globalPosition) catList--        it "runCommand emits a Command span with the stream name, db.system.name, and keiro.events.appended" $ \storeHandle -> do-            (processor, spansRef) <- inMemoryListExporter-            provider <- createTracerProvider [processor] emptyTracerProviderOptions-            let tracer = makeTracer provider "keiro-test" tracerOptions-                target = stream "counter-command-otel" :: Stream CounterEventStream-                options = defaultRunCommandOptions & #tracer ?~ tracer-            Right (Right commandResult) <--                Store.runStoreIO storeHandle $-                    runCommand options counterEventStream target (Add 9)-            commandResult ^. #streamVersion `shouldBe` StreamVersion 1-            _ <- shutdownTracerProvider provider Nothing-            spans <- traverse captureSpan =<< readIORef spansRef-            length spans `shouldBe` 1-            let sp = case spans of-                    (s : _) -> s-                    [] -> error "no command span captured"-            csName sp `shouldBe` "counter-command-otel"-            show (csKind sp) `shouldBe` "Internal"-            textAttr (csAttributes sp) "keiro.stream.name" `shouldBe` Just "counter-command-otel"-            textAttr (csAttributes sp) "db.system.name" `shouldBe` Just "postgresql"-            -- keiro.events.appended is an Int64 attribute, not Text.-            case lookupAttribute (csAttributes sp) "keiro.events.appended" of-                Just (AttributeValue (IntAttribute n)) -> n `shouldBe` 1-                other -> expectationFailure ("expected IntAttribute 1, got " <> show other)-            case csStatus sp of-                Unset -> pure ()-                Ok -> pure ()-                other -> expectationFailure ("expected Unset/Ok, got " <> show other)--    describe "Keiro.Command enrichment parity" $ do-        let addMarker eventData = pure (eventData & #metadata %~ injectMarker)-            injectMarker = \case-                Just (Aeson.Object fields) ->-                    Just (Aeson.Object (KeyMap.insert "enriched" (Aeson.Bool True) fields))-                _ -> Just (object ["enriched" Aeson..= True])-            installHook = #storeSettings . #enrichEvent ?~ addMarker-            hasMarker = \case-                Just (Aeson.Object fields) ->-                    KeyMap.lookup "enriched" fields == Just (Aeson.Bool True)-                _ -> False-        around (withFreshResourceStoreWith fixture installHook) $-            it "applies the store enrichment hook to both command append paths" $ \(_storeHandle, StoreRunner runner) -> do-                let plainTarget = stream "enrich-plain" :: Stream CounterEventStream-                    transactionalTarget = stream "enrich-transactional" :: Stream CounterEventStream-                Right (Right _) <--                    runner $-                        runCommand defaultRunCommandOptions counterEventStream plainTarget (Add 1)-                Right (Right (_, Just callbackRecordeds)) <--                    runner $-                        runCommandWithSqlEvents-                            defaultRunCommandOptions-                            counterEventStream-                            transactionalTarget-                            (Add 1)-                            (\pairs _ -> pure (fmap snd pairs))-                Right plainEvents <--                    runner $-                        Store.readStreamForward (StreamName "enrich-plain") (StreamVersion 0) 10-                Right transactionalEvents <--                    runner $-                        Store.readStreamForward (StreamName "enrich-transactional") (StreamVersion 0) 10-                for_ (Vector.toList plainEvents <> Vector.toList transactionalEvents) $ \recorded ->-                    recorded ^. #metadata `shouldSatisfy` hasMarker-                for_ callbackRecordeds $ \recorded ->-                    recorded ^. #metadata `shouldSatisfy` hasMarker--    describe "Keiro.Snapshot" $ around (withFreshStore fixture) $ do-        it "reports an ErrorCall when strict encoding reaches an empty register slot" $ \_storeHandle -> do-            result <--                encodeSnapshotStrict-                    (defaultStateCodec @SnapshotCounterRegs @CounterState 1)-                    (Counting, emptyRegFile @SnapshotCounterRegs)-            case result of-                Left err -> displayException err `shouldSatisfy` isInfixOf "uninit: lastAmount"-                Right _ -> expectationFailure "expected strict snapshot encoding to fail on an empty register slot"--        it "writes a snapshot after policy threshold" $ \storeHandle -> do-            let target = stream "snapshot-write-threshold" :: Stream SnapshotCounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)-            Right snapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-write-threshold" snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 2)--        it "does not fail a committed command when the post-commit snapshot write fails" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "snapshot-write-failure-swallowed" :: Stream SnapshotCounterEventStream-                options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 2)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "ALTER TABLE keiro.keiro_snapshots ADD CONSTRAINT keiro_snapshots_no_writes CHECK (false) NOT VALID"-            result <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 3)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                    commandResult ^. #eventsAppended `shouldBe` 1-                other -> expectationFailure ("expected committed command despite snapshot failure, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "snapshot-write-failure-swallowed") (StreamVersion 0) 10-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 2, CounterAdded 3]-            Right snapshotVersionDuringFailure <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-write-failure-swallowed" snapshotVersionForStreamStmt-            snapshotVersionDuringFailure `shouldBe` Nothing-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.snapshot.write.failures" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "ALTER TABLE keiro.keiro_snapshots DROP CONSTRAINT keiro_snapshots_no_writes"-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 4)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 5)-            Right snapshotVersionAfterRecovery <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-write-failure-swallowed" snapshotVersionForStreamStmt-            snapshotVersionAfterRecovery `shouldBe` Just (StreamVersion 4)--        it "does not fail a committed command when strict snapshot encoding fails" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "snapshot-encode-failure-swallowed" :: Stream PartialSnapshotEventStream-                options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-            result <--                Store.runStoreIO storeHandle $-                    runCommand options partialSnapshotEventStream target (Add 7)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 1-                    commandResult ^. #eventsAppended `shouldBe` 1-                other -> expectationFailure ("expected committed command despite snapshot encode failure, got " <> show other)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "snapshot-encode-failure-swallowed") (StreamVersion 0) 10-            traverse (decodeRecorded counterCodec) (Vector.toList recorded)-                `shouldBe` Right [CounterAdded 7]-            Right snapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-encode-failure-swallowed" snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Nothing-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.snapshot.encode.failures" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.snapshot.write.failures" scalars `shouldBe` Nothing--        it "hydrates from snapshot and replays only the tail" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "snapshot-tail-hydration" :: Stream SnapshotCounterEventStream-                options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 3)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement-                            ( "snapshot-tail-hydration"-                            , (defaultStateCodec @SnapshotCounterRegs @CounterState 1 ^. #encode)-                                (Counting, RCons (Proxy @"lastAmount") 4 RNil)-                            )-                            corruptSnapshotStateStmt-            result <--                Store.runStoreIO storeHandle $-                    runCommand options guardedSnapshotCounterEventStream target (Add 4)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 3-                other -> expectationFailure ("expected snapshot-assisted command, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.snapshot.read.hits" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)--        it "falls back when snapshot JSON is corrupt" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "snapshot-corrupt-json" :: Stream SnapshotCounterEventStream-                options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 3)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("snapshot-corrupt-json", Aeson.String "bad") corruptSnapshotStateStmt-            result <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 4)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 3-                other -> expectationFailure ("expected corrupt snapshot fallback, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 3)--        it "falls back when shape hash mismatches" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "snapshot-shape-mismatch" :: Stream SnapshotCounterEventStream-                options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 3)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("snapshot-shape-mismatch", "stale-shape") corruptSnapshotShapeStmt-            result <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 4)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 3-                other -> expectationFailure ("expected stale shape fallback, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 3)-            lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Nothing--        it "invalidates a snapshot when the control-state shape changes" $ \storeHandle -> do-            let targetStreamName = StreamName "snapshot-state-shape-change"-                target = stream "snapshot-state-shape-change" :: Stream SnapshotCounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)-            lookupResult <--                Store.runStoreIO storeHandle $-                    lookupSnapshotSeed-                        targetStreamName-                        (defaultStateCodec @SnapshotCounterRegs @CounterStateV2 1)-            case lookupResult of-                Right (SnapshotUnavailable SnapshotNotFound) -> pure ()-                _ -> expectationFailure "expected the changed control-state shape to miss the stored snapshot"--        it "uses the fold fingerprint as a snapshot discriminator" $ \storeHandle -> do-            let targetStreamName = StreamName "snapshot-fold-fingerprint-lookup"-                target = stream "snapshot-fold-fingerprint-lookup" :: Stream SnapshotCounterEventStream-                foldV1Codec =-                    defaultStateCodecWithFold-                        @SnapshotCounterRegs-                        @CounterState-                        (FoldVersion "fold-v1")-                        1-                foldV2Codec =-                    defaultStateCodecWithFold-                        @SnapshotCounterRegs-                        @CounterState-                        (FoldVersion "fold-v2")-                        1-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)-            sameFingerprint <- Store.runStoreIO storeHandle $ lookupSnapshotSeed targetStreamName foldV1Codec-            case sameFingerprint of-                Right (SnapshotHit seed) -> seed ^. #streamVersion `shouldBe` StreamVersion 2-                _ -> expectationFailure "expected an equal fold fingerprint to reuse the snapshot"-            changedFingerprint <- Store.runStoreIO storeHandle $ lookupSnapshotSeed targetStreamName foldV2Codec-            case changedFingerprint of-                Right (SnapshotUnavailable SnapshotNotFound) -> pure ()-                _ -> expectationFailure "expected a changed fold fingerprint to miss the snapshot"--        it "composes the hand-owned fold version into the state discriminator" $ \_storeHandle -> do-            let plain = defaultStateCodec @SnapshotCounterRegs @CounterState 1-                withFold =-                    defaultStateCodecWithFold-                        @SnapshotCounterRegs-                        @CounterState-                        (FoldVersion "fold-v1")-                        1-            withFold ^. #stateShapeHash `shouldBe` (plain ^. #stateShapeHash <> ";fold=fold-v1")-            withFold ^. #stateCodecVersion `shouldBe` plain ^. #stateCodecVersion-            withFold ^. #shapeHash `shouldBe` plain ^. #shapeHash--        it "full-replays under a changed fold and persists the new discriminator" $ \storeHandle -> do-            let targetStreamName = "snapshot-fold-fingerprint-e2e"-                target = stream targetStreamName :: Stream SnapshotCounterEventStream-                candidateCodec =-                    defaultStateCodecWithFold-                        @SnapshotCounterRegs-                        @CounterState-                        (FoldVersion "fold-v2")-                        1-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)-            case Keiki.applyEventsEither-                foldV2SnapshotCounterTransducer-                (Counting, RCons (Proxy @"lastAmount") 0 RNil)-                [CounterAdded 2, CounterAdded 3] of-                Right (_, RCons _ fullReplayLastAmount RNil) ->-                    fullReplayLastAmount `shouldBe` 4-                Left failure ->-                    expectationFailure ("expected full replay under fold v2, got " <> show failure)-            candidateResult <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV2SnapshotCounterEventStream target (Add 104)-            case candidateResult of-                Right (Right result) -> do-                    result ^. #streamVersion `shouldBe` StreamVersion 3-                    result ^. #eventsAppended `shouldBe` 1-                other -> expectationFailure ("expected changed-fold full replay to accept probe command, got " <> show other)-            Right storedStateShape <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement targetStreamName snapshotStateShapeForStreamStmt-            storedStateShape `shouldBe` Just (candidateCodec ^. #stateShapeHash)--        it "pins the manual-contract hazard when fold logic changes without a discriminator bump" $ \storeHandle -> do-            let targetStreamName = StreamName "snapshot-fold-manual-contract"-                target = stream "snapshot-fold-manual-contract" :: Stream SnapshotCounterEventStream-                unchangedCodec =-                    defaultStateCodecWithFold-                        @SnapshotCounterRegs-                        @CounterState-                        (FoldVersion "fold-v1")-                        1-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)-            staleSeed <- Store.runStoreIO storeHandle $ lookupSnapshotSeed targetStreamName unchangedCodec-            case staleSeed of-                Right (SnapshotHit seed) ->-                    case seed ^. #registers of-                        RCons _ staleLastAmount RNil -> staleLastAmount `shouldBe` 3-                _ -> expectationFailure "expected the unchanged discriminator to serve the stale seed"-            residualResult <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV2WithoutFingerprintBumpEventStream target (Add 104)-            residualResult `shouldBe` Right (Left CommandRejected)--        it "samples a stale accepted seed without failing the command or writing a snapshot" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let targetName = "snapshot-seed-sampled-divergence"-                target = stream targetName :: Stream SnapshotCounterEventStream-                candidateStream :: ValidatedSnapshotCounterEventStream-                candidateStream =-                    mkEventStreamOrThrow-                        "snapshot-counter-fold-v2-sampled"-                        (foldV2WithoutFingerprintBumpEventStreamDef & #snapshotPolicy .~ Never)-                options =-                    defaultRunCommandOptions-                        & #metrics-                        ?~ keiroMetrics-                        & #seedVerifySampleRate-                        .~ 1-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)-            result <--                Store.runStoreIO storeHandle $-                    runCommand options candidateStream target (Add 4)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 3-                    commandResult ^. #eventsAppended `shouldBe` 1-                other -> expectationFailure ("expected sampled verification to stay advisory, got " <> show other)-            observed <--                timeout 5_000_000 $-                    let awaitDivergence = do-                            _ <- forceFlushMeterProvider provider Nothing-                            exported <- readIORef metricsRef-                            case lookup "keiro.snapshot.seed.divergence" (flattenScalarPoints exported) of-                                Just (IntNumber 1) -> pure ()-                                _ -> threadDelay 10_000 >> awaitDivergence-                     in awaitDivergence-            observed `shouldBe` Just ()-            Right snapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement targetName snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 2)--        it "disables sampled seed verification at rate zero" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let targetName = "snapshot-seed-sampling-disabled"-                target = stream targetName :: Stream SnapshotCounterEventStream-                candidateStream :: ValidatedSnapshotCounterEventStream-                candidateStream =-                    mkEventStreamOrThrow-                        "snapshot-counter-fold-v2-sampling-disabled"-                        (foldV2WithoutFingerprintBumpEventStreamDef & #snapshotPolicy .~ Never)-                options =-                    defaultRunCommandOptions-                        & #metrics-                        ?~ keiroMetrics-                        & #seedVerifySampleRate-                        .~ 0-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)-            Right (Right commandResult) <--                Store.runStoreIO storeHandle $-                    runCommand options candidateStream target (Add 4)-            commandResult ^. #streamVersion `shouldBe` StreamVersion 3-            threadDelay 100_000-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.snapshot.seed.divergence" (flattenScalarPoints exported) `shouldBe` Nothing-            Right snapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement targetName snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 2)--        it "falls back after operator truncation" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let target = stream "snapshot-operator-truncate" :: Stream SnapshotCounterEventStream-                options = defaultRunCommandOptions & #metrics ?~ keiroMetrics-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 3)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "TRUNCATE keiro.keiro_snapshots"-            result <--                Store.runStoreIO storeHandle $-                    runCommand options snapshotCounterEventStream target (Add 4)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 3-                other -> expectationFailure ("expected truncation fallback, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 3)-            lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Nothing--        it "writes snapshots after applying a complete multi-event command batch" $ \storeHandle -> do-            let target = stream "snapshot-multi-event-batch" :: Stream SnapshotCounterEventStream-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions multiSnapshotCounterEventStream target (Add 9)-            case result of-                Right (Right commandResult) -> do-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 2-                    commandResult ^. #eventsAppended `shouldBe` 2-                other -> expectationFailure ("expected multi-event snapshot command, got " <> show other)-            Right snapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-multi-event-batch" snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 2)--        it "writes a snapshot when a multi-event append crosses an Every boundary" $ \storeHandle -> do-            let target = stream "snapshot-multi-event-crosses-boundary" :: Stream SnapshotCounterEventStream-                boundaryEventStream :: SnapshotCounterEventStream-                boundaryEventStream =-                    snapshotCounterEventStreamDef-                        & #transducer-                        .~ multiSnapshotCounterTransducer-                        & #snapshotPolicy-                        .~ Every 3-                validatedBoundaryEventStream = mkEventStreamOrThrow "snapshot-multi-event-crosses-boundary" boundaryEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions validatedBoundaryEventStream target (Add 2)-            Right firstSnapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-multi-event-crosses-boundary" snapshotVersionForStreamStmt-            firstSnapshotVersion `shouldBe` Nothing-            result <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions validatedBoundaryEventStream target (Add 3)-            case result of-                Right (Right commandResult) ->-                    commandResult ^. #streamVersion `shouldBe` StreamVersion 4-                other -> expectationFailure ("expected successful boundary-crossing command, got " <> show other)-            Right snapshotVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-multi-event-crosses-boundary" snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 4)--        it "allows an incompatible snapshot codec to replace a higher-version row" $ \storeHandle -> do-            let target = stream "snapshot-codec-rollback-overwrite" :: Stream SnapshotCounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 1)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 4)-            Right snapshotVersionBefore <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-codec-rollback-overwrite" snapshotVersionForStreamStmt-            snapshotVersionBefore `shouldBe` Just (StreamVersion 4)-            let rollbackCodec = defaultStateCodec @SnapshotCounterRegs @CounterState 2-            streamId <--                Store.runStoreIO storeHandle (Store.lookupStreamId (StreamName "snapshot-codec-rollback-overwrite")) >>= \case-                    Right (Just sid) -> pure sid-                    other -> expectationFailure ("expected stream id, got " <> show other) *> error "unreachable"-            Right () <--                Store.runStoreIO storeHandle $-                    writeSnapshotRow-                        SnapshotWrite-                            { streamId = streamId-                            , streamVersion = StreamVersion 2-                            , state = (rollbackCodec ^. #encode) (Counting, RCons (Proxy @"lastAmount") 2 RNil)-                            , stateCodecVersion = rollbackCodec ^. #stateCodecVersion-                            , regfileShapeHash = rollbackCodec ^. #shapeHash-                            , stateShapeHash = rollbackCodec ^. #stateShapeHash-                            }-            Right snapshotVersionAfter <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "snapshot-codec-rollback-overwrite" snapshotVersionForStreamStmt-            snapshotVersionAfter `shouldBe` Just (StreamVersion 2)--    describe "Keiro.ReplayAudit" $ around (withFreshStore fixture) $ do-        it "accepts only stream names in the configured category" $ \_ -> do-            ReplayAudit.streamInCategory "counter" (StreamName "counter-one")-                `shouldBe` (Just (Stream.Stream (StreamName "counter-one")) :: Maybe (Stream ()))-            ReplayAudit.streamInCategory "counter" (StreamName "other-one")-                `shouldBe` (Nothing :: Maybe (Stream ()))--        it "catches a removed inverting edge while skipping unaffected streams" $ \storeHandle -> do-            let affectedTarget =-                    stream "auditremove-affected" :: Stream CounterEventStream-                unaffectedTarget =-                    stream "auditremove-unaffected" :: Stream CounterEventStream-                affected =-                    ReplayAudit.AffectedSet-                        { affectedEventTypes = Set.singleton (EventType "CounterAdded")-                        , includeSnapshotStreams = False-                        }-                budget = ReplayAudit.defaultAuditBudget & #parallelism .~ 2-                candidateTarget =-                    ReplayAudit.AuditTarget-                        { eventStream = auditedCounterEventStream-                        , category = "auditremove"-                        , mkStream = Just . Stream.Stream-                        }-                deployedTarget =-                    ReplayAudit.AuditTarget-                        { eventStream = counterEventStream-                        , category = "auditremove"-                        , mkStream = Just . Stream.Stream-                        }-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream affectedTarget (Add 7)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions auditedCounterEventStream unaffectedTarget (Add 9)--            Right candidateReport <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams-                        (ReplayAudit.AuditTargeted affected)-                        budget-                        candidateTarget-            candidateReport ^. #streamsSelected `shouldBe` 1-            candidateReport ^. #streamsSkipped `shouldBe` 1-            candidateReport ^. #failures `shouldBe` 1-            candidateReport ^. #divergences `shouldBe` 0-            candidateReport ^. #rejectedStreams `shouldBe` []-            case candidateReport ^. #results of-                [ ReplayAudit.StreamAuditResult-                        _-                        ( ReplayAudit.ReplayFailed-                                (HydrationReplayFailed _ HydrationNoInvertingEdge)-                            )-                    ] -> pure ()-                other ->-                    expectationFailure-                        ("expected a no-inverting-edge audit failure, got " <> show other)--            Right deployedReport <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams-                        (ReplayAudit.AuditTargeted affected)-                        budget-                        deployedTarget-            ReplayAudit.auditExitCode [deployedReport] `shouldBe` 0--            Right eventsAfterAudit <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward-                        (StreamName "auditremove-affected")-                        (StreamVersion 0)-                        10-            Vector.length eventsAfterAudit `shouldBe` 1--        it "proves a replay-only twin preserves the stored guard-tightening history" $ \storeHandle -> do-            let target = stream "divert-audit-replay-only" :: Stream DivertEventStream-                affected =-                    ReplayAudit.AffectedSet-                        { affectedEventTypes = Set.singleton (EventType "DivertConfirmed")-                        , includeSnapshotStreams = False-                        }-                budget = ReplayAudit.defaultAuditBudget & #parallelism .~ 1-                auditWith candidate =-                    ReplayAudit.auditStreams-                        (ReplayAudit.AuditTargeted affected)-                        budget-                        ReplayAudit.AuditTarget-                            { eventStream = candidate-                            , category = "divert"-                            , mkStream = Just . Stream.Stream-                            }-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)--            Right withoutTwin <--                Store.runStoreIO storeHandle $-                    auditWith tightenedDivertEventStream-            withoutTwin ^. #results-                `shouldBe` [ ReplayAudit.StreamAuditResult-                                (StreamName "divert-audit-replay-only")-                                ( ReplayAudit.ReplayFailed-                                    (HydrationReplayFailed (StreamVersion 1) HydrationNoInvertingEdge)-                                )-                           ]-            ReplayAudit.auditExitCode [withoutTwin] `shouldBe` 1--            Right withTwin <--                Store.runStoreIO storeHandle $-                    auditWith twinDivertEventStream-            withTwin ^. #results-                `shouldBe` [ ReplayAudit.StreamAuditResult-                                (StreamName "divert-audit-replay-only")-                                ReplayAudit.ReplayOk-                                    { ReplayAudit.streamVersion = StreamVersion 1-                                    , ReplayAudit.digest = Nothing-                                    }-                           ]-            ReplayAudit.auditExitCode [withTwin] `shouldBe` 0--        it "reports a stale accepted snapshot seed as a divergence" $ \storeHandle -> do-            let target =-                    stream "auditfold-stale" :: Stream SnapshotCounterEventStream-                auditTarget =-                    ReplayAudit.AuditTarget-                        { eventStream = foldV2WithoutFingerprintBumpEventStream-                        , category = "auditfold"-                        , mkStream = Just . Stream.Stream-                        }-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 7)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 8)--            Right outcome <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStream auditTarget target-            case outcome of-                ReplayAudit.SeedDivergence-                    { seedVersion = StreamVersion 2-                    , seededDigest-                    , fullDigest-                    } ->-                        seededDigest `shouldNotBe` fullDigest-                other ->-                    expectationFailure-                        ("expected a stale-seed divergence, got " <> show other)--        it "keeps clean digests stable and resumes without re-auditing" $ \storeHandle -> do-            let targets =-                    [ stream "auditclean-one" :: Stream SnapshotCounterEventStream-                    , stream "auditclean-two" :: Stream SnapshotCounterEventStream-                    ]-                affected =-                    ReplayAudit.AffectedSet-                        { affectedEventTypes = Set.singleton (EventType "CounterAdded")-                        , includeSnapshotStreams = False-                        }-                auditTarget =-                    ReplayAudit.AuditTarget-                        { eventStream = snapshotCounterEventStream-                        , category = "auditclean"-                        , mkStream = Just . Stream.Stream-                        }-                unbounded = ReplayAudit.defaultAuditBudget & #parallelism .~ 2-            for_ (zip targets [10, 20]) $ \(target, amount) -> do-                Right (Right _) <--                    Store.runStoreIO storeHandle $-                        runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add amount)-                Right (Right _) <--                    Store.runStoreIO storeHandle $-                        runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add (amount + 1))-                pure ()--            Right firstFull <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams ReplayAudit.AuditFull unbounded auditTarget-            Right secondFull <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams ReplayAudit.AuditFull unbounded auditTarget-            firstFull ^. #streamsSelected `shouldBe` 2-            firstFull ^. #streamsSkipped `shouldBe` 0-            firstFull ^. #results `shouldBe` secondFull ^. #results--            Right firstPage <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams-                        (ReplayAudit.AuditTargeted affected)-                        (unbounded & #maxStreams ?~ 1)-                        auditTarget-            firstPage ^. #streamsSelected `shouldBe` 1-            firstPage ^. #checkpoint `shouldSatisfy` isJust-            Right secondPage <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams-                        (ReplayAudit.AuditTargeted affected)-                        ( unbounded-                            & #maxStreams-                            ?~ 1-                            & #resumeFrom-                            .~ (firstPage ^. #checkpoint)-                        )-                        auditTarget-            secondPage ^. #streamsSelected `shouldBe` 1-            let firstNames = Set.fromList ((^. #streamName) <$> firstPage ^. #results)-                secondNames = Set.fromList ((^. #streamName) <$> secondPage ^. #results)-            Set.disjoint firstNames secondNames `shouldBe` True-            firstNames <> secondNames-                `shouldBe` Set.fromList (Stream.streamName <$> targets)--            Right targeted <--                Store.runStoreIO storeHandle $-                    ReplayAudit.auditStreams-                        (ReplayAudit.AuditTargeted affected)-                        unbounded-                        auditTarget-            targeted ^. #results `shouldBe` firstFull ^. #results--    describe "Keiro.Connection projection schema" $-        around (withFreshResourceStoreWith fixture (withProjectionSchema "app_reads")) $ do-            it "places a read-model table in a configured schema, separate from keiro metadata" $ \(storeHandle, StoreRunner runner) -> do-                -- qualifiedTableName builds the app's fully-qualified data table ref.-                qualifiedTableName placedReadModel `shouldBe` "\"app_reads\".\"placed_counter\""--                -- Create the app schema (opt-in) and the qualified read-model table.-                Right () <--                    Store.runStoreIO storeHandle $ do-                        ensureProjectionSchema "app_reads"-                        initializeRegisteredReadModel placedReadModel initializePlacedTable--                -- Drive a command with the inline projection that writes the app table.-                let target = stream "placed-in-app-reads" :: Stream CounterEventStream-                result <--                    runner $-                        runCommandWithProjections-                            defaultRunCommandOptions-                            counterEventStream-                            target-                            (Add 7)-                            [placedInlineProjection]-                case result of-                    Right (Right _) -> pure ()-                    other -> expectationFailure ("expected placed inline projection command, got " <> show other)--                -- Read it back through the configured-schema read model.-                queryResult <--                    Store.runStoreIO storeHandle $-                        runQuery Nothing placedReadModel "placed"-                queryResult `shouldBe` Right (Right 7)--                -- Prove placement: the app table is in app_reads, NOT in kiroku, and-                -- Keiro's own metadata (keiro_read_models) is in the keiro schema.-                Right (inApp, inKiroku, keiroMeta) <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            (,,)-                                <$> Tx.statement ("app_reads", "placed_counter") pgTableCountStmt-                                <*> Tx.statement ("kiroku", "placed_counter") pgTableCountStmt-                                <*> Tx.statement ("keiro", "keiro_read_models") pgTableCountStmt-                inApp `shouldBe` (1 :: Int)-                inKiroku `shouldBe` (0 :: Int)-                keiroMeta `shouldBe` (1 :: Int)--    describe "Keiro.ReadModel" $ around (withFreshStore fixture) $ do-        it "queries inline projection with Eventual consistency" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-                let target = stream "read-model-inline" :: Stream CounterEventStream-                result <--                    runner $-                        runCommandWithProjections-                            defaultRunCommandOptions-                            counterEventStream-                            target-                            (Add 5)-                            [counterInlineProjection]-                case result of-                    Right (Right commandResult) ->-                        commandResult ^. #globalPosition `shouldSatisfy` isJust-                    other -> expectationFailure ("expected inline projection command, got " <> show other)-                queryResult <--                    Store.runStoreIO storeHandle $-                        runQuery Nothing counterReadModel "inline"-                queryResult `shouldBe` Right (Right 5)--        it "reads the minimum checkpoint across consumer-group subscription members" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $ do-                        Tx.statement ("counter-read-model-sub", 1, 7) upsertSubscriptionCursorMemberStmt-                        Tx.statement ("counter-read-model-sub", 2, 3) upsertSubscriptionCursorMemberStmt-            position <--                Store.runStoreIO storeHandle $-                    readSubscriptionPosition "counter-read-model-sub"-            position `shouldBe` Right (Just (GlobalPosition 3))--        it "Strong returns immediately on an empty log" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            queryResult <--                Store.runStoreIO storeHandle $-                    runQueryWith Nothing Strong counterReadModel "empty"-            queryResult `shouldBe` Right (Right 0)--        it "Strong returns immediately when the subscription is already at the store head" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-                let target = stream "read-model-strong-at-head" :: Stream CounterEventStream-                Right (Right commandResult) <--                    runner $-                        runCommandWithProjections-                            defaultRunCommandOptions-                            counterEventStream-                            target-                            (Add 5)-                            [counterInlineProjection]-                globalPosition <- case commandResult ^. #globalPosition of-                    Just position -> pure position-                    Nothing -> expectationFailure "expected command global position" *> error "unreachable"-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement ("counter-read-model-sub", globalPositionToInt globalPosition) upsertSubscriptionCursorStmt-                queryResult <--                    Store.runStoreIO storeHandle $-                        runQueryWith Nothing Strong counterReadModel "inline"-                queryResult `shouldBe` Right (Right 5)--        it "Strong blocks until the subscription reaches the store head captured at query start" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-                let target = stream "read-model-strong-blocking" :: Stream CounterEventStream-                Right (Right commandResult) <--                    runner $-                        runCommandWithProjections-                            defaultRunCommandOptions-                            counterEventStream-                            target-                            (Add 6)-                            [counterInlineProjection]-                globalPosition <- case commandResult ^. #globalPosition of-                    Just position -> pure position-                    Nothing -> expectationFailure "expected command global position" *> error "unreachable"-                _ <- forkIO $ do-                    threadDelay 20000-                    advanced <--                        Store.runStoreIO storeHandle $-                            Store.runTransaction $-                                Tx.statement ("counter-read-model-sub", globalPositionToInt globalPosition) upsertSubscriptionCursorStmt-                    case advanced of-                        Right () -> pure ()-                        Left err -> expectationFailure ("failed to advance subscription cursor: " <> show err)-                queryResult <--                    Store.runStoreIO storeHandle $-                        runQueryWith Nothing Strong counterReadModel "inline"-                queryResult `shouldBe` Right (Right 6)--        it "Strong returns when its category is caught up despite another active category" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-                let counterTarget = stream "counter-strong-scope" :: Stream CounterEventStream-                    otherTarget = stream "otherload-1" :: Stream CounterEventStream-                Right (Right counterResult) <--                    runner $-                        runCommandWithProjections-                            defaultRunCommandOptions-                            counterEventStream-                            counterTarget-                            (Add 8)-                            [counterInlineProjection]-                counterPosition <- case counterResult ^. #globalPosition of-                    Just position -> pure position-                    Nothing -> expectationFailure "expected counter global position" *> error "unreachable"-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement-                                ("counter-read-model-sub", globalPositionToInt counterPosition)-                                upsertSubscriptionCursorStmt-                Right (Right _) <--                    Store.runStoreIO storeHandle $-                        runCommand defaultRunCommandOptions counterEventStream otherTarget (Add 1)-                queryResult <--                    Store.runStoreIO storeHandle $-                        runQueryWith Nothing Strong counterCategoryReadModel "inline"-                queryResult `shouldBe` Right (Right 8)--        it "inline projection populates actor and source_event_id from command metadata" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-                let target = stream "read-model-inline-metadata" :: Stream CounterEventStream-                    opts =-                        defaultRunCommandOptions-                            & #metadata-                            ?~ object ["actor" Aeson..= ("agent-7" :: Text)]-                Right (Right _) <--                    runner $-                        runCommandWithProjections opts counterEventStream target (Add 5) [counterInlineProjection]-                Right row <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction (Tx.statement "inline" selectCounterMetaStmt)-                -- selectCounterMetaStmt returns (amount, actor, source_event_id).-                row `shouldSatisfy` \(amount, actor, srcId) ->-                    amount == 5 && actor == Just "agent-7" && isJust srcId--        it "waits for async projection cursor with PositionWait" $ \_ ->-            withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-                let target = stream "read-model-position-wait" :: Stream CounterEventStream-                Right (Right commandResult) <--                    runner $-                        runCommandWithProjections-                            defaultRunCommandOptions-                            counterEventStream-                            target-                            (Add 3)-                            [counterInlineProjection]-                globalPosition <- case commandResult ^. #globalPosition of-                    Just position -> pure position-                    Nothing -> expectationFailure "expected command global position" *> error "unreachable"-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement ("counter-read-model-sub", globalPositionToInt globalPosition) upsertSubscriptionCursorStmt-                queryResult <--                    Store.runStoreIO storeHandle $-                        runQueryWith-                            Nothing-                            (PositionWait (fastWaitOptions & #target .~ Just globalPosition))-                            counterReadModel-                            "inline"-                queryResult `shouldBe` Right (Right 3)--        it "times out when PositionWait target is not reached" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("counter-read-model-sub", 1) upsertSubscriptionCursorStmt-            queryResult <--                Store.runStoreIO storeHandle $-                    runQueryWith-                        Nothing-                        (PositionWait (fastWaitOptions & #target .~ Just (GlobalPosition 5)))-                        counterReadModel-                        "timeout"-            queryResult-                `shouldBe` Right-                    (Left (ReadModelWaitTimeout "counter-read-model" (GlobalPosition 5) (GlobalPosition 1)))--        it "does not write the registry row on repeated read-model queries" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            Right (Right 0) <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "no-churn"-            Right xminBefore <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "counter-read-model" readModelXminStmt-            Right (Right 0) <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "no-churn"-            Right xminAfter <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "counter-read-model" readModelXminStmt-            xminAfter `shouldBe` xminBefore--        it "rejects an unregistered model without creating a registry row" $ \storeHandle -> do-            let unregistered :: ReadModel Text Int-                unregistered = counterReadModel & #name .~ ("never-registered" :: Text)-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing unregistered "missing"-            queryResult `shouldBe` Right (Left (ReadModelUnregistered "never-registered"))-            found <--                Store.runStoreIO storeHandle $-                    lookupReadModel "never-registered"-            found `shouldBe` Right Nothing--        it "handles concurrent explicit read-model registration" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction initializeCounterReadModelTable-            resultA <- newEmptyMVar-            resultB <- newEmptyMVar-            _ <--                forkIO $-                    Store.runStoreIO storeHandle (registerReadModelDefinition counterReadModel)-                        >>= putMVar resultA-            _ <--                forkIO $-                    Store.runStoreIO storeHandle (registerReadModelDefinition counterReadModel)-                        >>= putMVar resultB-            first <- takeMVar resultA-            second <- takeMVar resultB-            first `shouldBe` Right ()-            second `shouldBe` Right ()-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "concurrent"-            queryResult `shouldBe` Right (Right 0)--        it "rejects stale read-model schema" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            Right (Right 0) <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "stale"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("counter-read-model", 99) updateReadModelVersionStmt-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "stale"-            queryResult-                `shouldBe` Right-                    (Left (ReadModelStaleSchema "counter-read-model" 1 99 "counter-read-model-v1" "counter-read-model-v1"))--        it "surfaces unknown read-model statuses with the raw status text" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            Right (Right 0) <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "unknown-status"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("counter-read-model", "wedged") updateReadModelStatusStmt-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "unknown-status"-            queryResult-                `shouldBe` Right-                    (Left (ReadModelNotLive "counter-read-model" (UnknownStatus "wedged")))--        it "ignores duplicate async event by source_event_id" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            let target = stream "read-model-async-idempotent" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 7)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "read-model-async-idempotent") (StreamVersion 0) 10-            event <- case Vector.toList recorded of-                [onlyEvent] -> pure onlyEvent-                other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"-            Right outcomes <- Store.runStoreIO storeHandle $-                Store.runTransaction $ do-                    first <- applyAsyncProjection counterAsyncProjection event-                    second <- applyAsyncProjection counterAsyncProjection event-                    pure (first, second)-            outcomes `shouldBe` (AsyncApplied, AsyncDuplicate)-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "async-idempotent"-            queryResult `shouldBe` Right (Right 7)--        it "deduplicates async projection application across transactions and reopens after pruning" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction initializeProjectionDedupCounterTable-            Right _ <--                Store.runStoreIO storeHandle $-                    registerReadModel "projection-dedup-counter-model" 1 "projection-dedup-counter-v1"-            let target = stream "read-model-async-dedup-window" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 7)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "read-model-async-dedup-window") (StreamVersion 0) 10-            event <- case Vector.toList recorded of-                [onlyEvent] -> pure onlyEvent-                other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"-            let incrementingProjection =-                    AsyncProjection-                        { name = "incrementing-async-projection"-                        , readModelName = "projection-dedup-counter-model"-                        , subscriptionName = "incrementing-async-projection-sub"-                        , applyRecorded = \_ -> Tx.statement () incrementProjectionDedupCounterStmt-                        , idempotencyKey = \recordedEvent -> recordedEvent ^. #eventId-                        }-            Right AsyncApplied <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjection incrementingProjection event-            Right AsyncDuplicate <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjection incrementingProjection event-            Right countAfterDuplicate <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement () selectProjectionDedupCounterStmt-            countAfterDuplicate `shouldBe` 1-            cutoff <- addUTCTime 1 <$> getCurrentTime-            pruned <- Store.runStoreIO storeHandle $ pruneAsyncProjectionDedupBefore cutoff-            pruned `shouldBe` Right 1-            Right AsyncApplied <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjection incrementingProjection event-            Right countAfterPrune <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement () selectProjectionDedupCounterStmt-            countAfterPrune `shouldBe` 2--        it "rebuild repopulates the projection table through the supported workflow" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            let target = stream "read-model-rebuild-runbook" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 7)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "read-model-rebuild-runbook") (StreamVersion 0) 10-            event <- case Vector.toList recorded of-                [onlyEvent] -> pure onlyEvent-                other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"-            Right AsyncApplied <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjection counterAsyncProjection event-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement-                            ( "counter-read-model-sub"-                            , globalPositionToInt (event ^. #globalPosition)-                            )-                            upsertSubscriptionCursorStmt-            beforeRebuild <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "async-idempotent"-            beforeRebuild `shouldBe` Right (Right 7)--            Right rebuilding <--                Store.runStoreIO storeHandle $-                    Rebuild.startRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            rebuilding ^. #status `shouldBe` Rebuilding-            checkpointAfterReset <--                Store.runStoreIO storeHandle $-                    readSubscriptionPosition "counter-read-model-sub"-            checkpointAfterReset `shouldBe` Right (Just (GlobalPosition 0))-            Right AsyncApplied <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjectionUnfenced counterAsyncProjection event-            Right (Right live) <--                Store.runStoreIO storeHandle $-                    Rebuild.finishRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            live ^. #status `shouldBe` Live--            afterRebuild <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "async-idempotent"-            afterRebuild `shouldBe` Right (Right 7)--        it "keeps a non-empty-log rebuild offline when replay applies nothing" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            let target = stream "read-model-rebuild-empty-replay" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 7)-            Right _ <--                Store.runStoreIO storeHandle $-                    Rebuild.startRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            finishResult <--                Store.runStoreIO storeHandle $-                    Rebuild.finishRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            case finishResult of-                Right (Left (Rebuild.RebuildProducedNoApplies modelName headPosition)) -> do-                    modelName `shouldBe` "counter-read-model"-                    headPosition `shouldSatisfy` (> GlobalPosition 0)-                other -> expectationFailure ("expected zero-apply guard, got " <> show other)-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "async-idempotent"-            queryResult-                `shouldBe` Right-                    (Left (ReadModelNotLive "counter-read-model" Rebuilding))--        it "fences live async application while a model is rebuilding" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            let target = stream "read-model-fenced-apply" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 7)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "read-model-fenced-apply") (StreamVersion 0) 10-            event <- case Vector.toList recorded of-                [onlyEvent] -> pure onlyEvent-                other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"-            Right _ <--                Store.runStoreIO storeHandle $-                    Rebuild.startRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            outcome <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjection counterAsyncProjection event-            outcome `shouldBe` Right AsyncFenced-            Right dedupCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement (counterAsyncProjection ^. #name) projectionDedupCountStmt-            dedupCount `shouldBe` 0-            Right amount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "async-idempotent" selectCounterReadModelStmt-            amount `shouldBe` 0--        it "keeps a live applier out of the rebuild window and reopens it after promotion" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            let target = stream "read-model-fence-race" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 7)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "read-model-fence-race") (StreamVersion 0) 10-            event <- case Vector.toList recorded of-                [onlyEvent] -> pure onlyEvent-                other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"-            enterRebuildWindow <- newEmptyMVar-            liveApplyResult <- newEmptyMVar-            _ <--                forkIO $ do-                    takeMVar enterRebuildWindow-                    Store.runStoreIO-                        storeHandle-                        (Store.runTransaction (applyAsyncProjection counterAsyncProjection event))-                        >>= putMVar liveApplyResult-            Right _ <--                Store.runStoreIO storeHandle $-                    Rebuild.startRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            putMVar enterRebuildWindow ()-            takeMVar liveApplyResult `shouldReturn` Right AsyncFenced--            Right AsyncApplied <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjectionUnfenced counterAsyncProjection event-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    Rebuild.finishRebuild-                        counterReadModel-                        [counterAsyncProjection ^. #name]-                        (GlobalPosition 0)-            cutoff <- addUTCTime 1 <$> getCurrentTime-            pruned <- Store.runStoreIO storeHandle $ pruneAsyncProjectionDedupBefore cutoff-            pruned `shouldBe` Right 1-            reapplied <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        applyAsyncProjection counterAsyncProjection event-            reapplied `shouldBe` Right AsyncApplied-            queryResult <--                Store.runStoreIO storeHandle $-                    runQuery Nothing counterReadModel "async-idempotent"-            queryResult `shouldBe` Right (Right 7)--        it "tracks rebuild state transitions" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    registerReadModelDefinition counterReadModel-            Right rebuilding <--                Store.runStoreIO storeHandle $-                    Rebuild.rebuild counterReadModel-            rebuilding ^. #status `shouldBe` Rebuilding-            Right live <--                Store.runStoreIO storeHandle $-                    Rebuild.promote counterReadModel-            live ^. #status `shouldBe` Live-            Right abandoned <--                Store.runStoreIO storeHandle $-                    Rebuild.abandonRebuild counterReadModel-            abandoned ^. #status `shouldBe` Abandoned--        it "records projection lag behind the log head" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            let target = stream "read-model-lag" :: Stream CounterEventStream-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 1)-            Right (Right _) <--                Store.runStoreIO storeHandle $-                    runCommand defaultRunCommandOptions counterEventStream target (Add 1)-            -- The subscription cursor is never advanced, so the read model is behind-            -- the head by every appended event: the lag gauge records that gap.-            Right () <--                Store.runStoreIO storeHandle $-                    recordProjectionLag (Just keiroMetrics) counterAsyncProjection-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            case lookup "keiro.projection.lag" scalars of-                Just (IntNumber n) -> n `shouldSatisfy` (>= 1)-                other -> expectationFailure ("expected an integer projection lag, got " <> show other)--        it "counts a position-wait timeout in the timeout counter" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            Right () <--                Store.runStoreIO storeHandle $-                    initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("counter-read-model-sub", 1) upsertSubscriptionCursorStmt-            queryResult <--                Store.runStoreIO storeHandle $-                    runQueryWith-                        (Just keiroMetrics)-                        (PositionWait (fastWaitOptions & #target .~ Just (GlobalPosition 5)))-                        counterReadModel-                        "timeout"-            queryResult-                `shouldBe` Right-                    (Left (ReadModelWaitTimeout "counter-read-model" (GlobalPosition 5) (GlobalPosition 1)))-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            -- The single give-up bumped the counter exactly once.-            lookup "keiro.projection.wait.timeouts" scalars `shouldBe` Just (IntNumber 1)--    describe "Keiro.ProcessManager" $ around (withFreshResourceStore fixture) $ do-        it "advances manager state, emits a deterministic target command once, and schedules a timer" $ \(_storeHandle, StoreRunner _runner) -> do-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-            result <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions counterProcessManager sourceEvent (CounterAdded 9)-            case result of-                Right (Right pmResult) -> do-                    case pmResult ^. #managerResult of-                        PMStateAppended managerResult ->-                            managerResult ^. #streamVersion `shouldBe` StreamVersion 1-                        other -> expectationFailure ("expected appended manager state, got " <> show other)-                    case pmResult ^. #commandResults of-                        [PMCommandAppended commandResult] ->-                            commandResult ^. #eventsAppended `shouldBe` 1-                        other -> expectationFailure ("expected one emitted command, got " <> show other)-                    pmResult ^. #timersScheduled `shouldBe` 1-                other -> expectationFailure ("expected process-manager success, got " <> show other)-            Right managerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10-            Right targetEvents <--                _runner $-                    Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10-            Vector.length managerEvents `shouldBe` 1-            Vector.length targetEvents `shouldBe` 1-            timer <--                _runner $-                    claimDueTimer dueTimerTime-            case timer of-                Right (Just row) -> do-                    row ^. #processManagerName `shouldBe` "counter-pm"-                    row ^. #correlationId `shouldBe` "order-1"-                other -> expectationFailure ("expected scheduled timer row, got " <> show other)--        it "schedules timers when the manager command emits no events" $ \(_storeHandle, StoreRunner _runner) -> do-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-            result <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions timerOnlyProcessManager sourceEvent (CounterAdded 9)-            case result of-                Right (Right pmResult) -> do-                    case pmResult ^. #managerResult of-                        PMStateAppended managerResult -> do-                            managerResult ^. #streamVersion `shouldBe` StreamVersion 0-                            managerResult ^. #eventsAppended `shouldBe` 0-                        other -> expectationFailure ("expected no-op manager state, got " <> show other)-                    pmResult ^. #commandResults `shouldBe` []-                    pmResult ^. #timersScheduled `shouldBe` 1-                other -> expectationFailure ("expected process-manager success, got " <> show other)-            dueCount <--                _runner $-                    countDueTimers dueTimerTime-            dueCount `shouldBe` Right 1-            timer <--                _runner $-                    claimDueTimer dueTimerTime-            case timer of-                Right (Just row) -> do-                    row ^. #processManagerName `shouldBe` "timer-only-pm"-                    row ^. #correlationId `shouldBe` "order-1"-                other -> expectationFailure ("expected scheduled timer row, got " <> show other)--        it "treats duplicate input delivery as idempotent state and command dispatch" $ \(_storeHandle, StoreRunner _runner) -> do-            let sourceEvent = recordedFromEventId (EventId sampleUuid2) (CounterAdded 4)-            Right (Right _) <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions counterProcessManager sourceEvent (CounterAdded 4)-            duplicate <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions counterProcessManager sourceEvent (CounterAdded 4)-            case duplicate of-                Right (Right pmResult) -> do-                    pmResult ^. #managerResult `shouldSatisfy` \case-                        PMStateDuplicate{} -> True-                        _ -> False-                    pmResult ^. #commandResults `shouldSatisfy` \case-                        [PMCommandDuplicate{}] -> True-                        _ -> False-                other -> expectationFailure ("expected idempotent duplicate handling, got " <> show other)-            Right managerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10-            Right targetEvents <--                _runner $-                    Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10-            Vector.length managerEvents `shouldBe` 1-            Vector.length targetEvents `shouldBe` 1--        it "replays a Kiroku dead letter freshly and deduplicates a second replay" $ \(_storeHandle, StoreRunner _runner) -> do-            let subName = SubscriptionName "counter-pm-replay-fresh"-                replayHandler recorded =-                    case decodeRecorded counterCodec recorded of-                        Left err -> pure (Left (Text.pack (show err)))-                        Right input -> do-                            outcome <--                                runProcessManagerOnce-                                    defaultRunCommandOptions-                                    counterProcessManager-                                    recorded-                                    input-                            pure $-                                case outcome of-                                    Left err -> Left (Text.pack (show err))-                                    Right result -> Right (classifyProcessManagerReplay result)-            source <- deadLetterCounterSource _storeHandle subName (CounterAdded 7)-            Right listed <- _runner (listSubscriptionDeadLetters subName 0)-            Vector.length listed `shouldBe` 1--            Right firstPass <--                _runner $-                    replaySubscriptionDeadLetters subName 0 replayHandler-            firstPass-                `shouldBe` [ ReplayOutcome-                                { replayGlobalPosition = source ^. #globalPosition-                                , replayEventId = source ^. #eventId-                                , replayResult = ReplayedFresh-                                }-                           ]-            processManagerReplayCounts _storeHandle `shouldReturn` (1, 1)--            Right secondPass <--                _runner $-                    replaySubscriptionDeadLetters subName 0 replayHandler-            secondPass-                `shouldBe` [ ReplayOutcome-                                { replayGlobalPosition = source ^. #globalPosition-                                , replayEventId = source ^. #eventId-                                , replayResult = ReplayedDuplicate-                                }-                           ]-            processManagerReplayCounts _storeHandle `shouldReturn` (1, 1)-            Right retained <- _runner (listSubscriptionDeadLetters subName 0)-            Vector.length retained `shouldBe` 1--        it "reports an already-processed Kiroku dead letter without appending" $ \(_storeHandle, StoreRunner _runner) -> do-            let subName = SubscriptionName "counter-pm-replay-duplicate"-                replayHandler recorded =-                    case decodeRecorded counterCodec recorded of-                        Left err -> pure (Left (Text.pack (show err)))-                        Right input -> do-                            outcome <--                                runProcessManagerOnce-                                    defaultRunCommandOptions-                                    counterProcessManager-                                    recorded-                                    input-                            pure $-                                case outcome of-                                    Left err -> Left (Text.pack (show err))-                                    Right result -> Right (classifyProcessManagerReplay result)-            source <- deadLetterCounterSource _storeHandle subName (CounterAdded 8)-            Right (Right _) <--                _runner $-                    runProcessManagerOnce-                        defaultRunCommandOptions-                        counterProcessManager-                        source-                        (CounterAdded 8)-            countsBefore <- processManagerReplayCounts _storeHandle--            Right outcomes <--                _runner $-                    replaySubscriptionDeadLetters subName 0 replayHandler-            outcomes-                `shouldBe` [ ReplayOutcome-                                { replayGlobalPosition = source ^. #globalPosition-                                , replayEventId = source ^. #eventId-                                , replayResult = ReplayedDuplicate-                                }-                           ]-            processManagerReplayCounts _storeHandle `shouldReturn` countsBefore--        it "keeps multiple workflow process managers isolated by configured streams and categories" $ \(_storeHandle, StoreRunner _runner) -> do-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 6)-                fulfillmentManager =-                    workflowProcessManager-                        "fulfillment-pm"-                        "pm:fulfillment"-                        "fulfillment-target-order-1"-                billingManager =-                    workflowProcessManager-                        "billing-pm"-                        "pm:billing"-                        "billing-target-order-1"-            fulfillmentResult <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions fulfillmentManager sourceEvent (CounterAdded 6)-            billingResult <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions billingManager sourceEvent (CounterAdded 6)-            assertWorkflowProcessManagerAppended fulfillmentResult-            assertWorkflowProcessManagerAppended billingResult--            Right fulfillmentManagerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:fulfillment-order-1") (StreamVersion 0) 10-            Right billingManagerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:billing-order-1") (StreamVersion 0) 10-            Right fulfillmentTargetEvents <--                _runner $-                    Store.readStreamForward (StreamName "fulfillment-target-order-1") (StreamVersion 0) 10-            Right billingTargetEvents <--                _runner $-                    Store.readStreamForward (StreamName "billing-target-order-1") (StreamVersion 0) 10-            Vector.length fulfillmentManagerEvents `shouldBe` 1-            Vector.length billingManagerEvents `shouldBe` 1-            Vector.length fulfillmentTargetEvents `shouldBe` 1-            Vector.length billingTargetEvents `shouldBe` 1--            Right fulfillmentCategoryEvents <--                _runner $-                    Store.readCategory (CategoryName "pm:fulfillment") (GlobalPosition 0) 10-            Right billingCategoryEvents <--                _runner $-                    Store.readCategory (CategoryName "pm:billing") (GlobalPosition 0) 10-            Right sharedPmCategoryEvents <--                _runner $-                    Store.readCategory (CategoryName "pm") (GlobalPosition 0) 10-            Right sharedPmNamespaceEvents <--                _runner $-                    Store.readCategory (CategoryName "pm:") (GlobalPosition 0) 10-            Vector.length fulfillmentCategoryEvents `shouldBe` 1-            Vector.length billingCategoryEvents `shouldBe` 1-            sharedPmCategoryEvents `shouldBe` Vector.empty-            sharedPmNamespaceEvents `shouldBe` Vector.empty--        it "worker finalizes AckOk through the ack handle on success" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                messages = [(sourceEvent, CounterAdded 9)]-                adapter = inMemoryAdapter decisionsRef messages-            Right () <--                _runner $-                    runProcessManagerWorker defaultRunCommandOptions counterProcessManager adapter Just-            decisions <- readIORef decisionsRef-            decisions `shouldBe` [AckOk]--        it "worker halts instead of acking when a target dispatch is rejected" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                messages = [(sourceEvent, CounterAdded 9)]-                adapter = inMemoryAdapter decisionsRef messages-                rejectingPm =-                    (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)-                        { targetEventStream = rejectingEventStream-                        }-            Right () <--                _runner $-                    runProcessManagerWorker defaultRunCommandOptions rejectingPm adapter Just-            decisions <- readIORef decisionsRef-            decisions `shouldSatisfy` \case-                [AckHalt (HaltFatal _)] -> True-                _ -> False-            Right targetEvents <--                _runner $-                    Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10-            Right managerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10-            Vector.length targetEvents `shouldBe` 0-            Vector.length managerEvents `shouldBe` 1--        it "dead-letters a rejected dispatch and continues to the next event" $ \(_storeHandle, StoreRunner _runner) -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            decisionsRef <- newIORef []-            let first = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                second = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)-                messages = [(first, CounterAdded 9), (second, CounterAdded 1)]-                adapter = inMemoryAdapter decisionsRef messages-                policyPm =-                    (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)-                        { targetEventStream = rejectNineEventStream-                        }-                workerOptions =-                    defaultWorkerOptions-                        & #rejectedCommandPolicy-                        .~ RejectedDeadLetter-                        & #metrics-                        ?~ keiroMetrics-            Right () <--                _runner $-                    runProcessManagerWorkerWith workerOptions defaultRunCommandOptions policyPm adapter Just-            readIORef decisionsRef `shouldReturn` [AckOk, AckOk]-            Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")-            case deadLetters of-                [row] -> do-                    row ^. #dispatcherKind `shouldBe` DispatcherProcessManager-                    row ^. #correlationId `shouldBe` "order-1"-                    row ^. #sourceEventId `shouldBe` EventId sampleUuid-                    row ^. #emitIndex `shouldBe` 0-                    row ^. #targetStreamName `shouldBe` StreamName "counter-target-order-1"-                    row ^. #errorClass `shouldBe` "command_rejected"-                    row ^. #attemptCount `shouldBe` 1-                other -> expectationFailure ("expected one rejected dispatch dead letter, got " <> show other)-            Right targetEvents <--                _runner $-                    Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10-            Right managerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10-            Vector.length targetEvents `shouldBe` 1-            Vector.length managerEvents `shouldBe` 2-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.dispatch.deadlettered" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)--        it "skips a rejected dispatch without writing a dead-letter row" $ \(_storeHandle, StoreRunner _runner) -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                adapter = inMemoryAdapter decisionsRef [(sourceEvent, CounterAdded 9)]-                rejectingPm =-                    (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)-                        { targetEventStream = rejectingEventStream-                        }-                workerOptions =-                    defaultWorkerOptions-                        & #rejectedCommandPolicy-                        .~ RejectedSkip-                        & #metrics-                        ?~ keiroMetrics-            Right () <--                _runner $-                    runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingPm adapter Just-            readIORef decisionsRef `shouldReturn` [AckOk]-            Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")-            deadLetters `shouldBe` []-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.dispatch.deadlettered" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)--        it "dead-letters a manager-state rejection at emit index minus one" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                adapter = inMemoryAdapter decisionsRef [(sourceEvent, CounterAdded 9)]-                rejectingManager =-                    (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)-                        { eventStream = rejectingEventStream-                        }-                workerOptions = defaultWorkerOptions & #rejectedCommandPolicy .~ RejectedDeadLetter-            Right () <--                _runner $-                    runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingManager adapter Just-            readIORef decisionsRef `shouldReturn` [AckOk]-            Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")-            case deadLetters of-                [row] -> do-                    row ^. #emitIndex `shouldBe` (-1)-                    row ^. #targetStreamName `shouldBe` StreamName "pm:counter-order-1"-                    row ^. #errorClass `shouldBe` "command_rejected"-                other -> expectationFailure ("expected one manager-state dead letter, got " <> show other)-            Right managerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10-            managerEvents `shouldBe` Vector.empty--        it "keeps rejected-dispatch dead letters idempotent on source redelivery" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                adapter = inMemoryAdapter decisionsRef [(sourceEvent, CounterAdded 9), (sourceEvent, CounterAdded 9)]-                rejectingPm =-                    (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)-                        { targetEventStream = rejectingEventStream-                        }-                workerOptions = defaultWorkerOptions & #rejectedCommandPolicy .~ RejectedDeadLetter-            Right () <--                _runner $-                    runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingPm adapter Just-            readIORef decisionsRef `shouldReturn` [AckOk, AckOk]-            Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")-            Prelude.length deadLetters `shouldBe` 1--        it "records dispatch failures through worker metrics" $ \(_storeHandle, StoreRunner _runner) -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                messages = [(sourceEvent, CounterAdded 9)]-                adapter = inMemoryAdapter decisionsRef messages-                rejectingPm =-                    (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)-                        { targetEventStream = rejectingEventStream-                        }-                workerOptions = defaultWorkerOptions & #metrics ?~ keiroMetrics-            Right () <--                _runner $-                    runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingPm adapter Just-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.dispatch.failed" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)--        it "classifies transient store failures as retry and deterministic command failures as halt" $ \(_storeHandle, StoreRunner _runner) -> do-            isRejectionClass CommandRejected `shouldBe` True-            isRejectionClass (CommandAmbiguous [0, 1]) `shouldBe` True-            isRejectionClass (EncodeFailed (NonObjectCallerMetadata Aeson.Null)) `shouldBe` False-            ackForCommandError (RetryDelay 5) (StoreFailed (Store.ConnectionLost "boom"))-                `shouldBe` AckRetry (RetryDelay 5)-            ackForCommandError (RetryDelay 5) CommandRejected `shouldSatisfy` \case-                AckHalt (HaltFatal _) -> True-                _ -> False-            ackForCommandError (RetryDelay 5) (CommandAmbiguous [0, 1]) `shouldSatisfy` \case-                AckHalt (HaltFatal _) -> True-                _ -> False--        it "worker applies poison-message policy on decode failure" $ \(_storeHandle, StoreRunner _runner) -> do-            let badMessages = ["not-decodable" :: Text]-            defaultDecisions <- newIORef []-            Right () <--                _runner $-                    runProcessManagerWorker-                        defaultRunCommandOptions-                        counterProcessManager-                        (inMemoryAdapter defaultDecisions badMessages)-                        (const Nothing)-            defaultObserved <- readIORef defaultDecisions-            defaultObserved `shouldSatisfy` \case-                [AckHalt (HaltFatal _)] -> True-                _ -> False--            skippedRef <- newIORef []-            skipDecisions <- newIORef []-            let skipOptions =-                    defaultWorkerOptions-                        & #poisonPolicy-                        .~ PoisonSkip (\env -> liftIO (modifyIORef' skippedRef (<> [env ^. #payload])))-            Right () <--                _runner $-                    runProcessManagerWorkerWith-                        skipOptions-                        defaultRunCommandOptions-                        counterProcessManager-                        (inMemoryAdapter skipDecisions badMessages)-                        (const Nothing)-            readIORef skipDecisions `shouldReturn` [AckOk]-            readIORef skippedRef `shouldReturn` badMessages--            deadLetterDecisions <- newIORef []-            deadLetterRef <- newIORef []-            let deadLetterOptions =-                    defaultWorkerOptions-                        & #poisonPolicy-                        .~ PoisonDeadLetter (\env -> liftIO (modifyIORef' deadLetterRef (<> [env ^. #payload])))-            Right () <--                _runner $-                    runProcessManagerWorkerWith-                        deadLetterOptions-                        defaultRunCommandOptions-                        counterProcessManager-                        (inMemoryAdapter deadLetterDecisions badMessages)-                        (const Nothing)-            deadLetterObserved <- readIORef deadLetterDecisions-            deadLetterObserved `shouldSatisfy` \case-                [AckDeadLetter (InvalidPayload _)] -> True-                _ -> False-            readIORef deadLetterRef `shouldReturn` badMessages--        it "folds a concurrent duplicate target dispatch to PMCommandDuplicate" $ \(_storeHandle, StoreRunner _runner) -> do-            insertCount <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                commandId = deterministicCommandId "counter-pm" "order-1" (sourceEvent ^. #eventId) 0-                targetStreamName = StreamName "counter-target-order-1"-                insertConcurrentTarget = do-                    callNo <- atomicModifyIORef' insertCount (\n -> (n + 1, n))-                    when (callNo == 1) $ appendCounterEventWithId _storeHandle targetStreamName commandId (CounterAdded 9)-                options =-                    defaultRunCommandOptions-                        & #beforeAppend-                        .~ insertConcurrentTarget-                        & #retryBackoffMicros-                        .~ 0-            result <--                _runner $-                    runProcessManagerOnce options counterProcessManager sourceEvent (CounterAdded 9)-            case result of-                Right (Right pmResult) ->-                    pmResult ^. #commandResults `shouldSatisfy` \case-                        [PMCommandDuplicate duplicateId] -> duplicateId == commandId-                        _ -> False-                other -> expectationFailure ("expected duplicate target dispatch fold, got " <> show other)-            Right targetEvents <--                _runner $-                    Store.readStreamForward targetStreamName (StreamVersion 0) 10-            Vector.length targetEvents `shouldBe` 1--        it "folds a concurrent duplicate manager-state append to PMStateDuplicate" $ \(_storeHandle, StoreRunner _runner) -> do-            insertCount <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)-                managerId = deterministicCommandId "counter-pm" "order-1" (sourceEvent ^. #eventId) (-1)-                managerStreamName = StreamName "pm:counter-order-1"-                insertConcurrentManager = do-                    callNo <- atomicModifyIORef' insertCount (\n -> (n + 1, n))-                    when (callNo == 0) $ appendCounterEventWithId _storeHandle managerStreamName managerId (CounterAdded 9)-                options =-                    defaultRunCommandOptions-                        & #beforeAppend-                        .~ insertConcurrentManager-                        & #retryBackoffMicros-                        .~ 0-            result <--                _runner $-                    runProcessManagerOnce options counterProcessManager sourceEvent (CounterAdded 9)-            case result of-                Right (Right pmResult) -> do-                    pmResult ^. #managerResult `shouldSatisfy` \case-                        PMStateDuplicate duplicateId -> duplicateId == managerId-                        _ -> False-                    pmResult ^. #commandResults `shouldSatisfy` \case-                        [PMCommandAppended{}] -> True-                        _ -> False-                other -> expectationFailure ("expected duplicate manager-state fold, got " <> show other)--    describe "Keiro.ProcessManager duplicate confirmation" $ around (withFreshResourceStore fixture) $ do-        it "rejects a duplicate report carrying a different id" $ \(_storeHandle, StoreRunner _runner) -> do-            let targetStreamName = StreamName "duplicate-confirmation-mismatch"-                ourId = EventId sampleUuid-                otherId = EventId sampleUuid2-            appendCounterEventWithId _storeHandle targetStreamName otherId (CounterAdded 1)-            outcome <--                _runner $-                    confirmBenignDuplicate-                        targetStreamName-                        ourId-                        (StoreFailed (Store.DuplicateEvent (Just otherId)))-            outcome `shouldBe` Right False--        it "rejects a matching id that exists only in another stream" $ \(_storeHandle, StoreRunner _runner) -> do-            let targetStreamName = StreamName "duplicate-confirmation-target"-                otherStreamName = StreamName "duplicate-confirmation-other"-                ourId = EventId sampleUuid-                targetEventId = EventId sampleUuid2-            appendCounterEventWithId _storeHandle targetStreamName targetEventId (CounterAdded 1)-            appendCounterEventWithId _storeHandle otherStreamName ourId (CounterAdded 1)-            outcome <--                _runner $-                    confirmBenignDuplicate-                        targetStreamName-                        ourId-                        (StoreFailed (Store.DuplicateEvent (Just ourId)))-            outcome `shouldBe` Right False--        it "confirms matching and id-less duplicate reports when the id is in the target stream" $ \(_storeHandle, StoreRunner _runner) -> do-            let targetStreamName = StreamName "duplicate-confirmation-present"-                ourId = EventId sampleUuid-            appendCounterEventWithId _storeHandle targetStreamName ourId (CounterAdded 1)-            matchingOutcome <--                _runner $-                    confirmBenignDuplicate-                        targetStreamName-                        ourId-                        (StoreFailed (Store.DuplicateEvent (Just ourId)))-            missingDetailOutcome <--                _runner $-                    confirmBenignDuplicate-                        targetStreamName-                        ourId-                        (StoreFailed (Store.DuplicateEvent Nothing))-            matchingOutcome `shouldBe` Right True-            missingDetailOutcome `shouldBe` Right True--        it "rejects non-duplicate command failures" $ \(_storeHandle, StoreRunner _runner) -> do-            let targetStreamName = StreamName "duplicate-confirmation-non-duplicate"-                ourId = EventId sampleUuid-            appendCounterEventWithId _storeHandle targetStreamName ourId (CounterAdded 1)-            outcome <--                _runner $-                    confirmBenignDuplicate-                        targetStreamName-                        ourId-                        (StoreFailed (Store.ConnectionLost "boom"))-            outcome `shouldBe` Right False--    describe "Keiro.ProcessManager snapshots" $ around (withFreshResourceStore fixture) $ do-        it "writes a snapshot of the manager state stream after the policy threshold" $ \(_storeHandle, StoreRunner _runner) -> do-            -- Two distinct source events, both correlating to "order-1", drive the one-            -- manager instance to manager-stream version 2, which Every 2 snapshots.-            let sourceA = recordedFromEventId (EventId sampleUuid) (CounterAdded 2)-                sourceB = recordedFromEventId (EventId sampleUuid2) (CounterAdded 3)-            Right (Right _) <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceA (CounterAdded 2)-            Right (Right _) <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceB (CounterAdded 3)-            Right managerEvents <--                _runner $-                    Store.readStreamForward (StreamName "pm:counter-snap-order-1") (StreamVersion 0) 10-            Vector.length managerEvents `shouldBe` 2-            Right snapshotVersion <--                _runner $-                    Store.runTransaction $-                        Tx.statement "pm:counter-snap-order-1" snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 2)--        it "hydrates the manager from its snapshot and replays only the tail" $ \(_storeHandle, StoreRunner _runner) -> do-            -- After the threshold snapshot exists, a third reaction should land on top of-            -- the snapshot at version 3 rather than replaying from version 0.-            let sourceA = recordedFromEventId (EventId sampleUuid) (CounterAdded 2)-                sourceB = recordedFromEventId (EventId sampleUuid2) (CounterAdded 3)-                sourceC = recordedFromEventId (EventId sampleUuid3) (CounterAdded 4)-            Right (Right _) <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceA (CounterAdded 2)-            Right (Right _) <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceB (CounterAdded 3)-            -- Confirm the snapshot is present before the tail-replay reaction.-            Right snapshotVersion <--                _runner $-                    Store.runTransaction $-                        Tx.statement "pm:counter-snap-order-1" snapshotVersionForStreamStmt-            snapshotVersion `shouldBe` Just (StreamVersion 2)-            result <--                _runner $-                    runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceC (CounterAdded 4)-            case result of-                Right (Right pmResult) ->-                    case pmResult ^. #managerResult of-                        PMStateAppended managerResult ->-                            managerResult ^. #streamVersion `shouldBe` StreamVersion 3-                        other -> expectationFailure ("expected appended manager state, got " <> show other)-                other -> expectationFailure ("expected snapshot-assisted PM reaction, got " <> show other)--    describe "Keiro.Router" $ around (withFreshResourceStore fixture) $ do-        it "encodes colon-bearing and non-ASCII id components without collisions" $ \(_storeHandle, StoreRunner _runner) -> do-            let sourceEventId = EventId sampleUuid-                colonLeft =-                    deterministicRouterCommandId-                        "router:a"-                        "key"-                        sourceEventId-                        (StreamName "target")-                        0-                colonRight =-                    deterministicRouterCommandId-                        "router"-                        "a:key"-                        sourceEventId-                        (StreamName "target")-                        0-                unicodeLeft =-                    deterministicRouterCommandId-                        "router"-                        "key"-                        sourceEventId-                        (StreamName ("target-" <> Text.singleton '\x101'))-                        0-                unicodeRight =-                    deterministicRouterCommandId-                        "router"-                        "key"-                        sourceEventId-                        (StreamName ("target-" <> Text.singleton '\x201'))-                        0-            colonLeft `shouldNotBe` colonRight-            unicodeLeft `shouldNotBe` unicodeRight--        it "resolves targets effectfully and fans out one command per target" $ \(_storeHandle, StoreRunner _runner) -> do-            Right () <--                _runner $-                    initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable-            Right () <- _runner $-                Store.runTransaction $ do-                    Tx.statement ("g1", "router-target-a") insertRouterTargetStmt-                    Tx.statement ("g1", "router-target-b") insertRouterTargetStmt-                    Tx.statement ("g1", "router-target-c") insertRouterTargetStmt-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-            Right (RouterResult rs1) <--                _runner $-                    runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")-            length rs1 `shouldBe` 3-            rs1 `shouldSatisfy` all isAppended-            -- Data-dependence is load-bearing: an unseeded group resolves to no-            -- targets, so the count tracks the read model, not a fixed list.-            Right (RouterResult rsEmpty) <--                _runner $-                    runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "no-such-group")-            length rsEmpty `shouldBe` 0-            -- Each resolved target stream received exactly one command.-            Right targetA <--                _runner $-                    Store.readStreamForward (StreamName "router-target-a") (StreamVersion 0) 10-            Right targetB <--                _runner $-                    Store.readStreamForward (StreamName "router-target-b") (StreamVersion 0) 10-            Right targetC <--                _runner $-                    Store.readStreamForward (StreamName "router-target-c") (StreamVersion 0) 10-            Vector.length targetA `shouldBe` 1-            Vector.length targetB `shouldBe` 1-            Vector.length targetC `shouldBe` 1--        it "reports every dispatch as a duplicate on replay, writing no new events" $ \(_storeHandle, StoreRunner _runner) -> do-            Right () <--                _runner $-                    initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable-            Right () <- _runner $-                Store.runTransaction $ do-                    Tx.statement ("g1", "router-target-a") insertRouterTargetStmt-                    Tx.statement ("g1", "router-target-b") insertRouterTargetStmt-                    Tx.statement ("g1", "router-target-c") insertRouterTargetStmt-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-            Right (RouterResult rs1) <--                _runner $-                    runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")-            rs1 `shouldSatisfy` all isAppended-            Right (RouterResult rs2) <--                _runner $-                    runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")-            length rs2 `shouldBe` 3-            rs2 `shouldSatisfy` all isDuplicate-            -- Replay added nothing: each target stream still holds exactly one event.-            Right targetA <--                _runner $-                    Store.readStreamForward (StreamName "router-target-a") (StreamVersion 0) 10-            Right targetB <--                _runner $-                    Store.readStreamForward (StreamName "router-target-b") (StreamVersion 0) 10-            Right targetC <--                _runner $-                    Store.readStreamForward (StreamName "router-target-c") (StreamVersion 0) 10-            Vector.length targetA `shouldBe` 1-            Vector.length targetB `shouldBe` 1-            Vector.length targetC `shouldBe` 1--        it "dedups by target identity when a redelivered resolve reorders targets after a partial dispatch" $ \(_storeHandle, StoreRunner _runner) -> do-            attemptsRef <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                router = unstableRouter attemptsRef $ \case-                    0 -> ["swap-a"]-                    _ -> ["swap-b", "swap-a"]-            Right (RouterResult firstAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            firstAttempt `shouldSatisfy` all isAppended-            Right (RouterResult secondAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            secondAttempt `shouldSatisfy` \case-                [swapB, swapA] -> isAppended swapB && isDuplicate swapA-                _ -> False-            Right swapAEvents <--                _runner $-                    Store.readStreamForward (StreamName "swap-a") (StreamVersion 0) 10-            Right swapBEvents <--                _runner $-                    Store.readStreamForward (StreamName "swap-b") (StreamVersion 0) 10-            Vector.length swapAEvents `shouldBe` 1-            Vector.length swapBEvents `shouldBe` 1--        it "dispatches a target added by resolve drift instead of misreading it as a duplicate" $ \(_storeHandle, StoreRunner _runner) -> do-            attemptsRef <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                router = unstableRouter attemptsRef $ \case-                    0 -> ["growth-a", "growth-b"]-                    _ -> ["growth-a", "growth-c"]-            Right (RouterResult firstAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            firstAttempt `shouldSatisfy` all isAppended-            Right (RouterResult secondAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            secondAttempt `shouldSatisfy` \case-                [growthA, growthC] -> isDuplicate growthA && isAppended growthC-                _ -> False-            Right growthAEvents <--                _runner $-                    Store.readStreamForward (StreamName "growth-a") (StreamVersion 0) 10-            Right growthBEvents <--                _runner $-                    Store.readStreamForward (StreamName "growth-b") (StreamVersion 0) 10-            Right growthCEvents <--                _runner $-                    Store.readStreamForward (StreamName "growth-c") (StreamVersion 0) 10-            Vector.length growthAEvents `shouldBe` 1-            Vector.length growthBEvents `shouldBe` 1-            Vector.length growthCEvents `shouldBe` 1--        it "keeps full-completion order swaps idempotent" $ \(_storeHandle, StoreRunner _runner) -> do-            attemptsRef <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                router = unstableRouter attemptsRef $ \case-                    0 -> ["order-a", "order-b"]-                    _ -> ["order-b", "order-a"]-            Right (RouterResult firstAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            firstAttempt `shouldSatisfy` all isAppended-            Right (RouterResult secondAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            secondAttempt `shouldSatisfy` all isDuplicate-            Right orderAEvents <--                _runner $-                    Store.readStreamForward (StreamName "order-a") (StreamVersion 0) 10-            Right orderBEvents <--                _runner $-                    Store.readStreamForward (StreamName "order-b") (StreamVersion 0) 10-            Vector.length orderAEvents `shouldBe` 1-            Vector.length orderBEvents `shouldBe` 1--        it "keeps dispatches to targets dropped by a later resolve attempt" $ \(_storeHandle, StoreRunner _runner) -> do-            attemptsRef <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                router = unstableRouter attemptsRef $ \case-                    0 -> ["drop-a", "drop-b"]-                    _ -> ["drop-b"]-            Right (RouterResult firstAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            firstAttempt `shouldSatisfy` all isAppended-            Right (RouterResult secondAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            secondAttempt `shouldSatisfy` \case-                [dropB] -> isDuplicate dropB-                _ -> False-            -- Resolve is authoritative per attempt. Across redeliveries, the-            -- dispatched set is the union of each attempt's resolved targets.-            Right dropAEvents <--                _runner $-                    Store.readStreamForward (StreamName "drop-a") (StreamVersion 0) 10-            Right dropBEvents <--                _runner $-                    Store.readStreamForward (StreamName "drop-b") (StreamVersion 0) 10-            Vector.length dropAEvents `shouldBe` 1-            Vector.length dropBEvents `shouldBe` 1--        it "keeps repeated commands to one target distinct within a resolve batch" $ \(_storeHandle, StoreRunner _runner) -> do-            attemptsRef <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                router = unstableRouter attemptsRef (const ["twin", "twin"])-            Right (RouterResult firstAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            firstAttempt `shouldSatisfy` all isAppended-            Right twinEventsAfterFirstAttempt <--                _runner $-                    Store.readStreamForward (StreamName "twin") (StreamVersion 0) 10-            Vector.length twinEventsAfterFirstAttempt `shouldBe` 2-            Right (RouterResult secondAttempt) <--                _runner $-                    runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")-            secondAttempt `shouldSatisfy` all isDuplicate-            Right twinEventsAfterSecondAttempt <--                _runner $-                    Store.readStreamForward (StreamName "twin") (StreamVersion 0) 10-            Vector.length twinEventsAfterSecondAttempt `shouldBe` 2--        it "drains an adapter, dispatching one command per resolved target for every message" $ \(_storeHandle, StoreRunner _runner) -> do-            Right () <--                _runner $-                    initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable-            Right () <- _runner $-                Store.runTransaction $ do-                    Tx.statement ("g1", "worker-a") insertRouterTargetStmt-                    Tx.statement ("g1", "worker-b") insertRouterTargetStmt-                    Tx.statement ("g2", "worker-c") insertRouterTargetStmt-            decisionsRef <- newIORef []-            let sourceEvent1 = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                sourceEvent2 = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)-                messages =-                    [ (sourceEvent1, RouteGroup "g1")-                    , (sourceEvent2, RouteGroup "g2")-                    ]-                adapter = inMemoryAdapter decisionsRef messages-            Right () <--                _runner $-                    runRouterWorker defaultRunCommandOptions demoRouter adapter Just-            decisions <- readIORef decisionsRef-            decisions `shouldBe` [AckOk, AckOk]-            Right wa <--                _runner $-                    Store.readStreamForward (StreamName "worker-a") (StreamVersion 0) 10-            Right wb <--                _runner $-                    Store.readStreamForward (StreamName "worker-b") (StreamVersion 0) 10-            Right wc <--                _runner $-                    Store.readStreamForward (StreamName "worker-c") (StreamVersion 0) 10-            Vector.length wa `shouldBe` 1-            Vector.length wb `shouldBe` 1-            Vector.length wc `shouldBe` 1--        it "finalizes AckHalt rather than AckOk when a dispatched command fails" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                messages = [(sourceEvent, RouteGroup "g1")]-                adapter = inMemoryAdapter decisionsRef messages-            Right () <--                _runner $-                    runRouterWorker defaultRunCommandOptions failingRouter adapter Just-            decisions <- readIORef decisionsRef-            decisions `shouldSatisfy` \case-                [AckHalt (HaltFatal _)] -> True-                _ -> False--        it "dead-letters a rejected router dispatch and acknowledges the source event" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                adapter = inMemoryAdapter decisionsRef [(sourceEvent, RouteGroup "g1")]-                workerOptions = defaultWorkerOptions & #rejectedCommandPolicy .~ RejectedDeadLetter-            Right () <--                _runner $-                    runRouterWorkerWith workerOptions defaultRunCommandOptions failingRouter adapter Just-            readIORef decisionsRef `shouldReturn` [AckOk]-            Right deadLetters <- _runner (listDispatchDeadLetters "failing-router")-            case deadLetters of-                [row] -> do-                    row ^. #dispatcherKind `shouldBe` DispatcherRouter-                    row ^. #correlationId `shouldBe` "g1"-                    row ^. #targetStreamName `shouldBe` StreamName "failing-target"-                    row ^. #errorClass `shouldBe` "command_rejected"-                other -> expectationFailure ("expected one router dead letter, got " <> show other)--        it "finalizes AckRetry for a transient thrown resolver error and continues" $ \(_storeHandle, StoreRunner _runner) -> do-            Right () <--                _runner $-                    initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable-            Right () <--                _runner $-                    Store.runTransaction (Tx.statement ("g2", "worker-after-retry") insertRouterTargetStmt)-            decisionsRef <- newIORef []-            attemptsRef <- newIORef (0 :: Int)-            let sourceEvent1 = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                sourceEvent2 = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)-                messages = [(sourceEvent1, RouteGroup "g1"), (sourceEvent2, RouteGroup "g2")]-                adapter = inMemoryAdapter decisionsRef messages-                flakyRouter ::-                    (IOE :> es, Store :> es, Error Store.StoreError :> es) =>-                    Router RouteGroup (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent es-                flakyRouter =-                    Router-                        { name = "flaky-router"-                        , key = \(RouteGroup g) -> g-                        , resolve = \(RouteGroup g) -> do-                            attempt <- liftIO (atomicModifyIORef' attemptsRef (\n -> (n + 1, n)))-                            if attempt == 0-                                then throwError (Store.ConnectionLost "injected")-                                else do-                                    result <- runQuery Nothing routerTargetsReadModel g-                                    pure $ case result of-                                        Right targetIds ->-                                            [ PMCommand{target = stream targetId, command = Add 1}-                                            | targetId <- targetIds-                                            ]-                                        Left _ -> []-                        , targetEventStream = counterEventStream-                        , targetProjections = const []-                        }-            Right () <--                _runner $-                    runRouterWorker defaultRunCommandOptions flakyRouter adapter Just-            decisions <- readIORef decisionsRef-            decisions `shouldSatisfy` \case-                [AckRetry{}, AckOk] -> True-                _ -> False--        it "finalizes AckHalt for a deterministic thrown resolver error" $ \(_storeHandle, StoreRunner _runner) -> do-            decisionsRef <- newIORef []-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                messages = [(sourceEvent, RouteGroup "g1")]-                adapter = inMemoryAdapter decisionsRef messages-                failingResolveRouter ::-                    (Error Store.StoreError :> es) =>-                    Router RouteGroup (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent es-                failingResolveRouter =-                    Router-                        { name = "failing-resolve-router"-                        , key = \(RouteGroup g) -> g-                        , resolve = \_ -> throwError (Store.UnexpectedServerError "XX000" "boom")-                        , targetEventStream = counterEventStream-                        , targetProjections = const []-                        }-            Right () <--                _runner $-                    runRouterWorker defaultRunCommandOptions failingResolveRouter adapter Just-            decisions <- readIORef decisionsRef-            decisions `shouldSatisfy` \case-                [AckHalt (HaltFatal _)] -> True-                _ -> False--        it "folds a concurrent duplicate router dispatch to PMCommandDuplicate" $ \(_storeHandle, StoreRunner _runner) -> do-            Right () <--                _runner $-                    initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable-            Right () <--                _runner $-                    Store.runTransaction (Tx.statement ("g1", "router-duplicate-target") insertRouterTargetStmt)-            insertCount <- newIORef (0 :: Int)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                targetStreamName = StreamName "router-duplicate-target"-                commandId =-                    deterministicRouterCommandId-                        "demo-router"-                        "g1"-                        (sourceEvent ^. #eventId)-                        targetStreamName-                        0-                insertConcurrentTarget = do-                    callNo <- atomicModifyIORef' insertCount (\n -> (n + 1, n))-                    when (callNo == 0) $ appendCounterEventWithId _storeHandle targetStreamName commandId (CounterAdded 1)-                options =-                    defaultRunCommandOptions-                        & #beforeAppend-                        .~ insertConcurrentTarget-                        & #retryBackoffMicros-                        .~ 0-            result <--                _runner $-                    runRouterOnce options demoRouter sourceEvent (RouteGroup "g1")-            case result of-                Right (RouterResult [PMCommandDuplicate duplicateId]) ->-                    duplicateId `shouldBe` commandId-                other -> expectationFailure ("expected duplicate router dispatch fold, got " <> show other)-            Right targetEvents <--                _runner $-                    Store.readStreamForward targetStreamName (StreamVersion 0) 10-            Vector.length targetEvents `shouldBe` 1--        it "dedups a pre-upgrade positional router dispatch during the transition" $ \(_storeHandle, StoreRunner _runner) -> do-            Right () <--                _runner $-                    initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable-            Right () <--                _runner $-                    Store.runTransaction (Tx.statement ("g1", "transition-target") insertRouterTargetStmt)-            let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)-                legacyId = deterministicCommandId "demo-router" "g1" (sourceEvent ^. #eventId) 0-                targetStreamName = StreamName "transition-target"-            appendCounterEventWithId _storeHandle targetStreamName legacyId (CounterAdded 1)-            result <--                _runner $-                    runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")-            case result of-                Right (RouterResult [PMCommandDuplicate duplicateId]) ->-                    duplicateId `shouldBe` legacyId-                other -> expectationFailure ("expected transition duplicate, got " <> show other)-            Right targetEvents <--                _runner $-                    Store.readStreamForward targetStreamName (StreamVersion 0) 10-            Vector.length targetEvents `shouldBe` 1--    describe "Keiro.Timer" $ around (withFreshStore fixture) $ do-        it "validates worker options before startup" $ \_storeHandle -> do-            shouldBeRight_ (mkTimerWorkerOptions defaultTimerWorkerOptions)-            mkTimerWorkerOptions (defaultTimerWorkerOptions & #maxAttempts ?~ (-1))-                `shouldBeLeft` InvalidTimerMaxAttempts (-1)-            mkTimerWorkerOptions (defaultTimerWorkerOptions & #requeueStuckAfter ?~ 0)-                `shouldBeLeft` InvalidTimerRequeueStuckAfter 0--        it "claims a due timer, fires a command, and marks it complete once" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            let firedEventId = EventId sampleUuid2-            workerResult <- Store.runStoreIO storeHandle $-                runTimerWorker Nothing dueTimerTime $ \_ -> do-                    fired <--                        runCommand-                            (defaultRunCommandOptions & #eventIds .~ [firedEventId])-                            counterEventStream-                            (stream "timer-target")-                            (Add 11)-                    case fired of-                        Right _ -> pure (Just firedEventId)-                        Left err -> liftIO (expectationFailure ("expected timer command to fire, got " <> show err)) *> pure Nothing-            case workerResult of-                Right (Just timer) ->-                    timer ^. #status `shouldBe` Firing-                other -> expectationFailure ("expected fired timer, got " <> show other)-            secondWorkerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker Nothing dueTimerTime (\_ -> pure (Just firedEventId))-            secondWorkerResult `shouldBe` Right Nothing-            Right targetEvents <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "timer-target") (StreamVersion 0) 10-            fmap (^. #eventId) (Vector.toList targetEvents) `shouldBe` [firedEventId]--        it "records timer backlog, fire lag, attempts, and stuck count" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            let firedEventId = EventId sampleUuid2-            workerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker (Just keiroMetrics) dueTimerTime (\_ -> pure (Just firedEventId))-            case workerResult of-                Right (Just _) -> pure ()-                other -> expectationFailure ("expected a fired timer, got " <> show other)-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-                hists = flattenHistogramPoints exported-            -- One scheduled+due row at the start of the pass: backlog gauge holds 1.-            lookup "keiro.timer.backlog" scalars `shouldBe` Just (IntNumber 1)-            -- Nothing was stranded in 'firing' before this pass: stuck gauge holds 0.-            lookup "keiro.timer.stuck" scalars `shouldBe` Just (IntNumber 0)-            -- The claimed timer was due exactly at 'now' and is on its first attempt:-            -- one fire.lag observation of 0 ms and one attempts observation of 1.-            [(c, s) | (n, c, s) <- hists, n == "keiro.timer.fire.lag"] `shouldBe` [(1, 0.0)]-            [(c, s) | (n, c, s) <- hists, n == "keiro.timer.attempts"] `shouldBe` [(1, 1.0)]--        it "finds a firing timer with findStuckTimers and requeues it for re-firing" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            -- Strand it in Firing by claiming without firing.-            claimed <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            case claimed of-                Right (Just timer) -> timer ^. #status `shouldBe` Firing-                other -> expectationFailure ("expected a claimed timer, got " <> show other)-            -- It surfaces as stuck under the permissive filter.-            Right stuck <--                Store.runStoreIO storeHandle $-                    findStuckTimers dueTimerTime anyStuckTimer-            fmap (^. #timerId) stuck `shouldBe` [counterTimerRequest ^. #timerId]-            -- A bound it does not meet (only one attempt) excludes it.-            Right unmatched <--                Store.runStoreIO storeHandle $-                    findStuckTimers dueTimerTime (StuckTimerFilter Nothing (Just 5))-            unmatched `shouldBe` []-            -- Requeue is idempotent: True the first time, False once it is scheduled.-            requeued <--                Store.runStoreIO storeHandle $-                    requeueStuckTimer (counterTimerRequest ^. #timerId)-            requeued `shouldBe` Right True-            requeuedAgain <--                Store.runStoreIO storeHandle $-                    requeueStuckTimer (counterTimerRequest ^. #timerId)-            requeuedAgain `shouldBe` Right False-            -- The ordinary loop re-claims and fires it exactly once.-            let firedEventId = EventId sampleUuid2-            workerResult <- Store.runStoreIO storeHandle $-                runTimerWorker Nothing dueTimerTime $ \_ -> do-                    fired <--                        runCommand-                            (defaultRunCommandOptions & #eventIds .~ [firedEventId])-                            counterEventStream-                            (stream "timer-target")-                            (Add 7)-                    case fired of-                        Right _ -> pure (Just firedEventId)-                        Left err -> liftIO (expectationFailure ("expected timer command to fire, got " <> show err)) *> pure Nothing-            case workerResult of-                Right (Just timer) ->-                    timer ^. #status `shouldBe` Firing-                other -> expectationFailure ("expected re-fired timer, got " <> show other)-            secondWorkerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker Nothing dueTimerTime (\_ -> pure (Just firedEventId))-            secondWorkerResult `shouldBe` Right Nothing-            Right targetEvents <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "timer-target") (StreamVersion 0) 10-            fmap (^. #eventId) (Vector.toList targetEvents) `shouldBe` [firedEventId]--        it "re-fires a timer stranded by a crashed worker" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            Right (Just claimed) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            claimed ^. #status `shouldBe` Firing-            realNow <- getCurrentTime-            firedRef <- newIORef []-            let futureNow = addUTCTime 400 realNow-                firedEventId = EventId sampleUuid2-            workerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker Nothing futureNow $ \timer -> do-                        liftIO (modifyIORef' firedRef (<> [timer ^. #timerId]))-                        pure (Just firedEventId)-            case workerResult of-                Right (Just timer) -> timer ^. #timerId `shouldBe` counterTimerRequest ^. #timerId-                other -> expectationFailure ("expected stale timer to be requeued and claimed, got " <> show other)-            firedTimers <- readIORef firedRef-            firedTimers `shouldBe` [counterTimerRequest ^. #timerId]-            Right statusRow <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement sampleUuid timerStatusAndErrorStmt-            statusRow `shouldBe` Just ("fired", Nothing)-            secondWorkerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker Nothing futureNow (\_ -> pure (Just firedEventId))-            secondWorkerResult `shouldBe` Right Nothing--        it "does not requeue a fresh firing row" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            realNow <- getCurrentTime-            firedRef <- newIORef False-            workerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker Nothing realNow $ \_ -> do-                        liftIO (writeIORef firedRef True)-                        pure (Just (EventId sampleUuid2))-            workerResult `shouldBe` Right Nothing-            didFire <- readIORef firedRef-            didFire `shouldBe` False-            Right statusRow <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement sampleUuid timerStatusAndErrorStmt-            statusRow `shouldBe` Just ("firing", Nothing)--        it "requeueStuckAfter = Nothing preserves a stranded firing row" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            realNow <- getCurrentTime-            firedRef <- newIORef False-            let opts = defaultTimerWorkerOptions & #requeueStuckAfter .~ Nothing-            workerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorkerWith Nothing opts (addUTCTime 400 realNow) $ \_ -> do-                        liftIO (writeIORef firedRef True)-                        pure (Just (EventId sampleUuid2))-            workerResult `shouldBe` Right Nothing-            didFire <- readIORef firedRef-            didFire `shouldBe` False-            Right statusRow <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement sampleUuid timerStatusAndErrorStmt-            statusRow `shouldBe` Just ("firing", Nothing)--        it "does not claim a cancelled timer" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            cancelled <--                Store.runStoreIO storeHandle $-                    cancelTimer (counterTimerRequest ^. #timerId)-            cancelled `shouldBe` Right True-            claimed <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            claimed `shouldBe` Right Nothing-            cancelledAgain <--                Store.runStoreIO storeHandle $-                    cancelTimer (counterTimerRequest ^. #timerId)-            cancelledAgain `shouldBe` Right False--        it "dead-letters a timer that exceeds the attempt ceiling and never reclaims it" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            firedRef <- newIORef False-            let firedEventId = EventId sampleUuid2-            -- maxAttempts = Just 0: the first claim sets attempts = 1 > 0, so the-            -- worker dead-letters instead of firing.-            result <- Store.runStoreIO storeHandle $-                runTimerWorkerWith Nothing (defaultTimerWorkerOptions & #maxAttempts .~ Just 0) dueTimerTime $ \_ -> do-                    liftIO (writeIORef firedRef True)-                    pure (Just firedEventId)-            case result of-                Right (Just timer) ->-                    timer ^. #status `shouldBe` Firing-                other -> expectationFailure ("expected a claimed timer, got " <> show other)-            -- The fire action never ran.-            didFire <- readIORef firedRef-            didFire `shouldBe` False-            -- The row landed in 'dead' with the expected reason in last_error.-            Right statusRow <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement sampleUuid timerStatusAndErrorStmt-            statusRow `shouldBe` Just ("dead", Just "timer exceeded attempt ceiling of 0")-            -- A dead row is never re-claimed.-            secondWorkerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker Nothing dueTimerTime (\_ -> pure (Just firedEventId))-            secondWorkerResult `shouldBe` Right Nothing--        it "markTimerFired does not resurrect a dead timer" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            deadened <--                Store.runStoreIO storeHandle $-                    deadLetterTimer (counterTimerRequest ^. #timerId) "operator dead-letter"-            deadened `shouldBe` Right True-            marked <--                Store.runStoreIO storeHandle $-                    markTimerFired (counterTimerRequest ^. #timerId) (EventId sampleUuid2)-            marked `shouldBe` Right False-            Right statusRow <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement sampleUuid timerStatusAndErrorStmt-            statusRow `shouldBe` Just ("dead", Just "operator dead-letter")--        it "records a row stranded in Firing in the stuck gauge" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        scheduleTimerTx counterTimerRequest-            -- Strand it in Firing by claiming without firing (a crashed worker).-            Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime-            -- A later pass finds nothing scheduled and due, but sees the stranded row.-            workerResult <--                Store.runStoreIO storeHandle $-                    runTimerWorker (Just keiroMetrics) dueTimerTime (\_ -> pure Nothing)-            workerResult `shouldBe` Right Nothing-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            -- The one firing row is counted as stuck.-            lookup "keiro.timer.stuck" scalars `shouldBe` Just (IntNumber 1)-            -- It is not 'scheduled', so it does not show up as backlog.-            lookup "keiro.timer.backlog" scalars `shouldBe` Just (IntNumber 0)--    describe "Keiro.Outbox.Kafka" $ do-        it "converts an outbox row to a Kafka producer record" $ do-            let envelope = sampleIntegrationEnvelope-                row = sampleOutboxRow envelope-                record = OutboxKafka.outboxRowToKafkaRecord row-            record ^. #topic `shouldBe` envelope ^. #destination-            record ^. #key `shouldBe` Just "order-123"-            record ^. #payload `shouldBe` envelope ^. #payloadBytes-            -- Headers include identity fields and content type.-            let headers = record ^. #headers-                messageIdHeader = Prelude.lookup "keiro-message-id" headers-            messageIdHeader `shouldBe` Just "018f0f18-17aa-7000-8000-0000000000aa"--        it "drops the partition key when the envelope has no key" $ do-            let envelope = sampleIntegrationEnvelope & #key .~ Nothing-                record = OutboxKafka.integrationEventToKafkaRecord envelope-            record ^. #key `shouldBe` Nothing--    describe "Keiro.Outbox" $ around (withFreshStore fixture) $ do-        it "validates publisher options before startup" $ \_storeHandle -> do-            shouldBeRight_ (mkOutboxPublishOptions defaultPublishOptions)-            mkOutboxPublishOptions (defaultPublishOptions & #batchSize .~ 0)-                `shouldBeLeft` InvalidOutboxBatchSize 0-            mkOutboxPublishOptions (defaultPublishOptions & #maxAttempts .~ 0)-                `shouldBeLeft` InvalidOutboxMaxAttempts 0-            mkOutboxPublishOptions (defaultPublishOptions & #publishingTimeout .~ 0)-                `shouldBeLeft` InvalidOutboxPublishingTimeout 0-            mkOutboxPublishOptions (defaultPublishOptions & #backoff .~ ConstantBackoff (-1))-                `shouldBeLeft` InvalidConstantBackoff (-1)-            mkOutboxPublishOptions-                ( defaultPublishOptions-                    & #backoff-                    .~ ExponentialBackoff-                        ExponentialBackoffOptions-                            { initial = 0-                            , maxDelay = 1-                            , multiplier = 2-                            }-                )-                `shouldBeLeft` InvalidExponentialBackoffInitial 0-            mkOutboxPublishOptions-                ( defaultPublishOptions-                    & #backoff-                    .~ ExponentialBackoff-                        ExponentialBackoffOptions-                            { initial = 1-                            , maxDelay = 10-                            , multiplier = 0.5-                            }-                )-                `shouldBeLeft` InvalidExponentialBackoffMultiplier 0.5-            mkOutboxPublishOptions-                ( defaultPublishOptions-                    & #backoff-                    .~ ExponentialBackoff-                        ExponentialBackoffOptions-                            { initial = 5-                            , maxDelay = 4-                            , multiplier = 2-                            }-                )-                `shouldBeLeft` InvalidExponentialBackoffMaxDelay 5 4--        it "enqueues and looks up an outbox row" $ \storeHandle -> do-            let envelope = sampleIntegrationEnvelope-                oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid envelope)-            lookedUp <- Store.runStoreIO storeHandle (lookupOutbox oid)-            case lookedUp of-                Right (Just row) -> do-                    row ^. #outboxId `shouldBe` oid-                    row ^. #status `shouldBe` OutboxPending-                    row ^. #attemptCount `shouldBe` 0-                    row ^. #event . #messageId `shouldBe` envelope ^. #messageId-                    row ^. #event . #destination `shouldBe` envelope ^. #destination-                    row ^. #event . #payloadBytes `shouldBe` envelope ^. #payloadBytes-                other -> expectationFailure ("expected enqueued row, got " <> show other)--        it "claims a pending row, transitions it to publishing, and increments attempt count" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            now <- getCurrentTime-            Right rows <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            case rows of-                [row] -> do-                    row ^. #outboxId `shouldBe` oid-                    row ^. #status `shouldBe` OutboxPublishing-                    row ^. #attemptCount `shouldBe` 1-                other -> expectationFailure ("expected one claimed row, got " <> show other)--        it "claims contiguous per-key runs in one pass" $ \storeHandle -> do-            let keyedRows =-                    [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "run-a1" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "run-a2" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "run-a3" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 4, sampleIntegrationEnvelope & #messageId .~ "run-a4" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 5, sampleIntegrationEnvelope & #messageId .~ "run-a5" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 6, sampleIntegrationEnvelope & #messageId .~ "run-b1" & #key .~ Just "B")-                    , (outboxIdFromOrdinal 7, sampleIntegrationEnvelope & #messageId .~ "run-b2" & #key .~ Just "B")-                    , (outboxIdFromOrdinal 8, sampleIntegrationEnvelope & #messageId .~ "run-b3" & #key .~ Just "B")-                    ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) keyedRows-            now <- getCurrentTime-            Right rows <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            fmap (^. #outboxId) rows `shouldBe` fmap fst keyedRows-            fmap (^. #attemptCount) rows `shouldBe` replicate 8 1--        it "does not let a backoff head starve other keys" $ \storeHandle -> do-            let a1Id = outboxIdFromOrdinal 1-                a2Id = outboxIdFromOrdinal 2-                b1Id = outboxIdFromOrdinal 3-                b2Id = outboxIdFromOrdinal 4-                rows =-                    [ (a1Id, sampleIntegrationEnvelope & #messageId .~ "backoff-a1" & #key .~ Just "A")-                    , (a2Id, sampleIntegrationEnvelope & #messageId .~ "backoff-a2" & #key .~ Just "A")-                    , (b1Id, sampleIntegrationEnvelope & #messageId .~ "backoff-b1" & #key .~ Just "B")-                    , (b2Id, sampleIntegrationEnvelope & #messageId .~ "backoff-b2" & #key .~ Just "B")-                    ]-                failA1 row-                    | row ^. #outboxId == a1Id = pure (PublishFailed "wait")-                    | otherwise = pure PublishSucceeded-                opts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 1-                        & #backoff-                        .~ ConstantBackoff 3600-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            Right failedPass <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow failA1) opts Nothing)-            failedPass ^. #retried `shouldBe` 1-            now <- getCurrentTime-            Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            fmap (^. #outboxId) claimed `shouldBe` [b1Id, b2Id]-            Right (Just a2Row) <- Store.runStoreIO storeHandle (lookupOutbox a2Id)-            a2Row ^. #status `shouldBe` OutboxPending--        it "claims contiguous per-source runs in one pass" $ \storeHandle -> do-            let rows =-                    [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "source-a1" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "source-b1" & #key .~ Just "B")-                    , (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "source-a2" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 4, sampleIntegrationEnvelope & #messageId .~ "source-b2" & #key .~ Just "B")-                    ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            now <- getCurrentTime-            Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerSourceStream 10 now)-            fmap (^. #outboxId) claimed `shouldBe` fmap fst rows--        it "claims null-keyed rows freely alongside keyed runs" $ \storeHandle -> do-            let rows =-                    [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "null-1" & #key .~ Nothing)-                    , (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "keyed-1" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "null-2" & #key .~ Nothing)-                    , (outboxIdFromOrdinal 4, sampleIntegrationEnvelope & #messageId .~ "keyed-2" & #key .~ Just "A")-                    ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            now <- getCurrentTime-            Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            fmap (^. #outboxId) claimed `shouldBe` fmap fst rows--        it "does not claim a tail while the previous run is still publishing" $ \storeHandle -> do-            let rows =-                    [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "publishing-a1" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "publishing-a2" & #key .~ Just "A")-                    , (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "publishing-a3" & #key .~ Just "A")-                    ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            now <- getCurrentTime-            Right firstClaim <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            fmap (^. #outboxId) firstClaim `shouldBe` fmap fst rows-            Right secondClaim <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            secondClaim `shouldBe` []--        it "marks a claimed row as sent with published_at set" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            now <- getCurrentTime-            Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            Right True <- Store.runStoreIO storeHandle (markOutboxSent oid now)-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxSent-            row ^. #publishedAt `shouldSatisfy` isJust-            row ^. #lastError `shouldBe` Nothing--        it "reclaims a row stranded in publishing by a crashed worker through maintenance" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            now <- getCurrentTime-            let pastNow = addUTCTime (-3600) now-            Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            Right () <- Store.runStoreIO storeHandle (backdateOutboxUpdatedAt oid pastNow)-            Right (Just stranded) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            stranded ^. #status `shouldBe` OutboxPublishing-            publishedRef <- newIORef (0 :: Int)-            let publish _ = do-                    liftIO (modifyIORef' publishedRef (+ 1))-                    pure PublishSucceeded-            Right noPublish <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)-            noPublish ^. #claimed `shouldBe` 0-            Right (Just stillStranded) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            stillStranded ^. #status `shouldBe` OutboxPublishing-            Right maintenance <- Store.runStoreIO storeHandle (outboxMaintenancePass defaultMaintenanceOptions Nothing)-            maintenance ^. #requeued `shouldBe` 1-            maintenance ^. #deadLettered `shouldBe` 0-            Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)-            summary ^. #published `shouldBe` 1-            published <- readIORef publishedRef-            published `shouldBe` 1-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxSent--        it "head-of-line traffic unwedges after reclaim" $ \storeHandle -> do-            let firstId = OutboxId outboxUuid1-                secondId = OutboxId outboxUuid2-                first = sampleIntegrationEnvelope & #messageId .~ "stuck-first" & #key .~ Just "same-key"-                second = sampleIntegrationEnvelope & #messageId .~ "stuck-second" & #key .~ Just "same-key"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx firstId first)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx secondId second)-            now <- getCurrentTime-            let pastNow = addUTCTime (-3600) now-            Right [claimedFirst] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 1 now)-            claimedFirst ^. #outboxId `shouldBe` firstId-            Right () <- Store.runStoreIO storeHandle (backdateOutboxUpdatedAt firstId pastNow)-            publishedRef <- newIORef []-            let publish row = do-                    liftIO (modifyIORef' publishedRef (<> [row ^. #outboxId]))-                    pure PublishSucceeded-            Right maintenance <- Store.runStoreIO storeHandle (outboxMaintenancePass defaultMaintenanceOptions Nothing)-            maintenance ^. #requeued `shouldBe` 1-            Right firstPass <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)-            firstPass ^. #published `shouldBe` 2-            Right secondPass <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)-            secondPass ^. #published `shouldBe` 0-            published <- readIORef publishedRef-            published `shouldBe` [firstId, secondId]-            Right (Just secondRow) <- Store.runStoreIO storeHandle (lookupOutbox secondId)-            secondRow ^. #status `shouldBe` OutboxSent--        it "does not reclaim a recently claimed row" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            now <- getCurrentTime-            Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            publishedRef <- newIORef (0 :: Int)-            let publish _ = do-                    liftIO (modifyIORef' publishedRef (+ 1))-                    pure PublishSucceeded-            Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)-            summary ^. #claimed `shouldBe` 0-            published <- readIORef publishedRef-            published `shouldBe` 0-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxPublishing--        it "a throwing batch publish callback fails every row in that publish call" $ \storeHandle -> do-            let throwId = OutboxId outboxUuid1-                okId = OutboxId outboxUuid2-                throwEvent = sampleIntegrationEnvelope & #messageId .~ "throwing-publish" & #key .~ Just "throw-key"-                okEvent = sampleIntegrationEnvelope & #messageId .~ "ok-after-throw" & #key .~ Just "ok-key"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx throwId throwEvent)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx okId okEvent)-            let publish row-                    | row ^. #outboxId == throwId = liftIO (throwIO (userError "kafka exploded"))-                    | otherwise = pure PublishSucceeded-            Right summary <--                Store.runStoreIO storeHandle $-                    publishClaimedOutbox (perRow publish) (defaultPublishOptions & #backoff .~ ConstantBackoff 0) Nothing-            summary ^. #retried `shouldBe` 2-            summary ^. #published `shouldBe` 0-            Right (Just throwRow) <- Store.runStoreIO storeHandle (lookupOutbox throwId)-            throwRow ^. #status `shouldBe` OutboxFailed-            throwRow ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "kafka exploded")-            Right (Just okRow) <- Store.runStoreIO storeHandle (lookupOutbox okId)-            okRow ^. #status `shouldBe` OutboxFailed-            okRow ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "kafka exploded")--        it "a row that exhausts attempts while crash-looping is dead-lettered by maintenance" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-                opts = defaultMaintenanceOptions & #maxAttempts .~ 1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            now <- getCurrentTime-            let pastNow = addUTCTime (-3600) now-            Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            Right () <- Store.runStoreIO storeHandle (backdateOutboxUpdatedAt oid pastNow)-            Right summary <- Store.runStoreIO storeHandle (outboxMaintenancePass opts Nothing)-            summary ^. #requeued `shouldBe` 0-            summary ^. #deadLettered `shouldBe` 1-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxDead--        it "markOutboxSent does not resurrect a dead row" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-                opts = defaultPublishOptions & #maxAttempts .~ 1 & #backoff .~ ConstantBackoff 0-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            let publish _ = pure (PublishFailed "boom")-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)-            now <- getCurrentTime-            Right marked <- Store.runStoreIO storeHandle (markOutboxSent oid now)-            marked `shouldBe` False-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxDead--        it "publishClaimedOutbox marks success and records failures with last_error" $ \storeHandle -> do-            let okId = OutboxId outboxUuid1-                failId = OutboxId outboxUuid2-                okEvent = sampleIntegrationEnvelope-                failEvent =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "msg-fail-1"-                        & #key-                        .~ Just "order-789"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx okId okEvent)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx failId failEvent)-            let publish row-                    | row ^. #outboxId == okId = pure PublishSucceeded-                    | otherwise = pure (PublishFailed "broker unreachable")-            Right summary <--                Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)-            summary ^. #claimed `shouldBe` 2-            summary ^. #published `shouldBe` 1-            summary ^. #retried `shouldBe` 1-            summary ^. #dead `shouldBe` 0-            Right (Just okRow) <- Store.runStoreIO storeHandle (lookupOutbox okId)-            okRow ^. #status `shouldBe` OutboxSent-            Right (Just failRow) <- Store.runStoreIO storeHandle (lookupOutbox failId)-            failRow ^. #status `shouldBe` OutboxFailed-            failRow ^. #lastError `shouldBe` Just "broker unreachable"--        it "publishClaimedOutbox hands a same-key run to one batch publish call" $ \storeHandle -> do-            let rows =-                    [ (outboxIdFromOrdinal (fromIntegral i), sampleIntegrationEnvelope & #messageId .~ ("batch-ok-" <> Text.pack (show i)) & #key .~ Just "batch-key")-                    | i <- [1 .. 10 :: Int]-                    ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            invocationRef <- newIORef (0 :: Int)-            let publish claimed = do-                    liftIO (modifyIORef' invocationRef (+ 1))-                    pure [(row ^. #outboxId, PublishSucceeded) | row <- claimed]-            Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox publish defaultPublishOptions Nothing)-            summary ^. #claimed `shouldBe` 10-            summary ^. #published `shouldBe` 10-            invocations <- readIORef invocationRef-            invocations `shouldBe` 1-            for_ (fmap fst rows) $ \oid -> do-                Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-                row ^. #status `shouldBe` OutboxSent--        it "publishClaimedOutbox skips the same-key suffix after a mid-run failure" $ \storeHandle -> do-            let row1Id = outboxIdFromOrdinal 1-                row2Id = outboxIdFromOrdinal 2-                row3Id = outboxIdFromOrdinal 3-                row4Id = outboxIdFromOrdinal 4-                row5Id = outboxIdFromOrdinal 5-                ids = [row1Id, row2Id, row3Id, row4Id, row5Id]-                rows =-                    [ (oid, sampleIntegrationEnvelope & #messageId .~ ("batch-fail-" <> Text.pack (show i)) & #key .~ Just "batch-fail-key")-                    | (i, oid) <- zip [1 .. 5 :: Int] ids-                    ]-                publish claimed =-                    pure-                        [ ( row ^. #outboxId-                          , if row ^. #outboxId == row3Id-                                then PublishFailed "pivot failed"-                                else PublishSucceeded-                          )-                        | row <- claimed-                        ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            Right summary <--                Store.runStoreIO storeHandle $-                    publishClaimedOutbox publish (defaultPublishOptions & #backoff .~ ConstantBackoff 0) Nothing-            summary ^. #published `shouldBe` 2-            summary ^. #retried `shouldBe` 3-            Right (Just row1) <- Store.runStoreIO storeHandle (lookupOutbox row1Id)-            Right (Just row2) <- Store.runStoreIO storeHandle (lookupOutbox row2Id)-            Right (Just row3) <- Store.runStoreIO storeHandle (lookupOutbox row3Id)-            Right (Just row4) <- Store.runStoreIO storeHandle (lookupOutbox row4Id)-            Right (Just row5) <- Store.runStoreIO storeHandle (lookupOutbox row5Id)-            row1 ^. #status `shouldBe` OutboxSent-            row2 ^. #status `shouldBe` OutboxSent-            row3 ^. #status `shouldBe` OutboxFailed-            row3 ^. #attemptCount `shouldBe` 1-            row3 ^. #lastError `shouldBe` Just "pivot failed"-            row4 ^. #status `shouldBe` OutboxFailed-            row4 ^. #attemptCount `shouldBe` 0-            row4 ^. #lastError `shouldBe` Just "skipped: earlier record for the same key failed"-            row5 ^. #status `shouldBe` OutboxFailed-            row5 ^. #attemptCount `shouldBe` 0--        it "PerSourceStream keeps one source's failure from skipping another source's rows" $ \storeHandle -> do-            let rowA1 = outboxIdFromOrdinal 1-                rowB1 = outboxIdFromOrdinal 2-                rowA2 = outboxIdFromOrdinal 3-                rowB2 = outboxIdFromOrdinal 4-                mkRow oid src msgId =-                    (oid, sampleIntegrationEnvelope & #messageId .~ msgId & #source .~ src & #key .~ Nothing)-                rows =-                    [ mkRow rowA1 "per-source-a" "ps-a1"-                    , mkRow rowB1 "per-source-b" "ps-b1"-                    , mkRow rowA2 "per-source-a" "ps-a2"-                    , mkRow rowB2 "per-source-b" "ps-b2"-                    ]-                publish claimed =-                    pure-                        [ ( row ^. #outboxId-                          , if row ^. #outboxId == rowA2-                                then PublishFailed "source-a pivot failed"-                                else PublishSucceeded-                          )-                        | row <- claimed-                        ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            Right summary <--                Store.runStoreIO storeHandle $-                    publishClaimedOutbox publish (defaultPublishOptions & #orderingPolicy .~ PerSourceStream & #backoff .~ ConstantBackoff 0) Nothing-            summary ^. #claimed `shouldBe` 4-            summary ^. #published `shouldBe` 3-            summary ^. #retried `shouldBe` 1-            Right (Just a1) <- Store.runStoreIO storeHandle (lookupOutbox rowA1)-            Right (Just a2) <- Store.runStoreIO storeHandle (lookupOutbox rowA2)-            Right (Just b1) <- Store.runStoreIO storeHandle (lookupOutbox rowB1)-            Right (Just b2) <- Store.runStoreIO storeHandle (lookupOutbox rowB2)-            a1 ^. #status `shouldBe` OutboxSent-            a2 ^. #status `shouldBe` OutboxFailed-            a2 ^. #attemptCount `shouldBe` 1-            a2 ^. #lastError `shouldBe` Just "source-a pivot failed"-            b1 ^. #status `shouldBe` OutboxSent-            b2 ^. #status `shouldBe` OutboxSent--        it "a late failure mark does not clobber a row that already reached a terminal state" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)-            now <- getCurrentTime-            Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            Right True <- Store.runStoreIO storeHandle (markOutboxSent oid now)-            Right _ <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (markOutboxFailedTx oid "late failure from a timed-out worker" 5 60 now)-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxSent-            row ^. #lastError `shouldBe` Nothing--        it "claims nothing while another transaction holds an uncommitted claim on a key's head" $ \storeHandle -> do-            let headId = outboxIdFromOrdinal 1-                tailId = outboxIdFromOrdinal 2-                rows =-                    [ (headId, sampleIntegrationEnvelope & #messageId .~ "claim-race-1" & #key .~ Just "claim-race-key")-                    , (tailId, sampleIntegrationEnvelope & #messageId .~ "claim-race-2" & #key .~ Just "claim-race-key")-                    ]-                OutboxId headUuid = headId-                holdClaimSql =-                    TE.encodeUtf8 $-                        "UPDATE keiro.keiro_outbox SET status = 'publishing', attempt_count = attempt_count + 1, updated_at = now() WHERE outbox_id = '"-                            <> UUID.toText headUuid-                            <> "'"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            holderDone <- newEmptyMVar-            _ <- forkIO $ do-                holder <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $ do-                            Tx.sql holdClaimSql-                            Tx.sql "SELECT pg_sleep(2)"-                putMVar holderDone holder-            -- Let the holder acquire its uncommitted row lock, then race a claim.-            threadDelay 500000-            now <- getCurrentTime-            Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)-            fmap (^. #outboxId) claimed `shouldBe` []-            Right () <- takeMVar holderDone-            pure ()--        it "StopTheLine publishes singleton batches and skips the unattempted suffix" $ \storeHandle -> do-            let row1Id = outboxIdFromOrdinal 1-                row2Id = outboxIdFromOrdinal 2-                row3Id = outboxIdFromOrdinal 3-                row4Id = outboxIdFromOrdinal 4-                ids = [row1Id, row2Id, row3Id, row4Id]-                rows =-                    [ (oid, sampleIntegrationEnvelope & #messageId .~ ("stop-line-" <> Text.pack (show i)) & #key .~ Just "stop-key")-                    | (i, oid) <- zip [1 .. 4 :: Int] ids-                    ]-                publishRef = fmap (^. #outboxId)-                publish claimed =-                    pure-                        [ ( row ^. #outboxId-                          , if row ^. #outboxId == row2Id-                                then PublishFailed "stop here"-                                else PublishSucceeded-                          )-                        | row <- claimed-                        ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        traverse_ (uncurry enqueueIntegrationEventTx) rows-            seenRef <- newIORef []-            let trackedPublish claimed = do-                    liftIO (modifyIORef' seenRef (<> publishRef claimed))-                    publish claimed-                opts = defaultPublishOptions & #orderingPolicy .~ StopTheLine & #backoff .~ ConstantBackoff 0-            Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox trackedPublish opts Nothing)-            summary ^. #published `shouldBe` 1-            summary ^. #retried `shouldBe` 3-            summary ^. #haltedOn `shouldBe` Just row2Id-            seen <- readIORef seenRef-            seen `shouldBe` take 2 ids-            Right (Just row3) <- Store.runStoreIO storeHandle (lookupOutbox row3Id)-            Right (Just row4) <- Store.runStoreIO storeHandle (lookupOutbox row4Id)-            row3 ^. #status `shouldBe` OutboxFailed-            row3 ^. #attemptCount `shouldBe` 0-            row4 ^. #status `shouldBe` OutboxFailed-            row4 ^. #attemptCount `shouldBe` 0--        it "publishClaimedOutbox treats a missing batch outcome as a failed row" $ \storeHandle -> do-            let okId = outboxIdFromOrdinal 1-                missingId = outboxIdFromOrdinal 2-                okEvent = sampleIntegrationEnvelope & #messageId .~ "missing-outcome-ok" & #key .~ Just "ok-key"-                missingEvent = sampleIntegrationEnvelope & #messageId .~ "missing-outcome-fail" & #key .~ Just "missing-key"-                publish claimed =-                    pure-                        [ (row ^. #outboxId, PublishSucceeded)-                        | row <- claimed-                        , row ^. #outboxId == okId-                        ]-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $ do-                        enqueueIntegrationEventTx okId okEvent-                        enqueueIntegrationEventTx missingId missingEvent-            Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox publish defaultPublishOptions Nothing)-            summary ^. #published `shouldBe` 1-            summary ^. #retried `shouldBe` 1-            Right (Just missingRow) <- Store.runStoreIO storeHandle (lookupOutbox missingId)-            missingRow ^. #status `shouldBe` OutboxFailed-            missingRow ^. #lastError `shouldBe` Just "publisher returned no outcome"--        it "auto-dead-letters a row after maxAttempts consecutive failures" $ \storeHandle -> do-            let oid = OutboxId outboxUuid1-                event = sampleIntegrationEnvelope & #key .~ Nothing-                opts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 10-                        & #maxAttempts-                        .~ 3-                        & #backoff-                        .~ ConstantBackoff 0-                        & #orderingPolicy-                        .~ BestEffort-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oid event)-            let publish _ = pure (PublishFailed "broker exploded")-            -- First two failures retain Failed status.-            Right s1 <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)-            s1 ^. #retried `shouldBe` 1-            s1 ^. #dead `shouldBe` 0-            Right s2 <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)-            s2 ^. #retried `shouldBe` 1-            s2 ^. #dead `shouldBe` 0-            -- Third failure crosses the threshold.-            Right s3 <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)-            s3 ^. #dead `shouldBe` 1-            Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)-            row ^. #status `shouldBe` OutboxDead-            -- A dead row is not claimable.-            now <- getCurrentTime-            Right reclaimed <- Store.runStoreIO storeHandle (claimOutboxBatch BestEffort 10 now)-            reclaimed `shouldBe` []--        it "garbageCollectSent deletes only old sent rows" $ \storeHandle -> do-            let oldSentId = OutboxId outboxUuid1-                recentSentId = OutboxId outboxUuid2-                failedId = OutboxId outboxUuid3-                deadId = OutboxId outboxUuid4-                base = sampleIntegrationEnvelope & #key .~ Nothing-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx oldSentId (base & #messageId .~ "gc-old-sent"))-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx recentSentId (base & #messageId .~ "gc-recent-sent"))-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx failedId (base & #messageId .~ "gc-failed"))-            let firstPass row-                    | row ^. #outboxId == failedId = pure (PublishFailed "keep failed")-                    | otherwise = pure PublishSucceeded-                firstPassOpts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 10-                        & #orderingPolicy-                        .~ BestEffort-                        & #backoff-                        .~ ConstantBackoff 3600-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow firstPass) firstPassOpts Nothing)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx deadId (base & #messageId .~ "gc-dead"))-            let deadPass row-                    | row ^. #outboxId == deadId = pure (PublishFailed "keep dead")-                    | otherwise = pure PublishSucceeded-                deadPassOpts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 10-                        & #maxAttempts-                        .~ 1-                        & #orderingPolicy-                        .~ BestEffort-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow deadPass) deadPassOpts Nothing)-            now <- getCurrentTime-            Right () <- Store.runStoreIO storeHandle (backdateOutboxPublishedAt oldSentId (addUTCTime (-3600) now))-            Right deleted <- Store.runStoreIO storeHandle (garbageCollectSent 300 now)-            deleted `shouldBe` 1-            Right oldRow <- Store.runStoreIO storeHandle (lookupOutbox oldSentId)-            oldRow `shouldBe` Nothing-            Right (Just recentRow) <- Store.runStoreIO storeHandle (lookupOutbox recentSentId)-            recentRow ^. #status `shouldBe` OutboxSent-            Right (Just failedRow) <- Store.runStoreIO storeHandle (lookupOutbox failedId)-            failedRow ^. #status `shouldBe` OutboxFailed-            Right (Just deadRow) <- Store.runStoreIO storeHandle (lookupOutbox deadId)-            deadRow ^. #status `shouldBe` OutboxDead--        it "enforces per-key head-of-line blocking and unblocks once the predecessor reaches a terminal state" $ \storeHandle -> do-            let a1Id = OutboxId outboxUuid1-                a2Id = OutboxId outboxUuid2-                b1Id = OutboxId outboxUuid3-                a1 = sampleIntegrationEnvelope & #messageId .~ "a1" & #key .~ Just "k1"-                a2 = sampleIntegrationEnvelope & #messageId .~ "a2" & #key .~ Just "k1"-                b1 = sampleIntegrationEnvelope & #messageId .~ "b1" & #key .~ Just "k2"-            -- Insert in created_at order (a1 first, then a2, then b1).-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx a1Id a1)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx a2Id a2)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx b1Id b1)-            claimed <- newIORef []-            let publish row = do-                    liftIO (atomicModifyIORef' claimed (\xs -> ((row ^. #outboxId) : xs, ())))-                    if row ^. #outboxId == a1Id-                        then pure (PublishFailed "broker hiccup")-                        else pure PublishSucceeded-            -- First pass: with a one-row batch, a1 fails and both later rows remain pending.-            let firstPassOpts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 1-                        & #backoff-                        .~ ConstantBackoff 0-            Right summary1 <--                Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) firstPassOpts Nothing)-            summary1 ^. #claimed `shouldBe` 1-            claimedIds <- readIORef claimed-            claimedIds `shouldSatisfy` (a2Id `notElem`)-            claimedIds `shouldSatisfy` (a1Id `elem`)-            claimedIds `shouldSatisfy` (b1Id `notElem`)-            Right (Just a1Row) <- Store.runStoreIO storeHandle (lookupOutbox a1Id)-            a1Row ^. #status `shouldBe` OutboxFailed-            Right (Just b1Row) <- Store.runStoreIO storeHandle (lookupOutbox b1Id)-            b1Row ^. #status `shouldBe` OutboxPending-            Right (Just a2Row) <- Store.runStoreIO storeHandle (lookupOutbox a2Id)-            a2Row ^. #status `shouldBe` OutboxPending-            -- Drive a1 to terminal sent state so a2 can move. One pass claims a1-            -- (now that next_attempt_at has passed). A second pass claims a2,-            -- which becomes head-of-line once a1 reaches `sent`.-            writeIORef claimed []-            let publishOk row = do-                    liftIO (atomicModifyIORef' claimed (\xs -> ((row ^. #outboxId) : xs, ())))-                    pure PublishSucceeded-                retryOpts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 1-                        & #backoff-                        .~ ConstantBackoff 0-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publishOk) retryOpts Nothing)-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publishOk) retryOpts Nothing)-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publishOk) retryOpts Nothing)-            claimedIds2 <- readIORef claimed-            claimedIds2 `shouldSatisfy` (a1Id `elem`)-            claimedIds2 `shouldSatisfy` (a2Id `elem`)-            claimedIds2 `shouldSatisfy` (b1Id `elem`)-            Right (Just a2Row') <- Store.runStoreIO storeHandle (lookupOutbox a2Id)-            a2Row' ^. #status `shouldBe` OutboxSent--        it "allows null-keyed rows to publish independently" $ \storeHandle -> do-            let n1 = OutboxId outboxUuid1-                n2 = OutboxId outboxUuid2-                e = sampleIntegrationEnvelope & #key .~ Nothing-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx n1 (e & #messageId .~ "n1"))-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx n2 (e & #messageId .~ "n2"))-            let publish row-                    | row ^. #outboxId == n1 = pure (PublishFailed "transient")-                    | otherwise = pure PublishSucceeded-            Right summary <--                Store.runStoreIO storeHandle $-                    publishClaimedOutbox (perRow publish) (defaultPublishOptions & #backoff .~ ConstantBackoff 0) Nothing-            summary ^. #claimed `shouldBe` 2-            summary ^. #published `shouldBe` 1-            summary ^. #retried `shouldBe` 1--        it "mints message ids with the configured TypeID prefix" $ \storeHandle -> do-            Right minted <--                Store.runStoreIO storeHandle (mintIntegrationEvent sampleProducer sampleDraft)-            minted ^. #source `shouldBe` "ordering"-            minted ^. #destination `shouldBe` "billing.orders.v1"-            Text.isPrefixOf "msg_" (minted ^. #messageId) `shouldBe` True--        it "validates integration producer message id prefixes before startup" $ \_storeHandle -> do-            shouldBeRight_ (mkIntegrationProducer sampleProducer)-            case mkIntegrationProducer (sampleProducer & #messageIdPrefix .~ "Bad-Prefix") of-                Left (InvalidMessageIdPrefix prefix reason) -> do-                    prefix `shouldBe` "Bad-Prefix"-                    reason `shouldSatisfy` (not . Text.null)-                other -> expectationFailure ("expected invalid prefix, got " <> show (void other))--        it "draftToEvent stamps source and messageId without minting" $ \_storeHandle -> do-            let event = draftToEvent "ordering" "msg-fixed-1" sampleDraft-            event ^. #messageId `shouldBe` "msg-fixed-1"-            event ^. #source `shouldBe` "ordering"-            event ^. #destination `shouldBe` "billing.orders.v1"--        it "freshOutboxId returns distinct UUIDv7 ids" $ \storeHandle -> do-            Right ids <--                Store.runStoreIO storeHandle (traverse (\_ -> freshOutboxId) [1 .. 4 :: Int])-            length ids `shouldBe` 4-            length (uniqueIds ids) `shouldBe` 4--        it "publishClaimedOutbox emits a Producer span with messaging semconv attributes" $ \storeHandle -> do-            (processor, spansRef) <- inMemoryListExporter-            provider <- createTracerProvider [processor] emptyTracerProviderOptions-            let tracer = makeTracer provider "keiro-test" tracerOptions-                okId = OutboxId outboxUuid1-                failId = OutboxId outboxUuid2-                okEvent = sampleIntegrationEnvelope-                failEvent =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "msg-fail-otel-1"-                        & #key-                        .~ Just "order-otel-fail"-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx okId okEvent)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx failId failEvent)-            let publish row-                    | row ^. #outboxId == okId = pure PublishSucceeded-                    | otherwise = pure (PublishFailed "broker unreachable")-                opts = defaultPublishOptions & #tracer ?~ tracer-            Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)-            _ <- shutdownTracerProvider provider Nothing-            spans <- traverse captureSpan =<< readIORef spansRef-            length spans `shouldBe` 1-            case spans of-                [batchSpan] -> do-                    csName batchSpan `shouldBe` ("send " <> (okEvent ^. #destination))-                    show (csKind batchSpan) `shouldBe` "Producer"-                    textAttr (csAttributes batchSpan) "messaging.system" `shouldBe` Just "kafka"-                    textAttr (csAttributes batchSpan) "messaging.operation.type" `shouldBe` Just "publish"-                    textAttr (csAttributes batchSpan) "messaging.operation.name" `shouldBe` Just "send"-                    textAttr (csAttributes batchSpan) "messaging.destination.name"-                        `shouldBe` Just (okEvent ^. #destination)-                    textAttr (csAttributes batchSpan) "messaging.kafka.message.key"-                        `shouldBe` (okEvent ^. #key)-                    intAttr (csAttributes batchSpan) "keiro.outbox.batch.size" `shouldBe` Just 2-                    textAttr (csAttributes batchSpan) "error.type" `shouldBe` Just "publish_failed"-                    case csStatus batchSpan of-                        Error msg -> msg `shouldBe` "broker unreachable"-                        other -> expectationFailure ("expected Error \"broker unreachable\", got " <> show other)-                other -> expectationFailure ("expected one batch span, got " <> show (length other))--        it "publishClaimedOutbox records counters and sampleOutboxBacklog records the gauge" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let okId = OutboxId outboxUuid1-                failId = OutboxId outboxUuid2-                okEvent = sampleIntegrationEnvelope & #messageId .~ "metrics-ok" & #key .~ Nothing-                failEvent = sampleIntegrationEnvelope & #messageId .~ "metrics-fail" & #key .~ Nothing-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx okId okEvent)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (enqueueIntegrationEventTx failId failEvent)-            let publish row-                    | row ^. #outboxId == okId = pure PublishSucceeded-                    | otherwise = pure (PublishFailed "broker down")-                retryPassOpts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 10-                        & #maxAttempts-                        .~ 5-                        & #backoff-                        .~ ConstantBackoff 0-                        & #orderingPolicy-                        .~ BestEffort-                deadPassOpts = retryPassOpts & #maxAttempts .~ 1-            -- Pass 1 (maxAttempts = 5): ok publishes, the fail row retries.-            Right summary1 <--                Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) retryPassOpts (Just keiroMetrics))-            summary1 ^. #published `shouldBe` 1-            summary1 ^. #retried `shouldBe` 1-            -- Pass 2 (maxAttempts = 1): the failed row crosses the ceiling and dies.-            Right summary2 <--                Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) deadPassOpts (Just keiroMetrics))-            summary2 ^. #dead `shouldBe` 1-            -- Flush so the in-memory exporter receives the aggregates.-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            -- Counters are cumulative across both passes.-            lookup "keiro.outbox.published" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.outbox.retried" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.outbox.deadlettered" scalars `shouldBe` Just (IntNumber 1)-            -- Publish passes no longer run the backlog COUNT(*) on the hot path.-            lookup "keiro.outbox.backlog" scalars `shouldBe` Nothing--            Store.runStoreIO storeHandle (sampleOutboxBacklog (Just keiroMetrics)) `shouldReturn` Right ()-            _ <- forceFlushMeterProvider provider Nothing-            sampled <- readIORef metricsRef-            let sampledScalars = flattenScalarPoints sampled-            lookup "keiro.outbox.backlog" sampledScalars `shouldBe` Just (IntNumber 0)--    describe "Keiro.Inbox" $ around (withFreshStore fixture) $ do-        it "runs the handler once and records the row as completed" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-1"-                        & #source-                        .~ "ordering"-                handler ev =-                    Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right result1 <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            case result1 of-                Right (InboxProcessed ()) -> pure ()-                other -> expectationFailure ("expected InboxProcessed, got " <> show other)-            Right rowCount1 <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount1 `shouldBe` 1-            Right (Just inboxRow) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-1")-            inboxRow ^. #status `shouldBe` InboxCompleted-            inboxRow ^. #completedAt `shouldSatisfy` isJust--        it "treats a redelivery with the same messageId as a duplicate" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-dup"-                        & #source-                        .~ "ordering"-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            Right result2 <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            result2 `shouldBe` Right InboxDuplicate-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 1--        it "records inbox counters and samples backlog separately under the in-memory exporter" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event = sampleIntegrationEnvelope & #messageId .~ "inbox-metrics-dup" & #source .~ "ordering"-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            -- First delivery runs the handler: processed.-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction (Just keiroMetrics) PreferIntegrationMessageId event Nothing handler-            -- Second delivery of the same (source, message_id): duplicate.-            Right result2 <--                Store.runStoreIO storeHandle $-                    runInboxTransaction (Just keiroMetrics) PreferIntegrationMessageId event Nothing handler-            result2 `shouldBe` Right InboxDuplicate-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.inbox.processed" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.inbox.duplicates" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.inbox.backlog" scalars `shouldBe` Nothing-            Store.runStoreIO storeHandle (sampleInboxBacklog (Just keiroMetrics)) `shouldReturn` Right ()-            _ <- forceFlushMeterProvider provider Nothing-            sampled <- readIORef metricsRef-            let sampledScalars = flattenScalarPoints sampled-            lookup "keiro.inbox.backlog" sampledScalars `shouldBe` Just (IntNumber 0)-            -- The handler ran exactly once (the duplicate path does not re-run it).-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 1--        it "deduplicates via PreferSourceEventIdentity even when messageId differs" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let shared = sampleIntegrationEnvelope & #source .~ "ordering"-                first = shared & #messageId .~ "republish-1"-                second = shared & #messageId .~ "republish-2"-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferSourceEventIdentity first Nothing handler-            Right result2 <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferSourceEventIdentity second Nothing handler-            result2 `shouldBe` Right InboxDuplicate--        it "uses KafkaDeliveryIdentity when supplied" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event = sampleIntegrationEnvelope & #source .~ "ordering"-                kafka = KafkaDeliveryRef "billing.orders.v1" 0 17-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing KafkaDeliveryIdentity event (Just kafka) handler-            Right (Right InboxDuplicate) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing KafkaDeliveryIdentity event (Just kafka) handler-            Right (Just row) <--                Store.runStoreIO storeHandle $-                    lookupInbox "ordering" "billing.orders.v1:0:17"-            row ^. #status `shouldBe` InboxCompleted--        it "reports DedupePolicyUnsatisfied when the envelope lacks the required field" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #source-                        .~ "ordering"-                        & #sourceEventId-                        .~ Nothing-                        & #sourceGlobalPosition-                        .~ Nothing-            Right result <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferSourceEventIdentity event Nothing (\_ -> pure ())-            result `shouldBe` Left (DedupePolicyUnsatisfied PreferSourceEventIdentity)--        it "leaves no inbox row when the handler condemns the transaction" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-rollback"-                        & #source-                        .~ "ordering"-                handler _ = do-                    Tx.condemn-                    pure ()-            _ <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            Right row <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-rollback")-            row `shouldBe` Nothing--        it "leaves no inbox row when the plain handler throws" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-throw-plain"-                        & #source-                        .~ "ordering"-                handler _ = (pure $! error "plain inbox handler failed") :: Tx.Transaction ()-            thrown <--                try $-                    Store.runStoreIO storeHandle $-                        runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            case thrown of-                Left (_ :: SomeException) -> pure ()-                Right other -> expectationFailure ("expected handler exception, got " <> show (void other))-            Right row <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-throw-plain")-            row `shouldBe` Nothing--        it "exports markFailedTx from the public inbox module and preserves explicit failure marks" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-public-failed"-                        & #source-                        .~ "ordering"-                handler _ = do-                    markFailedTx "ordering" "inbox-msg-public-failed" "operator failed" (event ^. #occurredAt)-                    pure ()-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-public-failed")-            row ^. #status `shouldBe` InboxFailed-            row ^. #lastError `shouldBe` Just "operator failed"--        it "a throwing handler records a failed attempt instead of looping" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-poison-1"-                        & #source-                        .~ "ordering"-                handler _ = (pure $! error "inbox exploded") :: Tx.Transaction ()-            Right result <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetries Nothing 3 PreferIntegrationMessageId event Nothing handler-            case result of-                Right (InboxHandlerFailed err attempts) -> do-                    Text.isInfixOf "inbox exploded" err `shouldBe` True-                    attempts `shouldBe` 1-                other -> expectationFailure ("expected InboxHandlerFailed, got " <> show other)-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-poison-1")-            row ^. #status `shouldBe` InboxFailed-            row ^. #attemptCount `shouldBe` 1-            row ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "inbox exploded")--        it "a transient poison message succeeds on retry" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-poison-transient"-                        & #source-                        .~ "ordering"-                failOnce _ = (pure $! error "temporary inbox failure") :: Tx.Transaction ()-                succeeding ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right result1 <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetries Nothing 3 PreferIntegrationMessageId event Nothing failOnce-            case result1 of-                Right (InboxHandlerFailed _ 1) -> pure ()-                other -> expectationFailure ("expected first failed attempt, got " <> show other)-            Right result2 <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetries Nothing 3 PreferIntegrationMessageId event Nothing succeeding-            result2 `shouldBe` Right (InboxProcessed ())-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-poison-transient")-            row ^. #status `shouldBe` InboxCompleted-            row ^. #attemptCount `shouldBe` 1-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 1--        it "an unrecoverable message dead-letters at the ceiling" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-poison-dead"-                        & #source-                        .~ "ordering"-                handler _ = (pure $! error "always broken") :: Tx.Transaction ()-            Right result1 <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetries Nothing 2 PreferIntegrationMessageId event Nothing handler-            Right result2 <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetries Nothing 2 PreferIntegrationMessageId event Nothing handler-            Right result3 <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetries Nothing 2 PreferIntegrationMessageId event Nothing handler-            case (result1, result2, result3) of-                ( Right (InboxHandlerFailed _ 1)-                    , Right (InboxHandlerFailed _ 2)-                    , Right (InboxPreviouslyFailed _)-                    ) -> pure ()-                other -> expectationFailure ("unexpected poison lifecycle: " <> show other)-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-poison-dead")-            row ^. #status `shouldBe` InboxFailed-            row ^. #attemptCount `shouldBe` 2--        it "processes a batch of distinct messages in one transaction" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let events =-                    [ sampleIntegrationEnvelope-                        & #messageId-                        .~ ("inbox-batch-msg-" <> Text.pack (show n))-                        & #source-                        .~ "batch-ordering"-                    | n <- [1 .. 50 :: Int]-                    ]-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right results <--                Store.runStoreIO storeHandle $-                    runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope ((,Nothing) <$> events) handler-            results `shouldBe` replicate 50 (Right (InboxProcessed ()))-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 50-            Right inboxRows <- Store.runStoreIO storeHandle (listInbox "batch-ordering")-            length inboxRows `shouldBe` 50-            all ((== InboxCompleted) . (^. #status)) inboxRows `shouldBe` True--        it "deduplicates repeated messages within one batch" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-batch-dup"-                        & #source-                        .~ "batch-ordering"-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right results <--                Store.runStoreIO storeHandle $-                    runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope [(event, Nothing), (event, Nothing)] handler-            results `shouldBe` [Right (InboxProcessed ()), Right InboxDuplicate]-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 1--        it "falls back per message when one batch handler throws" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let events =-                    [ sampleIntegrationEnvelope-                        & #messageId-                        .~ ("inbox-batch-poison-" <> Text.pack (show n))-                        & #source-                        .~ "batch-ordering"-                    | n <- [1 .. 5 :: Int]-                    ]-                handler ev-                    | ev ^. #messageId == "inbox-batch-poison-3" =-                        (pure $! error "batch poison") :: Tx.Transaction ()-                    | otherwise =-                        Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right results <--                Store.runStoreIO storeHandle $-                    runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope ((,Nothing) <$> events) handler-            case results of-                [ Right (InboxProcessed ())-                    , Right (InboxProcessed ())-                    , Right (InboxHandlerFailed err 1)-                    , Right (InboxProcessed ())-                    , Right (InboxProcessed ())-                    ] ->-                        Text.isInfixOf "batch poison" err `shouldBe` True-                other -> expectationFailure ("unexpected batch fallback results: " <> show other)-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 4-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-poison-3")-            row ^. #status `shouldBe` InboxFailed-            row ^. #attemptCount `shouldBe` 1-            row ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "batch poison")--        it "reports duplicates across batch calls" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-batch-existing-dup"-                        & #source-                        .~ "batch-ordering"-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right first <--                Store.runStoreIO storeHandle $-                    runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope [(event, Nothing)] handler-            first `shouldBe` [Right (InboxProcessed ())]-            Right second <--                Store.runStoreIO storeHandle $-                    runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope [(event, Nothing)] handler-            second `shouldBe` [Right InboxDuplicate]-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 1--        it "falls back per message when one batch handler condemns the transaction" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let events =-                    [ sampleIntegrationEnvelope-                        & #messageId-                        .~ ("inbox-batch-condemn-" <> Text.pack (show n))-                        & #source-                        .~ "batch-ordering"-                    | n <- [1 .. 3 :: Int]-                    ]-                handler ev-                    | ev ^. #messageId == "inbox-batch-condemn-2" = Tx.condemn-                    | otherwise = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right results <--                Store.runStoreIO storeHandle $-                    runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope ((,Nothing) <$> events) handler-            -- The condemned single-message retry reports processed by the-            -- documented single-path contract; what matters is that the-            -- innocent batch mates actually committed.-            results `shouldBe` replicate 3 (Right (InboxProcessed ()))-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 2-            Right (Just mate1) <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-condemn-1")-            Right (Just mate3) <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-condemn-3")-            mate1 ^. #status `shouldBe` InboxCompleted-            mate3 ^. #status `shouldBe` InboxCompleted-            Right condemned <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-condemn-2")-            condemned `shouldBe` Nothing--        it "classifies a legacy processing row as InboxInProgress without running the handler" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-legacy-processing"-                        & #source-                        .~ "ordering"-                handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "INSERT INTO keiro.keiro_inbox (source, dedupe_key, content_type, payload_bytes, status) VALUES ('ordering', 'inbox-legacy-processing', 'application/json', ''::bytea, 'processing')"-            Right result <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            result `shouldBe` Right InboxInProgress-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 0-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-legacy-processing")-            row ^. #status `shouldBe` InboxProcessing--        it "runs the handler once when two workers race the same dedupe key" $ \storeHandle -> do-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-race-dup"-                        & #source-                        .~ "ordering"-                slowHandler ev = do-                    Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-                    Tx.sql "SELECT pg_sleep(1.5)"-                fastHandler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt-            firstDone <- newEmptyMVar-            _ <- forkIO $ do-                first <--                    Store.runStoreIO storeHandle $-                        runInboxTransaction Nothing PreferIntegrationMessageId event Nothing slowHandler-                putMVar firstDone first-            -- Let the slow worker insert its uncommitted row, then race the-            -- same dedupe key: the second insert must block on the unique-            -- constraint until the first commits, then classify as duplicate.-            threadDelay 400000-            Right second <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing fastHandler-            Right first <- takeMVar firstDone-            first `shouldBe` Right (InboxProcessed ())-            second `shouldBe` Right InboxDuplicate-            Right rowCount <--                Store.runStoreIO storeHandle $-                    Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)-            rowCount `shouldBe` 1--        it "can persist only dedupe columns for successful rows" $ \storeHandle -> do-            let kafka = KafkaDeliveryRef "billing.orders.v1" 1 42-                event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-slim-success"-                        & #source-                        .~ "ordering"-                        & #payloadBytes-                        .~ "full success payload"-                        & #attributes-                        ?~ object ["source" Aeson..= ("slim-test" :: Text)]-                handler _ = pure ()-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWith Nothing PersistDedupeOnly PreferIntegrationMessageId event (Just kafka) handler-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-slim-success")-            row ^. #event . #payloadBytes `shouldBe` ""-            row ^. #event . #attributes `shouldBe` Nothing-            row ^. #event . #traceContext `shouldBe` Nothing-            row ^. #event . #schemaReference `shouldBe` Nothing-            row ^. #event . #messageId `shouldBe` "inbox-slim-success"-            row ^. #event . #sourceEventId `shouldBe` event ^. #sourceEventId-            row ^. #event . #sourceGlobalPosition `shouldBe` event ^. #sourceGlobalPosition-            row ^. #event . #causationId `shouldBe` event ^. #causationId-            row ^. #event . #correlationId `shouldBe` event ^. #correlationId-            row ^. #event . #occurredAt `shouldBe` event ^. #occurredAt-            row ^. #kafka `shouldBe` Just kafka-            Right redelivery <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWith Nothing PersistDedupeOnly PreferIntegrationMessageId event (Just kafka) handler-            redelivery `shouldBe` Right InboxDuplicate--        it "keeps full failed rows even when successful rows are dedupe-only" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-slim-failed"-                        & #source-                        .~ "ordering"-                        & #payloadBytes-                        .~ "full failed payload"-                        & #attributes-                        ?~ object ["source" Aeson..= ("failed-slim-test" :: Text)]-                handler _ = (pure $! error "slim failure") :: Tx.Transaction ()-            Right result <--                Store.runStoreIO storeHandle $-                    runInboxTransactionWithRetriesWith Nothing 3 PersistDedupeOnly PreferIntegrationMessageId event Nothing handler-            case result of-                Right (InboxHandlerFailed err 1) ->-                    Text.isInfixOf "slim failure" err `shouldBe` True-                other -> expectationFailure ("expected InboxHandlerFailed, got " <> show other)-            Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-slim-failed")-            row ^. #status `shouldBe` InboxFailed-            row ^. #event . #payloadBytes `shouldBe` event ^. #payloadBytes-            row ^. #event . #attributes `shouldBe` event ^. #attributes-            row ^. #event . #traceContext `shouldBe` event ^. #traceContext-            row ^. #event . #schemaReference `shouldBe` event ^. #schemaReference--        it "garbage-collects completed rows older than the retention window" $ \storeHandle -> do-            let event =-                    sampleIntegrationEnvelope-                        & #messageId-                        .~ "inbox-msg-gc"-                        & #source-                        .~ "ordering"-                handler _ = pure ()-            Right (Right (InboxProcessed ())) <--                Store.runStoreIO storeHandle $-                    runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler-            -- Backdate the row so it falls outside the retention window.-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql-                            "UPDATE keiro.keiro_inbox SET completed_at = now() - interval '40 days' WHERE message_id = 'inbox-msg-gc'"-            now <- getCurrentTime-            Right deleted <- Store.runStoreIO storeHandle (garbageCollectCompleted (nominalDays 30) now)-            deleted `shouldBe` 1-            Right rows <- Store.runStoreIO storeHandle (listInbox "ordering")-            rows `shouldBe` []--    describe "Keiro.Inbox.Kafka" $ do-        it "reconstructs an integration event from headers and payload" $ do-            let envelope = sampleIntegrationEnvelope-                headers = integrationHeaders envelope-                receivedAt = addUTCTime 60 (envelope ^. #occurredAt)-                record =-                    InboxKafka.KafkaInboundRecord-                        { topic = "billing.orders.v1"-                        , partition = 2-                        , offset = 113-                        , key = Just "order-123"-                        , payload = envelope ^. #payloadBytes-                        , headers-                        , receivedAt-                        }-            case InboxKafka.integrationEventFromKafka record of-                Right (rebuilt, kafkaRef) -> do-                    rebuilt ^. #messageId `shouldBe` envelope ^. #messageId-                    rebuilt ^. #source `shouldBe` envelope ^. #source-                    rebuilt ^. #destination `shouldBe` envelope ^. #destination-                    rebuilt ^. #eventType `shouldBe` envelope ^. #eventType-                    rebuilt ^. #schemaVersion `shouldBe` envelope ^. #schemaVersion-                    rebuilt ^. #sourceEventId `shouldBe` envelope ^. #sourceEventId-                    rebuilt ^. #sourceGlobalPosition `shouldBe` envelope ^. #sourceGlobalPosition-                    rebuilt ^. #payloadBytes `shouldBe` envelope ^. #payloadBytes-                    rebuilt ^. #occurredAt `shouldBe` envelope ^. #occurredAt-                    rebuilt ^. #attributes `shouldBe` envelope ^. #attributes-                    kafkaRef ^. #topic `shouldBe` "billing.orders.v1"-                    kafkaRef ^. #partition `shouldBe` 2-                    kafkaRef ^. #offset `shouldBe` 113-                Left err -> expectationFailure ("expected Right, got Left " <> show err)--        it "falls back to receivedAt when the occurredAt header is absent" $ do-            let envelope = sampleIntegrationEnvelope-                receivedAt = addUTCTime 60 (envelope ^. #occurredAt)-                headers = filter ((/= "keiro-occurred-at") . Prelude.fst) (integrationHeaders envelope)-                record =-                    InboxKafka.KafkaInboundRecord-                        { topic = "billing.orders.v1"-                        , partition = 2-                        , offset = 113-                        , key = Just "order-123"-                        , payload = envelope ^. #payloadBytes-                        , headers-                        , receivedAt-                        }-            case InboxKafka.integrationEventFromKafka record of-                Right (rebuilt, _) -> rebuilt ^. #occurredAt `shouldBe` receivedAt-                Left err -> expectationFailure ("expected Right, got Left " <> show err)--        it "rejects malformed occurredAt headers" $ do-            let envelope = sampleIntegrationEnvelope-                headers = ("keiro-occurred-at", "not-a-time") : filter ((/= "keiro-occurred-at") . Prelude.fst) (integrationHeaders envelope)-                record =-                    InboxKafka.KafkaInboundRecord-                        { topic = "billing.orders.v1"-                        , partition = 2-                        , offset = 113-                        , key = Just "order-123"-                        , payload = envelope ^. #payloadBytes-                        , headers-                        , receivedAt = envelope ^. #occurredAt-                        }-            InboxKafka.integrationEventFromKafka record-                `shouldBe` Left (InboxKafka.InvalidTimeHeader "keiro-occurred-at" "not-a-time")--        it "reports MissingHeader for an essential header" $ do-            let envelope = sampleIntegrationEnvelope-                headers = filter ((/= "keiro-message-id") . Prelude.fst) (integrationHeaders envelope)-                record =-                    InboxKafka.KafkaInboundRecord-                        { topic = "billing.orders.v1"-                        , partition = 0-                        , offset = 0-                        , key = Nothing-                        , payload = envelope ^. #payloadBytes-                        , headers-                        , receivedAt = envelope ^. #occurredAt-                        }-            InboxKafka.integrationEventFromKafka record-                `shouldBe` Left (InboxKafka.MissingHeader "keiro-message-id")--        it "withConsumerSpan parents the consumer span under an upstream producer span via W3C headers" $ do-            (processor, spansRef) <- inMemoryListExporter-            provider <- createTracerProvider [processor] emptyTracerProviderOptions-            let tracer = makeTracer provider "keiro-test" tracerOptions-                -- Clear the baked-in TraceContext on the sample so the only-                -- `traceparent` on the wire comes from the active producer-                -- span (via `injectTraceContext`).-                envelope = sampleIntegrationEnvelope & #traceContext .~ Nothing-                producerRecord = OutboxKafka.integrationEventToKafkaRecord envelope-            producerHeadersText <--                Telemetry.withProducerSpan (Just tracer) envelope producerRecord $ \_ -> do-                    let baseHeaders =-                            [(TE.decodeUtf8 n, TE.decodeUtf8 v) | (n, v) <- producerRecord ^. #headers]-                    Telemetry.injectTraceContext baseHeaders-            -- Build the inbound record the consumer would receive and open the-            -- consumer span around a no-op body.-            now <- getCurrentTime-            let inbound =-                    InboxKafka.KafkaInboundRecord-                        { topic = envelope ^. #destination-                        , partition = 7-                        , offset = 42-                        , key = envelope ^. #key-                        , payload = envelope ^. #payloadBytes-                        , headers = producerHeadersText-                        , receivedAt = now-                        }-            Telemetry.withConsumerSpan (Just tracer) (Just "billing-cg") inbound (Just envelope) $ \_ ->-                pure ()-            _ <- shutdownTracerProvider provider Nothing-            spans <- traverse captureSpan =<< readIORef spansRef-            length spans `shouldBe` 2-            let findByName needle = case [s | s <- spans, csName s == needle] of-                    (s : _) -> s-                    [] -> error ("no span captured with name=" <> Text.unpack needle)-                producerSp = findByName ("send " <> envelope ^. #destination)-                consumerSp = findByName ("process " <> envelope ^. #destination)-            -- Same trace id end-to-end (cross-process parenting).-            traceId (csContext producerSp) `shouldBe` traceId (csContext consumerSp)-            -- Consumer's parent is the producer span.-            case csParent consumerSp of-                Nothing -> expectationFailure "consumer span has no parent"-                Just parent -> do-                    parentCtx <- getSpanContext parent-                    spanId parentCtx `shouldBe` spanId (csContext producerSp)-            -- Consumer span carries the expected attributes.-            show (csKind consumerSp) `shouldBe` "Consumer"-            textAttr (csAttributes consumerSp) "messaging.system" `shouldBe` Just "kafka"-            textAttr (csAttributes consumerSp) "messaging.operation.type" `shouldBe` Just "process"-            textAttr (csAttributes consumerSp) "messaging.destination.name"-                `shouldBe` Just (envelope ^. #destination)-            textAttr (csAttributes consumerSp) "messaging.destination.partition.id"-                `shouldBe` Just "7"-            textAttr (csAttributes consumerSp) "messaging.consumer.group.name"-                `shouldBe` Just "billing-cg"-            textAttr (csAttributes consumerSp) "messaging.message.id"-                `shouldBe` Just (envelope ^. #messageId)--    describe "Keiro cross-context Kafka integration" $ around (withFreshStores2 fixture) $ do-        it "publishes an Ordering integration event and runs the Billing handler exactly once across duplicate deliveries" $ \(ordering, billing) -> do-            Right () <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_received_orders (order_id TEXT PRIMARY KEY, quantity BIGINT NOT NULL)")-            topic <- newKafkaTopic-            -- Ordering side: enqueue an outbox row representing a published event.-            let orderingEvent = orderSubmittedEnvelope "order-aaa" 7 "msg-aaa"-                oid = OutboxId outboxUuid1-            Right () <--                Store.runStoreIO ordering $-                    Store.runTransaction (enqueueIntegrationEventTx oid orderingEvent)-            -- Run the publisher worker: push records to the in-process topic.-            Right pubSummary1 <--                Store.runStoreIO ordering $-                    publishClaimedOutbox (perRow (kafkaTopicPublish topic)) defaultPublishOptions Nothing-            pubSummary1 ^. #published `shouldBe` 1-            -- Billing side: consume from the topic.-            records1 <- drainKafkaTopic topic-            record1 <- case records1 of-                [r] -> pure r-                other -> expectationFailure ("expected 1 record, got " <> show (length other)) *> error "unreachable"-            Right consumed1 <--                Store.runStoreIO billing $-                    consumeAndApply record1 billingReactionHandler-            consumed1 `shouldBe` ConsumeApplied (InboxProcessed ())-            Right rowCount1 <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.statement () billingReceivedOrdersCountStmt)-            rowCount1 `shouldBe` 1--            -- Simulate Kafka redelivery: pretend the same Kafka record was-            -- delivered again at a different offset. The producer also retries-            -- (the outbox flips back to pending and the worker republishes).-            let redelivered = redeliverWithDifferentOffset record1-            Right consumed2 <--                Store.runStoreIO billing $-                    consumeAndApply redelivered billingReactionHandler-            consumed2 `shouldBe` ConsumeApplied InboxDuplicate-            Right rowCount2 <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.statement () billingReceivedOrdersCountStmt)-            rowCount2 `shouldBe` 1--        it "preserves per-partition ordering for two events sharing a Kafka key" $ \(ordering, billing) -> do-            Right () <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_received_orders (order_id TEXT PRIMARY KEY, quantity BIGINT NOT NULL)")-            Right () <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_event_log (seq BIGSERIAL PRIMARY KEY, source TEXT NOT NULL, event_type TEXT NOT NULL, order_id TEXT NOT NULL)")-            topic <- newKafkaTopic-            -- Two events for the same order key.-            let submittedEnv = orderSubmittedEnvelope "order-bbb" 4 "msg-bbb-1"-                cancelledEnv = orderCancelledEnvelope "order-bbb" "msg-bbb-2"-                submittedId = OutboxId outboxUuid1-                cancelledId = OutboxId outboxUuid2-            Right () <--                Store.runStoreIO ordering $-                    Store.runTransaction (enqueueIntegrationEventTx submittedId submittedEnv)-            Right () <--                Store.runStoreIO ordering $-                    Store.runTransaction (enqueueIntegrationEventTx cancelledId cancelledEnv)-            -- Run-claiming lets a same-key contiguous run drain in one pass.-            let drainOnce =-                    publishClaimedOutbox-                        (perRow (kafkaTopicPublish topic))-                        (defaultPublishOptions & #backoff .~ ConstantBackoff 0)-                        Nothing-            Right s1 <- Store.runStoreIO ordering drainOnce-            Right s2 <- Store.runStoreIO ordering drainOnce-            (s1 ^. #published) + (s2 ^. #published) `shouldBe` 2-            records <- drainKafkaTopic topic-            length records `shouldBe` 2-            -- Apply both records to billing in delivery order.-            for_ records $ \record -> do-                Right consumed <--                    Store.runStoreIO billing $-                        consumeAndApply record (loggingReactionHandler "billing")-                case consumed of-                    ConsumeApplied (InboxProcessed ()) -> pure ()-                    other -> expectationFailure ("expected processed, got " <> show other)-            Right events <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.statement () billingEventLogStmt)-            events `shouldBe` [("OrderSubmitted", "order-bbb"), ("OrderCancelled", "order-bbb")]--        it "head-of-line blocks a same-key successor when the first send fails repeatedly until the first row reaches dead status" $ \(ordering, billing) -> do-            Right () <--                Store.runStoreIO billing $-                    Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_received_orders (order_id TEXT PRIMARY KEY, quantity BIGINT NOT NULL)")-            topic <- newKafkaTopic-            let submittedEnv = orderSubmittedEnvelope "order-ccc" 1 "msg-ccc-1"-                cancelledEnv = orderCancelledEnvelope "order-ccc" "msg-ccc-2"-                firstId = OutboxId outboxUuid1-                secondId = OutboxId outboxUuid2-            Right () <--                Store.runStoreIO ordering $-                    Store.runTransaction (enqueueIntegrationEventTx firstId submittedEnv)-            Right () <--                Store.runStoreIO ordering $-                    Store.runTransaction (enqueueIntegrationEventTx secondId cancelledEnv)-            -- Failing publish for the first row, success for any other.-            let publish row-                    | row ^. #outboxId == firstId =-                        pure (PublishFailed "simulated broker reject")-                    | otherwise = do-                        kafkaTopicAccept topic row-                        pure PublishSucceeded-                deadOpts =-                    defaultPublishOptions-                        & #batchSize-                        .~ 1-                        & #backoff-                        .~ ConstantBackoff 0-                        & #maxAttempts-                        .~ 2-            -- This test drives the pre-M3 sequential failure/dead-letter path-            -- with one-row batches. M3 adds suffix skipping for larger claimed-            -- same-key runs.-            -- First pass: the first row attempts once and fails; the second is-            -- outside the one-row claim window.-            Right pass1 <- Store.runStoreIO ordering (publishClaimedOutbox (perRow publish) deadOpts Nothing)-            pass1 ^. #retried `shouldBe` 1-            pass1 ^. #published `shouldBe` 0-            -- Second pass crosses maxAttempts and dead-letters the first row.-            Right pass2 <- Store.runStoreIO ordering (publishClaimedOutbox (perRow publish) deadOpts Nothing)-            pass2 ^. #dead `shouldBe` 1-            Right (Just firstRow) <- Store.runStoreIO ordering (lookupOutbox firstId)-            firstRow ^. #status `shouldBe` OutboxDead-            -- With the first row dead, the second becomes claimable and publishes.-            Right pass3 <- Store.runStoreIO ordering (publishClaimedOutbox (perRow publish) deadOpts Nothing)-            pass3 ^. #published `shouldBe` 1-            Right (Just secondRow) <- Store.runStoreIO ordering (lookupOutbox secondId)-            secondRow ^. #status `shouldBe` OutboxSent-            -- Billing only sees the second event.-            records <- drainKafkaTopic topic-            record <- case records of-                [r] -> pure r-                other -> expectationFailure ("expected 1 record, got " <> show (length other)) *> error "unreachable"-            Right consumed <--                Store.runStoreIO billing $-                    consumeAndApply record billingReactionHandler-            consumed `shouldBe` ConsumeApplied (InboxProcessed ())--    describe "Keiro.Integration.Event" $ do-        it "round-trips a JSON envelope through encode and decode" $ do-            let envelope = sampleIntegrationEnvelope-                payload = OrderSubmittedPayload "order-123" 5-                encoded = encodeJsonIntegrationEvent envelope payload-            decodeJsonIntegrationEvent encoded `shouldBe` Right payload--        it "preserves identity and routing through encode" $ do-            let envelope = sampleIntegrationEnvelope-                encoded = encodeJsonIntegrationEvent envelope (OrderSubmittedPayload "order-123" 5)-            encoded ^. #messageId `shouldBe` envelope ^. #messageId-            encoded ^. #source `shouldBe` "ordering"-            encoded ^. #destination `shouldBe` "billing.orders.v1"-            encoded ^. #key `shouldBe` Just "order-123"-            encoded ^. #eventType `shouldBe` "OrderSubmitted"-            encoded ^. #schemaVersion `shouldBe` 1-            encoded ^. #contentType `shouldBe` ApplicationJson--        it "emits the canonical wire headers" $ do-            let envelope = sampleIntegrationEnvelope-                headers = integrationHeaders envelope-            Prelude.lookup headerMessageId headers `shouldBe` Just (envelope ^. #messageId)-            Prelude.lookup headerSchemaVersion headers `shouldBe` Just "1"-            Prelude.lookup headerContentType headers `shouldBe` Just "application/json"-            Prelude.lookup headerSchemaSubject headers `shouldBe` Just "billing.orders.v1.OrderSubmitted"-            Prelude.lookup headerSourceEventId headers `shouldBe` Just "018f0f18-17aa-7000-8000-000000000003"-            Prelude.lookup headerSourceGlobalPosition headers `shouldBe` Just "42"-            Prelude.lookup headerTraceParent headers-                `shouldBe` Just "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"--        it "preserves a different content type without claiming JSON" $ do-            let envelope =-                    sampleIntegrationEnvelope-                        & #contentType-                        .~ OtherContentType "application/vnd.apache.avro.binary"-                        & #payloadBytes-                        .~ "\x00\x01\x02"-                headers = integrationHeaders envelope-            Prelude.lookup headerContentType headers-                `shouldBe` Just "application/vnd.apache.avro.binary"-            decodeJsonIntegrationEvent envelope-                `shouldBe` ( Left (IntegrationEvent.UnsupportedContentType "application/vnd.apache.avro.binary") ::-                                Either IntegrationEvent.IntegrationEventError OrderSubmittedPayload-                           )--        it "reports malformed JSON payloads as decode errors instead of throwing" $ do-            let envelope =-                    sampleIntegrationEnvelope-                        & #payloadBytes-                        .~ "{not-json"-            case decodeJsonIntegrationEvent envelope :: Either IntegrationEvent.IntegrationEventError OrderSubmittedPayload of-                Left (IntegrationEvent.MalformedPayload _) -> pure ()-                other -> expectationFailure ("expected MalformedPayload, got " <> show other)--        it "reports a JSON value that does not satisfy the target type as DecodeFailed" $ do-            let envelope =-                    sampleIntegrationEnvelope-                        & #payloadBytes-                        .~ "{\"orderId\":\"order-123\"}"-            case decodeJsonIntegrationEvent envelope :: Either IntegrationEvent.IntegrationEventError OrderSubmittedPayload of-                Left (IntegrationEvent.DecodeFailed _) -> pure ()-                other -> expectationFailure ("expected DecodeFailed, got " <> show other)--        it "parses content-type headers back to the canonical type" $ do-            parseContentType "application/json" `shouldBe` ApplicationJson-            parseContentType "Application/JSON" `shouldBe` ApplicationJson-            parseContentType "application/json; charset=utf-8" `shouldBe` ApplicationJson-            parseContentType "APPLICATION/JSON ; CHARSET=UTF-8" `shouldBe` ApplicationJson-            parseContentType "application/vnd.apache.avro.binary"-                `shouldBe` OtherContentType "application/vnd.apache.avro.binary"--        it "preserves the payload bytes through integrationPayload" $ do-            let envelope = sampleIntegrationEnvelope-                encoded = encodeJsonIntegrationEvent envelope (OrderSubmittedPayload "order-123" 5)-            integrationPayload encoded `shouldBe` (encoded ^. #payloadBytes)--    describe "Keiro.Telemetry" $ do-        it "is a pass-through under a noop (Nothing) tracer" $ do-            counter <- newIORef (0 :: Int)-            let envelope = sampleIntegrationEnvelope-                record = OutboxKafka.integrationEventToKafkaRecord envelope-            result <--                Telemetry.withProducerSpan Nothing envelope record $ \mSpan -> do-                    atomicModifyIORef' counter (\n -> (n + 1, ()))-                    pure (mSpan, "ok" :: Text)-            callsAfter <- readIORef counter-            callsAfter `shouldBe` (1 :: Int)-            snd result `shouldBe` "ok"-            fst result `shouldSatisfy` isNothing--        it "re-exports AttributeKeys whose textual payload matches the spec name" $ do-            attrKeyText Telemetry.messaging_operation_type `shouldBe` "messaging.operation.type"-            attrKeyText Telemetry.messaging_operation_name `shouldBe` "messaging.operation.name"-            attrKeyText Telemetry.messaging_destination_partition_id `shouldBe` "messaging.destination.partition.id"-            attrKeyText Telemetry.messaging_consumer_group_name `shouldBe` "messaging.consumer.group.name"-            attrKeyText Telemetry.messaging_client_id `shouldBe` "messaging.client.id"-            attrKeyTextInt64 Telemetry.messaging_kafka_offset `shouldBe` "messaging.kafka.offset"-            attrKeyText Telemetry.db_system_name `shouldBe` "db.system.name"-            attrKeyText Telemetry.db_namespace `shouldBe` "db.namespace"-            attrKeyText Telemetry.db_collection_name `shouldBe` "db.collection.name"-            attrKeyText Telemetry.db_operation_name `shouldBe` "db.operation.name"-            attrKeyText Telemetry.keiro_stream_name `shouldBe` "keiro.stream.name"-            attrKeyTextInt64 Telemetry.keiro_retry_attempt `shouldBe` "keiro.retry.attempt"-            attrKeyTextInt64 Telemetry.keiro_events_appended `shouldBe` "keiro.events.appended"-            attrKeyText Telemetry.keiro_replay_divergence `shouldBe` "keiro.replay.divergence"--        it "extracts a TraceContext from a W3C traceparent header pair" $ do-            let traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"-                tracestate = "vendor1=value1"-                hs = [(headerTraceParent, traceparent), ("tracestate", tracestate)]-            Telemetry.traceContextFromHeaders hs-                `shouldBe` Just (TraceContext traceparent (Just tracestate))--        it "returns Nothing when the traceparent header is missing" $ do-            Telemetry.traceContextFromHeaders [("content-type", "application/json")]-                `shouldBe` Nothing--        it "injectTraceContext is a no-op when no span is active on the thread" $ do-            let baseline = [("content-type", "application/json")]-            injected <- Telemetry.injectTraceContext baseline-            injected `shouldBe` baseline--        it "traceContextFromCurrentSpan returns Nothing outside any span" $ do-            tc <- Telemetry.traceContextFromCurrentSpan-            tc `shouldBe` Nothing--    describe "Keiro.Workflow" $ around (withFreshStore fixture) $ do-        it "journals each step once, returns Completed, and runs each side effect once" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "demo"-                wid = WorkflowId "demo-1"-            result <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)-            result `shouldBe` Right (Completed (1, 2))-            sideEffects <- readIORef counter-            sideEffects `shouldBe` 2-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:demo-demo-1") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 3-            traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)-                `shouldSatisfy` \case-                    Right [StepRecorded "first" _ _, StepRecorded "second" _ _, WorkflowCompleted _] -> True-                    _ -> False--        it "replays recorded steps without re-running their side effects" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "replay"-                wid = WorkflowId "r-1"-            first <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)-            first `shouldBe` Right (Completed (1, 2))-            afterFirst <- readIORef counter-            afterFirst `shouldBe` 2-            -- A second run with the same id is exactly the crash-restart scenario.-            second <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)-            second `shouldBe` Right (Completed (1, 2))-            afterSecond <- readIORef counter-            afterSecond `shouldBe` 2-            -- The deterministic ids and pre-load gating leave the journal at 3 events.-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:replay-r-1") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 3--        it "reuses the recorded result for a repeated step name in one run" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "samename"-                wid = WorkflowId "s-1"-                duplicateStepWorkflow = do-                    a <- step (StepName "dup") (liftIO (incrementAndRead counter))-                    b <- step (StepName "dup") (liftIO (incrementAndRead counter))-                    pure (a, b)-            result <- Store.runStoreIO storeHandle $ runWorkflow name wid duplicateStepWorkflow-            result `shouldBe` Right (Completed (1, 1))-            sideEffects <- readIORef counter-            sideEffects `shouldBe` 1--        it "suspends on an unresolved awaitStep, journaling no completion" $ \storeHandle -> do-            let name = WorkflowName "awaiter"-                wid = WorkflowId "a-1"-            result <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow-            result `shouldBe` Right Suspended-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:awaiter-a-1") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 0--        it "resumes and completes once an awaited step is externally completed" $ \storeHandle -> do-            let name = WorkflowName "awaiter2"-                wid = WorkflowId "a-2"-            suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow-            suspended `shouldBe` Right Suspended-            -- Simulate a wake source recording the awaited step's resolution.-            Right () <- Store.runStoreIO storeHandle $ do-                now <- liftIO getCurrentTime-                appendJournalEntry name wid (StepRecorded "awk:test" (toJSON (42 :: Int)) now)-            resumed <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow-            resumed `shouldBe` Right (Completed 42)-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:awaiter2-a-2") (StreamVersion 0) 10-            traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)-                `shouldSatisfy` \case-                    Right [StepRecorded "awk:test" _ _, WorkflowCompleted _] -> True-                    _ -> False--        it "treats a duplicate external journal append as idempotent" $ \storeHandle -> do-            let name = WorkflowName "duplicate-append"-                wid = WorkflowId "da-1"-                stepKey = "awk:test"-                eventAt t = StepRecorded stepKey (toJSON (42 :: Int)) t-            now <- getCurrentTime-            Right firstId <--                Store.runStoreIO storeHandle $-                    appendJournalEntryReturningId name wid (eventAt now)-            secondResult <--                Store.runStoreIO storeHandle $-                    appendJournalEntryReturningId name wid (eventAt now)-            secondId <- case secondResult of-                Right value -> pure value-                Left err -> expectationFailure ("expected idempotent duplicate append, got " <> show err) *> error "unreachable"-            secondId `shouldBe` firstId-            Right indexed <- Store.runStoreIO storeHandle $ loadStepIndex name wid 0-            Map.lookup stepKey indexed `shouldBe` Just (toJSON (42 :: Int))-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:duplicate-append-da-1") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 1--        it "returns the journaled value when another writer records the same step mid-flight" $ \storeHandle -> do-            let name = WorkflowName "journal-race"-                wid = WorkflowId "jr-1"-                body =-                    step (StepName "raced") $ do-                        now <- liftIO getCurrentTime-                        appendJournalEntry name wid (StepRecorded "raced" (toJSON ("winner" :: Text)) now)-                        pure ("loser" :: Text)-            outcome <- Store.runStoreIO storeHandle $ runWorkflow name wid body-            outcome `shouldBe` Right (Completed "winner")-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:journal-race-jr-1") (StreamVersion 0) 10-            traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)-                `shouldSatisfy` \case-                    Right [StepRecorded "raced" value _, WorkflowCompleted _] -> value == toJSON ("winner" :: Text)-                    _ -> False--        it "returns the JSON round-trip of a fresh step result" $ \storeHandle -> do-            let name = WorkflowName "roundtrip-step"-                wid = WorkflowId "rs-1"-                body = step (StepName "approx") (pure (Approx 1.7))-            first <- Store.runStoreIO storeHandle $ runWorkflow name wid body-            first `shouldBe` Right (Completed (Approx 2.0))-            replay <- Store.runStoreIO storeHandle $ runWorkflow name wid body-            replay `shouldBe` Right (Completed (Approx 2.0))--        it "throws WorkflowStepDecodeError on the first run when the recorded result cannot decode" $ \storeHandle -> do-            let name = WorkflowName "bad-roundtrip"-                wid = WorkflowId "br-1"-                body = step (StepName "bad") (pure RejectingRoundTrip)-            Store.runStoreIO storeHandle (runWorkflow name wid body)-                `shouldThrow` \case-                    WorkflowStepDecodeError key _ -> key == "bad"-                    _ -> False-            Store.runStoreIO storeHandle (stepExists name wid 0 "bad")-                `shouldReturn` Right True--        it "discovers unfinished workflows via the step index" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            Right (Completed _) <--                Store.runStoreIO storeHandle $-                    runWorkflow (WorkflowName "done") (WorkflowId "d-1") (demoWorkflow counter)-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflow (WorkflowName "pending") (WorkflowId "p-1") (stepThenAwaitWorkflow counter)-            now <- getCurrentTime-            Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)-            unfinished `shouldBe` [("p-1", "pending")]--    describe "Keiro.Workflow instance table" $ around (withFreshStore fixture) $ do-        it "creates and completes a workflow instance row transactionally with the journal" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "inst-complete"-                wid = WorkflowId "ic-1"-            Right (Completed _) <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #workflowId `shouldBe` "ic-1"-            row ^. #workflowName `shouldBe` "inst-complete"-            row ^. #generation `shouldBe` 0-            row ^. #status `shouldBe` Instance.WfCompleted-            row ^. #completedAt `shouldSatisfy` isJust--        it "records suspended status for workflows that park before journaling" $ \storeHandle -> do-            let name = WorkflowName "inst-suspended"-                wid = WorkflowId "is-1"-            Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #status `shouldBe` Instance.WfSuspended-            row ^. #generation `shouldBe` 0-            row ^. #completedAt `shouldBe` Nothing--        it "creates child instance rows at spawn time and flips them to cancelled" $ \storeHandle -> do-            let childWid = WorkflowId "inst-child"-                childName = WorkflowName "ship"-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflow (WorkflowName "inst-parent") (WorkflowId "ip-1") (parentWorkflow childWid)-            Right (Just spawned) <- Store.runStoreIO storeHandle $ Instance.lookupInstance childName childWid-            spawned ^. #status `shouldBe` Instance.WfRunning-            Right True <- Store.runStoreIO storeHandle $ cancelChild (ChildHandle childName childWid)-            Right (Just cancelledRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance childName childWid-            cancelledRow ^. #status `shouldBe` Instance.WfCancelled-            cancelledRow ^. #completedAt `shouldSatisfy` isJust--        it "bumps the instance generation when continueAsNew rotates" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "inst-rotate"-                wid = WorkflowId "ir-1"-            Right ContinuedAsNew <--                Store.runStoreIO storeHandle $-                    runWorkflow name wid (rollingTotal counter 1 2)-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #generation `shouldBe` 1-            row ^. #status `shouldBe` Instance.WfRunning--        it "does not let a late append resurrect a terminal instance row" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "inst-terminal"-                wid = WorkflowId "it-1"-            Right (Completed _) <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (StepRecorded "late" (toJSON True) now)-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #status `shouldBe` Instance.WfCompleted--        it "discovers unfinished workflows from the instance table" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let completedName = WorkflowName "discover-completed"-                cancelledName = WorkflowName "discover-cancelled"-                crashedName = WorkflowName "discover-crashed"-                rotatedName = WorkflowName "discover-rotated"-            Right (Completed _) <--                Store.runStoreIO storeHandle $-                    runWorkflow completedName (WorkflowId "done") (demoWorkflow counter)-            cancelledAt <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry cancelledName (WorkflowId "cancelled") (WorkflowCancelled cancelledAt)-            Left (_ :: SimulatedCrash) <--                try $-                    Store.runStoreIO storeHandle $-                        runWorkflow crashedName (WorkflowId "crashed") (crashAfterStep1 counter)-            Right ContinuedAsNew <--                Store.runStoreIO storeHandle $-                    runWorkflow rotatedName (WorkflowId "rotated") (rollingTotal counter 1 2)-            now <- getCurrentTime-            Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)-            unfinished-                `shouldBe` [ ("crashed", "discover-crashed")-                           , ("rotated", "discover-rotated")-                           ]--    describe "Keiro.Workflow snapshots" $ around (withFreshStore fixture) $ do-        it "does not fail committed workflow steps when snapshot writes fail" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            let opts =-                    defaultWorkflowRunOptions-                        & #snapshotPolicy-                        .~ Every 2-                        & #metrics-                        ?~ keiroMetrics-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "ALTER TABLE keiro.keiro_snapshots ADD CONSTRAINT keiro_snapshots_no_writes CHECK (false) NOT VALID"-            counter <- newIORef (0 :: Int)-            result <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts (WorkflowName "snap-write-failure") (WorkflowId "wf1") (countingSixSteps counter)-            result `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            Right journal <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:snap-write-failure-wf1") (StreamVersion 0) 100-            Vector.length journal `shouldBe` 7-            Right snapshotVersionDuringFailure <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "wf:snap-write-failure-wf1" snapshotVersionForStreamStmt-            snapshotVersionDuringFailure `shouldBe` Nothing-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            lookup "keiro.snapshot.write.failures" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 3)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "ALTER TABLE keiro.keiro_snapshots DROP CONSTRAINT keiro_snapshots_no_writes"-            recoveryCounter <- newIORef (0 :: Int)-            recovery <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts (WorkflowName "snap-write-recovery") (WorkflowId "wf2") (countingSixSteps recoveryCounter)-            recovery `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            Right snapshotVersionAfterRecovery <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "wf:snap-write-recovery-wf2" snapshotVersionForStreamStmt-            snapshotVersionAfterRecovery `shouldBe` Just (StreamVersion 6)--        -- Validation (a): a snapshot row appears at the expected version and-        -- decodes to the full accumulated step map.-        it "writes a snapshot of the accumulated step map after Every 2 fires" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "snap"-                wid = WorkflowId "w1"-            result <--                Store.runStoreIO storeHandle $-                    runWorkflowWith-                        (defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2)-                        name-                        wid-                        (countingSixSteps counter)-            result `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            -- Every 2 fired at versions 2, 4, 6; the upsert keeps the highest (6).-            Right snapVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "wf:snap-w1" snapshotVersionForStreamStmt-            snapVersion `shouldBe` Just (StreamVersion 6)-            -- and the row decodes to the six-entry accumulated map.-            Right mSeed <- Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:snap-w1")-            case mSeed of-                Just (m, v) -> do-                    v `shouldBe` StreamVersion 6-                    Map.keys m `shouldBe` ["s1", "s2", "s3", "s4", "s5", "s6"]-                Nothing -> expectationFailure "expected a workflow snapshot row"--        -- The OnTerminal completion-site wiring: only the final WorkflowCompleted-        -- append (version 7) triggers the snapshot.-        it "writes a terminal snapshot under OnTerminal at the completion version" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "term"-                wid = WorkflowId "tm1"-            result <--                Store.runStoreIO storeHandle $-                    runWorkflowWith-                        (defaultWorkflowRunOptions & #snapshotPolicy .~ OnTerminal)-                        name-                        wid-                        (countingSixSteps counter)-            result `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            Right snapVersion <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement "wf:term-tm1" snapshotVersionForStreamStmt-            snapVersion `shouldBe` Just (StreamVersion 7)--        -- Validation (b): re-hydration reads only the tail after the snapshot-        -- version, and the journaled steps short-circuit (the counter stays put).-        it "reads only the tail after the snapshot version on re-hydration" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "tail"-                wid = WorkflowId "t1"-                opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2-            first <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)-            first `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            afterFirst <- readIORef counter-            afterFirst `shouldBe` 6-            -- A full version-0 replay would read every journal event...-            Right full <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:tail-t1") (StreamVersion 0) 100-            Vector.length full `shouldBe` 7 -- six StepRecorded + one WorkflowCompleted-            -- ...whereas the runtime seeds from the snapshot and reads only the tail.-            Right (Just (seedMap, StreamVersion sv)) <--                Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:tail-t1")-            Map.size seedMap `shouldBe` 6-            Right tailEvents <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:tail-t1") (StreamVersion sv) 100-            Vector.length tailEvents `shouldSatisfy` (< Vector.length full)-            Vector.length tailEvents `shouldBe` 1 -- only the WorkflowCompleted at v7-            -- Re-hydration completes from the seed without re-running any step.-            second <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)-            second `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            afterSecond <- readIORef counter-            afterSecond `shouldBe` 6--        -- Validation (c): a Never run and an Every 2 run produce identical results-        -- and identical journals, and the snapshot seed equals a full replay.-        it "produces identical results and journals under Never and Every 2" $ \storeHandle -> do-            counterN <- newIORef (0 :: Int)-            counterE <- newIORef (0 :: Int)-            neverRes <--                Store.runStoreIO storeHandle $-                    runWorkflowWith-                        (defaultWorkflowRunOptions & #snapshotPolicy .~ Never)-                        (WorkflowName "corr-never")-                        (WorkflowId "c1")-                        (countingSixSteps counterN)-            everyRes <--                Store.runStoreIO storeHandle $-                    runWorkflowWith-                        (defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2)-                        (WorkflowName "corr-every")-                        (WorkflowId "c1")-                        (countingSixSteps counterE)-            neverRes `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            everyRes `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            Right neverEvents <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:corr-never-c1") (StreamVersion 0) 100-            Right everyEvents <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:corr-every-c1") (StreamVersion 0) 100-            let stepResults evs =-                    [ (k, v)-                    | Right (StepRecorded k v _) <- decodeRecorded workflowJournalCodec <$> Vector.toList evs-                    ]-            stepResults neverEvents `shouldBe` stepResults everyEvents-            -- The snapshot seed equals the map a full version-0 replay would fold.-            Right (Just (seedMap, _)) <--                Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:corr-every-c1")-            seedMap `shouldBe` Map.fromList (stepResults everyEvents)--        -- Validation (d): an advisory snapshot whose discriminant no longer matches-        -- is ignored and the workflow hydrates via full replay.-        it "hydrates via full replay when the snapshot discriminant mismatches" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "dmiss"-                wid = WorkflowId "d1"-                opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2-            _ <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("wf:dmiss-d1", "stale-shape") corruptSnapshotShapeStmt-            Right mSeed <- Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:dmiss-d1")-            mSeed `shouldBe` Nothing-            resumed <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)-            resumed `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])--        -- Validation (d), second arm: corrupt snapshot JSON is treated as a miss.-        it "hydrates via full replay when the snapshot JSON is corrupt" $ \storeHandle -> do-            (exporter, metricsRef) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            keiroMetrics <- Telemetry.newKeiroMetrics meter-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "cjson"-                wid = WorkflowId "d2"-                opts =-                    defaultWorkflowRunOptions-                        & #snapshotPolicy-                        .~ Every 2-                        & #metrics-                        ?~ keiroMetrics-            _ <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("wf:cjson-d2", Aeson.String "bad") corruptSnapshotStateStmt-            Right mSeed <- Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:cjson-d2")-            mSeed `shouldBe` Nothing-            resumed <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)-            resumed `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef metricsRef-            let scalars = flattenScalarPoints exported-            lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 2)--    describe "Keiro.Workflow snapshot wake-safety" $ around (withFreshStore fixture) $ do-        it "keeps a genuinely unresolved awakeable pending under Every 1" $ \storeHandle -> do-            aidRef <- newIORef Nothing-            let name = WorkflowName "snapshot-unsignalled"-                wid = WorkflowId "wf1"-                opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1-                run = Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (snapshotUnsignalledAwakeable aidRef)-            first <- run-            first `shouldBe` Right Suspended-            aid <- readRequiredAwakeableId aidRef-            Right (Just rowAfterFirst) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)-            rowAfterFirst ^. #status `shouldBe` Awk.Pending-            rowAfterFirst ^. #payload `shouldBe` Nothing-            second <- run-            second `shouldBe` Right Suspended-            Right (Just rowAfterSecond) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)-            rowAfterSecond ^. #status `shouldBe` Awk.Pending-            rowAfterSecond ^. #payload `shouldBe` Nothing--        it "delivers an awakeable signalled mid-run despite the stale in-memory map" $ \storeHandle -> do-            aidRef <- newIORef Nothing-            let name = WorkflowName "snapshot-midrun-awakeable"-                wid = WorkflowId "wf1"-                opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1-                run = Store.runStoreIO storeHandle $ runWorkflowWith opts name wid snapshotShadowedAwakeable-            armed <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts name wid (snapshotUnsignalledAwakeable aidRef)-            armed `shouldBe` Right Suspended-            first <- run-            first `shouldBe` Right (Completed "payload")-            second <- run-            second `shouldBe` Right (Completed "payload")--        it "delivers an awakeable shadowed by a snapshot on a later run" $ \storeHandle -> do-            aidRef <- newIORef Nothing-            let name = WorkflowName "snapshot-stale-awakeable"-                wid = WorkflowId "wf1"-                opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1-            armed <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts name wid (snapshotUnsignalledAwakeable aidRef)-            armed `shouldBe` Right Suspended-            first <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts name wid (snapshotStaleAwakeablePhaseOne aidRef)-            first `shouldBe` Right Suspended-            aid <- readRequiredAwakeableId aidRef-            Right (Just (staleSeed, _)) <--                Store.runStoreIO storeHandle $-                    loadWorkflowSnapshot (workflowGenerationStreamName name wid 0)-            staleSeed `shouldSatisfy` Map.notMember ("awk:" <> awakeableIdText aid)-            second <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts name wid snapshotStaleAwakeablePhaseTwo-            second `shouldBe` Right (Completed "payload")--        it "delivers a child completion shadowed by a snapshot on a later run" $ \storeHandle -> do-            let name = WorkflowName "snapshot-stale-child-parent"-                wid = WorkflowId "wf1"-                childWid = WorkflowId "child1"-                opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1-            first <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts name wid (snapshotStaleChildPhaseOne childWid)-            first `shouldBe` Right Suspended-            Right (Just (staleSeed, _)) <--                Store.runStoreIO storeHandle $-                    loadWorkflowSnapshot (workflowGenerationStreamName name wid 0)-            staleSeed `shouldSatisfy` Map.notMember (childResultStepName childWid)-            second <--                Store.runStoreIO storeHandle $-                    runWorkflowWith opts name wid (snapshotStaleChildPhaseTwo childWid)-            second `shouldBe` Right (Completed "packed+labelled")--    describe "Keiro.Workflow.Resume" $ around (withFreshStore fixture) $ do-        -- M2: crash mid-run, then a resume pass drives the workflow to Completed-        -- without re-running the already-journaled step.-        it "resumes a crashed mid-run workflow, running only the un-journaled tail" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "crash-demo"-                wid = WorkflowId "cd-1"-            -- Simulate a crash after step 1's append has committed.-            crashed <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflow name wid (crashAfterStep1 counter)-                    ) ::-                    IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))-            case crashed of-                Left _ -> pure () -- the SimulatedCrash unwound the run, as intended-                Right other -> expectationFailure ("expected a simulated crash, got " <> show other)-            readIORef counter >>= \c -> c `shouldBe` 1-            -- Resume with a registry mapping the name to the FULL definition.-            let registry = Map.singleton name (WorkflowDef (\_wid -> threeStep counter))-            Right summary <--                Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry-            summary-                `shouldBe` ResumeSummary-                    { discovered = 1-                    , resumed = 1-                    , completed = 1-                    , stillSuspended = 0-                    , unknownName = 0-                    , failed = 0-                    , transientErrors = 0-                    , leaseSkipped = 0-                    }-            -- Step 1 short-circuited; steps 2 and 3 ran exactly once.-            readIORef counter >>= \c -> c `shouldBe` 3-            -- The journal now holds s1, s2, s3, WorkflowCompleted.-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:crash-demo-cd-1") (StreamVersion 0) 10-            traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)-                `shouldSatisfy` \case-                    Right [StepRecorded "s1" _ _, StepRecorded "s2" _ _, StepRecorded "s3" _ _, WorkflowCompleted _] -> True-                    _ -> False-            -- A second pass discovers nothing — the workflow is finished.-            Right summary2 <--                Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry-            summary2 `shouldBe` emptyResumeSummary--        -- M3: a workflow suspended on an awaited step is driven to Completed once-        -- that step is journaled (here simulated; an EP-39/EP-40 wake source would-        -- journal the same StepRecorded end to end).-        it "resumes a suspended workflow once its awaited step is journaled" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "await-demo"-                wid = WorkflowId "ad-1"-            suspended <--                Store.runStoreIO storeHandle $ runWorkflow name wid (awaitingThenStep counter)-            suspended `shouldBe` Right Suspended-            -- Simulate the wake source resolving the await.-            Right () <- Store.runStoreIO storeHandle $ do-                now <- liftIO getCurrentTime-                appendJournalEntry name wid (StepRecorded "awk:approval" (toJSON ("ok" :: Text)) now)-            let registry = Map.singleton name (WorkflowDef (\_wid -> awaitingThenStep counter))-            Right summary <--                Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry-            summary-                `shouldBe` ResumeSummary-                    { discovered = 1-                    , resumed = 1-                    , completed = 1-                    , stillSuspended = 0-                    , unknownName = 0-                    , failed = 0-                    , transientErrors = 0-                    , leaseSkipped = 0-                    }-            readIORef counter >>= \c -> c `shouldBe` 1-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:await-demo-ad-1") (StreamVersion 0) 10-            traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)-                `shouldSatisfy` \case-                    Right [StepRecorded "awk:approval" _ _, StepRecorded "use" _ _, WorkflowCompleted _] -> True-                    _ -> False--        -- M4: a discovered workflow whose name is absent from the registry is-        -- skipped and counted, never silently dropped or fatal.-        it "skips and counts a workflow whose name is absent from the registry" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "orphan"-                wid = WorkflowId "or-1"-            crashed <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflow name wid (crashAfterStep1 counter)-                    ) ::-                    IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))-            case crashed of-                Left _ -> pure ()-                Right other -> expectationFailure ("expected a simulated crash, got " <> show other)-            -- Empty registry: the orphan is surfaced via unknownName, not completed.-            Right summary <--                Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions Map.empty-            summary-                `shouldBe` ResumeSummary-                    { discovered = 1-                    , resumed = 0-                    , completed = 0-                    , stillSuspended = 0-                    , unknownName = 1-                    , failed = 0-                    , transientErrors = 0-                    , leaseSkipped = 0-                    }-            -- The journal is unchanged: still one step, no completion.-            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:orphan-or-1") (StreamVersion 0) 10-            Vector.length recorded `shouldBe` 1--        it "isolates a poison workflow so a healthy workflow still completes" $ \storeHandle -> do-            healthyCounter <- newIORef (0 :: Int)-            let poisonName = WorkflowName "poison"-                poisonId = WorkflowId "poison-1"-                healthyName = WorkflowName "healthy"-                healthyId = WorkflowId "healthy-1"-                opts =-                    defaultWorkflowResumeOptions-                        & #maxAttempts-                        .~ 1-                        & #logEvent-                        .~ const (pure ())-                registry =-                    Map.fromList-                        [ (poisonName, WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))-                        , (healthyName, WorkflowDef (\_ -> threeStep healthyCounter))-                        ]-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry poisonName poisonId (StepRecorded "seed" (toJSON True) now)-            crashed <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflow healthyName healthyId (crashAfterStep1 healthyCounter)-                    ) ::-                    IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))-            case crashed of-                Left _ -> pure ()-                Right other -> expectationFailure ("expected a simulated crash, got " <> show other)-            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            summary-                `shouldBe` emptyResumeSummary-                    { discovered = 2-                    , resumed = 2-                    , completed = 1-                    , failed = 1-                    }-            readIORef healthyCounter >>= \c -> c `shouldBe` 3-            Right (Just poisonRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance poisonName poisonId-            poisonRow ^. #status `shouldBe` Instance.WfFailed--        it "marks a crashing workflow failed and short-circuits later direct runs" $ \storeHandle -> do-            let name = WorkflowName "terminal-poison"-                wid = WorkflowId "tp-1"-                opts =-                    defaultWorkflowResumeOptions-                        & #maxAttempts-                        .~ 1-                        & #logEvent-                        .~ const (pure ())-                registry = Map.singleton name (WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)-            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            failed summary `shouldBe` 1-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #status `shouldBe` Instance.WfFailed-            row ^. #attempts `shouldBe` 1-            direct <- Store.runStoreIO storeHandle $ runWorkflow name wid (step (StepName "never") (pure (1 :: Int)))-            direct `shouldBe` Right Failed-            Right recordedFailed <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:terminal-poison-tp-1") (StreamVersion 0) 10-            traverse (decodeRecorded workflowJournalCodec) (Vector.toList recordedFailed)-                `shouldSatisfy` \case-                    Right events -> any (\case WorkflowFailed{} -> True; _ -> False) events-                    _ -> False--        it "resurrects a failed workflow and completes without rerunning its journaled prefix" $ \storeHandle -> do-            shouldCrash <- newIORef True-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "resurrect-complete"-                wid = WorkflowId "rc-1"-                opts =-                    defaultWorkflowResumeOptions-                        & #maxAttempts-                        .~ 1-                        & #logEvent-                        .~ const (pure ())-                registry = Map.singleton name (WorkflowDef (\_ -> recoverableWorkflow shouldCrash counter))-            crashed <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflow name wid (recoverableWorkflow shouldCrash counter)-                    ) ::-                    IO (Either SomeException (Either Store.StoreError (WorkflowOutcome Int)))-            case crashed of-                Left _ -> pure ()-                Right other -> expectationFailure ("expected a simulated crash, got " <> show other)-            readIORef counter `shouldReturn` 1--            Right failedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            failed failedPass `shouldBe` 1-            Right (Just failedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            failedRow ^. #status `shouldBe` Instance.WfFailed--            writeIORef shouldCrash False-            resurrected <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid-            resurrected `shouldBe` Right Instance.WorkflowResurrected-            Right (Just revivedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            revivedRow ^. #status `shouldBe` Instance.WfRunning-            revivedRow ^. #attempts `shouldBe` 0-            revivedRow ^. #lastError `shouldBe` Nothing-            revivedRow ^. #nextAttemptAt `shouldBe` Nothing--            Right completedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            completed completedPass `shouldBe` 1-            readIORef counter `shouldReturn` 2-            Right (Just completedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            completedRow ^. #status `shouldBe` Instance.WfCompleted--        it "can fail again in the same generation after resurrection" $ \storeHandle -> do-            shouldCrash <- newIORef True-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "resurrect-refail"-                wid = WorkflowId "rr-1"-                opts =-                    defaultWorkflowResumeOptions-                        & #maxAttempts-                        .~ 1-                        & #logEvent-                        .~ const (pure ())-                registry = Map.singleton name (WorkflowDef (\_ -> recoverableWorkflow shouldCrash counter))-            crashed <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflow name wid (recoverableWorkflow shouldCrash counter)-                    ) ::-                    IO (Either SomeException (Either Store.StoreError (WorkflowOutcome Int)))-            case crashed of-                Left _ -> pure ()-                Right other -> expectationFailure ("expected a simulated crash, got " <> show other)--            Right firstFailedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            failed firstFailedPass `shouldBe` 1-            firstRevival <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid-            firstRevival `shouldBe` Right Instance.WorkflowResurrected-            Right secondFailedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            failed secondFailedPass `shouldBe` 1-            Right (Just refailedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            refailedRow ^. #status `shouldBe` Instance.WfFailed--            Right recorded <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward-                        (workflowGenerationStreamName name wid 0)-                        (StreamVersion 0)-                        10-            let failureIds =-                    [ event ^. #eventId-                    | event <- Vector.toList recorded-                    , Right decoded <- [decodeRecorded workflowJournalCodec event]-                    , WorkflowFailed{} <- [decoded]-                    ]-            case failureIds of-                [firstFailureId, secondFailureId] ->-                    firstFailureId `shouldNotBe` secondFailureId-                other ->-                    expectationFailure ("expected two failure events, got " <> show other)--            secondRevival <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid-            secondRevival `shouldBe` Right Instance.WorkflowResurrected--        it "guards resurrection and revives a failed child link transactionally" $ \storeHandle -> do-            let runningName = WorkflowName "resurrect-running"-                runningId = WorkflowId "running-1"-                missingName = WorkflowName "resurrect-missing"-                missingId = WorkflowId "missing-1"-                childName = WorkflowName "resurrect-child"-                childId = WorkflowId "child-1"-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry runningName runningId (StepRecorded "seed" (toJSON True) now)-            runningOutcome <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow runningName runningId-            runningOutcome `shouldBe` Right Instance.WorkflowNotFailed-            missingOutcome <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow missingName missingId-            missingOutcome `shouldBe` Right Instance.WorkflowNotFound--            Right childMarkedFailed <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $ do-                        Child.registerChildTx-                            "child-1"-                            "resurrect-child"-                            "parent-1"-                            "resurrect-parent"-                            "child:child-1:result"-                        Child.markChildFailedTx "child-1" "resurrect-child" "simulated terminal failure"-            childMarkedFailed `shouldBe` True-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry childName childId (WorkflowFailed "simulated terminal failure" now)--            childOutcome <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow childName childId-            childOutcome `shouldBe` Right Instance.WorkflowResurrected-            Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "child-1" "resurrect-child"-            childRow ^. #status `shouldBe` Child.Running-            childRow ^. #result `shouldBe` Nothing-            childRow ^. #failureReason `shouldBe` Nothing-            childRow ^. #completedAt `shouldBe` Nothing--        it "classifies thrown store errors as transient without consuming attempts" $ \storeHandle -> do-            let name = WorkflowName "transient"-                wid = WorkflowId "tr-1"-                opts = defaultWorkflowResumeOptions & #logEvent .~ const (pure ())-                registry =-                    Map.singleton name $-                        WorkflowDef-                            ( \_ -> do-                                _ <- throwError (Store.ConnectionLost "boom")-                                pure (0 :: Int)-                            )-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)-            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            transientErrors summary `shouldBe` 1-            failed summary `shouldBe` 0-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #attempts `shouldBe` 0-            row ^. #status `shouldBe` Instance.WfRunning--        it "keeps the fixed-poll loop alive when one pass contains a poison workflow" $ \storeHandle -> do-            done <- newEmptyMVar-            healthyCounter <- newIORef (0 :: Int)-            let poisonName = WorkflowName "fixed-loop-poison"-                poisonId = WorkflowId "flp-1"-                healthyName = WorkflowName "fixed-loop-healthy"-                healthyId = WorkflowId "flh-1"-                opts =-                    defaultWorkflowResumeOptions-                        & #pollInterval-                        .~ 50_000-                        & #maxAttempts-                        .~ 1-                        & #logEvent-                        .~ const (pure ())-                healthyBody = threeStepThenSignal healthyCounter done-                registry =-                    Map.fromList-                        [ (poisonName, WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))-                        , (healthyName, WorkflowDef (\_ -> healthyBody))-                        ]-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry poisonName poisonId (StepRecorded "seed" (toJSON True) now)-            crashed <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflow healthyName healthyId (crashAfterStep1 healthyCounter)-                    ) ::-                    IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))-            case crashed of-                Left _ -> pure ()-                Right other -> expectationFailure ("expected a simulated crash, got " <> show other)-            worker <- forkIO (void (Store.runStoreIO storeHandle (runWorkflowResumeWorkerWith opts registry)))-            completed <- timeout 5_000_000 (takeMVar done)-            status <- threadStatus worker-            killThread worker-            completed `shouldBe` Just ()-            status `shouldSatisfy` \case-                ThreadFinished -> False-                ThreadDied -> False-                _ -> True--        it "claims one workflow instance for a single live owner and releases it" $ \storeHandle -> do-            let name = WorkflowName "lease-claim"-                wid = WorkflowId "lc-1"-            Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 30 name wid-            claimedA `shouldBe` True-            Right claimedB <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid-            claimedB `shouldBe` False-            Right () <- Store.runStoreIO storeHandle $ Instance.releaseInstance "owner-a" False name wid-            Right claimedBAfterRelease <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid-            claimedBAfterRelease `shouldBe` True--        it "lets an expired workflow lease be taken and resets attempts on progressed release" $ \storeHandle -> do-            let name = WorkflowName "lease-expire"-                wid = WorkflowId "le-1"-            Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 30 name wid-            claimedA `shouldBe` True-            Right attempt <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Instance.recordCrashTx "le-1" "lease-expire" "boom"-            attempt `shouldBe` 1-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.sql "UPDATE keiro.keiro_workflows SET lease_expires_at = now() - interval '1 second', next_attempt_at = now() - interval '1 second' WHERE workflow_id = 'le-1' AND workflow_name = 'lease-expire'"-            Right claimedB <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid-            claimedB `shouldBe` True-            Right () <- Store.runStoreIO storeHandle $ Instance.releaseInstance "owner-b" True name wid-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #attempts `shouldBe` 0-            row ^. #lastError `shouldBe` Nothing-            row ^. #nextAttemptAt `shouldBe` Nothing-            row ^. #leasedBy `shouldBe` Nothing--        it "skips a resume candidate held by another live lease owner" $ \storeHandle -> do-            ran <- newIORef False-            let name = WorkflowName "lease-skip"-                wid = WorkflowId "ls-1"-                registry =-                    Map.singleton name $-                        WorkflowDef-                            ( \_ -> do-                                liftIO (writeIORef ran True)-                                pure (0 :: Int)-                            )-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)-            Right foreignClaim <- Store.runStoreIO storeHandle $ Instance.claimInstance "foreign-owner" 30 name wid-            foreignClaim `shouldBe` True-            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry-            summary-                `shouldBe` emptyResumeSummary-                    { discovered = 1-                    , leaseSkipped = 1-                    }-            readIORef ran `shouldReturn` False--        -- M4: resume on an already-completed workflow is a genuine no-op.-        it "discovers nothing for an already-completed workflow and is stable" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "done-demo"-                wid = WorkflowId "dd-1"-            done <- Store.runStoreIO storeHandle $ runWorkflow name wid (threeStep counter)-            done `shouldBe` Right (Completed (1, 2, 3))-            readIORef counter >>= \c -> c `shouldBe` 3-            let registry = Map.singleton name (WorkflowDef (\_wid -> threeStep counter))-            Right summary1 <--                Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry-            summary1 `shouldBe` emptyResumeSummary-            Right summary2 <--                Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry-            summary2 `shouldBe` emptyResumeSummary-            readIORef counter >>= \c -> c `shouldBe` 3--    describe "Keiro.Workflow lease renewal" $ around (withFreshStore fixture) $ do-        it "renews before a slow fresh step so the original lease cannot be stolen" $ \storeHandle -> do-            attemptedClaim <- newIORef Nothing-            let name = WorkflowName "lease-heartbeat"-                wid = WorkflowId "heartbeat-1"-                runOpts =-                    defaultWorkflowRunOptions-                        & #leaseHeartbeat-                        .~ Just LeaseHeartbeat{owner = "owner-a", ttl = 60}-                body =-                    step (StepName "slow-boundary") $ do-                        liftIO (threadDelay 300_000)-                        claimed <--                            Instance.claimInstance-                                "owner-b"-                                60-                                name-                                wid-                        liftIO (writeIORef attemptedClaim (Just claimed))-                        pure claimed-            Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 0.2 name wid-            claimedA `shouldBe` True-            outcome <- Store.runStoreIO storeHandle $ runWorkflowWith runOpts name wid body-            outcome `shouldBe` Right (Completed False)-            readIORef attemptedClaim `shouldReturn` Just False-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            row ^. #leasedBy `shouldBe` Just "owner-a"--        it "stops at a lost lease boundary and the resume worker records no crash" $ \storeHandle -> do-            let directName = WorkflowName "lease-lost-direct"-                directId = WorkflowId "lost-direct-1"-                directOpts =-                    defaultWorkflowRunOptions-                        & #leaseHeartbeat-                        .~ Just LeaseHeartbeat{owner = "owner-a", ttl = 60}-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry directName directId (StepRecorded "seed" (toJSON True) now)-            Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 60 directName directId-            claimedA `shouldBe` True-            leaseUntil <- addUTCTime 60 <$> getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement-                            ("lost-direct-1", "lease-lost-direct", "owner-b", leaseUntil)-                            forceWorkflowLeaseStmt-            firstDirectEffect <- newIORef (0 :: Int)-            secondDirectEffect <- newIORef (0 :: Int)-            direct <--                try-                    ( Store.runStoreIO storeHandle $-                        runWorkflowWith directOpts directName directId $ do-                            _ <- step (StepName "first") (liftIO (incrementAndRead firstDirectEffect))-                            step (StepName "second") (liftIO (incrementAndRead secondDirectEffect))-                    ) ::-                    IO-                        ( Either-                            WorkflowLeaseLost-                            (Either Store.StoreError (WorkflowOutcome Int))-                        )-            direct `shouldBe` Left WorkflowLeaseLost-            readIORef firstDirectEffect `shouldReturn` 0-            readIORef secondDirectEffect `shouldReturn` 0-            directFinishedAt <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry directName directId (WorkflowCompleted directFinishedAt)--            firstWorkerEffect <- newIORef (0 :: Int)-            secondWorkerEffect <- newIORef (0 :: Int)-            let workerName = WorkflowName "lease-lost-worker"-                workerId = WorkflowId "lost-worker-1"-                workerOpts =-                    defaultWorkflowResumeOptions-                        & #logEvent-                        .~ const (pure ())-                registry =-                    Map.singleton workerName $-                        WorkflowDef $ \_ -> do-                            _ <--                                step (StepName "first") $ do-                                    value <- liftIO (incrementAndRead firstWorkerEffect)-                                    expires <- liftIO (addUTCTime 60 <$> getCurrentTime)-                                    Store.runTransaction $-                                        Tx.statement-                                            ("lost-worker-1", "lease-lost-worker", "owner-b", expires)-                                            forceWorkflowLeaseStmt-                                    pure value-                            step (StepName "second") (liftIO (incrementAndRead secondWorkerEffect))-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry workerName workerId (StepRecorded "seed" (toJSON True) now)-            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce workerOpts registry-            summary-                `shouldBe` emptyResumeSummary-                    { discovered = 1-                    , leaseSkipped = 1-                    }-            readIORef firstWorkerEffect `shouldReturn` 1-            readIORef secondWorkerEffect `shouldReturn` 0-            Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance workerName workerId-            row ^. #attempts `shouldBe` 0-            row ^. #leasedBy `shouldBe` Just "owner-b"--    describe "Keiro.Workflow continue-as-new" $ around (withFreshStore fixture) $ do-        -- EP-48 headline proof (Checks 1 & 2): a 300-step rolling-total workflow that-        -- rotates every 50 steps keeps each physical generation journal bounded by-        -- K = rotateEvery + 2 (at most rotateEvery work steps + the one seed step that-        -- opened the generation + the one terminal marker), yet returns the correct-        -- final total. A single non-rotating run would put all 300 steps on one-        -- journal and the per-generation `<= K` bound would fail.-        it "rotates a long workflow, bounds each generation, and returns the correct total" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "roller"-                wid = WorkflowId "r-1"-                rotateEvery = 50 :: Int-                total = 300 :: Int-                k = rotateEvery + 2-                body = rollingTotal counter rotateEvery total-                -- Re-invoke runWorkflow until it Completes; each call resolves and-                -- advances the current generation, exactly as the resume worker does.-                drive :: Int -> IO Int-                drive budget-                    | budget <= 0 =-                        expectationFailure "workflow did not complete within the rotation budget" >> pure (-1)-                    | otherwise = do-                        outcome <- Store.runStoreIO storeHandle (runWorkflow name wid body)-                        case outcome of-                            Right ContinuedAsNew -> drive (budget - 1)-                            Right (Completed t) -> pure t-                            other -> expectationFailure ("unexpected outcome: " <> show other) >> pure (-1)-            -- The first invocation rotates (generation 0 did rotateEvery steps).-            firstOutcome <- Store.runStoreIO storeHandle (runWorkflow name wid body)-            firstOutcome `shouldBe` Right ContinuedAsNew-            -- Drive the remaining generations to completion (bounded passes).-            finalTotal <- drive (total `div` rotateEvery + 3)-            -- Check 2: correct result, and each side effect ran exactly once.-            finalTotal `shouldBe` total-            readIORef counter >>= (`shouldBe` total)-            -- The workflow rotated to its final generation (300/50 = 6 generations: 0..5).-            Right gen <- Store.runStoreIO storeHandle (currentGeneration name wid)-            gen `shouldBe` (total `div` rotateEvery - 1)-            -- Check 1: every generation's physical journal is bounded by K, and the-            -- total is split ACROSS generations (bounded per generation, not in-            -- aggregate). Each generation holds exactly 1 seed + rotateEvery work + 1-            -- marker = K events, so the sum is total + 2 per generation.-            lengths <--                traverse-                    ( \g -> do-                        let streamName = workflowGenerationStreamName name wid g-                        Right evs <- Store.runStoreIO storeHandle (Store.readStreamForward streamName (StreamVersion 0) 1000)-                        pure (Vector.length evs)-                    )-                    [0 .. gen]-            for_ lengths (`shouldSatisfy` (<= k))-            sum lengths `shouldBe` (total + 2 * (gen + 1))-            -- The first generation ends with a rotation marker; the last with a-            -- completion marker.-            Right gen0evs <- Store.runStoreIO storeHandle (Store.readStreamForward (workflowGenerationStreamName name wid 0) (StreamVersion 0) 1000)-            (decodeRecorded workflowJournalCodec <$> Vector.toList gen0evs)-                `shouldSatisfy` any-                    ( \case-                        Right (WorkflowContinuedAsNew 1 _) -> True-                        _ -> False-                    )-            Right lastEvs <- Store.runStoreIO storeHandle (Store.readStreamForward (workflowGenerationStreamName name wid gen) (StreamVersion 0) 1000)-            (decodeRecorded workflowJournalCodec <$> Vector.toList lastEvs)-                `shouldSatisfy` any-                    ( \case-                        Right (WorkflowCompleted _) -> True-                        _ -> False-                    )--        -- EP-48 Check 3: discovery and resume follow the CURRENT generation. After a-        -- rotation the rotated (newer) generation is unfinished and discoverable —-        -- the older generation's WorkflowContinuedAsNew marker does NOT mask it — and-        -- the resume worker drives the rotated generation forward to completion.-        it "rediscovers and resumes a rotated workflow on its current generation" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "roller2"-                wid = WorkflowId "r-2"-                rotateEvery = 50 :: Int-                total = 150 :: Int-                registry = Map.singleton name (WorkflowDef (\_ -> rollingTotal counter rotateEvery total))-                resumeUntilDone :: Int -> IO ()-                resumeUntilDone budget-                    | budget <= 0 = expectationFailure "resume did not complete the rotated workflow"-                    | otherwise = do-                        Right summary <--                            Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)-                        if completed summary == 1 then pure () else resumeUntilDone (budget - 1)-            -- First run rotates onto generation 1.-            firstOutcome <- Store.runStoreIO storeHandle (runWorkflow name wid (rollingTotal counter rotateEvery total))-            firstOutcome `shouldBe` Right ContinuedAsNew-            -- The rotated current generation (1) is unfinished and discoverable.-            now <- getCurrentTime-            Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)-            unfinished `shouldBe` [("r-2", "roller2")]-            -- The resume worker drives the rotated generation(s) to completion.-            resumeUntilDone (total `div` rotateEvery + 3)-            readIORef counter >>= (`shouldBe` total)-            -- Finished: discovery now reports nothing for it.-            finalNow <- getCurrentTime-            Right finalUnfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds finalNow)-            finalUnfinished `shouldBe` []--    describe "Keiro.Workflow patch API" $ around (withFreshStore fixture) $ do-        it "an in-flight instance observes the OLD branch; a fresh instance the NEW branch; the decision is journaled once and stable" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "patchwf"-                inflight = WorkflowId "inflight-1"-                fresh = WorkflowId "fresh-1"-                patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId--            -- 1. Run the in-flight instance to a suspension under the PRE-patch code.-            pre <- Store.runStoreIO storeHandle $ runWorkflow name inflight (prePatchWorkflow counter)-            pre `shouldBe` Right Suspended--            -- 2. Redeploy: re-run the SAME instance id under the POST-patch code. It-            --    already journaled reserve-inventory, so it is in flight -> False.-            r1 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name inflight (postPatchWorkflow counter)-            r1 `shouldBe` Right (Completed "old-branch")--            -- 3. Replay the in-flight instance again: same OLD branch, every time.-            r2 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name inflight (postPatchWorkflow counter)-            r2 `shouldBe` Right (Completed "old-branch")--            -- 4. A fresh instance under the POST-patch code takes the NEW branch.-            f1 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name fresh (postPatchWorkflow counter)-            f1 `shouldBe` Right (Completed "new-branch")-            -- and stays on the new branch on replay.-            f2 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name fresh (postPatchWorkflow counter)-            f2 `shouldBe` Right (Completed "new-branch")--            -- 5. The patch decision is journaled exactly once per instance, with the-            --    expected Bool, on the patch:<id> key.-            Right inflightJournal <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:patchwf-inflight-1") (StreamVersion 0) 20-            let inflightDecisions =-                    [ v-                    | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList inflightJournal)-                    , StepRecorded k v _ <- [ev]-                    , k == patchStepName fraudPatchId-                    ]-            inflightDecisions `shouldBe` [toJSON False]--            Right freshJournal <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (StreamName "wf:patchwf-fresh-1") (StreamVersion 0) 20-            let freshDecisions =-                    [ v-                    | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList freshJournal)-                    , StepRecorded k v _ <- [ev]-                    , k == patchStepName fraudPatchId-                    ]-            freshDecisions `shouldBe` [toJSON True]-            let freshPatchSets =-                    [ v-                    | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList freshJournal)-                    , StepRecorded k v _ <- [ev]-                    , k == patchSetStepName-                    ]-            freshPatchSets `shouldBe` [toJSON [unPatchId fraudPatchId]]--        it "a fresh instance suspended before its patch call still takes the NEW branch" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "patch-after-suspend"-                wid = WorkflowId "pas-1"-                patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflowWith patchOptions name wid (postPatchAfterSuspendWorkflow counter)-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (StepRecorded "awk:gate" Aeson.Null now)-            resumed <--                Store.runStoreIO storeHandle $-                    runWorkflowWith patchOptions name wid (postPatchAfterSuspendWorkflow counter)-            resumed `shouldBe` Right (Completed "new-branch")--        it "an in-flight instance with only wake-source completions stays on the OLD branch" $ \storeHandle -> do-            let name = WorkflowName "patch-wake-only"-                wid = WorkflowId "pwo-1"-                patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId-            Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid prePatchWakeOnlyWorkflow-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (StepRecorded "awk:gate" Aeson.Null now)-            resumed <--                Store.runStoreIO storeHandle $-                    runWorkflowWith patchOptions name wid postPatchWakeOnlyWorkflow-            resumed `shouldBe` Right (Completed "old-branch")--        it "records the active patch set again for a fresh rotated generation" $ \storeHandle -> do-            let name = WorkflowName "patch-rotating"-                wid = WorkflowId "pr-1"-                patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId-            first <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name wid rotatingPatchWorkflow-            first `shouldBe` Right ContinuedAsNew-            second <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name wid rotatingPatchWorkflow-            second `shouldBe` Right (Completed "new-branch")-            Right gen1Journal <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward (workflowGenerationStreamName name wid 1) (StreamVersion 0) 20-            let gen1PatchSets =-                    [ v-                    | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList gen1Journal)-                    , StepRecorded k v _ <- [ev]-                    , k == patchSetStepName-                    ]-            gen1PatchSets `shouldBe` [toJSON [unPatchId fraudPatchId]]--    describe "Keiro.Workflow patch recording at rotation" $ around (withFreshStore fixture) $ do-        it "keeps the active patch after a wake append lands before the first rotated run" $ \storeHandle -> do-            let name = WorkflowName "patch-rotation-race"-                wid = WorkflowId "prr-1"-                patchOptions =-                    defaultWorkflowRunOptions-                        & #activePatches-                        .~ Set.singleton fraudPatchId-                generationOneStream = workflowGenerationStreamName name wid 1--            first <--                Store.runStoreIO storeHandle $-                    runWorkflowWith patchOptions name wid rotatingPatchWorkflow-            first `shouldBe` Right ContinuedAsNew-            Right patchSetRecorded <--                Store.runStoreIO storeHandle $-                    stepExists name wid 1 patchSetStepName-            patchSetRecorded `shouldBe` True--            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry-                        name-                        wid-                        ( StepRecorded-                            "awk:11111111-1111-1111-1111-111111111111"-                            (toJSON True)-                            now-                        )--            second <--                Store.runStoreIO storeHandle $-                    runWorkflowWith patchOptions name wid rotatingPatchWorkflow-            second `shouldBe` Right (Completed "new-branch")-            replayed <--                Store.runStoreIO storeHandle $-                    runWorkflowWith patchOptions name wid rotatingPatchWorkflow-            replayed `shouldBe` Right (Completed "new-branch")--            Right generationOneJournal <--                Store.runStoreIO storeHandle $-                    Store.readStreamForward generationOneStream (StreamVersion 0) 20-            let decoded =-                    map (decodeRecorded workflowJournalCodec) (Vector.toList generationOneJournal)-                patchSets =-                    [ value-                    | Right (StepRecorded key value _) <- decoded-                    , key == patchSetStepName-                    ]-                decisions =-                    [ value-                    | Right (StepRecorded key value _) <- decoded-                    , key == patchStepName fraudPatchId-                    ]-            patchSets `shouldBe` [toJSON [unPatchId fraudPatchId]]-            decisions `shouldBe` [toJSON True]--    describe "Keiro.Wake" $ around (withFreshStore fixture) $ do-        -- EP-50: the wake primitive over kiroku's existing per-store notifier.-        it "returns WokenByTimeout when idle (no append)" $ \store -> do-            wake <- wakeSignalFromStore store-            reason <- waitForWake wake 200000 -- 200 ms-            reason `shouldBe` WokenByTimeout--        it "returns WokenByNotify promptly after a real append" $ \store -> do-            wake <- wakeSignalFromStore store-            -- A real append bumps the streams row and fires kiroku's NOTIFY on-            -- kiroku.events; the store's notifier ticks the broadcast channel.-            now <- getCurrentTime-            Right () <--                Store.runStoreIO store $-                    appendJournalEntry (WorkflowName "wakedemo") (WorkflowId "w1") (StepRecorded "s" (toJSON True) now)-            reason <- waitForWake wake 5000000 -- generous 5 s ceiling; the round-trip is milliseconds-            reason `shouldBe` WokenByNotify--        it "neverWake always returns WokenByTimeout" $ \_store -> do-            reason <- waitForWake neverWake 100000-            reason `shouldBe` WokenByTimeout--    describe "Keiro.Workflow push latency (EP-50)" $ around (withFreshStore fixture) $ do-        -- The user-visible win: a gated workflow resumes within sub-second of the-        -- gate append, under a deliberately large (10 s) fallback — so a pass that-        -- resumes it sub-second can only have been woken by the NOTIFY, not the poll.-        it "resumes a gated workflow sub-second after the gate append (10s fallback)" $ \store -> do-            done <- newEmptyMVar-            let name = WorkflowName "pushwf"-                wid = WorkflowId "p-1"-                registry = Map.singleton name (WorkflowDef (\_ -> gateThenSignal done))-                opts = defaultWorkflowResumeOptions & #pollInterval .~ 10000000 -- 10 s fallback-            first <- Store.runStoreIO store (runWorkflow name wid (gateThenSignal done))-            first `shouldBe` Right Suspended-            worker <- forkIO (runWorkflowResumeWorkerPush store opts registry)-            -- Let the worker start, duplicate the tick channel, and park in its wait-            -- before we append, so the gate's NOTIFY cannot be missed.-            threadDelay 250000-            now <- getCurrentTime-            Right () <--                Store.runStoreIO store $-                    appendJournalEntry name wid (StepRecorded "awk:gate" (toJSON ()) now)-            resumed <- timeout 5000000 (takeMVar done)-            t1 <- getCurrentTime-            killThread worker-            resumed `shouldBe` Just ()-            let latency = realToFrac (diffUTCTime t1 now) :: Double-            latency `shouldSatisfy` (< 1.0)--        it "logs a failed push pass and keeps draining after the store recovers" $ \store -> do-            done <- newEmptyMVar-            logs <- newIORef []-            let name = WorkflowName "push-recover"-                wid = WorkflowId "pr-1"-                registry = Map.singleton name (WorkflowDef (\_ -> gateThenSignal done))-                opts =-                    defaultWorkflowResumeOptions-                        & #pollInterval-                        .~ 100_000-                        & #logEvent-                        .~ \event -> modifyIORef' logs (<> [event])-                waitForPassFailure = timeout 5_000_000 $ do-                    let go = do-                            seen <- readIORef logs-                            if any isPassFailure seen-                                then pure ()-                                else threadDelay 20_000 >> go-                    go-                isPassFailure = \case-                    ResumePassFailed{} -> True-                    _ -> False-            first <- Store.runStoreIO store (runWorkflow name wid (gateThenSignal done))-            first `shouldBe` Right Suspended-            Right () <--                Store.runStoreIO store $-                    Store.runTransaction $-                        Tx.sql "ALTER TABLE keiro.keiro_workflow_steps RENAME TO keiro_workflow_steps_hidden"-            worker <- forkIO (runWorkflowResumeWorkerPush store opts registry)-            logged <- waitForPassFailure-            logged `shouldBe` Just ()-            Right () <--                Store.runStoreIO store $-                    Store.runTransaction $-                        Tx.sql "ALTER TABLE keiro.keiro_workflow_steps_hidden RENAME TO keiro_workflow_steps"-            now <- getCurrentTime-            Right () <--                Store.runStoreIO store $-                    appendJournalEntry name wid (StepRecorded "awk:gate" (toJSON ()) now)-            resumed <- timeout 5_000_000 (takeMVar done)-            status <- threadStatus worker-            killThread worker-            resumed `shouldBe` Just ()-            status `shouldSatisfy` \case-                ThreadFinished -> False-                ThreadDied -> False-                _ -> True--    describe "Keiro.Workflow push fallback (EP-50)" $ around (withFreshStore fixture) $ do-        -- Push is strictly an optimization: with the worker on 'neverWake' (every-        -- NOTIFY dropped) and a small fallback, the gated workflow still drains on-        -- the durable poll.-        it "still drains on the fallback timeout when no notification is delivered" $ \store -> do-            done <- newEmptyMVar-            let name = WorkflowName "fallbackwf"-                wid = WorkflowId "f-1"-                registry = Map.singleton name (WorkflowDef (\_ -> gateThenSignal done))-                onePass = void (Store.runStoreIO store (resumeWorkflowsOnce defaultWorkflowResumeOptions registry))-            first <- Store.runStoreIO store (runWorkflow name wid (gateThenSignal done))-            first `shouldBe` Right Suspended-            worker <- forkIO (runPollLoopWith neverWake 200000 onePass) -- 200 ms fallback, no notifications-            now <- getCurrentTime-            Right () <--                Store.runStoreIO store $-                    appendJournalEntry name wid (StepRecorded "awk:gate" (toJSON ()) now)-            resumed <- timeout 5000000 (takeMVar done)-            killThread worker-            resumed `shouldBe` Just ()--    describe "Shard lease" $ around (withFreshStore fixture) $ do-        -- EP-51 M2: claim / renew / release / expiry at the SQL layer, with explicit-        -- `now` timestamps standing in for the passage of time (no workers yet). The-        -- exclusion guarantee is the FOR UPDATE SKIP LOCKED claim; disjointness and-        -- failover are both observable purely from the lease table.-        let subName = SubscriptionName "orders-shard"-            wA = WorkerId sampleUuid-            wB = WorkerId sampleUuid2-            ttl = 30 :: NominalDiffTime-            t0 = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)-            tExpired = addUTCTime 60 t0 -- past A's 30 s lease-            shardOpts = defaultShardedWorkerOptions (Category (CategoryName "orders")) 4-        it "validates sharded worker options before startup" $ \_store -> do-            shouldBeRight_ (mkShardedWorkerOptions shardOpts)-            mkShardedWorkerOptions (shardOpts & #shardCount .~ 0)-                `shouldBeLeft` InvalidShardCount 0-            mkShardedWorkerOptions (shardOpts & #leaseTtl .~ 0)-                `shouldBeLeft` InvalidShardLeaseTtl 0-            mkShardedWorkerOptions (shardOpts & #renewInterval .~ 0)-                `shouldBeLeft` InvalidShardRenewInterval 0-            mkShardedWorkerOptions (shardOpts & #leaseTtl .~ 10 & #renewInterval .~ 10)-                `shouldBeLeft` InvalidShardLeaseRenewInterval 10 10-            mkShardedWorkerOptions (shardOpts & #batchSize .~ 0)-                `shouldBeLeft` InvalidShardBatchSize 0-            mkShardedWorkerOptions (shardOpts & #bufferSize .~ 0)-                `shouldBeLeft` InvalidShardBufferSize 0-            mkShardedWorkerOptions (shardOpts & #handlerRetryDelay .~ KirokuSub.RetryDelay (-1))-                `shouldBeLeft` InvalidShardHandlerRetryDelay (KirokuSub.RetryDelay (-1))-            mkShardedWorkerOptions (shardOpts & #retryPolicy .~ KirokuSub.RetryPolicy 0)-                `shouldBeLeft` InvalidShardRetryMaxAttempts 0--        it "ensureShardRows populates N rows once (idempotent on re-run)" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction $ do-                ensureShardRows subName 4-                ensureShardRows subName 4-            Right rows <- Store.runStoreIO store $ Store.runTransaction (listShardOwnership subName)-            map (\(b, _, _) -> b) rows `shouldBe` [0, 1, 2, 3]-            all (\(_, o, _) -> isNothing o) rows `shouldBe` True--        it "worker A claims all N when free; B claims 0 while A holds valid leases" $ \store -> do-            Right claimedA <- Store.runStoreIO store $ Store.runTransaction $ do-                ensureShardRows subName 4-                claimShardsTx subName wA 4 t0 ttl-            claimedA `shouldBe` [0, 1, 2, 3]-            Right claimedB <- Store.runStoreIO store $ Store.runTransaction (claimShardsTx subName wB 4 t0 ttl)-            claimedB `shouldBe` []--        it "B claims A's buckets after A's lease expires; A then renews nothing" $ \store -> do-            Right _ <- Store.runStoreIO store $ Store.runTransaction $ do-                ensureShardRows subName 4-                claimShardsTx subName wA 4 t0 ttl-            Right claimedB <- Store.runStoreIO store $ Store.runTransaction (claimShardsTx subName wB 4 tExpired ttl)-            claimedB `shouldBe` [0, 1, 2, 3]-            -- A lost every bucket to B, so its renew returns the empty set: this is how-            -- a worker learns it no longer owns a bucket and stops reading it.-            Right heldA <- Store.runStoreIO store $ Store.runTransaction (renewLeaseTx subName wA tExpired ttl)-            heldA `shouldBe` []--        it "renewLease returns only still-held buckets" $ \store -> do-            Right held <- Store.runStoreIO store $ Store.runTransaction $ do-                ensureShardRows subName 4-                _ <- claimShardsTx subName wA 4 t0 ttl-                renewLeaseTx subName wA t0 ttl-            held `shouldBe` [0, 1, 2, 3]--        it "releaseShards: relinquished buckets are immediately claimable" $ \store -> do-            Right _ <- Store.runStoreIO store $ Store.runTransaction $ do-                ensureShardRows subName 4-                _ <- claimShardsTx subName wA 4 t0 ttl-                releaseShardsTx subName wA [0, 1]-            -- Even while A's lease over 2,3 is still valid, the released 0,1 are claimable.-            Right claimedB <- Store.runStoreIO store $ Store.runTransaction (claimShardsTx subName wB 4 t0 ttl)-            claimedB `shouldBe` [0, 1]--        it "fairShareTarget divides buckets evenly (ceil)" $ \_store -> do-            fairShareTarget 6 3 `shouldBe` 2-            fairShareTarget 6 4 `shouldBe` 2-            fairShareTarget 7 3 `shouldBe` 3-            fairShareTarget 4 0 `shouldBe` 4 -- a non-positive estimate claims everything-        it "acquireOutcome keeps previous ownership on acquire failure" $ \_store -> do-            let previous = Set.fromList [0, 2]-            acquireOutcome previous (Left "database unavailable")-                `shouldBe` (previous, Just (ShardAcquireFailed "database unavailable"))-            acquireOutcome previous (Right (Set.fromList [1, 3]))-                `shouldBe` (Set.fromList [1, 3], Nothing)--        it "ensureShards rejects a shardCount mismatch" $ \store -> do-            let lease4 =-                    ShardLease-                        { subscriptionName = subName-                        , workerId = wA-                        , shardCount = 4-                        , leaseTtl = ttl-                        }-                lease6 =-                    ShardLease-                        { subscriptionName = subName-                        , workerId = wA-                        , shardCount = 6-                        , leaseTtl = ttl-                        }-            Right () <- Store.runStoreIO store (ensureShards lease4)-            Store.runStoreIO store (ensureShards lease6)-                `shouldThrow` \case-                    ShardCountMismatch name configured found ->-                        name == "orders-shard" && configured == 6 && found == [4]--    describe "Sharded subscription single worker" $ around (withFreshStore fixture) $ do-        -- EP-51 M3: one process owning all N buckets drains a seeded category exactly-        -- once. The sink is idempotent on event_id, so "count == total" proves every-        -- event was delivered with none missing and none surviving as a duplicate row.-        it "one worker with N=4 buckets drains a seeded category exactly once" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            total <- seedOrders store 8 5 -- 40 events across 8 streams-            let opts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 4)-                        { leaseTtl = 3-                        , renewInterval = 0.3-                        }-            w <- forkIO (runShardedSubscriptionGroup store (SubscriptionName "orders-sub") opts (sinkHandler store 1))-            drained <- waitUntilSinkCount store total 20_000_000-            killThread w-            drained `shouldBe` True-            count <- shardSinkCount store-            count `shouldBe` total-            maxW <- maxWorkersPerStream store-            maxW `shouldBe` 1--    describe "Sharded subscription drain and failover" $ around (withFreshStore fixture) $ do-        -- EP-51 M5: the behavioural acceptance. Three worker processes cooperatively-        -- partition a category; we let ownership converge on the *empty* category-        -- first (so the churn of cold-start rebalancing touches no events), then seed-        -- and drain under stable membership — so each stream is owned by exactly one-        -- worker throughout the drain. Then we kill a worker and prove its buckets are-        -- re-homed and the new events drain (failover via lease expiry).-        let sub = SubscriptionName "orders-failover"-            mkOpts = (defaultShardedWorkerOptions (Category (CategoryName "orders")) 6){leaseTtl = 3, renewInterval = 0.3}-        it "three workers drain disjointly, then re-home a killed worker's buckets" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            w1 <- forkIO (runShardedSubscriptionGroup store sub mkOpts (sinkHandler store 1))-            w2 <- forkIO (runShardedSubscriptionGroup store sub mkOpts (sinkHandler store 2))-            w3 <- forkIO (runShardedSubscriptionGroup store sub mkOpts (sinkHandler store 3))-            -- Wait for cooperative balance on the empty category: all 6 buckets owned,-            -- spread across >= 2 workers, none holding more than its fair share.-            balanced <- waitShardsBalanced store sub 6 2 15_000_000-            balanced `shouldBe` True-            -- Now seed and drain under stable membership.-            total1 <- seedOrders store 12 5 -- 60 events-            ok1 <- waitUntilSinkCount store total1 25_000_000-            ok1 `shouldBe` True-            -- Disjoint: no stream key was processed by two workers (stable membership,-            -- so no re-homing split any stream).-            maxW <- maxWorkersPerStream store-            maxW `shouldBe` 1-            -- The work genuinely spread (not a monopoly): at least two workers participated.-            spread <- distinctWorkers store-            spread `shouldSatisfy` (>= 2)-            -- Counts sum to total with no duplicate event id (PK on event_id + count).-            c1 <- shardSinkCount store-            c1 `shouldBe` total1-            -- Kill worker 1 (its readers stop; it stops renewing, so its leases expire).-            killThread w1-            -- Seed more across all streams; some hash to worker 1's now-orphaned buckets.-            total2 <- seedOrders store 12 5 -- another 60-            -- Failover: a surviving worker re-claims the expired buckets and drains the-            -- new events. If re-homing did not happen, events on worker 1's buckets would-            -- never drain and this would time out.-            ok2 <- waitUntilSinkCount store (total1 + total2) 30_000_000-            killThread w2-            killThread w3-            ok2 `shouldBe` True-            c2 <- shardSinkCount store-            c2 `shouldBe` (total1 + total2)--        it "a killed worker relinquishes its leases immediately" $ \store -> do-            let subImmediate = SubscriptionName "orders-immediate-release"-                longTtlOpts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 4)-                        { leaseTtl = 30-                        , renewInterval = 0.2-                        }-            w <- forkIO (runShardedSubscriptionGroup store subImmediate longTtlOpts (sinkHandler store 1))-            owned <- waitShardsBalanced store subImmediate 4 1 10_000_000-            owned `shouldBe` True-            killThread w-            released <- waitShardsUnowned store subImmediate 4 3_000_000-            released `shouldBe` True--        it "a handler exception is retried in place and drains" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            thrown <- newIORef False-            errors <- newIORef []-            let subRestart = SubscriptionName "orders-reader-restart"-                opts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 2)-                        { leaseTtl = 3-                        , renewInterval = 0.2-                        , handlerRetryDelay = KirokuSub.RetryDelay 0.05-                        , onShardError = Just (\err -> modifyIORef' errors (err :))-                        }-                handler ev = do-                    firstTime <--                        atomicModifyIORef'-                            thrown-                            ( \seen ->-                                if seen-                                    then (seen, False)-                                    else (True, True)-                            )-                    when firstTime (throwIO (userError "reader boom"))-                    sinkHandler store 1 ev-            w <- forkIO (runShardedSubscriptionGroup store subRestart opts handler)-            balanced <- waitShardsBalanced store subRestart 2 1 10_000_000-            balanced `shouldBe` True-            total <- seedOrders store 4 2-            drained <- waitUntilSinkCount store total 20_000_000-            killThread w-            drained `shouldBe` True-            seenErrors <- readIORef errors-            seenErrors `shouldSatisfy` all (\case ShardReaderDied _ _ -> False; _ -> True)--    describe "Sharded subscription ack coupling" $ around (withFreshStore fixture) $ do-        it "redelivers a batch-tail event whose handler was killed mid-flight" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            total <- seedOrders store 1 5-            enteredTail <- newEmptyMVar-            holdTail <- newEmptyMVar-            let sub = SubscriptionName "orders-ack-tail"-                opts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 1)-                        { leaseTtl = 3-                        , renewInterval = 0.3-                        }-                blockingHandler ev = do-                    let orderNumber = parseEither (withObject "OrderPlaced" (.: "n")) (ev ^. #payload)-                    when (orderNumber == Right (4 :: Int)) $ do-                        putMVar enteredTail ()-                        takeMVar holdTail-                    sinkHandler store 1 ev-            first <- forkIO (runShardedSubscriptionGroup store sub opts blockingHandler)-            entered <- timeout 10_000_000 (takeMVar enteredTail)-            entered `shouldBe` Just ()-            -- The old pull bridge replies Continue before invoking the handler;-            -- leave enough time for its batch-tail checkpoint to commit while the-            -- handler remains blocked. The ack-coupled bridge introduced by EP-96-            -- remains blocked on the unfilled reply instead.-            threadDelay 200_000-            killThread first-            second <- forkIO (runShardedSubscriptionGroup store sub opts (sinkHandler store 2))-            drained <- waitUntilSinkCount store total 20_000_000-            killThread second-            drained `shouldBe` True-            shardSinkCount store `shouldReturn` total--        it "loses no events when a bucket is shed mid-drain during rebalance" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            total <- seedOrders store 24 5-            let sub = SubscriptionName "orders-ack-rebalance"-                opts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 4)-                        { leaseTtl = 3-                        , renewInterval = 0.3-                        , batchSize = 1-                        }-                slowHandler tag ev = do-                    threadDelay 100_000-                    sinkHandler store tag ev-            first <- forkIO (runShardedSubscriptionGroup store sub opts (slowHandler 1))-            -- acquireOwnedBuckets claims one bucket per pass. Starting the joiner-            -- while A owns three leaves one claimable bucket for B, making B visible;-            -- A's next pass then sheds its excess third bucket while its handler is-            -- deliberately slow and in flight.-            ownsThree <- waitUntilOwnedShardCount store sub 3 10_000_000-            ownsThree `shouldBe` True-            second <- forkIO (runShardedSubscriptionGroup store sub opts (slowHandler 2))-            drained <- waitUntilSinkCount store total 30_000_000-            killThread first-            killThread second-            drained `shouldBe` True-            shardSinkCount store `shouldReturn` total--        it "allows zombie overlap duplicates without losing an event" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            total <- seedOrders store 1 5-            entered <- newEmptyMVar-            release <- newEmptyMVar-            deliveries <- newIORef ([] :: [EventId])-            successor <- newIORef Nothing-            readersA <- newIORef Map.empty-            let sub = SubscriptionName "orders-ack-zombie"-                opts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 1)-                        { leaseTtl = 2-                        , renewInterval = 0.2-                        }-                leaseA =-                    ShardLease-                        { subscriptionName = sub-                        , workerId = WorkerId sampleUuid-                        , shardCount = 1-                        , leaseTtl = 2-                        }-                handlerA delivery = do-                    let ev = delivery ^. #event-                    modifyIORef' deliveries ((ev ^. #eventId) :)-                    putMVar entered ()-                    takeMVar release-                    sinkHandler store 1 ev-                    pure ShardAckOk-                handlerB delivery = do-                    let ev = delivery ^. #event-                    modifyIORef' deliveries ((ev ^. #eventId) :)-                    sinkHandler store 2 ev-                    pure ShardAckOk-                cleanup = do-                    void (tryPutMVar release ())-                    mSuccessor <- readIORef successor-                    for_ mSuccessor killThread-                    now <- getCurrentTime-                    let cleanupWorker = WorkerId sampleUuid2-                    _ <- Store.runStoreIO store $ Store.runTransaction $ do-                        releaseShardsTx sub (WorkerId sampleUuid) [0]-                        claimShardsTx sub cleanupWorker 1 now 30-                    void (reconcileShardsOnce store leaseA opts readersA handlerA)-            ( do-                    Right () <- Store.runStoreIO store (ensureShards leaseA)-                    void (reconcileShardsOnce store leaseA opts readersA handlerA)-                    timeout 10_000_000 (takeMVar entered) `shouldReturn` Just ()-                    -- A no longer renews, but its reader remains alive and blocked-                    -- with one unacknowledged event. B can claim after expiry and-                    -- must therefore receive that event again from the checkpoint.-                    threadDelay 2_500_000-                    workerB <- forkIO (runShardedSubscriptionGroupAck store sub opts handlerB)-                    writeIORef successor (Just workerB)-                    drained <- waitUntilSinkCount store total 20_000_000-                    drained `shouldBe` True-                    raw <- readIORef deliveries-                    length raw `shouldSatisfy` (> total)-                    shardSinkCount store `shouldReturn` total-                )-                `finally` cleanup--        it "dead-letters a poison event after bounded retries and keeps draining" $ \store -> do-            Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)-            total <- seedOrders store 1 4-            poisonDeliveries <- newIORef (0 :: Int)-            errors <- newIORef []-            let sub = SubscriptionName "orders-ack-poison"-                opts =-                    (defaultShardedWorkerOptions (Category (CategoryName "orders")) 1)-                        { leaseTtl = 3-                        , renewInterval = 0.2-                        , handlerRetryDelay = KirokuSub.RetryDelay 0.05-                        , retryPolicy = KirokuSub.RetryPolicy 3-                        , onShardError = Just (\err -> modifyIORef' errors (err :))-                        }-                handler ev = do-                    let orderNumber = parseEither (withObject "OrderPlaced" (.: "n")) (ev ^. #payload)-                    if orderNumber == Right (1 :: Int)-                        then do-                            modifyIORef' poisonDeliveries (+ 1)-                            throwIO (userError "poison order")-                        else sinkHandler store 1 ev-            worker <- forkIO (runShardedSubscriptionGroup store sub opts handler)-            drained <- waitUntilSinkCount store (total - 1) 20_000_000-            details <- shardDeadLetterDetails store "orders-ack-poison"-            attempts <- readIORef poisonDeliveries-            seenErrors <- readIORef errors-            killThread worker-            drained `shouldBe` True-            attempts `shouldBe` 3-            details `shouldBe` (1, Just "max retry attempts exceeded (3)", Just 3)-            seenErrors `shouldSatisfy` all (\case ShardReaderDied _ _ -> False; _ -> True)--    describe "Keiro.Workflow observability" $ around (withFreshStore fixture) $ do-        -- The headline operability signal: executed (real work) vs replayed-        -- (recorded history), recorded by the runtime through an SDK meter and read-        -- back from the in-memory exporter — plus the active gauge and the-        -- journal-length histogram.-        it "records workflow instruments through an SDK meter" $ \storeHandle -> do-            (exporter, ref) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            metrics <- Telemetry.newKeiroMetrics meter-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "obs"-                wid = WorkflowId "obs-1"-                opts = defaultWorkflowRunOptions & #metrics .~ Just metrics-            -- First run: both steps miss → two executions.-            first <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (demoWorkflow counter)-            first `shouldBe` Right (Completed (1, 2))-            -- Second run, same id: both steps hit → two replays.-            second <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (demoWorkflow counter)-            second `shouldBe` Right (Completed (1, 2))-            -- The side effects ran exactly twice across both runs (the replay run-            -- short-circuited every step).-            readIORef counter >>= \c -> c `shouldBe` 2-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef ref-            let scalars = flattenScalarPoints exported-                hists = flattenHistogramPoints exported-            lookup "keiro.workflow.steps.executed" scalars `shouldBe` Just (IntNumber 2)-            lookup "keiro.workflow.steps.replayed" scalars `shouldBe` Just (IntNumber 2)-            -- One journal-length observation per completed run (two completions).-            [c | (n, c, _) <- hists, n == "keiro.workflow.journal.length"] `shouldBe` [2]-            -- Both runs finished, so the live-run count returned to zero.-            lookup "keiro.workflow.active" scalars `shouldBe` Just (IntNumber 0)--        -- The resume worker increments keiro.workflow.resumed per re-invocation and-        -- samples keiro.workflow.awakeables.pending each pass.-        it "records a resume and the pending-awakeable count when the worker re-invokes" $ \storeHandle -> do-            (exporter, ref) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            meter <- getMeter provider Telemetry.keiroInstrumentationLibrary-            metrics <- Telemetry.newKeiroMetrics meter-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "obs-resume"-                wid = WorkflowId "obs-r-1"-            -- Suspend a workflow so it has a step row but no completion: the resume-            -- worker will re-invoke it (and stay Suspended, which still counts as a-            -- re-invocation).-            suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (stepThenAwaitWorkflow counter)-            suspended `shouldBe` Right Suspended-            -- Register one pending awakeable (independent of the suspended workflow's-            -- own await) so the pending gauge has something to count.-            let aid = awakeableIdToUuid (deterministicAwakeableId (WorkflowName "ext") (WorkflowId "1") "cb")-            Right () <--                Store.runStoreIO storeHandle $ Store.runTransaction $ Awk.registerAwakeableTx aid "ext" "1"-            -- One resume pass with metrics threaded through the run options.-            let registry = Map.singleton name (WorkflowDef (\_wid -> stepThenAwaitWorkflow counter))-                resumeOpts =-                    defaultWorkflowResumeOptions-                        & #runOptions-                        .~ (defaultWorkflowRunOptions & #metrics .~ Just metrics)-            Right _summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce resumeOpts registry-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef ref-            let scalars = flattenScalarPoints exported-            lookup "keiro.workflow.resumed" scalars `shouldBe` Just (IntNumber 1)-            lookup "keiro.workflow.awakeables.pending" scalars `shouldBe` Just (IntNumber 1)--        -- The no-op idiom end to end: defaultWorkflowRunOptions carries metrics =-        -- Nothing, so a run on a dedicated provider exports no points at all.-        it "records nothing through a Nothing handle" $ \storeHandle -> do-            (exporter, ref) <- inMemoryMetricExporter-            (provider, _env) <--                createMeterProvider-                    emptyMaterializedResources-                    defaultSdkMeterProviderOptions{metricExporter = Just exporter}-            counter <- newIORef (0 :: Int)-            result <--                Store.runStoreIO storeHandle $-                    runWorkflow (WorkflowName "obs-noop") (WorkflowId "obs-n-1") (demoWorkflow counter)-            result `shouldBe` Right (Completed (1, 2))-            _ <- forceFlushMeterProvider provider Nothing-            exported <- readIORef ref-            flattenScalarPoints exported `shouldBe` []-            flattenHistogramPoints exported `shouldBe` []--    describe "Keiro.Workflow.Snapshot codec" $ do-        -- Pure (no-DB) round-trip of the workflow state codec.-        it "round-trips a non-trivial accumulated step map and carries the sentinel shape hash" $ do-            let m =-                    Map.fromList-                        [ ("first", toJSON (1 :: Int))-                        , ("second", toJSON ["a", "b" :: Text])-                        , ("sleep:42", Aeson.Null)-                        ]-            (workflowStateCodec ^. #decode) ((workflowStateCodec ^. #encode) m) `shouldBe` Right m-            (workflowStateCodec ^. #shapeHash) `shouldBe` "keiro.workflow.stepmap.v1"-            (workflowStateCodec ^. #stateShapeHash) `shouldBe` "keiro.workflow.stepmap.v1"-            (workflowStateCodec ^. #stateCodecVersion) `shouldBe` 1--    describe "Keiro.Workflow.Types journal codec" $ do-        -- Pure (no-DB) round-trip of the EP-48 rotation marker, proving the-        -- additive WorkflowContinuedAsNew constructor encodes and decodes-        -- self-describingly within schemaVersion 1.-        it "round-trips a WorkflowContinuedAsNew rotation marker" $ do-            let t = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 3600)-                marker = WorkflowContinuedAsNew 3 t-            (workflowJournalCodec ^. #decode) ((workflowJournalCodec ^. #eventType) marker) ((workflowJournalCodec ^. #encode) marker)-                `shouldBe` Right marker-            (workflowJournalCodec ^. #schemaVersion) `shouldBe` 1-            EventType "WorkflowContinuedAsNew" `elem` (workflowJournalCodec ^. #eventTypes) `shouldBe` True--        it "validates workflow identity smart constructors" $ do-            mkWorkflowName "orderFulfillment" `shouldBe` Right (WorkflowName "orderFulfillment")-            mkWorkflowName "" `shouldBe` Left WorkflowNameEmpty-            mkWorkflowName "order-fulfillment" `shouldBe` Left (WorkflowNameInvalidChar '-' "order-fulfillment")-            mkWorkflowName "order:fulfillment" `shouldBe` Left (WorkflowNameInvalidChar ':' "order:fulfillment")-            mkWorkflowName "order#1" `shouldBe` Left (WorkflowNameInvalidChar '#' "order#1")-            mkWorkflowId "550e8400-e29b-41d4-a716-446655440000"-                `shouldBe` Right (WorkflowId "550e8400-e29b-41d4-a716-446655440000")-            mkWorkflowId "" `shouldBe` Left WorkflowIdEmpty-            mkWorkflowId "customer:42" `shouldBe` Left (WorkflowIdInvalidChar ':' "customer:42")-            mkWorkflowId "customer#42" `shouldBe` Left (WorkflowIdInvalidChar '#' "customer#42")--    describe "Keiro.Workflow.Sleep" $ do-        -- Pure (no-DB) checks of the id/payload/step-name helpers.-        it "derives a deterministic, distinct timer id" $ do-            let name = WorkflowName "wf"-                wid = WorkflowId "w-1"-                sleepGolden = uuidLiteral "a95d5e7f-a43d-5ee2-9243-8206f0d8734a"-            sleepTimerId name wid 0 "sleep:cool" `shouldBe` sleepTimerId name wid 0 "sleep:cool"-            (sleepTimerId name wid 0 "sleep:cool" == sleepTimerId name wid 0 "sleep:other")-                `shouldBe` False-            sleepTimerId name wid 0 "sleep:cool"-                `shouldBe` TimerId sleepGolden-            sleepTimerId name wid 1 "sleep:cool" `shouldNotBe` sleepTimerId name wid 0 "sleep:cool"-            sleepTimerId name wid 2 "sleep:cool" `shouldNotBe` sleepTimerId name wid 1 "sleep:cool"--        it "round-trips and recognises its timer payload" $ do-            parseSleepPayload (sleepTimerPayload 2 "sleep:cool")-                `shouldBe` Just ("sleep:cool", Just 2)-            parseSleepPayload-                ( object-                    [ "kind" Aeson..= ("keiro.workflow.sleep" :: Text)-                    , "step" Aeson..= ("sleep:legacy" :: Text)-                    ]-                )-                `shouldBe` Just ("sleep:legacy", Nothing)-            parseSleepPayload (object ["kind" Aeson..= ("counter-timeout" :: Text)])-                `shouldBe` Nothing--        it "recovers a legacy payload's generation from its deterministic timer id" $ do-            let name = WorkflowName "wf"-                wid = WorkflowId "w-legacy"-                full = "sleep:cool"-            for_ [0 .. 2] $ \gen ->-                matchSleepTimerGeneration name wid 2 full (sleepTimerId name wid gen full)-                    `shouldBe` Just gen--        it "prefixes the journal step name with the reserved sleep prefix" $-            sleepStepName (StepName "cool") `shouldBe` "sleep:cool"--        around (withFreshStore fixture) $ do-            it "arms a timer and suspends, then a fired timer resumes the workflow" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleepdemo"-                    wid = WorkflowId "sd-1"-                    journalStream = StreamName "wf:sleepdemo-sd-1"-                    TimerId timerUuid = sleepTimerId name wid 0 "sleep:cool"-                -- First run: 'a' runs, the sleep arms a timer, and the run suspends.-                outcome1 <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "cool") 0)-                outcome1 `shouldBe` Right Suspended-                afterFirst <- readIORef counter-                afterFirst `shouldBe` 1-                -- The journal holds only 'a' (no completion, no sleep:cool yet).-                Right recorded1 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward journalStream (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded1)-                    `shouldSatisfy` \case-                        Right [StepRecorded "a" _ _] -> True-                        _ -> False-                -- The durable wait is a single Scheduled timer row carrying the-                -- workflow-sleep payload.-                Right timerRow <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement timerUuid sleepTimerStatusStmt-                timerRow `shouldSatisfy` \case-                    Just (status, payload) ->-                        status == "scheduled"-                            && parseSleepPayload payload == Just ("sleep:cool", Just 0)-                    Nothing -> False-                -- Fire the timer through the routing worker (no PM fallback needed).-                fireTime <- getCurrentTime-                fireResult <--                    Store.runStoreIO storeHandle $-                        runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)-                case fireResult of-                    Right (Just timer) -> timer ^. #status `shouldBe` Firing-                    other -> expectationFailure ("expected a fired sleep timer, got " <> show other)-                -- The row is now Fired and the journal gained sleep:cool.-                Right afterFire <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement timerUuid sleepTimerStatusStmt-                fmap fst afterFire `shouldBe` Just "fired"-                Right recorded2 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward journalStream (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded2)-                    `shouldSatisfy` \case-                        Right [StepRecorded "a" _ _, StepRecorded "sleep:cool" _ _] -> True-                        _ -> False-                -- Second run completes: 'a' and the sleep short-circuit, only 'b' runs.-                outcome2 <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "cool") 0)-                outcome2 `shouldBe` Right (Completed (1, 2))-                afterSecond <- readIORef counter-                afterSecond `shouldBe` 2-                Right recorded3 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward journalStream (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded3)-                    `shouldSatisfy` \case-                        Right [StepRecorded "a" _ _, StepRecorded "sleep:cool" _ _, StepRecorded "b" _ _, WorkflowCompleted _] -> True-                        _ -> False--            it "respects a positive delay: not due before fire_at, fires after" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleepwait"-                    wid = WorkflowId "rt-1"-                    journalStream = StreamName "wf:sleepwait-rt-1"-                clockBeforeFire <- getCurrentTime-                outcome1 <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 1)-                outcome1 `shouldBe` Right Suspended-                afterFirst <- readIORef counter-                afterFirst `shouldBe` 1-                -- A worker whose clock is before fire_at claims nothing.-                notDue <--                    Store.runStoreIO storeHandle $-                        runTimerWorker Nothing clockBeforeFire workflowSleepFireAction-                notDue `shouldBe` Right Nothing-                Right recordedMid <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward journalStream (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList recordedMid)-                    `shouldSatisfy` \case-                        Right [StepRecorded "a" _ _] -> True-                        _ -> False-                -- Wait out the one-second delay, then the worker fires it.-                threadDelay 1_200_000-                afterDelay <- getCurrentTime-                fired <--                    Store.runStoreIO storeHandle $-                        runTimerWorker Nothing afterDelay workflowSleepFireAction-                fired `shouldSatisfy` \case-                    Right (Just _) -> True-                    _ -> False-                Right recordedWoken <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward journalStream (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList recordedWoken)-                    `shouldSatisfy` \case-                        Right [StepRecorded "a" _ _, StepRecorded "sleep:wait" _ _] -> True-                        _ -> False-                outcome2 <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 1)-                outcome2 `shouldBe` Right (Completed (1, 2))-                afterSecond <- readIORef counter-                afterSecond `shouldBe` 2--            it "does not postpone fire_at when a resume pass re-arms the sleep" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleeponce"-                    wid = WorkflowId "so-1"-                    TimerId timerUuid = sleepTimerId name wid 0 "sleep:cool"-                    registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "cool") 300))-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "cool") 300)-                Right (Just firstFireAt) <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement timerUuid sleepTimerFireAtStmt-                Right summary <--                    Store.runStoreIO storeHandle $-                        resumeWorkflowsOnce defaultWorkflowResumeOptions registry-                discovered summary `shouldBe` 0-                Right (Just secondFireAt) <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement timerUuid sleepTimerFireAtStmt-                secondFireAt `shouldBe` firstFireAt-                readIORef counter >>= (`shouldBe` 1)--            it "keeps a due wake_after stable on re-arm and clears it on fire" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleep-wake-stable"-                    wid = WorkflowId "sws-1"-                    registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") 0))-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 0)-                Right (Just firstWakeAfter) <--                    Store.runStoreIO storeHandle $-                        workflowWakeAfter name wid--                Right rearmed <--                    Store.runStoreIO storeHandle $-                        resumeWorkflowsOnce defaultWorkflowResumeOptions registry-                discovered rearmed `shouldBe` 1-                Right (Just secondWakeAfter) <--                    Store.runStoreIO storeHandle $-                        workflowWakeAfter name wid-                secondWakeAfter `shouldBe` firstWakeAfter--                fireTime <- getCurrentTime-                Right (Just _) <--                    Store.runStoreIO storeHandle $-                        runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)-                Right clearedWakeAfter <--                    Store.runStoreIO storeHandle $-                        workflowWakeAfter name wid-                clearedWakeAfter `shouldBe` Nothing--                Right resumed <--                    Store.runStoreIO storeHandle $-                        resumeWorkflowsOnce defaultWorkflowResumeOptions registry-                discovered resumed `shouldBe` 1-                completed resumed `shouldBe` 1-                readIORef counter >>= (`shouldBe` 2)--            it "skips a sleeping workflow until wake_after expires" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleepwakeafter"-                    wid = WorkflowId "swa-1"-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 60)-                now <- getCurrentTime-                Right mWakeAfter <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid-                case mWakeAfter of-                    Nothing -> expectationFailure "expected wake_after"-                    Just wakeAfter -> wakeAfter `shouldSatisfy` (> now)-                Right early <- Store.runStoreIO storeHandle $ findUnfinishedWorkflowIds now-                early `shouldBe` []-                Right due <- Store.runStoreIO storeHandle $ findUnfinishedWorkflowIds (addUTCTime 61 now)-                due `shouldBe` [("swa-1", "sleepwakeafter")]--            it "does not re-invoke a parked sleeper before wake_after" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleepquiet"-                    wid = WorkflowId "sq-1"-                    registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") 60))-                    pass = Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 60)-                Right s1 <- pass-                Right s2 <- pass-                Right s3 <- pass-                map discovered [s1, s2, s3] `shouldBe` [0, 0, 0]-                readIORef counter >>= (`shouldBe` 1)--            it "treats a missing instance row during sleep arm as a no-op wake hint update" $ \storeHandle -> do-                let name = WorkflowName "sleepmissingrow"-                    wid = WorkflowId "smr-1"-                    body = sleepNamed (StepName "wait") 60 >> pure ()-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement ("smr-1", "sleepmissingrow") deleteWorkflowInstanceStmt-                Store.runStoreIO storeHandle (runWorkflow name wid body)-                    `shouldReturn` Right Suspended--            it "fires a sleep whose instance row is missing after an arm crash" $ \storeHandle -> do-                let name = WorkflowName "sleep-missing-fire"-                    wid = WorkflowId "smf-1"-                    body = sleepNamed (StepName "wait") 0-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Tx.statement ("smf-1", "sleep-missing-fire") deleteWorkflowInstanceStmt-                fireTime <- getCurrentTime-                Right (Just _) <--                    Store.runStoreIO storeHandle $-                        runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)-                Right resolved <--                    Store.runStoreIO storeHandle $-                        stepExists name wid 0 "sleep:wait"-                resolved `shouldBe` True-                Right (Just recovered) <--                    Store.runStoreIO storeHandle $-                        Instance.lookupInstance name wid-                recovered ^. #status `shouldBe` Instance.WfRunning-                Store.runStoreIO storeHandle (runWorkflow name wid body)-                    `shouldReturn` Right (Completed ())--            it "fires a sleep longer than the resume cadence under an active resume worker" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleepactive"-                    wid = WorkflowId "sa-1"-                    registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") 1))-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 1)-                threadDelay 1_200_000-                Right boundaryPass <--                    Store.runStoreIO storeHandle $-                        resumeWorkflowsOnce defaultWorkflowResumeOptions registry-                discovered boundaryPass `shouldBe` 1-                fireTime <- getCurrentTime-                Right (Just _) <--                    Store.runStoreIO storeHandle $-                        runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)-                Right completionPass <--                    Store.runStoreIO storeHandle $-                        resumeWorkflowsOnce defaultWorkflowResumeOptions registry-                discovered completionPass `shouldBe` 1-                completed completionPass `shouldBe` 1-                readIORef counter >>= (`shouldBe` 2)--            it "uses generation-namespaced timer ids after continueAsNew" $ \storeHandle -> do-                counter <- newIORef (0 :: Int)-                let name = WorkflowName "sleeproll"-                    wid = WorkflowId "sr-1"-                    registry = Map.singleton name (WorkflowDef (\_ -> rollingSleepWorkflow counter))-                    drive 0 = expectationFailure "rolling sleep did not complete"-                    drive n = do-                        Right summary <--                            Store.runStoreIO storeHandle $-                                resumeWorkflowsOnce defaultWorkflowResumeOptions registry-                        now <- getCurrentTime-                        _ <--                            Store.runStoreIO storeHandle $-                                runWorkflowTimerWorker Nothing now (\_ -> pure Nothing)-                        if completed summary == 1-                            then pure ()-                            else drive (n - 1)-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (rollingSleepWorkflow counter)-                drive (12 :: Int)-                readIORef counter >>= (`shouldBe` 3)--    describe "Keiro.Workflow sleep generation pinning" $ around (withFreshStore fixture) $ do-        it "keeps a stale re-fire on the generation that armed the sleep" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "sleep-generation-pin"-                wid = WorkflowId "sgp-1"-                full = "sleep:cool"-                TimerId generationZeroTimerId = sleepTimerId name wid 0 full-                TimerId generationOneTimerId = sleepTimerId name wid 1 full-                body = do-                    seed <- restoreSeed (0 :: Int)-                    _ <- step (StepName "work") (liftIO (incrementAndRead counter))-                    if seed == 0-                        then sleepNamed (StepName "cool") 0 >> continueAsNew (1 :: Int)-                        else sleepNamed (StepName "cool") 3600 >> pure seed--            Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-            claimTime <- getCurrentTime-            Right (Just claimed) <- Store.runStoreIO storeHandle $ claimDueTimer claimTime-            claimed ^. #timerId `shouldBe` TimerId generationZeroTimerId-            Right (Just _) <--                Store.runStoreIO storeHandle $-                    workflowSleepFireAction claimed--            Right ContinuedAsNew <- Store.runStoreIO storeHandle $ runWorkflow name wid body-            Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-            Right (Just generationOneFireAt) <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement generationOneTimerId sleepTimerFireAtStmt--            requeueTime <- getCurrentTime-            Right requeued <--                Store.runStoreIO storeHandle $-                    requeueStuckTimers 0 (addUTCTime 1 requeueTime)-            requeued `shouldBe` 1-            Right (Just staleFire) <--                Store.runStoreIO storeHandle $-                    runWorkflowTimerWorker Nothing (addUTCTime 2 requeueTime) (\_ -> pure Nothing)-            staleFire ^. #timerId `shouldBe` TimerId generationZeroTimerId--            Right generationOneResolved <--                Store.runStoreIO storeHandle $-                    stepExists name wid 1 full-            generationOneResolved `shouldBe` False-            Right (Just instanceRow) <--                Store.runStoreIO storeHandle $-                    Instance.lookupInstance name wid-            instanceRow ^. #status `shouldBe` Instance.WfSuspended-            Right generationZeroStatus <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement generationZeroTimerId sleepTimerStatusStmt-            fmap fst generationZeroStatus `shouldBe` Just "fired"-            Right generationOneStatus <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement generationOneTimerId sleepTimerStatusStmt-            fmap fst generationOneStatus `shouldBe` Just "scheduled"-            Right (Just generationOneFireAtAfter) <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement generationOneTimerId sleepTimerFireAtStmt-            generationOneFireAtAfter `shouldBe` generationOneFireAt-            readIORef counter >>= (`shouldBe` 2)--    describe "Keiro.Workflow.Awakeable" $ do-        -- Pure (no-DB) check of the deterministic id derivation.-        it "derives a deterministic AwakeableId, stable across calls and label-sensitive" $ do-            let aid1 = deterministicAwakeableId (WorkflowName "w") (WorkflowId "1") "approval"-                aid2 = deterministicAwakeableId (WorkflowName "w") (WorkflowId "1") "approval"-                aidOther = deterministicAwakeableId (WorkflowName "w") (WorkflowId "1") "other"-                awakeableGolden = uuidLiteral "ccaeaf74-3ffe-5ea5-a118-a3441a95c279"-            aid1 `shouldBe` aid2-            (aid1 == aidOther) `shouldBe` False-            aid1 `shouldBe` AwakeableId awakeableGolden--        around (withFreshStore fixture) $ do-            it "schema: registers, completes once (idempotent), cancels, and counts pending rows" $ \storeHandle -> do-                let aidA = awakeableIdToUuid (deterministicAwakeableId (WorkflowName "sch") (WorkflowId "1") "a")-                    aidB = awakeableIdToUuid (deterministicAwakeableId (WorkflowName "sch") (WorkflowId "1") "b")-                now <- getCurrentTime-                Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ do-                    Awk.registerAwakeableTx aidA "sch" "1"-                    Awk.registerAwakeableTx aidB "sch" "1"-                Right pendingCount <- Store.runStoreIO storeHandle Awk.countPendingAwakeables-                pendingCount `shouldBe` 2-                Right (Just rowA) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable aidA-                rowA ^. #status `shouldBe` Awk.Pending-                rowA ^. #payload `shouldBe` Nothing-                -- Complete A once; the status-guarded UPDATE makes a re-complete a no-op.-                Right firstComplete <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Awk.completeAwakeableTx aidA (toJSON ("done" :: Text)) now-                firstComplete `shouldBe` True-                Right secondComplete <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Awk.completeAwakeableTx aidA (toJSON ("again" :: Text)) now-                secondComplete `shouldBe` False-                Right (Just rowA') <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable aidA-                rowA' ^. #status `shouldBe` Awk.Completed-                rowA' ^. #payload `shouldBe` Just (toJSON ("done" :: Text))-                -- Cancel the still-pending B; both rows are now resolved.-                Right cancelled <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Awk.cancelAwakeableTx aidB-                cancelled `shouldBe` True-                Right pendingAfter <- Store.runStoreIO storeHandle Awk.countPendingAwakeables-                pendingAfter `shouldBe` 0--            it "suspends on an unsignalled awakeable, recording a pending row and no completion" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "approval"-                    wid = WorkflowId "wf1"-                outcome1 <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                outcome1 `shouldBe` Right Suspended-                aid <- readRequiredAwakeableId aidRef-                Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)-                row ^. #status `shouldBe` Awk.Pending-                row ^. #payload `shouldBe` Nothing-                Right pendingNow <- Store.runStoreIO storeHandle Awk.countPendingAwakeables-                pendingNow `shouldBe` 1-                Right recorded <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:approval-wf1") (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)-                    `shouldSatisfy` \case-                        Right [StepRecorded stepName value _] ->-                            stepName == awakeableAllocStepPrefix <> "approval" && value == toJSON aid-                        _ -> False--            it "resumes with the signalled payload after signalAwakeable" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "approval"-                    wid = WorkflowId "wf1"-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                aid <- readRequiredAwakeableId aidRef-                let awkStep = "awk:" <> awakeableIdText aid-                Right signalled <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)-                signalled `shouldBe` True-                Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)-                row ^. #status `shouldBe` Awk.Completed-                row ^. #payload `shouldBe` Just (toJSON ("ok" :: Text))-                Right afterSignal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:approval-wf1") (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterSignal)-                    `shouldSatisfy` \case-                        Right [StepRecorded allocStep _ _, StepRecorded s r _] ->-                            allocStep == awakeableAllocStepPrefix <> "approval" && s == awkStep && r == toJSON ("ok" :: Text)-                        _ -> False-                outcome2 <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                outcome2 `shouldBe` Right (Completed "ok!")-                Right afterResume <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:approval-wf1") (StreamVersion 0) 100-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterResume)-                    `shouldSatisfy` \case-                        Right [StepRecorded allocStep _ _, StepRecorded s1 _ _, StepRecorded "use" _ _, WorkflowCompleted _] ->-                            allocStep == awakeableAllocStepPrefix <> "approval" && s1 == awkStep-                        _ -> False--            it "is idempotent: a second signal returns False and does not change the value" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "idem"-                    wid = WorkflowId "wf-i"-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                aid <- readRequiredAwakeableId aidRef-                let awkStep = "awk:" <> awakeableIdText aid-                Right True <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)-                Right again <- Store.runStoreIO storeHandle $ signalAwakeable aid ("later" :: Text)-                again `shouldBe` False-                Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)-                row ^. #payload `shouldBe` Just (toJSON ("ok" :: Text))-                Right recorded <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:idem-wf-i") (StreamVersion 0) 100-                Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))-                [r | StepRecorded s r _ <- decoded, s == awkStep] `shouldBe` [toJSON ("ok" :: Text)]--            it "throws WorkflowAwakeableCancelled after cancelAwakeable" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "cancelwf"-                    wid = WorkflowId "wf2"-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                aid <- readRequiredAwakeableId aidRef-                Right cancelled <- Store.runStoreIO storeHandle $ cancelAwakeable aid-                cancelled `shouldBe` True-                Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)-                row ^. #status `shouldBe` Awk.Cancelled-                Store.runStoreIO storeHandle (runWorkflow name wid (approvalFlowWithId aidRef))-                    `shouldThrow` (== WorkflowAwakeableCancelled aid)-                Right recorded <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:cancelwf-wf2") (StreamVersion 0) 100-                Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))-                any (\case WorkflowCompleted{} -> True; _ -> False) decoded `shouldBe` False--            it "re-appends a missing journal entry when re-signalled (crash-safe)" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "crash"-                    wid = WorkflowId "wf3"-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                aid <- readRequiredAwakeableId aidRef-                let awkStep = "awk:" <> awakeableIdText aid-                -- Simulate "row completed but the journal append did not happen" by-                -- completing the row directly, bypassing signalAwakeable's journal write.-                now <- getCurrentTime-                Right completedRow <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Awk.completeAwakeableTx (awakeableIdToUuid aid) (toJSON ("ok" :: Text)) now-                completedRow `shouldBe` True-                Right beforeRepair <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:crash-wf3") (StreamVersion 0) 100-                Right beforeDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList beforeRepair))-                [() | StepRecorded s _ _ <- beforeDecoded, s == awkStep] `shouldBe` []-                -- A re-signal with the same payload returns False (already completed) but-                -- repairs the missing journal entry from the stored payload.-                Right repaired <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)-                repaired `shouldBe` False-                Right afterRepair <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:crash-wf3") (StreamVersion 0) 100-                Right afterDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterRepair))-                [r | StepRecorded s r _ <- afterDecoded, s == awkStep] `shouldBe` [toJSON ("ok" :: Text)]--            it "repairs a completed awakeable row from the await arm without a second signal" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "crash-arm"-                    wid = WorkflowId "wf4"-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                aid <- readRequiredAwakeableId aidRef-                let awkStep = "awk:" <> awakeableIdText aid-                now <- getCurrentTime-                Right True <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Awk.completeAwakeableTx (awakeableIdToUuid aid) (toJSON ("ok" :: Text)) now-                repairedRun <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                repairedRun `shouldBe` Right Suspended-                Right repairedJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:crash-arm-wf4") (StreamVersion 0) 100-                Right repairedDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList repairedJournal))-                [r | StepRecorded s r _ <- repairedDecoded, s == awkStep] `shouldBe` [toJSON ("ok" :: Text)]-                completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                completed `shouldBe` Right (Completed "ok!")--            it "refuses a forged coordinate-derived id for a fresh awakeable" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "fresh-awake"-                    wid = WorkflowId "fa-1"-                    forged = deterministicAwakeableId name wid "approval"-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                real <- readRequiredAwakeableId aidRef-                real `shouldNotBe` forged-                Right forgedSignal <- Store.runStoreIO storeHandle $ signalAwakeable forged ("bad" :: Text)-                forgedSignal `shouldBe` False-                Right stillSuspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                stillSuspended `shouldBe` Suspended-                Right realSignal <- Store.runStoreIO storeHandle $ signalAwakeable real ("ok" :: Text)-                realSignal `shouldBe` True-                completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                completed `shouldBe` Right (Completed "ok!")--            it "adopts a generation-0 legacy deterministic row" $ \storeHandle -> do-                aidRef <- newIORef Nothing-                let name = WorkflowName "legacy-awake"-                    wid = WorkflowId "la-1"-                    legacy = deterministicAwakeableId name wid "approval"-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Awk.registerAwakeableTx (awakeableIdToUuid legacy) (unWorkflowName name) (unWorkflowId wid)-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                adopted <- readRequiredAwakeableId aidRef-                adopted `shouldBe` legacy-                Right True <- Store.runStoreIO storeHandle $ signalAwakeable legacy ("ok" :: Text)-                completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)-                completed `shouldBe` Right (Completed "ok!")--            it "allocates a fresh awakeable for the same label after continueAsNew" $ \storeHandle -> do-                idsRef <- newIORef []-                let name = WorkflowName "awake-roll"-                    wid = WorkflowId "ar-1"-                    body = rollingAwakeableWorkflow idsRef-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                ids1 <- readIORef idsRef-                [firstAid] <- pure ids1-                Right True <- Store.runStoreIO storeHandle $ signalAwakeable firstAid ("first" :: Text)-                Right ContinuedAsNew <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                ids2 <- readIORef idsRef-                case ids2 of-                    [firstAgain, secondAid] -> do-                        firstAgain `shouldBe` firstAid-                        secondAid `shouldNotBe` firstAid-                        Right staleSignal <- Store.runStoreIO storeHandle $ signalAwakeable firstAid ("stale" :: Text)-                        staleSignal `shouldBe` False-                        Right stillSuspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                        stillSuspended `shouldBe` Suspended-                        Right True <- Store.runStoreIO storeHandle $ signalAwakeable secondAid ("second" :: Text)-                        completed <- Store.runStoreIO storeHandle $ runWorkflow name wid body-                        completed `shouldBe` Right (Completed "second")-                    other -> expectationFailure ("expected two awakeable ids, got " <> show other)--    describe "Keiro.Workflow awakeable registration" $ around (withFreshStore fixture) $ do-        it "registers the row before a journaled hand-off can expose the id" $ \storeHandle -> do-            aidRef <- newIORef Nothing-            let name = WorkflowName "awakeable-signal-gap"-                wid = WorkflowId "asg-1"-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflow name wid (publishAwakeableBeforeAwait aidRef)-            aid <- readRequiredAwakeableId aidRef-            Right (Just pendingRow) <--                Store.runStoreIO storeHandle $-                    Awk.lookupAwakeable (awakeableIdToUuid aid)-            pendingRow ^. #status `shouldBe` Awk.Pending--            Right signalled <--                Store.runStoreIO storeHandle $-                    signalAwakeable aid ("ok" :: Text)-            signalled `shouldBe` True-            Right (Just completedRow) <--                Store.runStoreIO storeHandle $-                    Awk.lookupAwakeable (awakeableIdToUuid aid)-            completedRow ^. #status `shouldBe` Awk.Completed--            let unknown =-                    AwakeableId-                        (uuidLiteral "00000000-0000-0000-0000-0000000002f2")-            Right unknownSignal <--                Store.runStoreIO storeHandle $-                    signalAwakeable unknown ("forged" :: Text)-            unknownSignal `shouldBe` False--            completed <--                Store.runStoreIO storeHandle $-                    runWorkflow name wid (awaitPublishedAwakeable aidRef)-            completed `shouldBe` Right (Completed "ok")--    describe "Keiro.Workflow awakeable signal race" $ around (withFreshStore fixture) $ do-        it "does not append a value when cancellation wins after the signal pre-read" $ \storeHandle -> do-            aidRef <- newIORef Nothing-            let name = WorkflowName "awakeable-cancel-race"-                wid = WorkflowId "acr-1"-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflow name wid (approvalFlowWithId aidRef)-            aid <- readRequiredAwakeableId aidRef-            Right (Just stalePendingRow) <--                Store.runStoreIO storeHandle $-                    Awk.lookupAwakeable (awakeableIdToUuid aid)-            Right cancelled <- Store.runStoreIO storeHandle $ cancelAwakeable aid-            cancelled `shouldBe` True-            Right signalled <--                Store.runStoreIO storeHandle $-                    signalAwakeableFrom stalePendingRow ("late" :: Text)-            signalled `shouldBe` False-            Right recorded <--                Store.runStoreIO storeHandle $-                    stepExists-                        name-                        wid-                        0-                        (awakeableStepPrefix <> awakeableIdText aid)-            recorded `shouldBe` False-            Store.runStoreIO storeHandle (runWorkflow name wid (approvalFlowWithId aidRef))-                `shouldThrow` (== WorkflowAwakeableCancelled aid)--    describe "Keiro.Workflow.Child" $ do-        -- M2: the reserved spawn/result step-name derivations are stable.-        it "derives the child spawn and result step names" $ do-            childSpawnStepName (WorkflowId "c1") `shouldBe` "child:c1"-            childResultStepName (WorkflowId "c1") `shouldBe` "child:c1:result"--        -- M3(a): the new terminal journal constructors round-trip through the codec.-        it "round-trips WorkflowCancelled and WorkflowFailed through the journal codec" $ do-            let t = UTCTime (ModifiedJulianDay 0) 0-                rt ev = (workflowJournalCodec ^. #decode) ((workflowJournalCodec ^. #eventType) ev) ((workflowJournalCodec ^. #encode) ev)-            rt (WorkflowCancelled t) `shouldBe` Right (WorkflowCancelled t)-            rt (WorkflowFailed "boom" t) `shouldBe` Right (WorkflowFailed "boom" t)--        around (withFreshStore fixture) $ do-            -- M1: the keiro_workflow_children table and its schema helpers.-            it "schema: registers, completes, cancels, and counts child links" $ \storeHandle -> do-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.registerChildTx "c-1" "ship" "p-1" "parent" "child:c-1:result"-                Right (Just row) <- Store.runStoreIO storeHandle $ Child.lookupChild "c-1" "ship"-                row ^. #status `shouldBe` Child.Running-                row ^. #parentId `shouldBe` "p-1"-                row ^. #parentName `shouldBe` "parent"-                row ^. #awaitStep `shouldBe` "child:c-1:result"-                now <- getCurrentTime-                Right firstComplete <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.markChildResultTx "c-1" "ship" (toJSON ("packed+labelled" :: Text)) now-                firstComplete `shouldBe` True-                Right secondComplete <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.markChildResultTx "c-1" "ship" (toJSON ("again" :: Text)) now-                secondComplete `shouldBe` False-                Right () <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.registerChildTx "c-2" "ship" "p-1" "parent" "child:c-2:result"-                Right cancelled <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.markChildCancelledTx "c-2" "ship"-                cancelled `shouldBe` True-                Right kids <- Store.runStoreIO storeHandle $ Child.lookupChildrenOfParent "p-1" "parent"-                map (^. #childId) kids `shouldBe` ["c-1", "c-2"]-                Right active <- Store.runStoreIO storeHandle Child.countActiveChildren-                active `shouldBe` (0 :: Int)-                Right st <- Store.runStoreIO storeHandle $ Child.childStatus "c-1" "ship"-                st `shouldBe` Just Child.ChildCompleted--            -- M4: spawn -> drive the child (with the completion hook) -> resume parent.-            it "spawns a child, drives it, propagates its result, and resumes the parent to Completed" $ \storeHandle -> do-                let childWid = WorkflowId "ship-1"-                suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p1") (parentWorkflow childWid)-                suspended `shouldBe` Right Suspended-                Right parentJournal1 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p1") (StreamVersion 0) 10-                Right decoded1 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal1))-                decoded1 `shouldSatisfy` \case-                    [StepRecorded "child:ship-1" _ _] -> True-                    _ -> False-                Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-1" "ship"-                childRow ^. #status `shouldBe` Child.Running-                childRow ^. #parentId `shouldBe` "p1"-                childRow ^. #parentName `shouldBe` "parent"-                childRow ^. #awaitStep `shouldBe` "child:ship-1:result"-                -- 2) drive the child through runChildWorkflow (propagates on completion).-                childOutcome <--                    Store.runStoreIO storeHandle $-                        runChildWorkflow defaultWorkflowRunOptions (WorkflowName "ship") childWid shipWorkflow-                childOutcome `shouldBe` Right (Completed "packed+labelled")-                Right childJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:ship-ship-1") (StreamVersion 0) 10-                traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal)-                    `shouldSatisfy` \case-                        Right [StepRecorded "pack" _ _, StepRecorded "label" _ _, WorkflowCompleted _] -> True-                        _ -> False-                Right parentJournal2 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p1") (StreamVersion 0) 10-                Right decoded2 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal2))-                [r | StepRecorded "child:ship-1:result" r _ <- decoded2]-                    `shouldBe` [object ["ok" Aeson..= ("packed+labelled" :: Text)]]-                Right (Just childRow2) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-1" "ship"-                childRow2 ^. #status `shouldBe` Child.ChildCompleted-                -- 3) resume the parent: it replays past awaitChild and completes.-                resumed <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p1") (parentWorkflow childWid)-                resumed `shouldBe` Right (Completed "done:packed+labelled")-                Right parentJournal3 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p1") (StreamVersion 0) 10-                Right decoded3 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal3))-                any (\case StepRecorded "notify" _ _ -> True; _ -> False) decoded3 `shouldBe` True-                any (\case WorkflowCompleted{} -> True; _ -> False) decoded3 `shouldBe` True--            it "repairs a completed child row from awaitChild without another completion hook" $ \storeHandle -> do-                let childWid = WorkflowId "ship-crash"-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-crash") (parentWorkflow childWid)-                now <- getCurrentTime-                Right transitioned <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.markChildResultTx "ship-crash" "ship" (toJSON ("packed+labelled" :: Text)) now-                transitioned `shouldBe` True-                Right beforeRepair <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p-crash") (StreamVersion 0) 10-                Right beforeDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList beforeRepair))-                [r | StepRecorded "child:ship-crash:result" r _ <- beforeDecoded] `shouldBe` []-                repaired <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-crash") (parentWorkflow childWid)-                repaired `shouldBe` Right Suspended-                Right afterRepair <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p-crash") (StreamVersion 0) 10-                Right afterDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterRepair))-                [r | StepRecorded "child:ship-crash:result" r _ <- afterDecoded]-                    `shouldBe` [object ["ok" Aeson..= ("packed+labelled" :: Text)]]-                completed <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-crash") (parentWorkflow childWid)-                completed `shouldBe` Right (Completed "done:packed+labelled")--            -- M5: re-invoking the parent does not re-spawn the child (crash survival).-            it "does not re-spawn the child when the parent is re-invoked" $ \storeHandle -> do-                let childWid = WorkflowId "ship-2"-                s1 <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p2") (parentWorkflow childWid)-                s1 `shouldBe` Right Suspended-                Right (Just beforeRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-2" "ship"-                let createdAt0 = beforeRow ^. #createdAt-                s2 <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p2") (parentWorkflow childWid)-                s2 `shouldBe` Right Suspended-                Right parentJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p2") (StreamVersion 0) 10-                Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))-                length [() | StepRecorded "child:ship-2" _ _ <- decoded] `shouldBe` 1-                Right kids <- Store.runStoreIO storeHandle $ Child.lookupChildrenOfParent "p2" "parent"-                length kids `shouldBe` 1-                map (^. #createdAt) kids `shouldBe` [createdAt0]--            -- M5: cancelling a child stops it and makes the parent's awaitChild throw.-            it "cancels a child: the child stops and the parent's awaitChild throws" $ \storeHandle -> do-                let childWid = WorkflowId "cancel-child"-                    h = ChildHandle (WorkflowName "ship") childWid-                s1 <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p3") (parentWorkflow childWid)-                s1 `shouldBe` Right Suspended-                Right cancelled <- Store.runStoreIO storeHandle $ cancelChild h-                cancelled `shouldBe` True-                Right childJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:ship-cancel-child") (StreamVersion 0) 10-                Right childDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal))-                any (\case WorkflowCancelled{} -> True; _ -> False) childDecoded `shouldBe` True-                Right st <- Store.runStoreIO storeHandle $ Child.childStatus "cancel-child" "ship"-                st `shouldBe` Just Child.ChildCancelled-                Right parentJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p3") (StreamVersion 0) 10-                Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))-                [r | StepRecorded "child:cancel-child:result" r _ <- parentDecoded]-                    `shouldBe` [object ["cancelled" Aeson..= True]]-                -- driving the child returns Cancelled and runs none of its steps.-                childOutcome <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "ship") childWid shipWorkflow-                childOutcome `shouldBe` Right Keiro.Workflow.Cancelled-                Right childJournal2 <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:ship-cancel-child") (StreamVersion 0) 10-                Right childDecoded2 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal2))-                any (\case StepRecorded "pack" _ _ -> True; _ -> False) childDecoded2 `shouldBe` False-                -- re-invoking the parent throws WorkflowChildCancelled.-                Store.runStoreIO-                    storeHandle-                    (runWorkflow (WorkflowName "parent") (WorkflowId "p3") (parentWorkflow childWid))-                    `shouldThrow` (== WorkflowChildCancelled (WorkflowName "ship") childWid)--            it "repairs a cancelled child row when cancelChild is retried after the row flip" $ \storeHandle -> do-                let childWid = WorkflowId "cancel-child-crash"-                    h = ChildHandle (WorkflowName "ship") childWid-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-cancel-crash") (parentWorkflow childWid)-                Right transitioned <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.markChildCancelledTx "cancel-child-crash" "ship"-                transitioned `shouldBe` True-                Right retried <- Store.runStoreIO storeHandle $ cancelChild h-                retried `shouldBe` False-                Right childJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:ship-cancel-child-crash") (StreamVersion 0) 10-                Right childDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal))-                any (\case WorkflowCancelled{} -> True; _ -> False) childDecoded `shouldBe` True-                Right parentJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p-cancel-crash") (StreamVersion 0) 10-                Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))-                [r | StepRecorded "child:cancel-child-crash:result" r _ <- parentDecoded]-                    `shouldBe` [object ["cancelled" Aeson..= True]]--            it "heals a cancelled-but-unmarked child from runChildWorkflow" $ \storeHandle -> do-                let childWid = WorkflowId "cancel-child-drive"-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-cancel-drive") (parentWorkflow childWid)-                Right True <--                    Store.runStoreIO storeHandle $-                        Store.runTransaction $-                            Child.markChildCancelledTx "cancel-child-drive" "ship"-                childOutcome <--                    Store.runStoreIO storeHandle $-                        runChildWorkflow defaultWorkflowRunOptions (WorkflowName "ship") childWid shipWorkflow-                childOutcome `shouldBe` Right Keiro.Workflow.Cancelled-                Right childJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:ship-cancel-child-drive") (StreamVersion 0) 10-                Right childDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal))-                any (\case WorkflowCancelled{} -> True; _ -> False) childDecoded `shouldBe` True-                Right parentJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p-cancel-drive") (StreamVersion 0) 10-                Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))-                [r | StepRecorded "child:cancel-child-drive:result" r _ <- parentDecoded]-                    `shouldBe` [object ["cancelled" Aeson..= True]]--            it "delivers an honest child result equal to the old cancellation sentinel" $ \storeHandle -> do-                let childWid = WorkflowId "json-cancelled-object"-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "json-parent") (WorkflowId "jp1") (jsonObjectParentWorkflow childWid)-                childOutcome <--                    Store.runStoreIO storeHandle $-                        runChildWorkflow defaultWorkflowRunOptions (WorkflowName "json-child") childWid jsonObjectChildWorkflow-                childOutcome `shouldBe` Right (Completed (object ["cancelled" Aeson..= True]))-                completed <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "json-parent") (WorkflowId "jp1") (jsonObjectParentWorkflow childWid)-                completed `shouldBe` Right (Completed (object ["cancelled" Aeson..= True]))-                Right parentJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:json-parent-jp1") (StreamVersion 0) 10-                Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))-                [r | StepRecorded "child:json-cancelled-object:result" r _ <- parentDecoded]-                    `shouldBe` [object ["ok" Aeson..= object ["cancelled" Aeson..= True]]]--            it "throws WorkflowStepDecodeError when an enveloped child result has the wrong type" $ \storeHandle -> do-                let childWid = WorkflowId "decode-child"-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-decode") (parentWorkflow childWid)-                Store.runStoreIO-                    storeHandle-                    (childCompletionHook (WorkflowName "ship") childWid (toJSON (42 :: Int)))-                    `shouldReturn` Right ()-                Store.runStoreIO-                    storeHandle-                    (runWorkflow (WorkflowName "parent") (WorkflowId "p-decode") (parentWorkflow childWid))-                    `shouldThrow` \case-                        WorkflowStepDecodeError key _ -> key == "child:decode-child:result"-                        _ -> False--            it "wakes a parent with WorkflowChildFailed when a child reaches the failure ceiling" $ \storeHandle -> do-                let childWid = WorkflowId "failed-child"-                    registry =-                        Map.fromList-                            [ (WorkflowName "parent", WorkflowDef (\_ -> parentWorkflow childWid))-                            , (WorkflowName "ship", WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure ("" :: Text)))-                            ]-                    opts = defaultWorkflowResumeOptions & #maxAttempts .~ 1-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p-failed-child") (parentWorkflow childWid)-                Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-                failed summary `shouldBe` 1-                Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "failed-child" "ship"-                childRow ^. #status `shouldBe` Child.ChildFailed-                Store.runStoreIO-                    storeHandle-                    (runWorkflow (WorkflowName "parent") (WorkflowId "p-failed-child") (parentWorkflow childWid))-                    `shouldThrow` \case-                        WorkflowChildFailed (WorkflowName "ship") (WorkflowId "failed-child") reason ->-                            "SimulatedCrash" `Text.isInfixOf` reason-                        _ -> False--            it "stops at the next step boundary when a workflow is cancelled mid-run" $ \storeHandle -> do-                counter <- newIORef 0-                let name = WorkflowName "self-cancel"-                    wid = WorkflowId "sc1"-                outcome <--                    Store.runStoreIO storeHandle $-                        runWorkflow name wid (selfCancellingWorkflow name wid counter)-                outcome `shouldBe` Right Keiro.Workflow.Cancelled-                readIORef counter `shouldReturn` 2-                Right recorded <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:self-cancel-sc1") (StreamVersion 0) 10-                Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))-                any (\case StepRecorded "three" _ _ -> True; _ -> False) decoded `shouldBe` False--            -- EP-42 worker-driven variant: the resume worker drives both parent and-            -- child from a registry, selecting childCompletionHook for the child and-            -- union-discovering the zero-step child.-            it "drives a parent and its child to completion through the resume worker" $ \storeHandle -> do-                let childWid = WorkflowId "ship-3"-                    registry =-                        Map.fromList-                            [ (WorkflowName "parent", WorkflowDef (\_ -> parentWorkflow childWid))-                            , (WorkflowName "ship", WorkflowDef (\_ -> shipWorkflow))-                            ]-                Right Suspended <--                    Store.runStoreIO storeHandle $-                        runWorkflow (WorkflowName "parent") (WorkflowId "p4") (parentWorkflow childWid)-                let drive = Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)-                Right _ <- drive-                Right _ <- drive-                Right _ <- drive-                Right parentJournal <--                    Store.runStoreIO storeHandle $-                        Store.readStreamForward (StreamName "wf:parent-p4") (StreamVersion 0) 10-                Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))-                any (\case WorkflowCompleted{} -> True; _ -> False) parentDecoded `shouldBe` True-                Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-3" "ship"-                childRow ^. #status `shouldBe` Child.ChildCompleted--            it "attaches to a completed child after continueAsNew" $ \storeHandle -> do-                let childWid = WorkflowId "ship-rotated"-                    parentName = WorkflowName "parent-rotating"-                    parentId = WorkflowId "p-rotating"-                    body = rotatingParentWorkflow childWid-                Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body-                childOutcome <--                    Store.runStoreIO storeHandle $-                        runChildWorkflow defaultWorkflowRunOptions (WorkflowName "ship") childWid shipWorkflow-                childOutcome `shouldBe` Right (Completed "packed+labelled")-                Right ContinuedAsNew <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body-                repair <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body-                repair `shouldBe` Right Suspended-                completed <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body-                completed `shouldBe` Right (Completed "packed+labelled")--    describe "Keiro.Workflow.Child durable failed delivery" $ around (withFreshStore fixture) $ do-        it "delivers a persisted child failure after the parent rotates past the failure journal" $ \storeHandle -> do-            let childWid = WorkflowId "failed-before-rotation"-                parentName = WorkflowName "parent-failure-rotation"-                parentId = WorkflowId "p-failure-rotation"-                registry =-                    Map.fromList-                        [ (parentName, WorkflowDef (\_ -> failedChildBeforeRotation childWid))-                        , (WorkflowName "ship", WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure ("" :: Text)))-                        ]-                opts = defaultWorkflowResumeOptions & #maxAttempts .~ 1-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflow parentName parentId (failedChildBeforeRotation childWid)-            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry-            failed summary `shouldBe` 1-            Right (Just childRow) <--                Store.runStoreIO storeHandle $-                    Child.lookupChild "failed-before-rotation" "ship"-            childRow ^. #status `shouldBe` Child.ChildFailed-            childRow ^. #failureReason-                `shouldSatisfy` maybe False ("SimulatedCrash" `Text.isInfixOf`)-            Right failedOnGenerationZero <--                Store.runStoreIO storeHandle $-                    stepExists-                        parentName-                        parentId-                        0-                        (childResultStepName childWid)-            failedOnGenerationZero `shouldBe` True--            Right ContinuedAsNew <--                Store.runStoreIO storeHandle $-                    runWorkflow parentName parentId (rotatePastFailedChild childWid)-            Right generation <- Store.runStoreIO storeHandle $ currentGeneration parentName parentId-            generation `shouldBe` 1-            Right failedOnGenerationOne <--                Store.runStoreIO storeHandle $-                    stepExists-                        parentName-                        parentId-                        1-                        (childResultStepName childWid)-            failedOnGenerationOne `shouldBe` False--            delivered <--                Store.runStoreIO storeHandle $-                    runWorkflow parentName parentId (catchFailedChildAfterRotation childWid)-            delivered `shouldSatisfy` \case-                Right (Completed reason) -> "SimulatedCrash" `Text.isInfixOf` reason-                _ -> False--    describe "Keiro.Workflow.Gc" $ around (withFreshStore fixture) $ do-        it "deletes terminal workflow data after retention" $ \storeHandle -> do-            let name = WorkflowName "gc-basic"-                wid = WorkflowId "gb-1"-                gcStreamName = workflowGenerationStreamName name wid 0-                aid = fromMaybe (error "invalid gc awakeable uuid") (fromString "00000000-0000-0000-0000-0000000000a1")-                timerId = fromMaybe (error "invalid gc timer uuid") (fromString "00000000-0000-0000-0000-0000000000a2")-            counter <- newIORef (0 :: Int)-            Right (Completed _) <--                Store.runStoreIO storeHandle $-                    runWorkflowWith-                        (defaultWorkflowRunOptions & #snapshotPolicy .~ OnTerminal)-                        name-                        wid-                        (demoWorkflow counter)-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $ do-                        Awk.registerAwakeableTx aid "gc-basic" "gb-1"-                        Tx.statement (timerId, "gc-basic", "gb-1", now, object ["kind" Aeson..= ("keiro.workflow.sleep" :: Text)], "fired") insertGcTimerStmt-            Right beforeCounts <- Store.runStoreIO storeHandle $ workflowOwnedRowCounts "gc-basic" "gb-1"-            beforeCounts `shouldBe` (1, 3, 1, 0, 1, 1)-            Right freshSummary <--                Store.runStoreIO storeHandle $-                    WorkflowGc.gcWorkflowsOnce-                        now-                        WorkflowGc.WorkflowGcPolicy{retention = 3600, batchSize = 10}-            freshSummary `shouldBe` WorkflowGc.WorkflowGcSummary{scanned = 0, deleted = 0}-            Right (Just _) <- Store.runStoreIO storeHandle $ Store.lookupStreamId gcStreamName-            Right deletedSummary <--                Store.runStoreIO storeHandle $-                    WorkflowGc.gcWorkflowsOnce-                        (addUTCTime 1 now)-                        WorkflowGc.WorkflowGcPolicy{retention = 0, batchSize = 10}-            deletedSummary `shouldBe` WorkflowGc.WorkflowGcSummary{scanned = 1, deleted = 1}-            Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId gcStreamName-            Right afterCounts <- Store.runStoreIO storeHandle $ workflowOwnedRowCounts "gc-basic" "gb-1"-            afterCounts `shouldBe` (0, 0, 0, 0, 0, 0)--        it "deletes scheduled sleep timers so a collected workflow cannot resurrect" $ \storeHandle -> do-            counter <- newIORef (0 :: Int)-            let name = WorkflowName "gc-scheduled-sleep"-                wid = WorkflowId "gss-1"-                journalStream = workflowGenerationStreamName name wid 0-                TimerId timerUuid = sleepTimerId name wid 0 "sleep:wait"-                body = do-                    _ <- step (StepName "before-sleep") (liftIO (incrementAndRead counter))-                    sleepNamed (StepName "wait") 3600-            Right Suspended <--                Store.runStoreIO storeHandle $-                    runWorkflow name wid body-            Right timerBeforeGc <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement timerUuid sleepTimerStatusStmt-            fmap fst timerBeforeGc `shouldBe` Just "scheduled"--            cancelledAt <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry name wid (WorkflowCancelled cancelledAt)-            gcClock <- getCurrentTime-            Right collected <--                Store.runStoreIO storeHandle $-                    WorkflowGc.gcWorkflowsOnce-                        (addUTCTime 1 gcClock)-                        WorkflowGc.WorkflowGcPolicy{retention = 0, batchSize = 10}-            collected `shouldBe` WorkflowGc.WorkflowGcSummary{scanned = 1, deleted = 1}--            Right Nothing <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId journalStream-            Right timerAfterGc <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement timerUuid sleepTimerStatusStmt-            timerAfterGc `shouldBe` Nothing--            Right noClaim <--                Store.runStoreIO storeHandle $-                    runWorkflowTimerWorker Nothing (addUTCTime 7200 gcClock) (\_ -> pure Nothing)-            noClaim `shouldBe` Nothing-            Right Nothing <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid-            Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId journalStream-            readIORef counter >>= (`shouldBe` 1)--        it "cancels a sleep fire when a terminal instance survives partial GC" $ \storeHandle -> do-            let name = WorkflowName "gc-terminal-fire"-                wid = WorkflowId "gtf-1"-                full = "sleep:wait"-                timerId@(TimerId timerUuid) = sleepTimerId name wid 0 full-                journalStream = workflowGenerationStreamName name wid 0-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $ do-                        Instance.upsertInstanceTx "gtf-1" "gc-terminal-fire" 0 Instance.WfCancelled Nothing-                        void $-                            scheduleTimerOnceTx-                                TimerRequest-                                    { timerId-                                    , processManagerName = "gc-terminal-fire"-                                    , correlationId = "gtf-1"-                                    , fireAt = now-                                    , payload = sleepTimerPayload 0 full-                                    }-            Right (Just claimed) <--                Store.runStoreIO storeHandle $-                    runWorkflowTimerWorker Nothing now (\_ -> pure Nothing)-            claimed ^. #timerId `shouldBe` timerId-            Right terminalTimer <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement timerUuid sleepTimerStatusStmt-            fmap fst terminalTimer `shouldBe` Just "cancelled"-            Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId journalStream-            Right resolved <--                Store.runStoreIO storeHandle $-                    stepExists name wid 0 full-            resolved `shouldBe` False--        it "keeps completed children while a parent is live and converges after partial cleanup" $ \storeHandle -> do-            let parentName = WorkflowName "gc-live-parent"-                parentId = WorkflowId "gp-1"-                childName = WorkflowName "gc-child"-                childId = WorkflowId "gc-1"-            now <- getCurrentTime-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $ do-                        Instance.upsertInstanceTx "gp-1" "gc-live-parent" 0 Instance.WfRunning Nothing-                        Child.registerChildTx "gc-1" "gc-child" "gp-1" "gc-live-parent" "child:gc-1:result"-                        void (Child.markChildResultTx "gc-1" "gc-child" (toJSON ("ok" :: Text)) now)-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry childName childId (WorkflowCompleted now)-            Right held <--                Store.runStoreIO storeHandle $-                    WorkflowGc.gcWorkflowsOnce-                        (addUTCTime 1 now)-                        WorkflowGc.WorkflowGcPolicy{retention = 0, batchSize = 10}-            held `shouldBe` WorkflowGc.WorkflowGcSummary{scanned = 0, deleted = 0}-            Right childStillThere <- Store.runStoreIO storeHandle $ Store.lookupStreamId (workflowGenerationStreamName childName childId 0)-            childStillThere `shouldSatisfy` isJust-            Right () <--                Store.runStoreIO storeHandle $-                    appendJournalEntry parentName parentId (WorkflowCompleted now)-            Right () <--                Store.runStoreIO storeHandle $-                    Store.runTransaction $-                        Tx.statement ("gc-1", "gc-child") deleteGcStepsStmt-            Right collected <--                Store.runStoreIO storeHandle $-                    WorkflowGc.gcWorkflowsOnce-                        (addUTCTime 1 now)-                        WorkflowGc.WorkflowGcPolicy{retention = 0, batchSize = 10}-            collected `shouldBe` WorkflowGc.WorkflowGcSummary{scanned = 2, deleted = 2}-            Right parentGone <- Store.runStoreIO storeHandle $ Instance.lookupInstance parentName parentId-            parentGone `shouldBe` Nothing-            Right childGone <- Store.runStoreIO storeHandle $ Instance.lookupInstance childName childId-            childGone `shouldBe` Nothing-            Right childRows <- Store.runStoreIO storeHandle $ workflowOwnedChildCount "gc-child" "gc-1"-            childRows `shouldBe` 0--{- | Increment a shared counter and return its new value (the step's side-effect, so replay can be proven by watching the counter).--}-incrementAndRead :: IORef Int -> IO Int-incrementAndRead ref = atomicModifyIORef' ref (\n -> (n + 1, n + 1))--forceWorkflowLeaseStmt :: Statement (Text, Text, Text, UTCTime) ()-forceWorkflowLeaseStmt =-    preparable-        """-        UPDATE keiro.keiro_workflows-        SET leased_by = $3,-            lease_expires_at = $4,-            updated_at = now()-        WHERE workflow_id = $1-          AND workflow_name = $2-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult--{- | Six numbered steps, each returning its index after bumping a shared-counter. The counter lets a re-hydration prove the steps short-circuit-(it stays at 6 when every step is replayed from the journal/snapshot).--}-countingSixSteps :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es [Int]-countingSixSteps counter =-    mapM-        (\i -> step (StepName ("s" <> Text.pack (show i))) (liftIO (incrementAndRead counter) >> pure i))-        [1 .. 6]--newtype Approx = Approx Double-    deriving stock (Eq, Show)--instance ToJSON Approx where-    toJSON (Approx d) = toJSON (round d :: Int)--instance FromJSON Approx where-    parseJSON value = do-        n <- Aeson.parseJSON value-        pure (Approx (fromIntegral (n :: Int)))--data RejectingRoundTrip = RejectingRoundTrip-    deriving stock (Eq, Show)--instance ToJSON RejectingRoundTrip where-    toJSON RejectingRoundTrip = Aeson.String "not-an-object"--instance FromJSON RejectingRoundTrip where-    parseJSON = Aeson.withObject "RejectingRoundTrip" $ \_ -> pure RejectingRoundTrip--{- | A distinguished exception used to simulate a process crash mid-workflow-(after a step has committed its journal append but before completion).--}-data SimulatedCrash = SimulatedCrash-    deriving stock (Show)--instance Exception SimulatedCrash--{- | A three-step workflow; each step bumps a shared counter so a resume can-prove steps short-circuit (the counter only advances for steps that run).--}-threeStep :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int, Int)-threeStep counter = do-    a <- step (StepName "s1") (liftIO (incrementAndRead counter))-    b <- step (StepName "s2") (liftIO (incrementAndRead counter))-    c <- step (StepName "s3") (liftIO (incrementAndRead counter))-    pure (a, b, c)--threeStepThenSignal :: (Workflow :> es, IOE :> es) => IORef Int -> MVar () -> Eff es (Int, Int, Int)-threeStepThenSignal counter done = do-    result <- threeStep counter-    liftIO (putMVar done ())-    pure result--{- | Runs step @"s1"@ (which commits its own journal append) then crashes, so-the journal is left with one StepRecorded and no WorkflowCompleted.--}-crashAfterStep1 :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int, Int)-crashAfterStep1 counter = do-    _ <- step (StepName "s1") (liftIO (incrementAndRead counter))-    _ <- liftIO (throwIO SimulatedCrash)-    pure (0, 0, 0)--{- | A workflow with one durable side effect before a switchable failure and-one durable side effect after it. Resurrection tests use the counter to prove-the recorded prefix never executes again.--}-recoverableWorkflow ::-    (Workflow :> es, IOE :> es) =>-    IORef Bool ->-    IORef Int ->-    Eff es Int-recoverableWorkflow shouldCrash counter = do-    _ <- step (StepName "durable-prefix") (liftIO (incrementAndRead counter))-    crashing <- liftIO (readIORef shouldCrash)-    when crashing (liftIO (throwIO SimulatedCrash))-    step (StepName "durable-tail") (liftIO (incrementAndRead counter))--{- | Awaits an external step, then runs a step that bumps the counter. Used to-prove the resume worker drives a suspended workflow to completion once its-awaited step is journaled.--}-awaitingThenStep :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text-awaitingThenStep counter = do-    decision <- awaitStep (StepName "awk:approval") (pure ())-    _ <- step (StepName "use") (liftIO (incrementAndRead counter) >> pure (decision <> "!"))-    pure (decision <> "-done")--{- | A rolling-total workflow (EP-48 continue-as-new acceptance). It adds @total@-unit-valued work steps to a running total, rotating its journal every-@rotateEvery@ steps via 'continueAsNew'. The carried seed is the pair-@(runningTotal, stepsDoneGlobally)@ so each generation knows the global-progress; @genDone@ counts steps within the /current/ generation to bound it.-Each work step bumps @counter@ exactly once (proving rotation neither drops-nor double-counts) and returns 1, so the final total equals @total@.--Step names are the global step index (@w0@, @w1@, …), so they are unique-within each generation's journal and replay-stable. Note the regression-direction: on a tree where 'continueAsNew' did not rotate, this body would put-all @total@ steps on generation 0's single journal and the per-generation-@<= K@ bound below would fail for @total > K@.--}-rollingTotal :: (Workflow :> es, IOE :> es) => IORef Int -> Int -> Int -> Eff es Int-rollingTotal counter rotateEvery total = do-    (acc0, done0) <- restoreSeed (0 :: Int, 0 :: Int)-    go acc0 done0 0-  where-    go acc done genDone-        | done >= total = pure acc -- all global work done: this generation completes-        | genDone >= rotateEvery = continueAsNew (acc, done) -- bound this generation; carry onward-        | otherwise = do-            n <--                step-                    (StepName ("w" <> Text.pack (show done)))-                    (liftIO (modifyIORef' counter (+ 1) >> pure (1 :: Int)))-            go (acc + n) (done + 1) (genDone + 1)---- The patch id under test (EP-49).-fraudPatchId :: PatchId-fraudPatchId = PatchId "fraud-check-v2"--{- | The workflow BEFORE the patch shipped: reserve, then await an external step-(so an instance can be left in flight, mid-journal, with one ordinary step-recorded and no completion). Used to create the in-flight instance.--}-prePatchWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text-prePatchWorkflow counter = do-    _ <- step (StepName "reserve-inventory") (liftIO (incrementAndRead counter) >> pure ())-    (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ()) -- park here, in flight-    pure "old-done"--{- | The workflow AFTER the patch shipped: the same first step, then a-patch-gated cross-cutting branch. The in-flight instance (which already-journaled reserve-inventory under the pre-patch code) must observe False and-take the OLD branch; a fresh instance must observe True and take the NEW branch.--}-postPatchWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text-postPatchWorkflow counter = do-    _ <- step (StepName "reserve-inventory") (liftIO (incrementAndRead counter) >> pure ())-    useNew <- patch fraudPatchId-    if useNew-        then step (StepName "new-charge") (pure "new-branch")-        else step (StepName "old-charge") (pure "old-branch")--postPatchAfterSuspendWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text-postPatchAfterSuspendWorkflow counter = do-    _ <- step (StepName "reserve-inventory") (liftIO (incrementAndRead counter) >> pure ())-    (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())-    useNew <- patch fraudPatchId-    if useNew-        then step (StepName "new-charge") (pure "new-branch")-        else step (StepName "old-charge") (pure "old-branch")--prePatchWakeOnlyWorkflow :: (Workflow :> es) => Eff es Text-prePatchWakeOnlyWorkflow = do-    (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())-    pure "old-done"--postPatchWakeOnlyWorkflow :: (Workflow :> es) => Eff es Text-postPatchWakeOnlyWorkflow = do-    (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())-    useNew <- patch fraudPatchId-    if useNew-        then step (StepName "new-charge") (pure "new-branch")-        else step (StepName "old-charge") (pure "old-branch")--rotatingPatchWorkflow :: (Workflow :> es) => Eff es Text-rotatingPatchWorkflow = do-    seed <- restoreSeed (0 :: Int)-    if seed < 1-        then continueAsNew (seed + 1)-        else do-            useNew <- patch fraudPatchId-            if useNew-                then step (StepName "new-charge") (pure "new-branch")-                else step (StepName "old-charge") (pure "old-branch")--{- | A workflow (EP-50 push tests) that awaits an external "awk:gate" step, then-runs a step that fills @done@ — so a test can observe the exact moment the-workflow resumes to completion. Awaiting first means the journal is empty until-the external gate append, which is what makes the instance discoverable by the-resume worker (the gate's StepRecorded is the first index row).--}-gateThenSignal :: (Workflow :> es, IOE :> es) => MVar () -> Eff es Text-gateThenSignal done = do-    (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())-    _ <- step (StepName "after-gate") (liftIO (putMVar done ()) >> pure ())-    pure "resumed"---- | A two-step workflow whose steps each bump a shared counter.-demoWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int)-demoWorkflow counter = do-    a <- step (StepName "first") (liftIO (incrementAndRead counter))-    b <- step (StepName "second") (liftIO (incrementAndRead counter))-    pure (a, b)--{- | A workflow that immediately awaits a step nothing ever arms — used to-exercise the suspend path and external completion.--}-neverArmingWorkflow :: (Workflow :> es) => Eff es Int-neverArmingWorkflow = awaitStep (StepName "awk:test") (pure ())--{- | The awakeable validation workflow: allocate a durable promise, suspend on-it, and (once signalled) append "!" to the payload through a recorded step.--}-approvalFlowWithId :: (Workflow :> es, Store :> es, IOE :> es) => IORef (Maybe AwakeableId) -> Eff es Text-approvalFlowWithId ref = do-    (aid, await) <- awakeableNamed (StepName "approval")-    liftIO (writeIORef ref (Just aid))-    v <- await-    step (StepName "use") (pure (v <> "!"))--publishAwakeableBeforeAwait ::-    forall es.-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef (Maybe AwakeableId) ->-    Eff es ()-publishAwakeableBeforeAwait ref = do-    (aid, _await :: Eff es Text) <- awakeableNamed (StepName "gate")-    _ <--        step (StepName "publish") $ do-            liftIO (writeIORef ref (Just aid))-    (_ :: ()) <- awaitStep (StepName "hold") (pure ())-    pure ()--awaitPublishedAwakeable ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef (Maybe AwakeableId) ->-    Eff es Text-awaitPublishedAwakeable ref = do-    (aid, await) <- awakeableNamed (StepName "gate")-    _ <--        step (StepName "publish") $ do-            liftIO (writeIORef ref (Just aid))-    await--snapshotUnsignalledAwakeable ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef (Maybe AwakeableId) ->-    Eff es Text-snapshotUnsignalledAwakeable ref = do-    (aid, await) <- awakeableNamed (StepName "gate")-    liftIO (writeIORef ref (Just aid))-    await--snapshotShadowedAwakeable :: (Workflow :> es, Store :> es, IOE :> es) => Eff es Text-snapshotShadowedAwakeable = do-    (aid, await) <- awakeableNamed (StepName "gate")-    _ <- step (StepName "mid") (void (signalAwakeable aid ("payload" :: Text)))-    await--snapshotStaleAwakeablePhaseOne ::-    forall es.-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef (Maybe AwakeableId) ->-    Eff es ()-snapshotStaleAwakeablePhaseOne ref = do-    (aid, _await :: Eff es Text) <- awakeableNamed (StepName "gate")-    liftIO (writeIORef ref (Just aid))-    _ <- step (StepName "mid") (void (signalAwakeable aid ("payload" :: Text)))-    (_ :: ()) <- awaitStep (StepName "hold") (pure ())-    pure ()--snapshotStaleAwakeablePhaseTwo :: (Workflow :> es, Store :> es, IOE :> es) => Eff es Text-snapshotStaleAwakeablePhaseTwo = do-    (_aid, await) <- awakeableNamed (StepName "gate")-    _ <- step (StepName "mid") (pure ())-    await--snapshotStaleChildPhaseOne ::-    (Workflow :> es, Store :> es, IOE :> es, Error Store.StoreError :> es) =>-    WorkflowId ->-    Eff es ()-snapshotStaleChildPhaseOne childWid = do-    _h <- spawnChild (WorkflowName "snapshot-child") childWid shipWorkflow-    _ <--        step (StepName "drive") $-            void (runChildWorkflow defaultWorkflowRunOptions (WorkflowName "snapshot-child") childWid shipWorkflow)-    (_ :: ()) <- awaitStep (StepName "hold") (pure ())-    pure ()--snapshotStaleChildPhaseTwo ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    WorkflowId ->-    Eff es Text-snapshotStaleChildPhaseTwo childWid = do-    h <- spawnChild (WorkflowName "snapshot-child") childWid shipWorkflow-    _ <- step (StepName "drive") (pure ())-    awaitChild h--readRequiredAwakeableId :: IORef (Maybe AwakeableId) -> IO AwakeableId-readRequiredAwakeableId ref =-    readIORef ref >>= \case-        Just aid -> pure aid-        Nothing -> fail "workflow did not allocate an awakeable id"--uuidLiteral :: String -> UUID-uuidLiteral raw =-    case fromString raw of-        Just uuid -> uuid-        Nothing -> error ("invalid UUID literal in test: " <> raw)--{- | A two-step workflow with a durable sleep between the steps. The sleep's-name and delay are parameters so one helper drives both the zero-delta and-the real-time tests.--}-sleepDemoNamed ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef Int -> StepName -> NominalDiffTime -> Eff es (Int, Int)-sleepDemoNamed counter sName delta = do-    a <- step (StepName "a") (liftIO (incrementAndRead counter))-    sleepNamed sName delta-    b <- step (StepName "b") (liftIO (incrementAndRead counter))-    pure (a, b)--rollingSleepWorkflow ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef Int -> Eff es Int-rollingSleepWorkflow counter = do-    seed <- restoreSeed (0 :: Int)-    _ <- step (StepName "work") (liftIO (incrementAndRead counter))-    if seed < 2-        then sleepNamed (StepName "cool") 0 >> continueAsNew (seed + 1)-        else pure seed--rollingAwakeableWorkflow ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    IORef [AwakeableId] -> Eff es Text-rollingAwakeableWorkflow idsRef = do-    seed <- restoreSeed (0 :: Int)-    (aid, await) <- awakeableNamed (StepName "gate")-    liftIO (modifyIORef' idsRef (\ids -> if aid `elem` ids then ids else ids <> [aid]))-    value <- await-    if seed < 1-        then continueAsNew (seed + 1)-        else step (StepName "use") (pure value)--rotatingParentWorkflow ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    WorkflowId -> Eff es Text-rotatingParentWorkflow childWid = do-    seed <- restoreSeed (0 :: Int)-    h <- spawnChild (WorkflowName "ship") childWid shipWorkflow-    result <- awaitChild h-    if seed < 1-        then continueAsNew (seed + 1)-        else pure result--failedChildBeforeRotation ::-    (Workflow :> es, Store :> es) =>-    WorkflowId ->-    Eff es Text-failedChildBeforeRotation childWid = do-    _ <- spawnChild (WorkflowName "ship") childWid shipWorkflow-    awaitStep (StepName "rotation-gate") (pure ())--rotatePastFailedChild ::-    (Workflow :> es, Store :> es) =>-    WorkflowId ->-    Eff es Text-rotatePastFailedChild childWid = do-    _ <- spawnChild (WorkflowName "ship") childWid shipWorkflow-    continueAsNew ()--catchFailedChildAfterRotation ::-    (Workflow :> es, Store :> es, IOE :> es) =>-    WorkflowId ->-    Eff es Text-catchFailedChildAfterRotation childWid = do-    child <- spawnChild (WorkflowName "ship") childWid shipWorkflow-    EffException.catch-        (awaitChild child)-        (\(WorkflowChildFailed _ _ reason) -> pure reason)--{- | A workflow that records one step, then suspends on an await — so it has a-step row but no completion marker (the unfinished-discovery case).--}-stepThenAwaitWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int-stepThenAwaitWorkflow counter = do-    _ <- step (StepName "s1") (liftIO (incrementAndRead counter))-    awaitStep (StepName "awk:wait") (pure ())---- | A two-step child workflow used in the child-workflow tests.-shipWorkflow :: (Workflow :> es) => Eff es Text-shipWorkflow = do-    a <- step (StepName "pack") (pure ("packed" :: Text))-    b <- step (StepName "label") (pure (a <> "+labelled"))-    pure b--{- | A parent that spawns a @"ship"@ child (id supplied), awaits its result, and-then records a @notify@ step. Parametrised by child id so each test isolates-its own child journal.--}-parentWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowId -> Eff es Text-parentWorkflow childWid = do-    h <- spawnChild (WorkflowName "ship") childWid shipWorkflow-    result <- awaitChild h-    _ <- step (StepName "notify") (pure ("done:" <> result))-    pure ("done:" <> result)--jsonObjectChildWorkflow :: Eff es Aeson.Value-jsonObjectChildWorkflow =-    pure (object ["cancelled" Aeson..= True])--jsonObjectParentWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowId -> Eff es Aeson.Value-jsonObjectParentWorkflow childWid = do-    h <- spawnChild (WorkflowName "json-child") childWid jsonObjectChildWorkflow-    result <- awaitChild h-    _ <- step (StepName "json-notify") (pure ())-    pure result--selfCancellingWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowName -> WorkflowId -> IORef Int -> Eff es Int-selfCancellingWorkflow name wid counter = do-    _ <- step (StepName "one") (liftIO (incrementAndRead counter))-    _ <--        step (StepName "two") $ do-            now <- liftIO getCurrentTime-            appendJournalEntry name wid (WorkflowCancelled now)-            liftIO (incrementAndRead counter)-    step (StepName "three") (liftIO (incrementAndRead counter))--nominalDays :: Int -> NominalDiffTime-nominalDays n = fromIntegral n * 86400--attrKeyText :: AttributeKey Text -> Text-attrKeyText = unkey--attrKeyTextInt64 :: AttributeKey Int64 -> Text-attrKeyTextInt64 = unkey--textAttr :: Attributes -> Text -> Maybe Text-textAttr attrs name = case lookupAttribute attrs name of-    Just (AttributeValue (TextAttribute t)) -> Just t-    _ -> Nothing--intAttr :: Attributes -> Text -> Maybe Int64-intAttr attrs name = case lookupAttribute attrs name of-    Just (AttributeValue (IntAttribute n)) -> Just n-    _ -> Nothing--{- | A frozen snapshot of an 'ImmutableSpan'. In hs-opentelemetry 1.0 the-mutable span fields (name, attributes, status) live behind the-@spanHot :: IORef SpanHot@ field rather than directly on 'ImmutableSpan',-so the tests read that reference once after the span ends and assert on-this flat record.--}-data CapturedSpan = CapturedSpan-    { csName :: Text-    , csKind :: SpanKind-    , csAttributes :: Attributes-    , csStatus :: SpanStatus-    , csContext :: SpanContext-    , csParent :: Maybe Span-    }--captureSpan :: ImmutableSpan -> IO CapturedSpan-captureSpan sp = do-    hot <- readIORef (spanHot sp)-    pure-        CapturedSpan-            { csName = hotName hot-            , csKind = spanKind sp-            , csAttributes = hotAttributes hot-            , csStatus = hotStatus hot-            , csContext = spanContext sp-            , csParent = spanParent sp-            }--{- | Tiny in-process \"Kafka topic\": an MVar of consumed records plus an-incrementing offset. The publisher pushes records here; the consumer-drains the MVar. There is no real broker — the goal of the fixture is-to validate that the keiro envelope and outbox/inbox semantics-compose correctly across two isolated PostgreSQL contexts.--}-newtype KafkaTopic = KafkaTopic (MVar (Int64, [InboxKafka.KafkaInboundRecord]))--newKafkaTopic :: IO KafkaTopic-newKafkaTopic = KafkaTopic <$> newMVar (0, [])--kafkaTopicAccept :: (MonadIO m) => KafkaTopic -> OutboxRow -> m ()-kafkaTopicAccept (KafkaTopic ref) row = liftIO $ do-    let record = OutboxKafka.outboxRowToKafkaRecord row-        headersText =-            [ (TE.decodeUtf8 name, TE.decodeUtf8 value)-            | (name, value) <- record ^. #headers-            ]-    now <- getCurrentTime-    modifyMVar ref $ \(nextOffset, acc) ->-        let inbound =-                InboxKafka.KafkaInboundRecord-                    { topic = record ^. #topic-                    , partition = 0-                    , offset = nextOffset-                    , key = fmap TE.decodeUtf8 (record ^. #key)-                    , payload = record ^. #payload-                    , headers = headersText-                    , receivedAt = now-                    }-         in pure ((nextOffset + 1, inbound : acc), ())--kafkaTopicPublish ::-    forall es.-    (IOE :> es) =>-    KafkaTopic ->-    OutboxRow ->-    Eff es PublishOutcome-kafkaTopicPublish topic row = do-    kafkaTopicAccept topic row-    pure PublishSucceeded--perRow ::-    (OutboxRow -> Eff es PublishOutcome) ->-    [OutboxRow] ->-    Eff es [(OutboxId, PublishOutcome)]-perRow publish rows =-    traverse publishOne rows-  where-    publishOne row = do-        outcome <- publish row-        pure (row ^. #outboxId, outcome)--drainKafkaTopic :: KafkaTopic -> IO [InboxKafka.KafkaInboundRecord]-drainKafkaTopic (KafkaTopic ref) = do-    (_, acc) <- readMVar ref-    pure (reverse acc)--redeliverWithDifferentOffset ::-    InboxKafka.KafkaInboundRecord ->-    InboxKafka.KafkaInboundRecord-redeliverWithDifferentOffset record = record & #offset .~ (record ^. #offset) + 1000--data ConsumeResult a-    = ConsumeDecodeFailed !InboxKafka.KafkaDecodeError-    | ConsumePolicyUnsatisfied !InboxError-    | ConsumeApplied !(InboxResult a)-    deriving stock (Eq, Show)--{- | A worker-shaped consumer: decode the Kafka record into an-IntegrationEvent and run it through the inbox.--}-consumeAndApply ::-    forall es.-    (IOE :> es, Store :> es) =>-    InboxKafka.KafkaInboundRecord ->-    (IntegrationEvent -> Tx.Transaction ()) ->-    Eff es (ConsumeResult ())-consumeAndApply record handler =-    case InboxKafka.integrationEventFromKafka record of-        Left err -> pure (ConsumeDecodeFailed err)-        Right (event, kafkaRef) -> do-            result <--                runInboxTransaction Nothing PreferIntegrationMessageId event (Just kafkaRef) handler-            case result of-                Left err -> pure (ConsumePolicyUnsatisfied err)-                Right applied -> pure (ConsumeApplied applied)--billingReactionHandler :: IntegrationEvent -> Tx.Transaction ()-billingReactionHandler event = case decodeJsonIntegrationEvent event of-    Left _ -> Tx.condemn-    Right (OrderSubmittedPayload orderId quantity) ->-        Tx.statement (orderId, fromIntegral quantity :: Int64) insertReceivedOrderStmt--loggingReactionHandler :: Text -> IntegrationEvent -> Tx.Transaction ()-loggingReactionHandler _ event = do-    -- The cross-context test only needs the (eventType, key) pair, not-    -- the decoded payload.-    let key = fromMaybe "" (event ^. #key)-    Tx.statement (event ^. #source, event ^. #eventType, key) appendBillingEventLogStmt--insertReceivedOrderStmt :: Statement (Text, Int64) ()-insertReceivedOrderStmt =-    preparable-        """-        INSERT INTO billing_received_orders (order_id, quantity) VALUES ($1, $2)-        ON CONFLICT (order_id) DO NOTHING-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--billingReceivedOrdersCountStmt :: Statement () Int-billingReceivedOrdersCountStmt =-    preparable-        "SELECT COUNT(*)::bigint FROM billing_received_orders"-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))--appendBillingEventLogStmt :: Statement (Text, Text, Text) ()-appendBillingEventLogStmt =-    preparable-        "INSERT INTO billing_event_log (source, event_type, order_id) VALUES ($1, $2, $3)"-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--billingEventLogStmt :: Statement () [(Text, Text)]-billingEventLogStmt =-    preparable-        "SELECT event_type, order_id FROM billing_event_log ORDER BY seq"-        E.noParams-        ( D.rowList-            ( (,)-                <$> D.column (D.nonNullable D.text)-                <*> D.column (D.nonNullable D.text)-            )-        )--orderSubmittedEnvelope :: Text -> Int -> Text -> IntegrationEvent-orderSubmittedEnvelope orderId quantity messageId =-    encodeJsonIntegrationEvent-        ( sampleIntegrationEnvelope-            & #messageId-            .~ messageId-            & #eventType-            .~ "OrderSubmitted"-            & #key-            .~ Just orderId-        )-        (OrderSubmittedPayload orderId quantity)--orderCancelledEnvelope :: Text -> Text -> IntegrationEvent-orderCancelledEnvelope orderId messageId =-    sampleIntegrationEnvelope-        & #messageId-        .~ messageId-        & #eventType-        .~ "OrderCancelled"-        & #key-        .~ Just orderId-        & #payloadBytes-        .~ ("{\"orderId\":\"" <> TE.encodeUtf8 orderId <> "\"}")-        & #contentType-        .~ ApplicationJson--inboxTestCounterInsertStmt :: Statement Text ()-inboxTestCounterInsertStmt =-    preparable-        "INSERT INTO inbox_test_counter (message_id) VALUES ($1)"-        (E.param (E.nonNullable E.text))-        D.noResult--inboxTestCounterCountStmt :: Statement () Int-inboxTestCounterCountStmt =-    preparable-        "SELECT COUNT(*)::bigint FROM inbox_test_counter"-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))--sampleProducer :: IntegrationProducer ()-sampleProducer =-    IntegrationProducer-        { name = "ordering-integration-producer"-        , source = "ordering"-        , messageIdPrefix = "msg"-        , mapEvent = \_recorded () -> Just sampleDraft-        }--sampleDraft :: IntegrationEventDraft-sampleDraft =-    IntegrationEventDraft-        { destination = "billing.orders.v1"-        , key = Just "order-123"-        , eventType = "OrderSubmitted"-        , schemaVersion = 1-        , contentType = ApplicationJson-        , schemaReference = Nothing-        , sourceEventId = Nothing-        , sourceGlobalPosition = Nothing-        , payloadBytes = "{\"orderId\":\"order-123\",\"quantity\":5}"-        , occurredAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)-        , causationId = Nothing-        , correlationId = Nothing-        , traceContext = Nothing-        , attributes = Just (object ["source" Aeson..= ("test-suite" :: Text)])-        }--sampleOutboxRow :: IntegrationEvent -> OutboxRow-sampleOutboxRow event =-    OutboxRow-        { outboxId = OutboxId outboxUuid1-        , event-        , status = OutboxPending-        , attemptCount = 0-        , nextAttemptAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)-        , lastError = Nothing-        , publishedAt = Nothing-        , createdAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)-        , updatedAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)-        }--backdateOutboxUpdatedAt :: (Store :> es) => OutboxId -> UTCTime -> Eff es ()-backdateOutboxUpdatedAt oid timestamp =-    Store.runTransaction $-        Tx.statement (unOutboxId oid, timestamp) backdateOutboxUpdatedAtStmt--backdateOutboxUpdatedAtStmt :: Statement (UUID, UTCTime) ()-backdateOutboxUpdatedAtStmt =-    preparable-        "UPDATE keiro.keiro_outbox SET updated_at = $2 WHERE outbox_id = $1"-        ( contrazip2-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult--backdateOutboxPublishedAt :: (Store :> es) => OutboxId -> UTCTime -> Eff es ()-backdateOutboxPublishedAt oid timestamp =-    Store.runTransaction $-        Tx.statement (unOutboxId oid, timestamp) backdateOutboxPublishedAtStmt--backdateOutboxPublishedAtStmt :: Statement (UUID, UTCTime) ()-backdateOutboxPublishedAtStmt =-    preparable-        "UPDATE keiro.keiro_outbox SET published_at = $2 WHERE outbox_id = $1"-        ( contrazip2-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.timestamptz))-        )-        D.noResult--outboxUuid1, outboxUuid2, outboxUuid3, outboxUuid4 :: UUID-outboxUuid1 = case fromString "018f0f18-0000-7000-8000-000000000a01" of-    Just uuid -> uuid-    Nothing -> error "invalid outbox uuid 1"-outboxUuid2 = case fromString "018f0f18-0000-7000-8000-000000000a02" of-    Just uuid -> uuid-    Nothing -> error "invalid outbox uuid 2"-outboxUuid3 = case fromString "018f0f18-0000-7000-8000-000000000a03" of-    Just uuid -> uuid-    Nothing -> error "invalid outbox uuid 3"-outboxUuid4 = case fromString "018f0f18-0000-7000-8000-000000000a04" of-    Just uuid -> uuid-    Nothing -> error "invalid outbox uuid 4"--outboxIdFromOrdinal :: Word64 -> OutboxId-outboxIdFromOrdinal n =-    OutboxId (fromWords64 0x018f0f1800007000 (0x8000000000000000 + n))--uniqueIds :: (Eq a) => [a] -> [a]-uniqueIds = foldr (\x xs -> if x `elem` xs then xs else x : xs) []--data OrderSubmittedPayload = OrderSubmittedPayload-    { orderId :: !Text-    , quantity :: !Int-    }-    deriving stock (Generic, Eq, Show)--instance ToJSON OrderSubmittedPayload where-    toJSON = genericToJSON (aesonPrefix camelCase)-    toEncoding = genericToEncoding (aesonPrefix camelCase)--instance FromJSON OrderSubmittedPayload where-    parseJSON = genericParseJSON (aesonPrefix camelCase)--sampleIntegrationEnvelope :: IntegrationEvent-sampleIntegrationEnvelope =-    IntegrationEvent-        { messageId = "018f0f18-17aa-7000-8000-0000000000aa"-        , source = "ordering"-        , destination = "billing.orders.v1"-        , key = Just "order-123"-        , eventType = "OrderSubmitted"-        , schemaVersion = 1-        , contentType = ApplicationJson-        , schemaReference =-            Just-                SchemaReference-                    { registry = Just "https://schemas.example/registry"-                    , subject = Just "billing.orders.v1.OrderSubmitted"-                    , version = Just 1-                    , schemaId = Just 42-                    , fingerprint = Just "sha256:abc123"-                    }-        , sourceEventId = Just (EventId integrationSourceEventUuid)-        , sourceGlobalPosition = Just (GlobalPosition 42)-        , payloadBytes = "{\"orderId\":\"order-123\",\"quantity\":5}"-        , occurredAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)-        , causationId = Just (EventId integrationCausationUuid)-        , correlationId = Just (EventId integrationCorrelationUuid)-        , traceContext =-            Just-                TraceContext-                    { traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"-                    , tracestate = Just "rojo=00f067aa0ba902b7"-                    }-        , attributes = Nothing-        }--integrationSourceEventUuid :: UUID-integrationSourceEventUuid =-    case fromString "018f0f18-17aa-7000-8000-000000000003" of-        Just uuid -> uuid-        Nothing -> error "invalid integration source event UUID"--integrationCausationUuid :: UUID-integrationCausationUuid =-    case fromString "018f0f18-17aa-7000-8000-000000000004" of-        Just uuid -> uuid-        Nothing -> error "invalid integration causation UUID"--integrationCorrelationUuid :: UUID-integrationCorrelationUuid =-    case fromString "018f0f18-17aa-7000-8000-000000000005" of-        Just uuid -> uuid-        Nothing -> error "invalid integration correlation UUID"--data OrderStream--data OrderEvent-    = OrderPlaced !Text !Int-    deriving stock (Generic, Eq, Show)--data OrderState-    = Idle-    deriving stock (Generic, Eq, Show)--data OrderCommand-    = PlaceOrder-    deriving stock (Generic, Eq, Show)--orderCodec :: Codec OrderEvent-orderCodec =-    Codec-        { eventTypes = EventType "OrderPlaced" :| []-        , eventType = \case-            OrderPlaced{} -> EventType "OrderPlaced"-        , schemaVersion = 2-        , encode = \case-            OrderPlaced orderId quantity ->-                object ["orderId" Aeson..= orderId, "quantity" Aeson..= quantity]-        , decode = parseOrderPlaced-        , upcasters = [(1, const upcastOrderPlacedV1)]-        }--gappyCodec :: Codec OrderEvent-gappyCodec =-    Codec-        { eventTypes = orderCodec ^. #eventTypes-        , eventType = orderCodec ^. #eventType-        , schemaVersion = 4-        , encode = orderCodec ^. #encode-        , decode = orderCodec ^. #decode-        , upcasters = [(1, const upcastOrderPlacedV1), (3, const Right)]-        }--parseOrderPlaced :: EventType -> Value -> Either Text OrderEvent-parseOrderPlaced _ value =-    case parseEither parser value of-        Right event -> Right event-        Left message -> Left (fromStringLiteral message)-  where-    parser = withObject "OrderPlaced" $ \objectValue ->-        OrderPlaced-            <$> objectValue .: "orderId"-            <*> objectValue .: "quantity"--upcastOrderPlacedV1 :: Value -> Either Text Value-upcastOrderPlacedV1 value =-    case parseEither parser value of-        Right migrated -> Right migrated-        Left message -> Left (fromStringLiteral message)-  where-    parser = withObject "OrderPlacedV1" $ \objectValue -> do-        orderId <- objectValue .: "orderId"-        quantity <- objectValue .: "qty"-        pure (object ["orderId" Aeson..= (orderId :: Text), "quantity" Aeson..= (quantity :: Int)])--metadataForOrDie :: Int -> Maybe Value -> Value-metadataForOrDie version existing =-    either (error . show) id (metadataFor version existing)--emptyTransducer :: SymTransducer () '[] OrderState OrderCommand OrderEvent-emptyTransducer =-    SymTransducer-        { edgesOut = \_ -> []-        , initial = Idle-        , initialRegs = RNil-        , isFinal = \_ -> True-        }--type CounterEventStream = EventStream (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent--type ValidatedCounterEventStream = ValidatedEventStream (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent--type SnapshotCounterRegs = '[ '("lastAmount", Int)]--type UninitializedSnapshotRegs = '[ '("initialized", Int), '("neverWritten", Int)]--type SnapshotCounterEventStream = EventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent--type PartialSnapshotEventStream = EventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs PartialSnapshotState CounterCommand CounterEvent--type ValidatedSnapshotCounterEventStream = ValidatedEventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent--type UninitializedSnapshotEventStream = EventStream (HsPred UninitializedSnapshotRegs CounterCommand) UninitializedSnapshotRegs CounterState CounterCommand CounterEvent--data CounterCommand-    = Add !Int-    deriving stock (Generic, Eq, Show)--data SkipCommand-    = SAdd !Int-    | SSkip-    deriving stock (Generic, Eq, Show)--data CounterEvent-    = CounterAdded !Int-    | CounterAudited !Int-    deriving stock (Generic, Eq, Show)--data CounterState-    = Counting-    deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)-    deriving anyclass (FromJSON, ToJSON)--instance CanonicalStateShape CounterState--data CounterStateV2-    = CountingV2-    | PausedV2-    deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)-    deriving anyclass (FromJSON, ToJSON)--instance CanonicalStateShape CounterStateV2--data DrainState-    = Draining-    | Drained-    deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)--data PartialSnapshotState-    = SnapshotEncodable-    | SnapshotEncodeBomb-    deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)--instance CanonicalStateShape PartialSnapshotState--instance ToJSON PartialSnapshotState where-    toJSON SnapshotEncodable = Aeson.String "encodable"-    toJSON SnapshotEncodeBomb = error "snapshot state encoder exploded"--instance FromJSON PartialSnapshotState where-    parseJSON = Aeson.withText "PartialSnapshotState" $ \case-        "encodable" -> pure SnapshotEncodable-        "bomb" -> pure SnapshotEncodeBomb-        other -> fail ("unknown partial snapshot state: " <> Text.unpack other)--counterEventStreamDef :: CounterEventStream-counterEventStreamDef =-    EventStream-        { transducer = counterTransducer-        , initialState = Counting-        , initialRegisters = RNil-        , eventCodec = counterCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Never-        , stateCodec = Nothing-        }--counterEventStream :: ValidatedCounterEventStream-counterEventStream = mkEventStreamOrThrow "counter" counterEventStreamDef--auditedCounterEventStream :: ValidatedCounterEventStream-auditedCounterEventStream =-    mkEventStreamOrThrow-        "counter-audited-only"-        (counterEventStreamDef & #transducer .~ auditedCounterTransducer)--auditedCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-auditedCounterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update = UKeep-                    , output = [pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--noOpCounterEventStreamDef :: CounterEventStream-noOpCounterEventStreamDef =-    counterEventStreamDef & #transducer .~ noOpCounterTransducer--noOpCounterEventStream :: ValidatedCounterEventStream-noOpCounterEventStream = mkEventStreamOrThrow "counter-no-op" noOpCounterEventStreamDef--counterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-counterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update = UKeep-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--noOpCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-noOpCounterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update = UKeep-                    , output = []-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--multiCounterEventStreamDef :: CounterEventStream-multiCounterEventStreamDef =-    counterEventStreamDef & #transducer .~ multiCounterTransducer--multiCounterEventStream :: ValidatedCounterEventStream-multiCounterEventStream = mkEventStreamOrThrow "counter-multi" multiCounterEventStreamDef--multiCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-multiCounterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update = UKeep-                    , output =-                        [ pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)-                        , pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)-                        ]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--{- | Both guards match at runtime but remain outside keiki's conservative pure-overlap fragment. Distinct head event constructors keep inversion unambiguous,-so this is a validated stream that exercises the runtime step witness.--}-ambiguousCounterEventStreamDef :: CounterEventStream-ambiguousCounterEventStreamDef =-    counterEventStreamDef & #transducer .~ ambiguousCounterTransducer--ambiguousCounterEventStream :: ValidatedCounterEventStream-ambiguousCounterEventStream =-    mkEventStreamOrThrow "counter-ambiguous" ambiguousCounterEventStreamDef--ambiguousCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-ambiguousCounterTransducer =-    counterTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = ambiguousGuard-                    , update = UKeep-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                , Edge-                    { guard = ambiguousGuard-                    , update = UKeep-                    , output = [pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        }-  where-    ambiguousGuard = PAnd (matchInCtor addCtor) (PNot PBot)--snapshotCounterEventStreamDef :: SnapshotCounterEventStream-snapshotCounterEventStreamDef =-    EventStream-        { transducer = snapshotCounterTransducer-        , initialState = Counting-        , initialRegisters = RCons (Proxy @"lastAmount") 0 RNil-        , eventCodec = counterCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Every 2-        , stateCodec = Just (defaultStateCodec @SnapshotCounterRegs @CounterState 1)-        }--partialSnapshotEventStream :: ValidatedEventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs PartialSnapshotState CounterCommand CounterEvent-partialSnapshotEventStream = mkEventStreamOrThrow "partial-snapshot" partialSnapshotEventStreamDef--partialSnapshotEventStreamDef :: PartialSnapshotEventStream-partialSnapshotEventStreamDef =-    EventStream-        { transducer =-            SymTransducer-                { edgesOut = \_ ->-                    [ Edge-                        { guard = matchInCtor addCtor-                        , update =-                            USet-                                (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                                (inpCtor addCtor #amount)-                        , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                        , target = SnapshotEncodeBomb-                        , mode = Keiki.Live-                        }-                    ]-                , initial = SnapshotEncodable-                , initialRegs = RCons (Proxy @"lastAmount") 0 RNil-                , isFinal = \_ -> False-                }-        , initialState = SnapshotEncodable-        , initialRegisters = RCons (Proxy @"lastAmount") 0 RNil-        , eventCodec = counterCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Every 1-        , stateCodec = Just (defaultStateCodec @SnapshotCounterRegs @PartialSnapshotState 1)-        }--uninitializedSnapshotEventStreamDef :: UninitializedSnapshotEventStream-uninitializedSnapshotEventStreamDef =-    initializedSnapshotEventStreamDef-        & #initialRegisters-        .~ RCons (Proxy @"initialized") 0 (emptyRegFile @'[ '("neverWritten", Int)])--initializedSnapshotEventStreamDef :: UninitializedSnapshotEventStream-initializedSnapshotEventStreamDef =-    EventStream-        { transducer =-            SymTransducer-                { edgesOut = \case Counting -> []-                , initial = Counting-                , initialRegs = RCons (Proxy @"initialized") 0 (RCons (Proxy @"neverWritten") 0 RNil)-                , isFinal = \_ -> False-                }-        , initialState = Counting-        , initialRegisters = RCons (Proxy @"initialized") 0 (RCons (Proxy @"neverWritten") 0 RNil)-        , eventCodec = counterCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Every 2-        , stateCodec = Just (defaultStateCodec @UninitializedSnapshotRegs @CounterState 1)-        }--snapshotCounterEventStream :: ValidatedSnapshotCounterEventStream-snapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter" snapshotCounterEventStreamDef--snapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-snapshotCounterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update =-                        USet-                            (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                            (inpCtor addCtor #amount)-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RCons (Proxy @"lastAmount") 0 RNil-        , isFinal = \_ -> False-        }--foldV1SnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream-foldV1SnapshotCounterEventStream =-    mkEventStreamOrThrow "snapshot-counter-fold-v1" foldV1SnapshotCounterEventStreamDef--foldV1SnapshotCounterEventStreamDef :: SnapshotCounterEventStream-foldV1SnapshotCounterEventStreamDef =-    snapshotCounterEventStreamDef-        { transducer = foldV1SnapshotCounterTransducer-        , stateCodec =-            Just-                ( defaultStateCodecWithFold-                    @SnapshotCounterRegs-                    @CounterState-                    (FoldVersion "fold-v1")-                    1-                )-        }--foldV2SnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream-foldV2SnapshotCounterEventStream =-    mkEventStreamOrThrow "snapshot-counter-fold-v2" foldV2SnapshotCounterEventStreamDef--foldV2SnapshotCounterEventStreamDef :: SnapshotCounterEventStream-foldV2SnapshotCounterEventStreamDef =-    foldV1SnapshotCounterEventStreamDef-        { transducer = foldV2SnapshotCounterTransducer-        , snapshotPolicy = Every 1-        , stateCodec =-            Just-                ( defaultStateCodecWithFold-                    @SnapshotCounterRegs-                    @CounterState-                    (FoldVersion "fold-v2")-                    1-                )-        }--foldV2WithoutFingerprintBumpEventStream :: ValidatedSnapshotCounterEventStream-foldV2WithoutFingerprintBumpEventStream =-    mkEventStreamOrThrow-        "snapshot-counter-fold-v2-without-fingerprint-bump"-        foldV2WithoutFingerprintBumpEventStreamDef--foldV2WithoutFingerprintBumpEventStreamDef :: SnapshotCounterEventStream-foldV2WithoutFingerprintBumpEventStreamDef =-    foldV2SnapshotCounterEventStreamDef-        { stateCodec =-            Just-                ( defaultStateCodecWithFold-                    @SnapshotCounterRegs-                    @CounterState-                    (FoldVersion "fold-v1")-                    1-                )-        }--foldV1SnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-foldV1SnapshotCounterTransducer =-    foldSnapshotCounterTransducer-        (inpCtor addCtor #amount)--foldV2SnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-foldV2SnapshotCounterTransducer =-    foldSnapshotCounterTransducer-        (inpCtor addCtor #amount K..+ lit 1)--foldSnapshotCounterTransducer ::-    Keiki.Term SnapshotCounterRegs CounterCommand AddFields Int ->-    SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-foldSnapshotCounterTransducer nextLastAmount =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard =-                        PAnd-                            (matchInCtor addCtor)-                            (inpCtor addCtor #amount K..< lit 100)-                    , update =-                        USet-                            (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                            nextLastAmount-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                , Edge-                    { guard =-                        PAnd-                            (matchInCtor addCtor)-                            ( PAnd-                                (inpCtor addCtor #amount K..>= lit 100)-                                ( inpCtor addCtor #amount-                                    .== (proj (#lastAmount :: Keiki.Index SnapshotCounterRegs Int) K..+ lit 100)-                                )-                            )-                    , update = UKeep-                    , output = [pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RCons (Proxy @"lastAmount") 0 RNil-        , isFinal = \_ -> False-        }--multiSnapshotCounterEventStreamDef :: SnapshotCounterEventStream-multiSnapshotCounterEventStreamDef =-    snapshotCounterEventStreamDef-        & #transducer-        .~ multiSnapshotCounterTransducer-        & #snapshotPolicy-        .~ Every 1--multiSnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream-multiSnapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter-multi" multiSnapshotCounterEventStreamDef--multiSnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-multiSnapshotCounterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update =-                        USet-                            (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                            (inpCtor addCtor #amount)-                    , output =-                        [ pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)-                        , pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)-                        ]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RCons (Proxy @"lastAmount") 0 RNil-        , isFinal = \_ -> False-        }--guardedSnapshotCounterEventStreamDef :: SnapshotCounterEventStream-guardedSnapshotCounterEventStreamDef =-    snapshotCounterEventStreamDef & #transducer .~ guardedSnapshotCounterTransducer--guardedSnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream-guardedSnapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter-guarded" guardedSnapshotCounterEventStreamDef--guardedSnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-guardedSnapshotCounterTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard =-                        PAnd-                            (matchInCtor addCtor)-                            (inpCtor addCtor #amount .== proj (#lastAmount :: Keiki.Index SnapshotCounterRegs Int))-                    , update =-                        USet-                            (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                            (inpCtor addCtor #amount)-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RCons (Proxy @"lastAmount") 0 RNil-        , isFinal = \_ -> False-        }--{- | A deliberately replay-unsafe stream: its single edge is an ε-edge-(empty @output@) whose @update@ reads the command's @amount@. Because-the edge emits no event, that command field cannot be recovered on-replay, so keiki's hidden-input check flags it. Used to prove-'validateEventStream' / 'mkEventStream' reject an unsafe stream.--}-brokenHiddenInputEventStream :: SnapshotCounterEventStream-brokenHiddenInputEventStream =-    snapshotCounterEventStreamDef & #transducer .~ brokenHiddenInputTransducer--brokenHiddenInputTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-brokenHiddenInputTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update =-                        USet-                            (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                            (inpCtor addCtor #amount)-                    , output = []-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RCons (Proxy @"lastAmount") 0 RNil-        , isFinal = \_ -> False-        }--{- | A multi-event edge whose tail carries the command field omitted from its-head. The union of the outputs covers @amount@, but replay commits to an edge-by inverting only the head, so the stored chain cannot reconstruct @Add@.--}-headUnrecoverableEventStreamDef :: CounterEventStream-headUnrecoverableEventStreamDef =-    counterEventStreamDef & #transducer .~ headUnrecoverableTransducer--headUnrecoverableEventStream :: ValidatedCounterEventStream-headUnrecoverableEventStream = mkEventStreamUnchecked headUnrecoverableEventStreamDef--headUnrecoverableTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-headUnrecoverableTransducer =-    counterTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update = UKeep-                    , output =-                        [ pack addCtor counterAddedCtor (Keiki.lit 0 *: oNil)-                        , pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)-                        ]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        }--{- | Two edges share a head wire constructor, so one stored event can invert-through both. The double-negated guard is true at runtime but deliberately-outside keiki's pure overlap fragment, isolating the inversion warning from-the separate conservative determinism check.--}-inversionAmbiguousEventStreamDef :: CounterEventStream-inversionAmbiguousEventStreamDef =-    counterEventStreamDef & #transducer .~ inversionAmbiguousTransducer--inversionAmbiguousEventStream :: ValidatedCounterEventStream-inversionAmbiguousEventStream =-    case mkEventStreamWith-        Keiki.defaultValidationOptions{Keiki.checkInversionAmbiguity = False}-        "counter-inversion-ambiguous"-        inversionAmbiguousEventStreamDef of-        Right validated -> validated-        Left warnings -> error ("expected inversion-ambiguity override to validate: " <> show warnings)--inversionAmbiguousTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-inversionAmbiguousTransducer =-    counterTransducer-        { edgesOut = \case-            Counting ->-                [ ambiguousEdge-                , ambiguousEdge-                ]-        }-  where-    ambiguousEdge =-        Edge-            { guard = PAnd (matchInCtor addCtor) (PNot PBot)-            , update = UKeep-            , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-            , target = Counting-            , mode = Keiki.Live-            }--{- | This edge reads @Add.amount@ while guarded only by @PTop@. A different-command constructor would reach the partial projection and crash instead of-being rejected.--}-unguardedInputReadEventStreamDef :: CounterEventStream-unguardedInputReadEventStreamDef =-    counterEventStreamDef & #transducer .~ unguardedInputReadTransducer--unguardedInputReadTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-unguardedInputReadTransducer =-    counterTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = PTop-                    , update = UKeep-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        }--{- | A silent self-loop that writes a register. With no emitted event the-write cannot be reconstructed from the durable log.--}-stateChangingEpsilonEventStreamDef :: SnapshotCounterEventStream-stateChangingEpsilonEventStreamDef =-    snapshotCounterEventStreamDef & #transducer .~ stateChangingEpsilonTransducer--stateChangingEpsilonTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent-stateChangingEpsilonTransducer =-    snapshotCounterTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update =-                        USet-                            (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)-                            (Keiki.lit 0)-                    , output = []-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        }--type SilentMoveEventStream = EventStream (HsPred '[] CounterCommand) '[] DrainState CounterCommand CounterEvent--silentMoveEventStreamDef :: SilentMoveEventStream-silentMoveEventStreamDef =-    EventStream-        { transducer = silentMoveTransducer-        , initialState = Draining-        , initialRegisters = RNil-        , eventCodec = counterCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Never-        , stateCodec = Nothing-        }--silentMoveTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] DrainState CounterCommand CounterEvent-silentMoveTransducer =-    SymTransducer-        { edgesOut = \case-            Draining ->-                [ Edge-                    { guard = matchInCtor addCtor-                    , update = UKeep-                    , output = []-                    , target = Drained-                    , mode = Keiki.Live-                    }-                ]-            Drained -> []-        , initial = Draining-        , initialRegs = RNil-        , isFinal = (== Drained)-        }--isStateChangingEpsilon :: Keiki.TransducerValidationWarning s -> Bool-isStateChangingEpsilon = \case-    Keiki.StateChangingEpsilon{} -> True-    _ -> False--expectValidationWarning ::-    (Bounded s, Enum s, Ord s, Show s) =>-    Text ->-    Text ->-    EventStream (HsPred rs ci) rs s ci co ->-    Expectation-expectValidationWarning label prefix eventStream =-    case mkEventStream label eventStream of-        Left warnings -> do-            map eswStreamLabel warnings `shouldSatisfy` all (== label)-            map eswReason warnings `shouldSatisfy` any (Text.isInfixOf prefix)-        Right _ ->-            expectationFailure-                ( "expected mkEventStream to reject "-                    <> Text.unpack label-                    <> " with warning prefix "-                    <> Text.unpack prefix-                )--type AddFields = '[ '("amount", Int)]--type SkipEventStream = EventStream (HsPred '[] SkipCommand) '[] CounterState SkipCommand CounterEvent--type ValidatedSkipEventStream = ValidatedEventStream (HsPred '[] SkipCommand) '[] CounterState SkipCommand CounterEvent--skipEventStream :: ValidatedSkipEventStream-skipEventStream = mkEventStreamOrThrow "skip-command" skipEventStreamDef--skipEventStreamDef :: SkipEventStream-skipEventStreamDef =-    EventStream-        { transducer = skipTransducer-        , initialState = Counting-        , initialRegisters = RNil-        , eventCodec = counterCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Never-        , stateCodec = Nothing-        }--skipTransducer :: SymTransducer (HsPred '[] SkipCommand) '[] CounterState SkipCommand CounterEvent-skipTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard = matchInCtor sAddCtor-                    , update = UKeep-                    , output = [pack sAddCtor counterAddedCtor (inpCtor sAddCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                , Edge-                    { guard = matchInCtor sSkipCtor-                    , update = UKeep-                    , output = []-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--sAddCtor :: InCtor SkipCommand AddFields-sAddCtor =-    InCtor-        { icName = "SAdd"-        , icMatch = \case-            SAdd amount -> Just (RCons Proxy amount RNil)-            SSkip -> Nothing-        , icBuild = \case-            RCons _ amount RNil -> SAdd amount-        }--sSkipCtor :: InCtor SkipCommand '[]-sSkipCtor =-    InCtor-        { icName = "SSkip"-        , icMatch = \case-            SAdd{} -> Nothing-            SSkip -> Just RNil-        , icBuild = \case-            RNil -> SSkip-        }--addCtor :: InCtor CounterCommand AddFields-addCtor =-    InCtor-        { icName = "Add"-        , icMatch = \case-            Add amount -> Just (RCons Proxy amount RNil)-        , icBuild = \case-            RCons _ amount RNil -> Add amount-        }--counterAddedCtor :: WireCtor CounterEvent (Int, ())-counterAddedCtor =-    WireCtor-        { wcName = "CounterAdded"-        , wcMatch = \case-            CounterAdded amount -> Just (amount, ())-            CounterAudited{} -> Nothing-        , wcBuild = \case-            (amount, ()) -> CounterAdded amount-        }--counterAuditedCtor :: WireCtor CounterEvent (Int, ())-counterAuditedCtor =-    WireCtor-        { wcName = "CounterAudited"-        , wcMatch = \case-            CounterAudited amount -> Just (amount, ())-            CounterAdded{} -> Nothing-        , wcBuild = \case-            (amount, ()) -> CounterAudited amount-        }--counterCodec :: Codec CounterEvent-counterCodec =-    Codec-        { eventTypes = EventType "CounterAdded" :| [EventType "CounterAudited"]-        , eventType = \case-            CounterAdded{} -> EventType "CounterAdded"-            CounterAudited{} -> EventType "CounterAudited"-        , schemaVersion = 1-        , encode = \case-            CounterAdded amount -> object ["amount" Aeson..= amount]-            CounterAudited amount -> object ["amount" Aeson..= amount, "audited" Aeson..= True]-        , decode = parseCounterEvent-        , upcasters = []-        }--parseCounterEvent :: EventType -> Value -> Either Text CounterEvent-parseCounterEvent (EventType tag) value =-    case parseEither parser value of-        Right event -> Right event-        Left message -> Left (fromStringLiteral message)-  where-    parser = withObject "CounterEvent" $ \objectValue -> do-        amount <- objectValue .: "amount"-        case tag of-            "CounterAdded" -> pure (CounterAdded amount)-            "CounterAudited" -> pure (CounterAudited amount)-            _ -> fail "unknown counter event type"---- * Divert fixture (plan 143: replay-only transitions / black-acuity) -------type DivertEventStream = EventStream (HsPred '[] DivertCommand) '[] DivertState DivertCommand DivertEvent--type ValidatedDivertEventStream = ValidatedEventStream (HsPred '[] DivertCommand) '[] DivertState DivertCommand DivertEvent--data DivertCommand-    = ConfirmDivert !Bool-    deriving stock (Generic, Eq, Show)--newtype DivertEvent-    = DivertConfirmed Bool-    deriving stock (Generic, Eq, Show)--data DivertState-    = DivertHeld-    deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)--type DivertFields = '[ '("acuityBlack", Bool)]--confirmDivertCtor :: InCtor DivertCommand DivertFields-confirmDivertCtor =-    InCtor-        { icName = "ConfirmDivert"-        , icMatch = \case-            ConfirmDivert acuityBlack -> Just (RCons Proxy acuityBlack RNil)-        , icBuild = \case-            RCons _ acuityBlack RNil -> ConfirmDivert acuityBlack-        }--divertConfirmedCtor :: WireCtor DivertEvent (Bool, ())-divertConfirmedCtor =-    WireCtor-        { wcName = "DivertConfirmed"-        , wcMatch = \case-            DivertConfirmed acuityBlack -> Just (acuityBlack, ())-        , wcBuild = \case-            (acuityBlack, ()) -> DivertConfirmed acuityBlack-        }--divertCodec :: Codec DivertEvent-divertCodec =-    Codec-        { eventTypes = EventType "DivertConfirmed" :| []-        , eventType = \_ -> EventType "DivertConfirmed"-        , schemaVersion = 1-        , encode = \case-            DivertConfirmed acuityBlack -> object ["acuityBlack" Aeson..= acuityBlack]-        , decode = parseDivertEvent-        , upcasters = []-        }--parseDivertEvent :: EventType -> Value -> Either Text DivertEvent-parseDivertEvent _ value =-    case parseEither parser value of-        Right event -> Right event-        Left message -> Left (fromStringLiteral message)-  where-    parser = withObject "DivertConfirmed" $ \objectValue ->-        DivertConfirmed <$> objectValue .: "acuityBlack"---- | The old rule: confirm any reservation.-divertOldGuard :: HsPred '[] DivertCommand-divertOldGuard = matchInCtor confirmDivertCtor---- | The tightened rule: confirm only non-black acuity.-divertNewGuard :: HsPred '[] DivertCommand-divertNewGuard =-    PAnd-        (matchInCtor confirmDivertCtor)-        (inpCtor confirmDivertCtor #acuityBlack .== Keiki.lit False)---- | The removed region, @old ∧ ¬new@: exactly black acuity.-divertRemovedRegionGuard :: HsPred '[] DivertCommand-divertRemovedRegionGuard =-    PAnd-        (matchInCtor confirmDivertCtor)-        (inpCtor confirmDivertCtor #acuityBlack .== Keiki.lit True)--divertConfirmEdge ::-    HsPred '[] DivertCommand ->-    Keiki.EdgeMode ->-    Edge (HsPred '[] DivertCommand) '[] DivertCommand DivertEvent DivertState-divertConfirmEdge edgeGuard edgeMode =-    Edge-        { guard = edgeGuard-        , update = UKeep-        , output = [pack confirmDivertCtor divertConfirmedCtor (inpCtor confirmDivertCtor #acuityBlack *: oNil)]-        , target = DivertHeld-        , mode = edgeMode-        }--divertEventStreamDef ::-    [Edge (HsPred '[] DivertCommand) '[] DivertCommand DivertEvent DivertState] ->-    DivertEventStream-divertEventStreamDef heldEdges =-    EventStream-        { transducer =-            SymTransducer-                { edgesOut = \case-                    DivertHeld -> heldEdges-                , initial = DivertHeld-                , initialRegs = RNil-                , isFinal = const False-                }-        , initialState = DivertHeld-        , initialRegisters = RNil-        , eventCodec = divertCodec-        , resolveStreamName = Stream.streamName-        , snapshotPolicy = Never-        , stateCodec = Nothing-        }---- | Machine A: the original permissive rule.-permissiveDivertEventStream :: ValidatedDivertEventStream-permissiveDivertEventStream =-    mkEventStreamOrThrow-        "divert-permissive"-        (divertEventStreamDef [divertConfirmEdge divertOldGuard Keiki.Live])---- | Machine B without the twin: the tightened rule alone.-tightenedDivertEventStream :: ValidatedDivertEventStream-tightenedDivertEventStream =-    mkEventStreamOrThrow-        "divert-tightened"-        (divertEventStreamDef [divertConfirmEdge divertNewGuard Keiki.Live])--{- | Machine B with the replay-only twin carrying the removed region:-the tightened rule governs new traffic; black-acuity history keeps-its inverting edge.--}-twinDivertEventStream :: ValidatedDivertEventStream-twinDivertEventStream =-    mkEventStreamOrThrow-        "divert-twin"-        ( divertEventStreamDef-            [ divertConfirmEdge divertNewGuard Keiki.Live-            , divertConfirmEdge divertRemovedRegionGuard Keiki.ReplayOnly-            ]-        )--counterProcessManager ::-    ProcessManager-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-counterProcessManager =-    ProcessManager-        { name = "counter-pm"-        , correlate = \_ -> "order-1"-        , eventStream = counterEventStream-        , streamFor = \correlationId -> stream ("pm:counter-" <> correlationId)-        , targetEventStream = counterEventStream-        , targetProjections = const []-        , handle = \case-            CounterAdded amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands =-                        [ PMCommand-                            { target = stream "counter-target-order-1"-                            , command = Add amount-                            }-                        ]-                    , timers = [counterTimerRequest]-                    }-            CounterAudited amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands = []-                    , timers = []-                    }-        }--timerOnlyProcessManager ::-    ProcessManager-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-timerOnlyProcessManager =-    ProcessManager-        { name = "timer-only-pm"-        , correlate = \_ -> "order-1"-        , eventStream = noOpCounterEventStream-        , streamFor = \correlationId -> stream ("pm:timer-only-" <> correlationId)-        , targetEventStream = counterEventStream-        , targetProjections = const []-        , handle = \case-            CounterAdded amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands = []-                    , timers =-                        [ counterTimerRequest-                            & #processManagerName-                            .~ "timer-only-pm"-                        ]-                    }-            CounterAudited amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands = []-                    , timers = []-                    }-        }---- A process manager whose OWN state stream snapshots under Every 2.--- This is the first PM fixture to exercise a state-stream snapshot: the only--- difference from counterProcessManager is that its eventStream carries a--- snapshotPolicy + stateCodec (it reuses snapshotCounterEventStream), so--- runProcessManagerOnce's manager-state append (which goes through--- runCommandWithSql) writes and reuses snapshots. The manager registers are--- SnapshotCounterRegs because the eventStream is a SnapshotCounterEventStream;--- the target side stays '[]/counterEventStream exactly as counterProcessManager.-pmSnapshotCounterEventStreamDef :: SnapshotCounterEventStream-pmSnapshotCounterEventStreamDef = snapshotCounterEventStreamDef--pmSnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream-pmSnapshotCounterEventStream = mkEventStreamOrThrow "pm-snapshot-counter" pmSnapshotCounterEventStreamDef--pmSnapshotProcessManager ::-    ProcessManager-        CounterEvent-        (HsPred SnapshotCounterRegs CounterCommand)-        SnapshotCounterRegs-        CounterState-        CounterCommand-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-pmSnapshotProcessManager =-    ProcessManager-        { name = "counter-snap-pm"-        , correlate = \_ -> "order-1"-        , eventStream = pmSnapshotCounterEventStream-        , streamFor = \correlationId -> stream ("pm:counter-snap-" <> correlationId)-        , targetEventStream = counterEventStream-        , targetProjections = const []-        , handle = \case-            CounterAdded amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands = [] -- keep the test focused on the manager state stream-                    , timers = []-                    }-            CounterAudited amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands = []-                    , timers = []-                    }-        }--workflowProcessManager ::-    Text ->-    Text ->-    Text ->-    ProcessManager-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-workflowProcessManager managerName managerCategory targetStreamName =-    counterProcessManager-        { name = managerName-        , streamFor = \correlationId -> stream (managerCategory <> "-" <> correlationId)-        , handle = \case-            CounterAdded amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands =-                        [ PMCommand-                            { target = stream targetStreamName-                            , command = Add amount-                            }-                        ]-                    , timers = []-                    }-            CounterAudited amount ->-                ProcessManagerAction-                    { command = Add amount-                    , commands = []-                    , timers = []-                    }-        }--assertWorkflowProcessManagerAppended ::-    Either-        Store.StoreError-        ( Either-            CommandError-            (ProcessManagerResult CounterEventStream CounterEventStream)-        ) ->-    Expectation-assertWorkflowProcessManagerAppended = \case-    Right (Right pmResult) -> do-        pmResult ^. #managerResult `shouldSatisfy` \case-            PMStateAppended{} -> True-            _ -> False-        pmResult ^. #commandResults `shouldSatisfy` \case-            [PMCommandAppended{}] -> True-            _ -> False-    other -> expectationFailure ("expected workflow process-manager success, got " <> show other)--counterTimerRequest :: TimerRequest-counterTimerRequest =-    TimerRequest-        { timerId = TimerId sampleUuid-        , processManagerName = "counter-pm"-        , correlationId = "order-1"-        , fireAt = dueTimerTime-        , payload = object ["kind" Aeson..= ("counter-timeout" :: Text)]-        }--dueTimerTime :: UTCTime-dueTimerTime = UTCTime (ModifiedJulianDay 1) (secondsToDiffTime 0)--timerStatusAndErrorStmt :: Statement UUID (Maybe (Text, Maybe Text))-timerStatusAndErrorStmt =-    preparable-        """-        SELECT status, last_error-        FROM keiro.keiro_timers-        WHERE timer_id = $1-        """-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))---- | Read a timer's status and JSON payload by id (for the workflow-sleep tests).-sleepTimerStatusStmt :: Statement UUID (Maybe (Text, Value))-sleepTimerStatusStmt =-    preparable-        """-        SELECT status, payload-        FROM keiro.keiro_timers-        WHERE timer_id = $1-        """-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.jsonb)))---- | Read a timer's fire time by id (for workflow-sleep re-arm tests).-sleepTimerFireAtStmt :: Statement UUID (Maybe UTCTime)-sleepTimerFireAtStmt =-    preparable-        """-        SELECT fire_at-        FROM keiro.keiro_timers-        WHERE timer_id = $1-        """-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe (D.column (D.nonNullable D.timestamptz)))--recordedFrom :: EventData -> RecordedEvent-recordedFrom event =-    RecordedEvent-        { eventId = EventId sampleUuid-        , eventType = event ^. #eventType-        , streamVersion = StreamVersion 1-        , globalPosition = GlobalPosition 1-        , originalStreamId = StreamId 1-        , originalVersion = StreamVersion 1-        , payload = event ^. #payload-        , metadata = event ^. #metadata-        , causationId = Nothing-        , correlationId = Nothing-        , createdAt = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)-        }--recordedFromEventId :: EventId -> CounterEvent -> RecordedEvent-recordedFromEventId eventId event =-    case encodeForAppend counterCodec event of-        Right encoded -> recordedFrom encoded & #eventId .~ eventId-        Left err -> error ("test fixture failed to encode counter event: " <> show err)--appendCounterEventWithId :: Store.KirokuStore -> StreamName -> EventId -> CounterEvent -> IO ()-appendCounterEventWithId storeHandle streamName eventId event = do-    encoded <- shouldBeRight (encodeForAppend counterCodec event)-    outcome <--        Store.runStoreIO storeHandle $-            Store.appendToStream streamName NoStream [encoded & #eventId ?~ eventId]-    case outcome of-        Right _ -> pure ()-        Left err -> expectationFailure ("failed to insert concurrent duplicate event: " <> show err)--appendCounterEvents :: Store.KirokuStore -> StreamName -> [CounterEvent] -> IO ()-appendCounterEvents storeHandle destinationStreamName events = do-    encoded <- traverse (shouldBeRight . encodeForAppend counterCodec) events-    outcome <--        Store.runStoreIO storeHandle $-            Store.appendToStream destinationStreamName NoStream encoded-    case outcome of-        Right _ -> pure ()-        Left err -> expectationFailure ("failed to insert counter events: " <> show err)---- Insert a real source event and drive Kiroku's acknowledgement bridge to park--- it in kiroku.dead_letters. A second event lets the test observe that the--- checkpoint advanced after the dead letter before stopping the subscription.-deadLetterCounterSource :: Store.KirokuStore -> SubscriptionName -> CounterEvent -> IO RecordedEvent-deadLetterCounterSource storeHandle subName sourceEvent = do-    appendCounterEvents-        storeHandle-        (StreamName "counter-replay-source")-        [sourceEvent, CounterAdded 0]-    let subConfig =-            ( KirokuSub.defaultSubscriptionConfig-                subName-                AllStreams-                (\_ -> pure KirokuSub.Continue)-            )-                { KirokuSub.retryPolicy = KirokuSub.RetryPolicy 1-                }-        pull label source = do-            result <- timeout 5_000_000 (Streamly.uncons source)-            case result of-                Just (Just itemAndRest) -> pure itemAndRest-                Just Nothing -> fail (label <> ": subscription ended early")-                Nothing -> fail (label <> ": timed out waiting for delivery")-    (stream0, cancelStream) <- subscriptionAckStream storeHandle subConfig 4-    ( do-            (first, stream1) <- pull "source delivery" stream0-            atomically $-                putTMVar-                    (ackReply first)-                    (KirokuSub.Retry (KirokuSub.RetryDelay 0))-            (next, stream2) <- pull "event after source dead letter" stream1-            ackEvent next ^. #eventId `shouldNotBe` ackEvent first ^. #eventId-            atomically (putTMVar (ackReply next) KirokuSub.Stop)-            ended <- timeout 5_000_000 (Streamly.uncons stream2)-            case ended of-                Just Nothing -> pure ()-                Just (Just _) -> expectationFailure "replay fixture delivered after Stop"-                Nothing -> expectationFailure "replay fixture did not stop"-            pure (ackEvent first)-        )-        `finally` cancelStream--classifyProcessManagerReplay :: ProcessManagerResult managerTarget commandTarget -> ReplayResult-classifyProcessManagerReplay result =-    case result ^. #managerResult of-        PMStateDuplicate{}-            | Prelude.all commandIsDuplicate (result ^. #commandResults) -> ReplayedDuplicate-        _ -> ReplayedFresh-  where-    commandIsDuplicate = \case-        PMCommandDuplicate{} -> True-        _ -> False--processManagerReplayCounts :: Store.KirokuStore -> IO (Int, Int)-processManagerReplayCounts storeHandle = do-    Right managerEvents <--        Store.runStoreIO storeHandle $-            Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10-    Right targetEvents <--        Store.runStoreIO storeHandle $-            Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10-    pure (Vector.length managerEvents, Vector.length targetEvents)--sampleUuid :: UUID-sampleUuid =-    case fromString "018f0f18-17aa-7000-8000-000000000001" of-        Just uuid -> uuid-        Nothing -> error "invalid test UUID"--sampleUuid2 :: UUID-sampleUuid2 =-    case fromString "018f0f18-17aa-7000-8000-000000000002" of-        Just uuid -> uuid-        Nothing -> error "invalid test UUID"--sampleUuid3 :: UUID-sampleUuid3 =-    case fromString "018f0f18-17aa-7000-8000-000000000003" of-        Just uuid -> uuid-        Nothing -> error "invalid test UUID"--shouldBeRight :: (HasCallStack, Show e) => Either e a -> IO a-shouldBeRight = \case-    Right value -> pure value-    Left err -> expectationFailure ("expected Right, got Left " <> show err) *> error "unreachable"--shouldBeRight_ :: (HasCallStack, Show e) => Either e a -> Expectation-shouldBeRight_ = \case-    Right _ -> pure ()-    Left err -> expectationFailure ("expected Right, got Left " <> show err)--shouldBeLeft :: (HasCallStack, Eq e, Show e) => Either e a -> e -> Expectation-shouldBeLeft actual expected =-    case actual of-        Left err -> err `shouldBe` expected-        Right _ -> expectationFailure ("expected Left " <> show expected <> ", got Right")--fromStringLiteral :: String -> Text-fromStringLiteral = Text.pack--snapshotVersionForStreamStmt :: Statement Text (Maybe StreamVersion)-snapshotVersionForStreamStmt =-    preparable-        """-        SELECT ks.stream_version-        FROM keiro.keiro_snapshots ks-        JOIN streams s ON s.stream_id = ks.stream_id-        WHERE s.stream_name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (StreamVersion <$> D.column (D.nonNullable D.int8)))--snapshotStateShapeForStreamStmt :: Statement Text (Maybe Text)-snapshotStateShapeForStreamStmt =-    preparable-        """-        SELECT ks.state_shape_hash-        FROM keiro.keiro_snapshots ks-        JOIN streams s ON s.stream_id = ks.stream_id-        WHERE s.stream_name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (D.column (D.nonNullable D.text)))--corruptSnapshotStateStmt :: Statement (Text, Value) ()-corruptSnapshotStateStmt =-    preparable-        """-        UPDATE keiro.keiro_snapshots ks-        SET state = $2-        FROM streams s-        WHERE s.stream_id = ks.stream_id-          AND s.stream_name = $1-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.jsonb))-        )-        D.noResult--corruptSnapshotShapeStmt :: Statement (Text, Text) ()-corruptSnapshotShapeStmt =-    preparable-        """-        UPDATE keiro.keiro_snapshots ks-        SET regfile_shape_hash = $2-        FROM streams s-        WHERE s.stream_id = ks.stream_id-          AND s.stream_name = $1-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--counterReadModel :: ReadModel Text Int-counterReadModel =-    ReadModel-        { name = "counter-read-model"-        , tableName = "counter_read_model"-        , schema = "kiroku"-        , subscriptionName = "counter-read-model-sub"-        , version = 1-        , shapeHash = "counter-read-model-v1"-        , defaultConsistency = Eventual-        , strongScope = EntireLog-        , query = \modelId -> Tx.statement modelId selectCounterReadModelStmt-        }--counterCategoryReadModel :: ReadModel Text Int-counterCategoryReadModel =-    counterReadModel & #strongScope .~ CategoryHead "counter"--registerReadModelDefinition :: (Store :> es) => ReadModel q r -> Eff es ()-registerReadModelDefinition readModel =-    void $-        registerReadModel-            (readModel ^. #name)-            (readModel ^. #version)-            (readModel ^. #shapeHash)--initializeRegisteredReadModel ::-    (Store :> es) =>-    ReadModel q r ->-    Tx.Transaction () ->-    Eff es ()-initializeRegisteredReadModel readModel initializeTable = do-    Store.runTransaction initializeTable-    registerReadModelDefinition readModel--counterInlineProjection :: InlineProjection CounterEvent-counterInlineProjection =-    InlineProjection-        { name = "counter-inline-projection"-        , apply = \event recorded ->-            case event of-                CounterAdded amount ->-                    Tx.statement-                        ( "inline"-                        , Prelude.fromIntegral amount-                        , globalPositionToInt (recorded ^. #globalPosition)-                        , Just (eventIdToUuid (recorded ^. #eventId))-                        , metadataActor recorded-                        )-                        upsertCounterReadModelStmt-                CounterAudited{} -> pure ()-        }--counterAsyncProjection :: AsyncProjection-counterAsyncProjection =-    AsyncProjection-        { name = "counter-async-projection"-        , readModelName = "counter-read-model"-        , subscriptionName = "counter-read-model-sub"-        , applyRecorded = \recorded ->-            case decodeRecorded counterCodec recorded of-                Right (CounterAdded amount) ->-                    Tx.statement-                        ( "async-idempotent"-                        , Prelude.fromIntegral amount-                        , globalPositionToInt (recorded ^. #globalPosition)-                        , Just (eventIdToUuid (recorded ^. #eventId))-                        , Nothing-                        )-                        upsertCounterReadModelStmt-                Right CounterAudited{} -> pure ()-                Left _ -> pure ()-        , idempotencyKey = \recorded -> recorded ^. #eventId-        }--fastWaitOptions :: PositionWaitOptions-fastWaitOptions =-    PositionWaitOptions-        { target = Nothing-        , timeoutMicros = 50000-        , pollMicros = 5000-        }--initializeCounterReadModelTable :: Tx.Transaction ()-initializeCounterReadModelTable =-    Tx.sql-        """-        CREATE TABLE IF NOT EXISTS counter_read_model (-          model_id TEXT PRIMARY KEY,-          amount BIGINT NOT NULL,-          last_seen BIGINT NOT NULL,-          source_event_id UUID UNIQUE,-          actor TEXT-        )-        """---- A read model whose data table lives in an application-configured schema--- (@app_reads@), demonstrating EP-4's configurable projection schema. Its SQL is--- fully qualified via 'placedTable'; Keiro's own metadata stays in @keiro@.-placedTable :: Text-placedTable = qualifyTable "app_reads" "placed_counter"--placedReadModel :: ReadModel Text Int-placedReadModel =-    ReadModel-        { name = "placed-counter-read-model"-        , tableName = "placed_counter"-        , schema = "app_reads"-        , subscriptionName = "placed-counter-sub"-        , version = 1-        , shapeHash = "placed-counter-v1"-        , defaultConsistency = Eventual-        , strongScope = EntireLog-        , query = \modelId -> Tx.statement modelId selectPlacedStmt-        }--placedInlineProjection :: InlineProjection CounterEvent-placedInlineProjection =-    InlineProjection-        { name = "placed-inline-projection"-        , apply = \event recorded ->-            case event of-                CounterAdded amount ->-                    Tx.statement-                        ( "placed"-                        , Prelude.fromIntegral amount-                        , globalPositionToInt (recorded ^. #globalPosition)-                        )-                        upsertPlacedStmt-                CounterAudited{} -> pure ()-        }--initializePlacedTable :: Tx.Transaction ()-initializePlacedTable =-    Tx.sql $-        TE.encodeUtf8 $-            "CREATE TABLE IF NOT EXISTS "-                <> placedTable-                <> " (\n"-                <> "  model_id TEXT PRIMARY KEY,\n"-                <> "  amount BIGINT NOT NULL,\n"-                <> "  last_seen BIGINT NOT NULL\n"-                <> ")"--upsertPlacedStmt :: Statement (Text, Int64, Int64) ()-upsertPlacedStmt =-    preparable-        ( "INSERT INTO "-            <> placedTable-            <> " (model_id, amount, last_seen)\n"-            <> "VALUES ($1, $2, $3)\n"-            <> "ON CONFLICT (model_id) DO UPDATE\n"-            <> "  SET amount = EXCLUDED.amount, last_seen = EXCLUDED.last_seen"-        )-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--selectPlacedStmt :: Statement Text Int-selectPlacedStmt =-    preparable-        ("SELECT COALESCE((SELECT amount FROM " <> placedTable <> " WHERE model_id = $1), 0)")-        (E.param (E.nonNullable E.text))-        (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8)))---- Count matching base tables in a given schema; proves table placement.-pgTableCountStmt :: Statement (Text, Text) Int-pgTableCountStmt =-    preparable-        "SELECT count(*)::int FROM pg_tables WHERE schemaname = $1 AND tablename = $2"-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int4)))--initializeProjectionDedupCounterTable :: Tx.Transaction ()-initializeProjectionDedupCounterTable =-    Tx.sql-        """-        CREATE TABLE IF NOT EXISTS projection_dedup_counter (-          id BOOLEAN PRIMARY KEY DEFAULT TRUE,-          amount BIGINT NOT NULL-        );--        INSERT INTO projection_dedup_counter (id, amount)-        VALUES (TRUE, 0)-        ON CONFLICT (id) DO NOTHING;-        """--upsertCounterReadModelStmt :: Statement (Text, Int64, Int64, Maybe UUID, Maybe Text) ()-upsertCounterReadModelStmt =-    preparable-        """-        INSERT INTO counter_read_model (model_id, amount, last_seen, source_event_id, actor)-        VALUES ($1, $2, $3, $4, $5)-        ON CONFLICT (source_event_id) DO NOTHING-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nullable E.uuid))-            (E.param (E.nullable E.text))-        )-        D.noResult--incrementProjectionDedupCounterStmt :: Statement () ()-incrementProjectionDedupCounterStmt =-    preparable-        """-        UPDATE projection_dedup_counter-        SET amount = amount + 1-        WHERE id = TRUE-        """-        E.noParams-        D.noResult--selectProjectionDedupCounterStmt :: Statement () Int-selectProjectionDedupCounterStmt =-    preparable-        """-        SELECT amount-        FROM projection_dedup_counter-        WHERE id = TRUE-        """-        E.noParams-        (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8)))--projectionDedupCountStmt :: Statement Text Int64-projectionDedupCountStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_projection_dedup-        WHERE projection_name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (D.column (D.nonNullable D.int8)))--selectCounterMetaStmt :: Statement Text (Int64, Maybe Text, Maybe UUID)-selectCounterMetaStmt =-    preparable-        """-        SELECT amount, actor, source_event_id-        FROM counter_read_model-        WHERE model_id = $1-        """-        (E.param (E.nonNullable E.text))-        ( D.singleRow-            ( (,,)-                <$> D.column (D.nonNullable D.int8)-                <*> D.column (D.nullable D.text)-                <*> D.column (D.nullable D.uuid)-            )-        )--selectCounterReadModelStmt :: Statement Text Int-selectCounterReadModelStmt =-    preparable-        """-        SELECT COALESCE((SELECT amount FROM counter_read_model WHERE model_id = $1), 0)-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8)))--upsertSubscriptionCursorStmt :: Statement (Text, Int64) ()-upsertSubscriptionCursorStmt =-    preparable-        """-        INSERT INTO subscriptions (subscription_name, stream_name, last_seen)-        VALUES ($1, '$all', $2)-        ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE-          SET last_seen = EXCLUDED.last_seen,-              updated_at = now()-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--upsertSubscriptionCursorMemberStmt :: Statement (Text, Int32, Int64) ()-upsertSubscriptionCursorMemberStmt =-    preparable-        """-        INSERT INTO subscriptions (subscription_name, stream_name, consumer_group_member, consumer_group_size, last_seen)-        VALUES ($1, '$all', $2, 2, $3)-        ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE-          SET last_seen = EXCLUDED.last_seen,-              updated_at = now()-        """-        ( contrazip3-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--updateReadModelVersionStmt :: Statement (Text, Int64) ()-updateReadModelVersionStmt =-    preparable-        """-        UPDATE keiro.keiro_read_models-        SET version = $2-        WHERE name = $1-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--updateReadModelStatusStmt :: Statement (Text, Text) ()-updateReadModelStatusStmt =-    preparable-        """-        UPDATE keiro.keiro_read_models-        SET status = $2-        WHERE name = $1-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--readModelXminStmt :: Statement Text Text-readModelXminStmt =-    preparable-        """-        SELECT xmin::text-        FROM keiro.keiro_read_models-        WHERE name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.singleRow (D.column (D.nonNullable D.text)))--globalPositionToInt :: GlobalPosition -> Int64-globalPositionToInt (GlobalPosition value) = value--eventIdToUuid :: EventId -> UUID-eventIdToUuid (EventId value) = value--metadataActor :: RecordedEvent -> Maybe Text-metadataActor recorded = do-    Aeson.Object o <- recorded ^. #metadata-    Aeson.String s <- KeyMap.lookup "actor" o-    pure s---- Router test fixtures: an effectful, data-dependent fan-out whose target set--- is stored in a read-model table (router_targets) rather than computed purely.--newtype RouteGroup = RouteGroup Text-    deriving stock (Generic, Eq, Show)--{- | Maps a routing group to the list of target counter stream identifiers seeded-for it. The query is genuinely effectful: 'demoRouter' calls it via 'runQuery'.--}-routerTargetsReadModel :: ReadModel Text [Text]-routerTargetsReadModel =-    ReadModel-        { name = "router-targets-read-model"-        , tableName = "router_targets"-        , schema = "kiroku"-        , subscriptionName = "router-targets-sub"-        , version = 1-        , shapeHash = "router-targets-v1"-        , defaultConsistency = Eventual-        , strongScope = EntireLog-        , query = \groupId -> Tx.statement groupId selectRouterTargetsStmt-        }--demoRouter ::-    (IOE :> es, Store :> es) =>-    Router-        RouteGroup-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-        es-demoRouter =-    Router-        { name = "demo-router"-        , key = \(RouteGroup g) -> g-        , resolve = \(RouteGroup g) -> do-            result <- runQuery Nothing routerTargetsReadModel g-            pure $ case result of-                Right targetIds ->-                    [ PMCommand{target = stream targetId, command = Add 1}-                    | targetId <- targetIds-                    ]-                Left _ -> []-        , targetEventStream = counterEventStream-        , targetProjections = const []-        }--unstableRouter ::-    (IOE :> es) =>-    IORef Int ->-    (Int -> [Text]) ->-    Router-        RouteGroup-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-        es-unstableRouter attemptsRef targetsFor =-    Router-        { name = "unstable-router"-        , key = \(RouteGroup g) -> g-        , resolve = \_ -> do-            attempt <- liftIO (atomicModifyIORef' attemptsRef (\n -> (n + 1, n)))-            pure-                [ PMCommand{target = stream targetId, command = Add 1}-                | targetId <- targetsFor attempt-                ]-        , targetEventStream = counterEventStream-        , targetProjections = const []-        }--isAppended :: PMCommandResult target -> Bool-isAppended = \case-    PMCommandAppended{} -> True-    _ -> False--isDuplicate :: PMCommandResult target -> Bool-isDuplicate = \case-    PMCommandDuplicate{} -> True-    _ -> False--initializeRouterTargetsTable :: Tx.Transaction ()-initializeRouterTargetsTable =-    Tx.sql-        """-        CREATE TABLE IF NOT EXISTS router_targets (-          group_id TEXT NOT NULL,-          target_id TEXT NOT NULL-        )-        """--insertRouterTargetStmt :: Statement (Text, Text) ()-insertRouterTargetStmt =-    preparable-        """-        INSERT INTO router_targets (group_id, target_id)-        VALUES ($1, $2)-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--selectRouterTargetsStmt :: Statement Text [Text]-selectRouterTargetsStmt =-    preparable-        """-        SELECT target_id-        FROM router_targets-        WHERE group_id = $1-        ORDER BY target_id-        """-        (E.param (E.nonNullable E.text))-        (D.rowList (D.column (D.nonNullable D.text)))---- Router worker fixtures: an in-memory Shibuya adapter that records every--- finalized AckDecision, plus a router whose dispatch always fails.--inMemoryAdapter ::-    (IOE :> es) =>-    IORef [AckDecision] ->-    [msg] ->-    Adapter es msg-inMemoryAdapter decisionsRef messages =-    Adapter-        { adapterName = "router-test-adapter"-        , source = Streamly.fromList (fmap ingest messages)-        , shutdown = pure ()-        }-  where-    ingest message =-        Ingested-            { envelope = routerTestEnvelope message-            , ack = AckHandle (\decision -> liftIO (modifyIORef' decisionsRef (<> [decision])))-            , lease = Nothing-            }--routerTestEnvelope :: msg -> Envelope msg-routerTestEnvelope message =-    Envelope-        { messageId = "router-test-message"-        , cursor = Nothing-        , partition = Nothing-        , enqueuedAt = Nothing-        , traceContext = Nothing-        , headers = Nothing-        , attempt = Nothing-        , attributes = mempty-        , payload = message-        }--{- | A target aggregate with no outgoing edges: every command is rejected-(CommandRejected), so a dispatch through it surfaces as PMCommandFailed,-driving the worker's AckHalt branch.--}-rejectingEventStreamDef :: CounterEventStream-rejectingEventStreamDef =-    counterEventStreamDef & #transducer .~ rejectingTransducer--rejectingEventStream :: ValidatedCounterEventStream-rejectingEventStream = mkEventStreamOrThrow "rejecting-counter" rejectingEventStreamDef--{- | Accept every Add command except amount 9, which exercises a worker that-dead-letters one rejected dispatch and then successfully processes the next.--}-rejectNineEventStream :: ValidatedCounterEventStream-rejectNineEventStream = mkEventStreamOrThrow "reject-nine-counter" rejectNineEventStreamDef--rejectNineEventStreamDef :: CounterEventStream-rejectNineEventStreamDef =-    counterEventStreamDef & #transducer .~ rejectNineTransducer--rejectNineTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-rejectNineTransducer =-    SymTransducer-        { edgesOut = \case-            Counting ->-                [ Edge-                    { guard =-                        PAnd-                            (matchInCtor addCtor)-                            (PNot (inpCtor addCtor #amount .== Keiki.lit 9))-                    , update = UKeep-                    , output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)]-                    , target = Counting-                    , mode = Keiki.Live-                    }-                ]-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--rejectingTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent-rejectingTransducer =-    SymTransducer-        { edgesOut = \case-            Counting -> []-        , initial = Counting-        , initialRegs = RNil-        , isFinal = \_ -> False-        }--failingRouter ::-    Router-        RouteGroup-        (HsPred '[] CounterCommand)-        '[]-        CounterState-        CounterCommand-        CounterEvent-        es-failingRouter =-    Router-        { name = "failing-router"-        , key = \(RouteGroup g) -> g-        , resolve = \_ -> pure [PMCommand{target = stream "failing-target", command = Add 1}]-        , targetEventStream = rejectingEventStream-        , targetProjections = const []-        }---- Flatten exported counter/gauge points to (instrument name, value).-flattenScalarPoints :: [ResourceMetricsExport] -> [(Text, NumberValue)]-flattenScalarPoints rmes =-    [ (name, val)-    | rme <- rmes-    , scope <- Vector.toList (resourceMetricsScopes rme)-    , export <- Vector.toList (scopeMetricsExports scope)-    , (name, val) <- pointsOf export-    ]-  where-    pointsOf (MetricExportSum n _ _ _ _ _ _ pts) =-        [(n, sumDataPointValue p) | p <- Vector.toList pts]-    pointsOf (MetricExportGauge n _ _ _ _ pts) =-        [(n, gaugeDataPointValue p) | p <- Vector.toList pts]-    pointsOf _ = []---- Flatten exported histogram points to (instrument name, count, sum).-flattenHistogramPoints :: [ResourceMetricsExport] -> [(Text, Word64, Double)]-flattenHistogramPoints rmes =-    [ (n, histogramDataPointCount p, histogramDataPointSum p)-    | rme <- rmes-    , scope <- Vector.toList (resourceMetricsScopes rme)-    , export <- Vector.toList (scopeMetricsExports scope)-    , MetricExportHistogram n _ _ _ _ pts <- [export]-    , p <- Vector.toList pts-    ]---- ===========================================================================--- EP-51 sharded-subscription test helpers--- ===========================================================================---- A test sink the sharded handlers write to: one row per processed event,--- idempotent on event_id (an at-least-once handler may redeliver during a--- rebalance). worker_tag identifies which worker process handled it; stream_id--- is the originating stream (the partition key kiroku hashes on).-createShardSinkSql :: ByteString-createShardSinkSql =-    "CREATE TABLE IF NOT EXISTS shard_sink \-    \(event_id uuid PRIMARY KEY, worker_tag int NOT NULL, stream_id bigint NOT NULL)"---- Seed @nStreams@ category-@orders@ streams with @perStream@ events each--- (upsert append, so it is safe to call twice in one test). Returns the total--- number of events appended.-seedOrders :: Store.KirokuStore -> Int -> Int -> IO Int-seedOrders store nStreams perStream = do-    for_ [0 .. nStreams - 1] $ \i -> do-        let sname = StreamName ("orders-" <> Text.pack (show i))-            evs =-                [ EventData-                    { eventId = Nothing-                    , eventType = EventType "OrderPlaced"-                    , payload = object ["n" Aeson..= (j :: Int)]-                    , metadata = Nothing-                    , causationId = Nothing-                    , correlationId = Nothing-                    }-                | j <- [0 .. perStream - 1]-                ]-        Right _ <- Store.runStoreIO store $ Store.appendToStream sname AnyVersion evs-        pure ()-    pure (nStreams * perStream)---- A handler for worker @tag@: idempotently record (event_id, tag, stream_id).-sinkHandler :: Store.KirokuStore -> Int32 -> RecordedEvent -> IO ()-sinkHandler store tag ev =-    void $-        Store.runStoreIO store $-            Store.runTransaction $-                Tx.statement (eventUuid (ev ^. #eventId), tag, streamIdInt (ev ^. #originalStreamId)) insertShardSinkStmt-  where-    eventUuid (EventId u) = u-    streamIdInt (StreamId s) = s--insertShardSinkStmt :: Statement (UUID, Int32, Int64) ()-insertShardSinkStmt =-    preparable-        "INSERT INTO shard_sink (event_id, worker_tag, stream_id) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING"-        ( contrazip3-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--shardSinkCount :: Store.KirokuStore -> IO Int-shardSinkCount store =-    either (const 0) id-        <$> Store.runStoreIO store (Store.runTransaction (Tx.statement () countShardSinkStmt))--countShardSinkStmt :: Statement () Int-countShardSinkStmt =-    preparable-        "SELECT count(*) FROM shard_sink"-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))--shardDeadLetterDetails :: Store.KirokuStore -> Text -> IO (Int, Maybe Text, Maybe Int)-shardDeadLetterDetails store subscription =-    either (const (0, Nothing, Nothing)) id-        <$> Store.runStoreIO store (Store.runTransaction (Tx.statement subscription shardDeadLetterDetailsStmt))--shardDeadLetterDetailsStmt :: Statement Text (Int, Maybe Text, Maybe Int)-shardDeadLetterDetailsStmt =-    preparable-        "SELECT count(*)::bigint, max(reason_summary), max(attempt_count) \-        \FROM kiroku.dead_letters \-        \WHERE subscription_name = $1 AND consumer_group_member = 0"-        (E.param (E.nonNullable E.text))-        ( D.singleRow $-            (,,)-                <$> (fromIntegral <$> D.column (D.nonNullable D.int8))-                <*> D.column (D.nullable D.text)-                <*> (fmap fromIntegral <$> D.column (D.nullable D.int4))-        )---- The largest number of distinct workers that processed any single stream. 1--- means perfectly disjoint ownership (no stream split across workers).-maxWorkersPerStream :: Store.KirokuStore -> IO Int-maxWorkersPerStream store =-    either (const 0) id-        <$> Store.runStoreIO store (Store.runTransaction (Tx.statement () maxWorkersPerStreamStmt))--maxWorkersPerStreamStmt :: Statement () Int-maxWorkersPerStreamStmt =-    preparable-        "SELECT COALESCE(MAX(c), 0) FROM \-        \(SELECT count(DISTINCT worker_tag) AS c FROM shard_sink GROUP BY stream_id) s"-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))---- How many distinct workers processed at least one event (proves the work--- spread across the pool rather than monopolised by one worker).-distinctWorkers :: Store.KirokuStore -> IO Int-distinctWorkers store =-    either (const 0) id-        <$> Store.runStoreIO store (Store.runTransaction (Tx.statement () distinctWorkersStmt))--distinctWorkersStmt :: Statement () Int-distinctWorkersStmt =-    preparable-        "SELECT count(DISTINCT worker_tag) FROM shard_sink"-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))---- Poll the sink count until it reaches @target@ or the timeout elapses.-waitUntilSinkCount :: Store.KirokuStore -> Int -> Int -> IO Bool-waitUntilSinkCount store target timeoutMicros = go (max 1 (timeoutMicros `div` step))-  where-    step = 100_000-    go :: Int -> IO Bool-    go 0 = (>= target) <$> shardSinkCount store-    go n = do-        c <- shardSinkCount store-        if c >= target-            then pure True-            else threadDelay step >> go (n - 1)---- Poll until at least @target@ shard rows have a live owner. Tests use this to--- join a second worker at a precise point in the one-bucket-per-pass ramp-up.-waitUntilOwnedShardCount :: Store.KirokuStore -> SubscriptionName -> Int -> Int -> IO Bool-waitUntilOwnedShardCount store sub target timeoutMicros = go (max 1 (timeoutMicros `div` step))-  where-    step = 50_000-    go 0 = hasTarget-    go n = do-        reached <- hasTarget-        if reached then pure True else threadDelay step >> go (n - 1)-    hasTarget = do-        rows <- either (const []) id <$> Store.runStoreIO store (Store.runTransaction (listShardOwnership sub))-        pure (length [() | (_, Just _, _) <- rows] >= target)---- Poll the lease table until cooperative ownership has converged: every bucket--- owned, at least @minWorkers@ distinct owners, and no owner holding more than--- its fair share. This is the "balanced on the empty category" gate the--- failover test waits on before seeding, so the drain runs under stable--- membership.-waitShardsBalanced :: Store.KirokuStore -> SubscriptionName -> Int -> Int -> Int -> IO Bool-waitShardsBalanced store sub n minWorkers timeoutMicros = go (max 1 (timeoutMicros `div` step))-  where-    step = 200_000-    go :: Int -> IO Bool-    go 0 = isBalanced-    go k = do-        ok <- isBalanced-        if ok then pure True else threadDelay step >> go (k - 1)-    isBalanced :: IO Bool-    isBalanced = do-        rows <- either (const []) id <$> Store.runStoreIO store (Store.runTransaction (listShardOwnership sub))-        let owners = [w | (_, Just w, _) <- rows]-            distinct = length (nubOrd owners)-            perOwner = [length g | g <- groupByOwner owners]-            fairShare = (n + max 1 distinct - 1) `div` max 1 distinct-        pure (length rows == n && length owners == n && distinct >= minWorkers && all (<= fairShare) perOwner)-    groupByOwner ws = [filter (== w) ws | w <- nubOrd ws]-    nubOrd = Set.toList . Set.fromList--waitShardsUnowned :: Store.KirokuStore -> SubscriptionName -> Int -> Int -> IO Bool-waitShardsUnowned store sub n timeoutMicros = go (max 1 (timeoutMicros `div` step))-  where-    step = 100_000-    go 0 = isUnowned-    go k = do-        ok <- isUnowned-        if ok then pure True else threadDelay step >> go (k - 1)-    isUnowned = do-        rows <- either (const []) id <$> Store.runStoreIO store (Store.runTransaction (listShardOwnership sub))-        pure (length rows == n && all (\(_, owner, _) -> isNothing owner) rows)--workflowOwnedRowCounts :: (Store :> es) => Text -> Text -> Eff es (Int64, Int64, Int64, Int64, Int64, Int64)-workflowOwnedRowCounts name wid =-    Store.runTransaction (Tx.statement (wid, name) workflowOwnedRowCountsStmt)--workflowOwnedChildCount :: (Store :> es) => Text -> Text -> Eff es Int64-workflowOwnedChildCount name wid =-    Store.runTransaction (Tx.statement (wid, name, wid, name) workflowOwnedChildCountStmt)--workflowWakeAfter :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es (Maybe UTCTime)-workflowWakeAfter (WorkflowName name) (WorkflowId wid) =-    Store.runTransaction (Tx.statement (wid, name) workflowWakeAfterStmt)--insertGcTimerStmt :: Statement (UUID, Text, Text, UTCTime, Value, Text) ()-insertGcTimerStmt =-    preparable-        """-        INSERT INTO keiro.keiro_timers-          (timer_id, process_manager_name, correlation_id, fire_at, payload, status)-        VALUES ($1, $2, $3, $4, $5, $6)-        """-        ( contrazip6-            (E.param (E.nonNullable E.uuid))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.timestamptz))-            (E.param (E.nonNullable E.jsonb))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--deleteGcStepsStmt :: Statement (Text, Text) ()-deleteGcStepsStmt =-    preparable-        """-        DELETE FROM keiro.keiro_workflow_steps-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--deleteWorkflowInstanceStmt :: Statement (Text, Text) ()-deleteWorkflowInstanceStmt =-    preparable-        """-        DELETE FROM keiro.keiro_workflows-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        D.noResult--workflowWakeAfterStmt :: Statement (Text, Text) (Maybe UTCTime)-workflowWakeAfterStmt =-    preparable-        """-        SELECT wake_after-        FROM keiro.keiro_workflows-        WHERE workflow_id = $1 AND workflow_name = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (maybe Nothing id <$> D.rowMaybe (D.column (D.nullable D.timestamptz)))--workflowOwnedRowCountsStmt :: Statement (Text, Text) (Int64, Int64, Int64, Int64, Int64, Int64)-workflowOwnedRowCountsStmt =-    preparable-        """-        SELECT-          (SELECT count(*) FROM keiro.keiro_workflows WHERE workflow_id = $1 AND workflow_name = $2),-          (SELECT count(*) FROM keiro.keiro_workflow_steps WHERE workflow_id = $1 AND workflow_name = $2),-          (SELECT count(*) FROM keiro.keiro_awakeables WHERE owner_workflow_id = $1 AND owner_workflow_name = $2),-          (SELECT count(*) FROM keiro.keiro_workflow_children-            WHERE (parent_id = $1 AND parent_name = $2) OR (child_id = $1 AND child_name = $2)),-          (SELECT count(*) FROM keiro.keiro_timers-            WHERE correlation_id = $1 AND process_manager_name = $2 AND payload->>'kind' = 'keiro.workflow.sleep'),-          (SELECT count(*)-             FROM keiro.keiro_snapshots s-             JOIN streams st ON st.stream_id = s.stream_id-            WHERE st.stream_name = 'wf:' || $2 || '-' || $1)-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        ( D.singleRow $-            (,,,,,)-                <$> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-        )--workflowOwnedChildCountStmt :: Statement (Text, Text, Text, Text) Int64-workflowOwnedChildCountStmt =-    preparable-        """-        SELECT count(*)-        FROM keiro.keiro_workflow_children-        WHERE (parent_id = $1 AND parent_name = $2)-           OR (child_id = $3 AND child_name = $4)-        """-        ( contrazip4-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.text))-        )-        (D.singleRow (D.column (D.nonNullable D.int8)))+module Main+  ( main,+  )+where++import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)+import Control.Concurrent (forkIO, killThread, threadDelay)+import Control.Concurrent.MVar (MVar, modifyMVar, newEmptyMVar, newMVar, putMVar, readMVar, takeMVar, tryPutMVar)+import Control.Concurrent.STM (atomically, putTMVar)+import Control.Exception (ErrorCall, Exception, SomeException, displayException, evaluate, finally, throwIO, try)+import Data.Aeson (object, withObject, (.:), (.:?))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (parseEither)+import Data.ByteString (ByteString)+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.List (isInfixOf)+import Data.Map.Strict qualified as Map+import Data.Monoid (mempty)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.Time (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, secondsToDiffTime)+import Data.Time.Calendar (Day (ModifiedJulianDay))+import Data.UUID (UUID, fromString, fromWords64)+import Data.UUID qualified as UUID+import Data.Vector qualified as Vector+import Data.Word (Word64)+import Effectful (Eff, IOE, (:>))+import Effectful.Error.Static (Error, throwError)+import Effectful.Exception qualified as EffException+import GHC.Conc (ThreadStatus (..), threadStatus)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiki.Core+  ( Edge (..),+    HsPred (..),+    InCtor (..),+    IndexN,+    RegFile (..),+    SymTransducer (..),+    Update (..),+    WireCtor (..),+    inpCtor,+    lit,+    matchInCtor,+    oNil,+    pack,+    proj,+    (*:),+    (.==),+  )+import Keiki.Core qualified as Keiki+import Keiki.Generics (emptyRegFile)+import Keiki.Operators qualified as K+import Keiki.Shape (CanonicalStateShape)+import Keiro+import Keiro qualified as KeiroRoot+import Keiro.Codec.Nominal+  ( NominalBinding (..),+    NominalFixture (..),+    NominalFixtureCases (..),+    nominalDomainRoundTrip,+    nominalRepresentationRoundTrip,+  )+import Keiro.Codec.Structural+  ( StructuralBinding (..),+    bindingDomainRoundTrip,+    bindingShapeRoundTrip,+    decodeViaBinding,+    encodeViaBinding,+  )+import Keiro.Connection (ensureProjectionSchema, qualifyTable, withProjectionSchema)+import Keiro.DeadLetter+  ( DispatchDeadLetter (..),+    DispatcherKind (..),+    listDispatchDeadLetters,+    recordDispatchDeadLetter,+  )+import Keiro.DeadLetter.Replay+  ( ReplayOutcome (..),+    ReplayResult (..),+    listSubscriptionDeadLetters,+    replaySubscriptionDeadLetters,+  )+import Keiro.EventStream (Terminality (..))+import Keiro.EventStream.Validate+  ( EventStreamWarning (..),+    ValidatedEventStream,+    mkEventStream,+    mkEventStreamOrThrow,+    mkEventStreamUnchecked,+    mkEventStreamWith,+    validateEventStream,+  )+import Keiro.Inbox+  ( InboxDedupePolicy (..),+    InboxError (..),+    InboxPersistence (..),+    InboxResult (..),+    InboxStatus (..),+    KafkaDeliveryRef (..),+    garbageCollectCompleted,+    listInbox,+    lookupInbox,+    markFailedTx,+    runInboxTransaction,+    runInboxTransactionBatch,+    runInboxTransactionWith,+    runInboxTransactionWithRetries,+    runInboxTransactionWithRetriesWith,+    sampleInboxBacklog,+  )+import Keiro.Inbox.Kafka qualified as InboxKafka+import Keiro.Integration.Event+  ( IntegrationContentType (..),+    IntegrationEvent (..),+    SchemaReference (..),+    TraceContext (..),+    decodeJsonIntegrationEvent,+    encodeJsonIntegrationEvent,+    headerContentType,+    headerMessageId,+    headerSchemaSubject,+    headerSchemaVersion,+    headerSourceEventId,+    headerSourceGlobalPosition,+    headerTraceParent,+    integrationHeaders,+    integrationPayload,+    parseContentType,+  )+import Keiro.Integration.Event qualified as IntegrationEvent+import Keiro.Outbox+  ( BackoffSchedule (..),+    ExponentialBackoffOptions (..),+    IntegrationEventDraft (..),+    IntegrationProducer (..),+    IntegrationProducerConfigError (..),+    OrderingPolicy (..),+    OutboxId (..),+    OutboxPublishConfigError (..),+    OutboxRow (..),+    OutboxStatus (..),+    PublishOutcome (..),+    claimOutboxBatch,+    defaultMaintenanceOptions,+    defaultPublishOptions,+    draftToEvent,+    enqueueIntegrationEventTx,+    freshOutboxId,+    garbageCollectSent,+    lookupOutbox,+    markOutboxSent,+    mintIntegrationEvent,+    mkIntegrationProducer,+    mkOutboxPublishOptions,+    outboxMaintenancePass,+    publishClaimedOutbox,+    sampleOutboxBacklog,+  )+import Keiro.Outbox.Kafka qualified as OutboxKafka+import Keiro.Outbox.Schema (markOutboxFailedTx)+import Keiro.Prelude+import Keiro.ProcessManager+import Keiro.Projection+import Keiro.ReadModel+import Keiro.ReadModel.Rebuild qualified as Rebuild+import Keiro.ReplayAudit qualified as ReplayAudit+import Keiro.Snapshot.Policy (shouldSnapshot, shouldSnapshotSpan)+import Keiro.Stream qualified as Stream+import Keiro.Subscription.Shard+  ( ShardCountMismatch (..),+    ShardLease (..),+    WorkerId (..),+    ensureShards,+    fairShareTarget,+  )+import Keiro.Subscription.Shard.Schema+  ( claimShardsTx,+    ensureShardRows,+    listShardOwnership,+    releaseShardsTx,+    renewLeaseTx,+  )+import Keiro.Subscription.Shard.Worker+  ( ShardAck (..),+    ShardWorkerError (..),+    ShardedWorkerConfigError (..),+    ShardedWorkerOptions (..),+    acquireOutcome,+    defaultShardedWorkerOptions,+    mkShardedWorkerOptions,+    reconcileShardsOnce,+    runShardedSubscriptionGroup,+    runShardedSubscriptionGroupAck,+  )+import Keiro.Telemetry qualified as Telemetry+import Keiro.Test.Postgres+  ( StoreRunner (..),+    withFreshResourceStore,+    withFreshResourceStoreWith,+    withFreshStore,+    withFreshStoreWith,+    withFreshStores2,+    withMigratedSuite,+  )+import Keiro.Timer+import Keiro.Wake+  ( WakeReason (..),+    WakeSignal (..),+    neverWake,+    wakeSignalFromStore,+  )+import Keiro.Workflow+  ( LeaseHeartbeat (..),+    PatchId (..),+    StepName (..),+    Workflow,+    WorkflowError (..),+    WorkflowId (..),+    WorkflowIdentityError (..),+    WorkflowJournalEvent (StepRecorded, WorkflowCancelled, WorkflowCompleted, WorkflowContinuedAsNew, WorkflowFailed),+    WorkflowLeaseLost (..),+    WorkflowName (..),+    WorkflowOutcome (..),+    appendJournalEntry,+    appendJournalEntryReturningId,+    awaitStep,+    awakeableAllocStepPrefix,+    awakeableStepPrefix,+    continueAsNew,+    currentGeneration,+    defaultWorkflowRunOptions,+    findUnfinishedWorkflowIds,+    loadStepIndex,+    mkWorkflowId,+    mkWorkflowName,+    patch,+    patchSetStepName,+    patchStepName,+    restoreSeed,+    runWorkflow,+    runWorkflowWith,+    step,+    stepExists,+    workflowGenerationStreamName,+    workflowJournalCodec,+  )+import Keiro.Workflow.Awakeable+  ( AwakeableId (..),+    WorkflowAwakeableCancelled (..),+    awakeableIdText,+    awakeableIdToUuid,+    awakeableNamed,+    cancelAwakeable,+    deterministicAwakeableId,+    signalAwakeable,+    signalAwakeableFrom,+  )+import Keiro.Workflow.Awakeable.Schema qualified as Awk+import Keiro.Workflow.Child+  ( ChildHandle (..),+    WorkflowChildCancelled (..),+    WorkflowChildFailed (..),+    awaitChild,+    cancelChild,+    childCompletionHook,+    childResultStepName,+    childSpawnStepName,+    runChildWorkflow,+    spawnChild,+  )+import Keiro.Workflow.Child.Schema qualified as Child+import Keiro.Workflow.Gc qualified as WorkflowGc+import Keiro.Workflow.Instance qualified as Instance+import Keiro.Workflow.Resume+  ( ResumeLogEvent (..),+    ResumeSummary (..),+    WorkflowDef (..),+    defaultWorkflowResumeOptions,+    emptyResumeSummary,+    resumeWorkflowsOnce,+    runPollLoopWith,+    runWorkflowResumeWorkerPush,+    runWorkflowResumeWorkerWith,+  )+import Keiro.Workflow.Sleep+  ( matchSleepTimerGeneration,+    parseSleepPayload,+    runWorkflowTimerWorker,+    sleepNamed,+    sleepStepName,+    sleepTimerId,+    sleepTimerPayload,+    workflowSleepFireAction,+  )+import Keiro.Workflow.Snapshot+  ( loadWorkflowSnapshot,+    workflowStateCodec,+  )+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.SQL qualified as KirokuSQL+import Kiroku.Store.Subscription.Stream (AckItem (..), subscriptionAckStream)+import Kiroku.Store.Subscription.Types+  ( SubscriptionName (..),+    SubscriptionTarget (..),+  )+import Kiroku.Store.Subscription.Types qualified as KirokuSub+import Kiroku.Store.Types+  ( CategoryName (..),+    EventData (..),+    EventId (..),+    EventType (..),+    ExpectedVersion (..),+    GlobalPosition (..),+    RecordedEvent (..),+    StreamId (..),+    StreamName (..),+    StreamVersion (..),+  )+import OpenTelemetry.Attributes (Attribute (..), Attributes, PrimitiveAttribute (..), lookupAttribute)+import OpenTelemetry.Attributes.Key (AttributeKey, unkey)+import OpenTelemetry.Exporter.InMemory.Metric (inMemoryMetricExporter)+import OpenTelemetry.Exporter.InMemory.Span (inMemoryListExporter)+import OpenTelemetry.Exporter.Metric+  ( GaugeDataPoint (..),+    HistogramDataPoint (..),+    MetricExport (..),+    NumberValue (..),+    ResourceMetricsExport (..),+    ScopeMetricsExport (..),+    SumDataPoint (..),+  )+import OpenTelemetry.MeterProvider+  ( SdkMeterProviderOptions (..),+    createMeterProvider,+    defaultSdkMeterProviderOptions,+  )+import OpenTelemetry.Metric.Core+  ( forceFlushMeterProvider,+    getMeter,+  )+import OpenTelemetry.Resource (emptyMaterializedResources)+import OpenTelemetry.Trace+  ( SpanStatus (..),+    createTracerProvider,+    emptyTracerProviderOptions,+    makeTracer,+    shutdownTracerProvider,+    tracerOptions,+  )+import OpenTelemetry.Trace.Core+  ( ImmutableSpan (..),+    Span,+    SpanContext (..),+    SpanHot (..),+    SpanKind,+    getSpanContext,+  )+import Shibuya.Adapter (Adapter (..))+import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))+import Shibuya.Core.AckHandle (AckHandle (..))+import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Types (Envelope (..))+import Streamly.Data.Stream qualified as Streamly+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)+import System.Timeout (timeout)+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx++main :: IO ()+main = withMigratedSuite $ \fixture -> hspec $ do+  describe "Keiro" $ do+    it "exposes the scaffold version" $+      KeiroRoot.version `shouldBe` ("0.4.0.0" :: Text)++  describe "Keiro.Telemetry metrics" $ do+    it "records instrument names and values through an SDK meter" $ do+      (exporter, ref) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      metrics <- Telemetry.newKeiroMetrics meter+      let h = Just metrics+      -- A counter (monotonic sum), a gauge (last value wins), a histogram.+      Telemetry.recordOutboxPublished h 3+      Telemetry.recordOutboxPublished h 2+      Telemetry.recordOutboxBacklog h 7+      Telemetry.recordInboxDuplicates h 1+      Telemetry.recordTimerFireLag h 12.5+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef ref+      let scalars = flattenScalarPoints exported+          hists = flattenHistogramPoints exported+      -- The counter accumulated 3 + 2 = 5.+      lookup "keiro.outbox.published" scalars `shouldBe` Just (IntNumber 5)+      -- The gauge holds its last recorded value.+      lookup "keiro.outbox.backlog" scalars `shouldBe` Just (IntNumber 7)+      -- The duplicate counter holds 1.+      lookup "keiro.inbox.duplicates" scalars `shouldBe` Just (IntNumber 1)+      -- The histogram saw one observation summing to 12.5.+      let lag = [(c, s) | (n, c, s) <- hists, n == "keiro.timer.fire.lag"]+      lag `shouldBe` [(1, 12.5)]+      -- Instruments we never recorded export no points.+      lookup "keiro.timer.stuck" scalars `shouldBe` Nothing++    it "records nothing through a Nothing handle" $ do+      (exporter, ref) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      -- A Nothing handle is the no-op path: helpers must short-circuit.+      let h = Nothing+      Telemetry.recordOutboxPublished h 99+      Telemetry.recordOutboxBacklog h 99+      Telemetry.recordTimerFireLag h 99.0+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef ref+      flattenScalarPoints exported `shouldBe` []+      flattenHistogramPoints exported `shouldBe` []++  describe "Kiroku retry exhaustion observability" $ do+    it "dead-letters after the configured delivery bound, emits the metric, and advances" $ do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      metrics <- Telemetry.newKeiroMetrics meter+      forwarded <- newIORef (0 :: Int)+      let observe _ = modifyIORef' forwarded (+ 1)+          installBridge settings =+            settings+              & #eventHandler+              .~ Just (Telemetry.kirokuEventBridge (Just metrics) observe)+      withFreshStoreWith fixture installBridge $ \store -> do+        total <- seedOrders store 1 2+        total `shouldBe` 2+        let subName = SubscriptionName "orders-retry-exhaustion"+            subConfig =+              ( KirokuSub.defaultSubscriptionConfig+                  subName+                  (Category (CategoryName "orders"))+                  (\_ -> pure KirokuSub.Continue)+              )+                { KirokuSub.retryPolicy = KirokuSub.RetryPolicy 2+                }+            pull label source = do+              result <- timeout 5_000_000 (Streamly.uncons source)+              case result of+                Just (Just itemAndRest) -> pure itemAndRest+                Just Nothing -> fail (label <> ": subscription ended early")+                Nothing -> fail (label <> ": timed out waiting for delivery")+            number item =+              parseEither+                (withObject "OrderPlaced" (.: "n"))+                (ackEvent item ^. #payload)+        (stream0, cancelStream) <- subscriptionAckStream store subConfig 4+        ( do+            (first, stream1) <- pull "initial poison delivery" stream0+            ackAttempt first `shouldBe` 0+            number first `shouldBe` Right (0 :: Int)+            atomically $+              putTMVar+                (ackReply first)+                (KirokuSub.Retry (KirokuSub.RetryDelay 0))++            (retry, stream2) <- pull "poison redelivery" stream1+            ackAttempt retry `shouldBe` 1+            ackEvent retry ^. #eventId `shouldBe` ackEvent first ^. #eventId+            atomically $+              putTMVar+                (ackReply retry)+                (KirokuSub.Retry (KirokuSub.RetryDelay 0))++            (next, stream3) <- pull "event after exhausted poison" stream2+            ackAttempt next `shouldBe` 0+            number next `shouldBe` Right (1 :: Int)+            ackEvent next ^. #eventId `shouldNotBe` ackEvent first ^. #eventId+            atomically (putTMVar (ackReply next) KirokuSub.Stop)+            ended <- timeout 5_000_000 (Streamly.uncons stream3)+            case ended of+              Just Nothing -> pure ()+              Just (Just _) -> expectationFailure "subscription delivered after Stop"+              Nothing -> expectationFailure "subscription did not stop after the final acknowledgement"+          )+          `finally` cancelStream++        Right rows <-+          Store.runStoreIO store $+            Store.runTransaction $+              Tx.statement+                ("orders-retry-exhaustion", 0)+                KirokuSQL.readDeadLettersStmt+        case Vector.toList rows of+          [row] -> do+            row ^. #deadLetterReason+              `shouldBe` object+                [ "kind" Aeson..= ("max_attempts_exceeded" :: Text),+                  "attempts" Aeson..= (2 :: Int)+                ]+            row ^. #deadLetterReasonSummary `shouldBe` "max retry attempts exceeded (2)"+            row ^. #deadLetterAttemptCount `shouldBe` 2+          other -> expectationFailure ("expected one Kiroku dead letter, got " <> show (Vector.length rows) <> ": " <> show other)++        _ <- forceFlushMeterProvider provider Nothing+        exported <- readIORef metricsRef+        lookup "keiro.subscription.deadlettered" (flattenScalarPoints exported)+          `shouldBe` Just (IntNumber 1)+        readIORef forwarded >>= (`shouldSatisfy` (> 1))++  describe "Keiro.Stream" $ do+    it "wraps and unwraps kiroku stream names" $ do+      let orderStream = stream "order-123" :: Stream OrderStream+      Stream.streamName orderStream `shouldBe` StreamName "order-123"+      Stream.streamName (mapStreamName (\(StreamName name) -> StreamName (name <> "-archived")) orderStream)+        `shouldBe` StreamName "order-123-archived"++    it "validates categories, rejecting the dash boundary and reserved names" $ do+      fmap Stream.categoryText (Stream.category "incident" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Right "incident"+      -- compound categories are camelCase; ':' (reserved for the wf: family) is also accepted+      fmap Stream.categoryText (Stream.category "hospitalSurge" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Right "hospitalSurge"+      fmap Stream.categoryText (Stream.category "wf:fulfillment" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Right "wf:fulfillment"+      (Stream.category "" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Left Stream.CategoryEmpty+      (Stream.category "hospital-surge" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Left (Stream.CategoryContainsSeparator "hospital-surge")+      (Stream.category "$all" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Left (Stream.CategoryReserved "$all")+      (Stream.category "ord ers" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Left (Stream.CategoryContainsIllegalChar ' ' "ord ers")+      (Stream.category "ord\ners" :: Either Stream.CategoryError (Stream.StreamCategory ()))+        `shouldBe` Left (Stream.CategoryContainsIllegalChar '\n' "ord\ners")++    it "builds entity streams that round-trip through kiroku's category rule" $ do+      let cat = Stream.categoryUnsafe "orders" :: Stream.StreamCategory OrderStream+      Stream.streamName (Stream.entityStream cat "1") `shouldBe` StreamName "orders-1"+      Stream.categoryName cat `shouldBe` CategoryName "orders"+      -- The category keiro reports equals kiroku's own parse of the produced+      -- name, even when the id segment itself contains a dash.+      Store.categoryName (Stream.streamName (Stream.entityStream cat "a-b-c"))+        `shouldBe` Stream.categoryName cat++    it "entityStreamId renders ids via StreamIdSegment (Text and String)" $ do+      let cat = Stream.categoryUnsafe "orders" :: Stream.StreamCategory OrderStream+      Stream.streamName (Stream.entityStreamId cat ("o-1" :: Text)) `shouldBe` StreamName "orders-o-1"+      Stream.streamName (Stream.entityStreamId cat ("o-1" :: String)) `shouldBe` StreamName "orders-o-1"++    it "rejects blank entity stream id segments" $ do+      let cat = Stream.categoryUnsafe "orders" :: Stream.StreamCategory OrderStream+      evaluate (Stream.streamName (Stream.entityStream cat "")) `shouldThrow` anyErrorCall+      evaluate (Stream.streamName (Stream.entityStream cat "   ")) `shouldThrow` anyErrorCall++  describe "Keiro.DeadLetter" $ around (withFreshStore fixture) $ do+    it "records a dispatch dead letter idempotently" $ \storeHandle -> do+      let deadLetter =+            DispatchDeadLetter+              { dispatcherKind = DispatcherProcessManager,+                dispatcherName = "orders-pm",+                correlationId = "order-42",+                sourceEventId = EventId sampleUuid,+                sourceGlobalPosition = GlobalPosition 17,+                emitIndex = 0,+                targetStreamName = StreamName "orders-42",+                errorClass = "command_rejected",+                errorDetail = Text.replicate 1100 "x",+                attemptCount = 2+              }+      Right rows <-+        Store.runStoreIO storeHandle $ do+          recordDispatchDeadLetter deadLetter+          recordDispatchDeadLetter deadLetter+          listDispatchDeadLetters "orders-pm"+      case rows of+        [row] -> do+          row ^. #dispatcherKind `shouldBe` DispatcherProcessManager+          row ^. #dispatcherName `shouldBe` "orders-pm"+          row ^. #correlationId `shouldBe` "order-42"+          row ^. #sourceEventId `shouldBe` EventId sampleUuid+          row ^. #sourceGlobalPosition `shouldBe` GlobalPosition 17+          row ^. #emitIndex `shouldBe` 0+          row ^. #targetStreamName `shouldBe` StreamName "orders-42"+          row ^. #errorClass `shouldBe` "command_rejected"+          Text.length (row ^. #errorDetail) `shouldBe` 1024+          row ^. #attemptCount `shouldBe` 2+        other -> expectationFailure ("expected one idempotent dead-letter row, got " <> show other)++  describe "Keiro.Codec" $ do+    it "encodes current events with type tags and schema-version metadata" $ do+      encoded <- shouldBeRight (encodeForAppend orderCodec (OrderPlaced "order-123" 5))+      encoded ^. #eventType `shouldBe` EventType "OrderPlaced"+      encoded ^. #payload `shouldBe` object ["orderId" Aeson..= ("order-123" :: Text), "quantity" Aeson..= (5 :: Int)]+      extractSchemaVersion (recordedFrom encoded) `shouldBe` Right 2++    it "round-trips current events" $ do+      encoded <- shouldBeRight (encodeForAppend orderCodec (OrderPlaced "order-123" 5))+      decodeRecorded orderCodec (recordedFrom encoded) `shouldBe` Right (OrderPlaced "order-123" 5)++    it "decodes by the stored tag, not by payload shape (H1)" $ do+      let recorded =+            recordedFrom+              EventData+                { eventId = Nothing,+                  eventType = EventType "CounterAudited",+                  payload = object ["amount" Aeson..= (5 :: Int)],+                  metadata = Just (metadataForOrDie 1 Nothing),+                  causationId = Nothing,+                  correlationId = Nothing+                }+      decodeRecorded counterCodec recorded `shouldBe` Right (CounterAudited 5)++    it "runs upcasters in source-version order" $+      decodeRaw orderCodec (EventType "OrderPlaced") 1 (object ["orderId" Aeson..= ("order-123" :: Text), "qty" Aeson..= (5 :: Int)])+        `shouldBe` Right (OrderPlaced "order-123" 5)++    it "rejects gaps in upcaster chains" $+      decodeRaw gappyCodec (EventType "OrderPlaced") 1 (object ["orderId" Aeson..= ("order-123" :: Text), "qty" Aeson..= (5 :: Int)])+        `shouldBe` Left (GapInUpcasterChain 2 3)++    it "validates codec construction invariants" $ do+      fmap (const ()) (mkCodec (orderCodec {schemaVersion = 0})) `shouldBe` Left (CodecSchemaVersionInvalid 0)+      fmap (const ()) (mkCodec (orderCodec {eventTypes = EventType "OrderPlaced" :| [EventType "OrderPlaced"]}))+        `shouldBe` Left (CodecDuplicateEventTypes [EventType "OrderPlaced"])+      fmap (const ()) (mkCodec (orderCodec {schemaVersion = 3, upcasters = [(1, const upcastOrderPlacedV1), (1, const upcastOrderPlacedV1)]}))+        `shouldBe` Left (CodecDuplicateUpcasterSources [1])+      fmap (const ()) (mkCodec (orderCodec {schemaVersion = 3, upcasters = [(1, const upcastOrderPlacedV1)]}))+        `shouldBe` Left (CodecUpcasterChainIncomplete [2] 3)+      case mkCodec orderCodec of+        Right _ -> pure ()+        Left err -> expectationFailure ("expected orderCodec to validate, got " <> show err)++    it "rejects future-version, malformed metadata, and incomplete upcaster chains" $ do+      let v1Payload = object ["orderId" Aeson..= ("order-123" :: Text), "qty" Aeson..= (5 :: Int)]+          earlyEndCodec =+            orderCodec+              { schemaVersion = 4,+                upcasters = [(1, const upcastOrderPlacedV1), (2, const Right)]+              }+      decodeRaw orderCodec (EventType "OrderPlaced") 3 v1Payload+        `shouldBe` Left (VersionAhead 3 2)+      decodeRaw earlyEndCodec (EventType "OrderPlaced") 1 v1Payload+        `shouldBe` Left (IncompleteUpcasterChain 3 4)++      let malformedStamp =+            recordedFrom+              EventData+                { eventId = Nothing,+                  eventType = EventType "OrderPlaced",+                  payload = object ["orderId" Aeson..= ("order-123" :: Text), "quantity" Aeson..= (5 :: Int)],+                  metadata = Just (object ["schemaVersion" Aeson..= ("2" :: Text)]),+                  causationId = Nothing,+                  correlationId = Nothing+                }+      extractSchemaVersion malformedStamp+        `shouldBe` Left (MalformedSchemaVersionStamp (Aeson.String "2"))+      fmap (const ()) (encodeForAppendWithMetadata orderCodec (Just (Aeson.String "x")) (OrderPlaced "order-123" 5))+        `shouldBe` Left (NonObjectCallerMetadata (Aeson.String "x"))++    it "rejects recorded events with unknown type tags" $ do+      let encoded =+            recordedFrom+              EventData+                { eventId = Nothing,+                  eventType = EventType "OrderCancelled",+                  payload = object ["orderId" Aeson..= ("order-123" :: Text)],+                  metadata = Just (metadataForOrDie 2 Nothing),+                  causationId = Nothing,+                  correlationId = Nothing+                }+      decodeRecorded orderCodec encoded+        `shouldBe` Left (UnknownEventType (EventType "OrderCancelled") [EventType "OrderPlaced"])++  describe "Keiro.Codec.Structural" $ do+    let pairBinding :: StructuralBinding (Int, Bool) (Bool, Int)+        pairBinding =+          StructuralBinding+            { bindingToShape = \(amount, enabled) -> (enabled, amount),+              bindingFromShape = \(enabled, amount) -> (amount, enabled)+            }+        encodePairShape (enabled, amount) =+          object ["enabled" Aeson..= enabled, "amount" Aeson..= amount]+        decodePairShape value =+          case parseEither (withObject "PairShape" $ \objectValue -> (,) <$> objectValue .: "enabled" <*> objectValue .: "amount") value of+            Left err -> Left (Text.pack err)+            Right shape -> Right shape++    it "checks both total binding laws" $ do+      bindingDomainRoundTrip pairBinding (7, True) `shouldBe` True+      bindingShapeRoundTrip pairBinding (False, 9) `shouldBe` True++    it "delegates encoding to the generated shape codec" $+      encodeViaBinding pairBinding encodePairShape (7, True)+        `shouldBe` object ["enabled" Aeson..= True, "amount" Aeson..= (7 :: Int)]++    it "propagates only shape decode failures before total construction" $ do+      let encoded = object ["enabled" Aeson..= False, "amount" Aeson..= (9 :: Int)]+      decodeViaBinding pairBinding decodePairShape encoded `shouldBe` Right (9, False)+      decodeViaBinding pairBinding (const (Left "shape-error")) Aeson.Null+        `shouldBe` Left "shape-error"++  describe "Keiro.Codec.Nominal" $ do+    let swappedBinding :: NominalBinding (Int, Bool) (Bool, Int)+        swappedBinding =+          NominalBinding+            { nominalToRepresentation = \(amount, enabled) -> (enabled, amount),+              nominalFromRepresentation = \(enabled, amount) -> (amount, enabled)+            }+        fixtures =+          NominalFixtureCases+            ( NominalFixture "enabled" (object ["enabled" Aeson..= True, "amount" Aeson..= (7 :: Int)]) (7, True)+                :| [NominalFixture "disabled" (object ["enabled" Aeson..= False, "amount" Aeson..= (9 :: Int)]) (9, False)]+            )++    it "checks both total nominal binding laws" $ do+      nominalDomainRoundTrip swappedBinding (7, True) `shouldBe` True+      nominalRepresentationRoundTrip swappedBinding (False, 9) `shouldBe` True++    it "retains labelled expected-wire fixtures" $+      nominalFixtureCases fixtures+        `shouldBe` ( NominalFixture "enabled" (object ["enabled" Aeson..= True, "amount" Aeson..= (7 :: Int)]) (7, True)+                       :| [NominalFixture "disabled" (object ["enabled" Aeson..= False, "amount" Aeson..= (9 :: Int)]) (9, False)]+                   )++  describe "Keiro.EventStream" $ do+    it "constructs an author-facing EventStream contract" $ do+      let contract =+            EventStream+              { transducer = emptyTransducer,+                initialState = Idle,+                initialRegisters = RNil,+                eventCodec = orderCodec,+                resolveStreamName = \s -> Stream.streamName s,+                snapshotPolicy = Never,+                stateCodec = Nothing+              }+          typedStream = stream "order-123" :: Stream (EventStream () '[] OrderState OrderCommand OrderEvent)+      contract ^. #initialState `shouldBe` Idle+      (contract ^. #resolveStreamName) typedStream `shouldBe` StreamName "order-123"++    it "evaluates snapshot policies with explicit terminality" $ do+      shouldSnapshot (Every 2) NotTerminal () (StreamVersion 0) `shouldBe` False+      shouldSnapshot (Every 2) NotTerminal () (StreamVersion 2) `shouldBe` True+      shouldSnapshot OnTerminal Terminal () (StreamVersion 1) `shouldBe` True+      shouldSnapshot OnTerminal NotTerminal () (StreamVersion 1) `shouldBe` False+      shouldSnapshot (Custom (\terminality _ _ -> terminality == Terminal)) Terminal () (StreamVersion 1)+        `shouldBe` True+      shouldSnapshot (Custom (\terminality _ _ -> terminality == Terminal)) NotTerminal () (StreamVersion 1)+        `shouldBe` False+      shouldSnapshotSpan (Every 3) NotTerminal () (StreamVersion 2) (StreamVersion 4)+        `shouldBe` True+      shouldSnapshotSpan (Every 3) NotTerminal () (StreamVersion 4) (StreamVersion 5)+        `shouldBe` False++    it "rejects snapshot policies without a state codec" $ do+      let contract :: CounterEventStream+          contract = counterEventStreamDef {snapshotPolicy = Every 10, stateCodec = Nothing}+      fmap (const ()) (mkEventStream "snapshotless" contract)+        `shouldBe` Left [EventStreamWarning "snapshotless" "snapshotPolicy is set but stateCodec is Nothing; snapshots would never be written"]++  describe "EventStream replay-safety (validateEventStream)" $ do+    it "every production-intent stream validates clean" $+      concat+        [ validateEventStream "counter" counterEventStreamDef,+          validateEventStream "counter-no-op" noOpCounterEventStreamDef,+          validateEventStream "counter-multi" multiCounterEventStreamDef,+          validateEventStream "counter-ambiguous" ambiguousCounterEventStreamDef,+          validateEventStream "snapshot-counter" snapshotCounterEventStreamDef,+          validateEventStream "snapshot-counter-multi" multiSnapshotCounterEventStreamDef,+          validateEventStream "snapshot-counter-guarded" guardedSnapshotCounterEventStreamDef,+          validateEventStream "pm-snapshot-counter" pmSnapshotCounterEventStreamDef,+          validateEventStream "rejecting-counter" rejectingEventStreamDef+        ]+        `shouldBe` []++  describe "mkEventStream" $ do+    it "rejects duplicate upcaster sources at the stream boundary" $ do+      let duplicateCodec =+            counterCodec+              { schemaVersion = 3,+                upcasters = [(1, const Right), (1, const Right)]+              }+          duplicateStream = counterEventStreamDef {eventCodec = duplicateCodec}+      case mkEventStream "duplicate-codec" duplicateStream of+        Left warnings -> do+          map eswStreamLabel warnings `shouldSatisfy` all (== "duplicate-codec")+          map eswReason warnings `shouldSatisfy` any (Text.isInfixOf "duplicate upcaster source version(s): 1")+        Right _ -> expectationFailure "expected mkEventStream to reject duplicate upcaster sources"++    it "rejects a missing upcaster rung at the stream boundary" $ do+      let incompleteCodec =+            counterCodec+              { schemaVersion = 3,+                upcasters = [(2, const Right)]+              }+          incompleteStream = counterEventStreamDef {eventCodec = incompleteCodec}+      case mkEventStream "incomplete-codec" incompleteStream of+        Left warnings -> do+          map eswStreamLabel warnings `shouldSatisfy` all (== "incomplete-codec")+          map eswReason warnings `shouldSatisfy` any (Text.isInfixOf "missing upcaster source version(s): 1")+        Right _ -> expectationFailure "expected mkEventStream to reject an incomplete upcaster chain"++    it "includes the stream label when throwing for an invalid codec" $ do+      let incompleteCodec =+            counterCodec+              { schemaVersion = 3,+                upcasters = [(2, const Right)]+              }+          incompleteStream = counterEventStreamDef {eventCodec = incompleteCodec}+      result <- try @ErrorCall (evaluate (mkEventStreamOrThrow "throwing-incomplete-codec" incompleteStream))+      case result of+        Left err -> do+          displayException err `shouldSatisfy` isInfixOf "throwing-incomplete-codec"+          displayException err `shouldSatisfy` isInfixOf "missing upcaster source version(s): 1"+        Right _ -> expectationFailure "expected mkEventStreamOrThrow to reject an incomplete upcaster chain"++    it "keeps invalid codecs available through the unchecked escape hatch" $ do+      let duplicateCodec =+            counterCodec+              { schemaVersion = 3,+                upcasters = [(1, const Right), (1, const Right)]+              }+          incompleteCodec =+            counterCodec+              { schemaVersion = 3,+                upcasters = [(2, const Right)]+              }+      _ <- evaluate (mkEventStreamUnchecked counterEventStreamDef {eventCodec = duplicateCodec})+      _ <- evaluate (mkEventStreamUnchecked counterEventStreamDef {eventCodec = incompleteCodec})+      pure ()++    it "rejects a hidden-input stream by label" $ do+      let warns = validateEventStream "broken" brokenHiddenInputEventStream+      warns `shouldNotBe` []+      map eswStreamLabel warns `shouldSatisfy` all (== "broken")+      map eswReason warns `shouldSatisfy` any (Text.isInfixOf "hidden-input")+      case mkEventStream "broken" brokenHiddenInputEventStream of+        Left ws -> do+          map eswStreamLabel ws `shouldSatisfy` all (== "broken")+          map eswReason ws `shouldSatisfy` any (Text.isInfixOf "hidden-input")+        Right _ -> expectationFailure "expected mkEventStream to reject the hidden-input stream"++    it "rejects a head-unrecoverable multi-event stream" $+      expectValidationWarning+        "head-unrecoverable"+        "head-unrecoverable"+        headUnrecoverableEventStreamDef++    it "rejects replay inversion ambiguity" $+      expectValidationWarning+        "inversion-ambiguity"+        "inversion-ambiguity"+        inversionAmbiguousEventStreamDef++    it "rejects an unguarded command-field read" $+      expectValidationWarning+        "unguarded-input-read"+        "unguarded-input-read"+        unguardedInputReadEventStreamDef++    it "rejects a silent edge that writes registers" $ do+      Keiki.validateTransducer Keiki.defaultValidationOptions stateChangingEpsilonTransducer+        `shouldSatisfy` any isStateChangingEpsilon+      expectValidationWarning+        "state-changing-epsilon"+        "state-changing-epsilon"+        stateChangingEpsilonEventStreamDef++    it "rejects a silent edge that changes vertex" $ do+      Keiki.validateTransducer Keiki.defaultValidationOptions silentMoveTransducer+        `shouldSatisfy` any isStateChangingEpsilon+      expectValidationWarning+        "silent-move"+        "state-changing-epsilon"+        silentMoveEventStreamDef++    it "keeps replay-contract checks enabled when caller options weaken them" $ do+      case mkEventStreamWith+        Keiki.defaultValidationOptions {Keiki.checkStateChangingEpsilon = False}+        "silent-move-weakened"+        silentMoveEventStreamDef of+        Left warnings ->+          map eswReason warnings+            `shouldSatisfy` any (Text.isInfixOf "state-changing-epsilon")+        Right _ -> expectationFailure "expected the durable boundary to restore the state-changing-epsilon check"+      case mkEventStreamWith+        Keiki.defaultValidationOptions {Keiki.checkHeadRecoverability = False}+        "head-unrecoverable-weakened"+        headUnrecoverableEventStreamDef of+        Left warnings ->+          map eswReason warnings+            `shouldSatisfy` any (Text.isInfixOf "head-unrecoverable")+        Right _ -> expectationFailure "expected the durable boundary to restore the head-recoverability check"++    it "provides a loudly named unchecked escape hatch" $ do+      _ <- evaluate (mkEventStreamUnchecked silentMoveEventStreamDef)+      pure ()++    it "accepts every production-intent stream" $ do+      let expectAccepted label eventStream =+            case mkEventStream label eventStream of+              Right _ -> pure ()+              Left ws -> expectationFailure ("expected mkEventStream to accept " <> Text.unpack label <> ", got " <> show ws)+      expectAccepted "counter" counterEventStreamDef+      expectAccepted "counter-no-op" noOpCounterEventStreamDef+      expectAccepted "counter-multi" multiCounterEventStreamDef+      expectAccepted "counter-ambiguous" ambiguousCounterEventStreamDef+      expectAccepted "snapshot-counter" snapshotCounterEventStreamDef+      expectAccepted "snapshot-counter-multi" multiSnapshotCounterEventStreamDef+      expectAccepted "snapshot-counter-guarded" guardedSnapshotCounterEventStreamDef+      expectAccepted "pm-snapshot-counter" pmSnapshotCounterEventStreamDef+      expectAccepted "rejecting-counter" rejectingEventStreamDef++    it "rejects a snapshot codec whose initial register file contains an uninitialized slot" $ do+      case mkEventStream "uninitialized-snapshot" uninitializedSnapshotEventStreamDef of+        Left warns -> do+          map eswStreamLabel warns `shouldSatisfy` all (== "uninitialized-snapshot")+          map eswReason warns `shouldSatisfy` any (Text.isInfixOf "cannot encode the initial state/registers")+          map eswReason warns `shouldSatisfy` any (Text.isInfixOf "uninit: neverWritten")+        Right _ -> expectationFailure "expected mkEventStream to reject an uninitialized snapshot register"++    it "accepts the same snapshot stream when every initial register is initialized" $ do+      case mkEventStream "initialized-snapshot" initializedSnapshotEventStreamDef of+        Right _ -> pure ()+        Left warns -> expectationFailure ("expected initialized snapshot registers to validate, got " <> show warns)++    it "rejects a bare EventStream at runCommand (compile-time)" $ do+      (exitCode, _stdout, stderr) <-+        readProcessWithExitCode+          "cabal"+          [ "exec",+            "ghc",+            "--",+            "-fno-code",+            "-package",+            "keiro",+            "test/ReplaySafetyTypeProbe.hs"+          ]+          ""+      exitCode `shouldSatisfy` (/= ExitSuccess)+      stderr `shouldSatisfy` ("ValidatedEventStream" `isInfixOf`)++  describe "Keiro.Command" $ around (withFreshStore fixture) $ do+    it "creates a stream and appends the first command event" $ \storeHandle -> do+      let target = stream "counter-command-create" :: Stream CounterEventStream+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 2)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 1+          commandResult ^. #eventsAppended `shouldBe` 1+          commandResult ^. #globalPosition `shouldSatisfy` isJust+        other -> expectationFailure ("expected successful command, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-create") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 1+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 2]++    it "reports no global position for a no-op after prior events" $ \storeHandle -> do+      let target = stream "skip-command-no-op-position" :: Stream SkipEventStream+      Right (Right appended) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions skipEventStream target (SAdd 2)+      appended ^. #globalPosition `shouldSatisfy` isJust+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions skipEventStream target SSkip+      case result of+        Right (Right noOp) -> do+          noOp ^. #streamVersion `shouldBe` StreamVersion 1+          noOp ^. #eventsAppended `shouldBe` 0+          noOp ^. #globalPosition `shouldBe` Nothing+        other -> expectationFailure ("expected successful no-op command, got " <> show other)++    it "surfaces runtime edge ambiguity without appending" $ \storeHandle -> do+      (processor, spansRef) <- inMemoryListExporter+      provider <- createTracerProvider [processor] emptyTracerProviderOptions+      let tracer = makeTracer provider "keiro-test" tracerOptions+          target = stream "counter-command-ambiguous" :: Stream CounterEventStream+          options = defaultRunCommandOptions & #tracer ?~ tracer+      result <-+        Store.runStoreIO storeHandle $+          runCommand options ambiguousCounterEventStream target (Add 1)+      _ <- shutdownTracerProvider provider Nothing+      result `shouldBe` Right (Left (CommandAmbiguous [0, 1]))+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-ambiguous") (StreamVersion 0) 10+      recorded `shouldBe` Vector.empty+      spans <- traverse captureSpan =<< readIORef spansRef+      case spans of+        [sp] -> textAttr (csAttributes sp) "error.type" `shouldBe` Just "command_ambiguous"+        other -> expectationFailure ("expected one span, got " <> show (length other))++    it "rehydrates prior events before appending a second command event" $ \storeHandle -> do+      let target = stream "counter-command-update" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 2)+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 3)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 2+        other -> expectationFailure ("expected successful second command, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-update") (StreamVersion 0) 10+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 2, CounterAdded 3]++    it "rejects hydration after truncation without a covering snapshot" $ \storeHandle -> do+      let target = stream "counter-truncated-uncovered" :: Stream CounterEventStream+          targetName = StreamName "counter-truncated-uncovered"+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 1)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 3)+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          Store.setStreamTruncateBefore targetName (StreamVersion 3)+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 4)+      case result of+        Right (Left (HydrationGapDetected expected observed)) -> do+          expected `shouldBe` StreamVersion 1+          observed `shouldBe` StreamVersion 3+        other -> expectationFailure ("expected HydrationGapDetected, got " <> show other)++    it "rejects hydration when truncation lands inside a command batch" $ \storeHandle -> do+      let target = stream "counter-truncated-mid-batch" :: Stream CounterEventStream+          targetName = StreamName "counter-truncated-mid-batch"+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 1)+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          Store.setStreamTruncateBefore targetName (StreamVersion 2)+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 2)+      case result of+        Right (Left (HydrationGapDetected expected observed)) -> do+          expected `shouldBe` StreamVersion 1+          observed `shouldBe` StreamVersion 2+        other -> expectationFailure ("expected HydrationGapDetected, got " <> show other)++    it "hydrates normally after truncation covered by a snapshot" $ \storeHandle -> do+      let target = stream "counter-truncated-covered" :: Stream SnapshotCounterEventStream+          targetName = StreamName "counter-truncated-covered"+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 1)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          Store.setStreamTruncateBefore targetName (StreamVersion 2)+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 3+        other -> expectationFailure ("expected snapshot-covered command success, got " <> show other)++    it "uses caller-supplied event ids for idempotent command batches" $ \storeHandle -> do+      let target = stream "counter-command-event-id" :: Stream CounterEventStream+          supplied = EventId sampleUuid2+          options = defaultRunCommandOptions & #eventIds .~ [supplied]+      result <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 7)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 1+        other -> expectationFailure ("expected successful command, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-event-id") (StreamVersion 0) 10+      fmap (^. #eventId) (Vector.toList recorded) `shouldBe` [supplied]++    it "retries an optimistic conflict after rehydrating the winning event" $ \storeHandle -> do+      conflictInserted <- newIORef False+      let target = stream "counter-command-conflict" :: Stream CounterEventStream+          conflictStreamName = StreamName "counter-command-conflict"+          insertConflict = do+            shouldInsert <- atomicModifyIORef' conflictInserted $ \alreadyInserted ->+              if alreadyInserted+                then (True, False)+                else (True, True)+            when shouldInsert $ do+              encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 10))+              outcome <-+                Store.runStoreIO storeHandle $+                  Store.appendToStream conflictStreamName NoStream [encoded]+              case outcome of+                Right _ -> pure ()+                Left err -> expectationFailure ("failed to insert conflict event: " <> show err)+          options = defaultRunCommandOptions & #beforeAppend .~ insertConflict+      result <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 2)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 2+          commandResult ^. #eventsAppended `shouldBe` 1+        other -> expectationFailure ("expected retry to succeed, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward conflictStreamName (StreamVersion 0) 10+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 10, CounterAdded 2]++    it "reports true retry attempts and command conflict metrics when the retry budget is exhausted" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "counter-command-exhausted-conflict" :: Stream CounterEventStream+          conflictStreamName = StreamName "counter-command-exhausted-conflict"+          insertConflict = do+            encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 10))+            outcome <-+              Store.runStoreIO storeHandle $+                Store.appendToStream conflictStreamName AnyVersion [encoded]+            case outcome of+              Right _ -> pure ()+              Left err -> expectationFailure ("failed to insert conflict event: " <> show err)+          options =+            defaultRunCommandOptions+              & #beforeAppend+              .~ insertConflict+              & #retryLimit+              .~ 2+              & #retryBackoffMicros+              .~ 0+              & #metrics+              ?~ keiroMetrics+      result <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 2)+      case result of+        Right (Left (RetryExhausted attempts _)) ->+          attempts `shouldBe` 3+        other -> expectationFailure ("expected exhausted retry budget, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.command.conflicts" scalars `shouldBe` Just (IntNumber 3)+      lookup "keiro.command.retries" scalars `shouldBe` Just (IntNumber 2)++    it "records the successful retry attempt on the command span" $ \storeHandle -> do+      (processor, spansRef) <- inMemoryListExporter+      provider <- createTracerProvider [processor] emptyTracerProviderOptions+      conflictInserted <- newIORef False+      let tracer = makeTracer provider "keiro-test" tracerOptions+          target = stream "counter-command-retry-span" :: Stream CounterEventStream+          conflictStreamName = StreamName "counter-command-retry-span"+          insertConflict = do+            shouldInsert <- atomicModifyIORef' conflictInserted $ \alreadyInserted ->+              if alreadyInserted+                then (True, False)+                else (True, True)+            when shouldInsert $ do+              encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 10))+              outcome <-+                Store.runStoreIO storeHandle $+                  Store.appendToStream conflictStreamName NoStream [encoded]+              case outcome of+                Right _ -> pure ()+                Left err -> expectationFailure ("failed to insert conflict event: " <> show err)+          options =+            defaultRunCommandOptions+              & #beforeAppend+              .~ insertConflict+              & #retryBackoffMicros+              .~ 0+              & #tracer+              ?~ tracer+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 2)+      _ <- shutdownTracerProvider provider Nothing+      spans <- traverse captureSpan =<< readIORef spansRef+      case spans of+        [sp] ->+          case lookupAttribute (csAttributes sp) "keiro.retry.attempt" of+            Just (AttributeValue (IntAttribute n)) -> n `shouldBe` 2+            other -> expectationFailure ("expected retry attempt attribute 2, got " <> show other)+        other -> expectationFailure ("expected one span, got " <> show (length other))++    it "counts duplicate deterministic command events" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let supplied = EventId sampleUuid3+          first = stream "counter-command-duplicate-a" :: Stream CounterEventStream+          second = stream "counter-command-duplicate-b" :: Stream CounterEventStream+          options =+            defaultRunCommandOptions+              & #eventIds+              .~ [supplied]+              & #metrics+              ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream first (Add 1)+      result <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream second (Add 2)+      case result of+        Right (Left (StoreFailed Store.DuplicateEvent {})) -> pure ()+        other -> expectationFailure ("expected duplicate event failure, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.command.duplicates" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)++    it "fails fast when a soft-deleted stream causes a conflict fixpoint" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "counter-command-soft-deleted" :: Stream CounterEventStream+          options =+            defaultRunCommandOptions+              & #retryBackoffMicros+              .~ 0+              & #metrics+              ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 1)+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          Store.softDeleteStream (StreamName "counter-command-soft-deleted")+      result <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 2)+      case result of+        Right (Left (ConflictFixpoint (StreamVersion 0) Store.StreamAlreadyExists {})) -> pure ()+        other -> expectationFailure ("expected conflict fixpoint, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.command.conflicts" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)++    it "surfaces decode failure during hydration" $ \storeHandle -> do+      Right _ <-+        Store.runStoreIO storeHandle $+          Store.appendToStream+            (StreamName "counter-command-decode-failure")+            NoStream+            [ EventData+                { eventId = Nothing,+                  eventType = EventType "OtherEvent",+                  payload = object [],+                  metadata = Just (metadataForOrDie 1 Nothing),+                  causationId = Nothing,+                  correlationId = Nothing+                }+            ]+      let target = stream "counter-command-decode-failure" :: Stream CounterEventStream+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 1)+      result+        `shouldBe` Right+          (Left (HydrationDecodeFailed (UnknownEventType (EventType "OtherEvent") [EventType "CounterAdded", EventType "CounterAudited"])))++    it "surfaces a typed no-inverting-edge hydration failure" $ \storeHandle -> do+      let targetStreamName = StreamName "counter-command-no-inverting-edge"+          target = stream "counter-command-no-inverting-edge" :: Stream CounterEventStream+      appendCounterEvents storeHandle targetStreamName [CounterAudited 7]+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 1)+      result+        `shouldBe` Right+          (Left (HydrationReplayFailed (StreamVersion 1) HydrationNoInvertingEdge))++    it "fails hydration after guard tightening without a replay-only twin (plan 143 reproduction)" $ \storeHandle -> do+      let target = stream "divert-black-acuity-bad" :: Stream DivertEventStream+      Right (Right appended) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)+      appended ^. #streamVersion `shouldBe` StreamVersion 1+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions tightenedDivertEventStream target (ConfirmDivert False)+      result+        `shouldBe` Right+          (Left (HydrationReplayFailed (StreamVersion 1) HydrationNoInvertingEdge))++    it "replays black-acuity history through the replay-only twin and keeps serving the live rule" $ \storeHandle -> do+      let target = stream "divert-black-acuity-good" :: Stream DivertEventStream+      Right (Right appended) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)+      appended ^. #streamVersion `shouldBe` StreamVersion 1+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions twinDivertEventStream target (ConfirmDivert False)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 2+          commandResult ^. #eventsAppended `shouldBe` 1+        other ->+          expectationFailure ("expected hydration through the twin to succeed, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "divert-black-acuity-good") (StreamVersion 0) 10+      traverse (decodeRecorded divertCodec) (Vector.toList recorded)+        `shouldBe` Right [DivertConfirmed True, DivertConfirmed False]++    it "rejects a new command in the removed region under the twin-bearing machine" $ \storeHandle -> do+      let target = stream "divert-black-acuity-removed" :: Stream DivertEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions twinDivertEventStream target (ConfirmDivert True)+      result `shouldBe` Right (Left CommandRejected)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "divert-black-acuity-removed") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 1++    it "surfaces a typed queue-mismatch hydration failure with the failing version" $ \storeHandle -> do+      let targetStreamName = StreamName "counter-command-queue-mismatch"+          target = stream "counter-command-queue-mismatch" :: Stream CounterEventStream+      appendCounterEvents storeHandle targetStreamName [CounterAdded 5, CounterAudited 6]+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 1)+      result+        `shouldBe` Right+          (Left (HydrationReplayFailed (StreamVersion 2) HydrationQueueMismatch))++    it "surfaces a truncated multi-event chain as HydrationTruncatedChain" $ \storeHandle -> do+      let targetStreamName = StreamName "counter-command-truncated-chain"+          target = stream "counter-command-truncated-chain" :: Stream CounterEventStream+      appendCounterEvents storeHandle targetStreamName [CounterAdded 5]+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 1)+      result+        `shouldBe` Right+          (Left (HydrationReplayFailed (StreamVersion 1) HydrationTruncatedChain))++    it "surfaces ambiguous inversion during hydration" $ \storeHandle -> do+      let targetStreamName = StreamName "counter-command-ambiguous-inversion"+          target = stream "counter-command-ambiguous-inversion" :: Stream CounterEventStream+      appendCounterEvents storeHandle targetStreamName [CounterAdded 3]+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions inversionAmbiguousEventStream target (Add 1)+      result+        `shouldBe` Right+          (Left (HydrationReplayFailed (StreamVersion 1) HydrationAmbiguousInversion))++    it "truncates command span error status descriptions" $ \storeHandle -> do+      (processor, spansRef) <- inMemoryListExporter+      provider <- createTracerProvider [processor] emptyTracerProviderOptions+      let tracer = makeTracer provider "keiro-test" tracerOptions+          longTag = Text.replicate 400 "x"+      Right _ <-+        Store.runStoreIO storeHandle $+          Store.appendToStream+            (StreamName "counter-command-long-decode-failure")+            NoStream+            [ EventData+                { eventId = Nothing,+                  eventType = EventType longTag,+                  payload = object [],+                  metadata = Just (metadataForOrDie 1 Nothing),+                  causationId = Nothing,+                  correlationId = Nothing+                }+            ]+      let target = stream "counter-command-long-decode-failure" :: Stream CounterEventStream+          options = defaultRunCommandOptions & #tracer ?~ tracer+      _ <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 1)+      _ <- shutdownTracerProvider provider Nothing+      spans <- traverse captureSpan =<< readIORef spansRef+      case spans of+        [sp] ->+          case csStatus sp of+            Error description -> Text.length description `shouldSatisfy` (<= 256)+            other -> expectationFailure ("expected error span status, got " <> show other)+        other -> expectationFailure ("expected one span, got " <> show (length other))++    it "rolls back the append when inline SQL condemns the transaction" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        let target = stream "counter-command-rollback" :: Stream CounterEventStream+        result <-+          runner $+            runCommandWithSql+              defaultRunCommandOptions+              counterEventStream+              target+              (Add 1)+              (\_ -> Tx.condemn >> pure ("rolled-back" :: Text))+        case result of+          Right (Right (_, Just "rolled-back")) -> pure ()+          other -> expectationFailure ("expected condemned transaction result, got " <> show other)+        Right recorded <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "counter-command-rollback") (StreamVersion 0) 10+        recorded `shouldBe` Vector.empty++    it "appends all events emitted by one accepted command" $ \storeHandle -> do+      let target = stream "counter-command-multi-create" :: Stream CounterEventStream+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 5)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 2+          commandResult ^. #eventsAppended `shouldBe` 2+          commandResult ^. #globalPosition `shouldSatisfy` isJust+        other -> expectationFailure ("expected successful multi-event command, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-multi-create") (StreamVersion 0) 10+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 5, CounterAudited 5]++    it "counts and traces a just-appended batch that cannot replay" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (metricProvider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter metricProvider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      (processor, spansRef) <- inMemoryListExporter+      tracerProvider <- createTracerProvider [processor] emptyTracerProviderOptions+      let tracer = makeTracer tracerProvider "keiro-test" tracerOptions+          target = stream "counter-command-replay-divergence" :: Stream CounterEventStream+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #tracer+              ?~ tracer+      Right (Right commandResult) <-+        Store.runStoreIO storeHandle $+          runCommand options headUnrecoverableEventStream target (Add 2)+      commandResult ^. #streamVersion `shouldBe` StreamVersion 2+      commandResult ^. #eventsAppended `shouldBe` 2+      _ <- forceFlushMeterProvider metricProvider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.apply.divergence" (flattenScalarPoints exported)+        `shouldBe` Just (IntNumber 1)+      next <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions headUnrecoverableEventStream target (Add 3)+      case next of+        Right (Left HydrationReplayFailed {}) -> pure ()+        other -> expectationFailure ("expected the witnessed divergence to poison hydration, got " <> show other)+      _ <- shutdownTracerProvider tracerProvider Nothing+      spans <- traverse captureSpan =<< readIORef spansRef+      case spans of+        [sp] ->+          textAttr (csAttributes sp) "keiro.replay.divergence"+            `shouldBe` Just "event_index=0;reason=no_inverting_edge"+        other -> expectationFailure ("expected one divergence span, got " <> show (length other))++    it "skips replay verification for a snapshot-less stream when disabled" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "counter-command-replay-divergence-disabled" :: Stream CounterEventStream+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #verifyReplayOnAppend+              .~ False+      Right (Right commandResult) <-+        Store.runStoreIO storeHandle $+          runCommand options headUnrecoverableEventStream target (Add 2)+      commandResult ^. #eventsAppended `shouldBe` 2+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.apply.divergence" (flattenScalarPoints exported)+        `shouldBe` Nothing++    it "witnesses replay divergence on the transactional SQL append path" $ \_ ->+      withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+        (exporter, metricsRef) <- inMemoryMetricExporter+        (provider, _env) <-+          createMeterProvider+            emptyMaterializedResources+            defaultSdkMeterProviderOptions {metricExporter = Just exporter}+        meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+        keiroMetrics <- Telemetry.newKeiroMetrics meter+        let target = stream "counter-command-replay-divergence-sql" :: Stream CounterEventStream+            options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+        Right (Right (commandResult, Just ())) <-+          runner $+            runCommandWithSqlEvents+              options+              headUnrecoverableEventStream+              target+              (Add 2)+              (\_ _ -> pure ())+        commandResult ^. #eventsAppended `shouldBe` 2+        _ <- forceFlushMeterProvider provider Nothing+        exported <- readIORef metricsRef+        lookup "keiro.snapshot.apply.divergence" (flattenScalarPoints exported)+          `shouldBe` Just (IntNumber 1)++    it "replays a prior multi-event command before appending the next batch" $ \storeHandle -> do+      let target = stream "counter-command-multi-replay" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 2)+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiCounterEventStream target (Add 3)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 4+          commandResult ^. #eventsAppended `shouldBe` 2+        other -> expectationFailure ("expected successful second multi-event command, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-multi-replay") (StreamVersion 0) 10+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 2, CounterAudited 2, CounterAdded 3, CounterAudited 3]++    it "passes the complete multi-event batch to inline SQL in append order" $ \_ ->+      withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+        let target = stream "counter-command-multi-sql-events" :: Stream CounterEventStream+        result <-+          runner $+            runCommandWithSqlEvents+              defaultRunCommandOptions+              multiCounterEventStream+              target+              (Add 8)+              (\pairs _ -> pure (Prelude.map Prelude.fst pairs))+        case result of+          Right (Right (commandResult, Just observed)) -> do+            commandResult ^. #streamVersion `shouldBe` StreamVersion 2+            commandResult ^. #eventsAppended `shouldBe` 2+            observed `shouldBe` [CounterAdded 8, CounterAudited 8]+          other -> expectationFailure ("expected successful SQL multi-event command, got " <> show other)++    it "command metadata is merged into stored event metadata" $ \storeHandle -> do+      let target = stream "counter-command-metadata" :: Stream CounterEventStream+          opts =+            defaultRunCommandOptions+              & #metadata+              ?~ object ["actor" Aeson..= ("agent-7" :: Text)]+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand opts counterEventStream target (Add 4)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "counter-command-metadata") (StreamVersion 0) 10+      case Vector.toList recorded of+        [event] ->+          event ^. #metadata+            `shouldBe` Just (object ["actor" Aeson..= ("agent-7" :: Text), "schemaVersion" Aeson..= (1 :: Int)])+        other -> expectationFailure ("expected a single recorded event, got " <> show other)++    it "reconstructed RecordedEvents match the stored batch" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        let target = stream "counter-reconstruct-fidelity" :: Stream CounterEventStream+            opts =+              defaultRunCommandOptions+                & #metadata+                ?~ object ["actor" Aeson..= ("agent-7" :: Text)]+        Right (Right (_, Just pairs)) <-+          runner $+            runCommandWithSqlEvents opts multiCounterEventStream target (Add 8) (\ps _ -> pure ps)+        let reconstructed = Prelude.map Prelude.snd pairs+        -- Read the stored events back from their source stream.+        Right storedVec <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "counter-reconstruct-fidelity") (StreamVersion 0) 10+        let stored = Vector.toList storedVec+        -- readStreamForward reports globalPosition 0 for stream reads, so take+        -- the true global positions from a category read (the DB is fresh per+        -- test, so category "counter" holds exactly this batch).+        Right catVec <-+          Store.runStoreIO storeHandle $+            Store.readCategory (CategoryName "counter") (GlobalPosition 0) 10+        let catList = Vector.toList catVec+        Prelude.length reconstructed `shouldBe` 2+        Prelude.length stored `shouldBe` 2+        fmap (^. #eventId) reconstructed `shouldBe` fmap (^. #eventId) stored+        fmap (^. #eventType) reconstructed `shouldBe` fmap (^. #eventType) stored+        fmap (^. #streamVersion) reconstructed `shouldBe` fmap (^. #streamVersion) stored+        fmap (^. #originalVersion) reconstructed `shouldBe` fmap (^. #originalVersion) stored+        fmap (^. #originalStreamId) reconstructed `shouldBe` fmap (^. #originalStreamId) stored+        fmap (^. #payload) reconstructed `shouldBe` fmap (^. #payload) stored+        fmap (^. #metadata) reconstructed `shouldBe` fmap (^. #metadata) stored+        fmap (^. #globalPosition) reconstructed `shouldBe` fmap (^. #globalPosition) catList++    it "runCommand emits a Command span with the stream name, db.system.name, and keiro.events.appended" $ \storeHandle -> do+      (processor, spansRef) <- inMemoryListExporter+      provider <- createTracerProvider [processor] emptyTracerProviderOptions+      let tracer = makeTracer provider "keiro-test" tracerOptions+          target = stream "counter-command-otel" :: Stream CounterEventStream+          options = defaultRunCommandOptions & #tracer ?~ tracer+      Right (Right commandResult) <-+        Store.runStoreIO storeHandle $+          runCommand options counterEventStream target (Add 9)+      commandResult ^. #streamVersion `shouldBe` StreamVersion 1+      _ <- shutdownTracerProvider provider Nothing+      spans <- traverse captureSpan =<< readIORef spansRef+      length spans `shouldBe` 1+      let sp = case spans of+            (s : _) -> s+            [] -> error "no command span captured"+      csName sp `shouldBe` "counter-command-otel"+      show (csKind sp) `shouldBe` "Internal"+      textAttr (csAttributes sp) "keiro.stream.name" `shouldBe` Just "counter-command-otel"+      textAttr (csAttributes sp) "db.system.name" `shouldBe` Just "postgresql"+      -- keiro.events.appended is an Int64 attribute, not Text.+      case lookupAttribute (csAttributes sp) "keiro.events.appended" of+        Just (AttributeValue (IntAttribute n)) -> n `shouldBe` 1+        other -> expectationFailure ("expected IntAttribute 1, got " <> show other)+      case csStatus sp of+        Unset -> pure ()+        Ok -> pure ()+        other -> expectationFailure ("expected Unset/Ok, got " <> show other)++  describe "Keiro.Command enrichment parity" $ do+    let addMarker eventData = pure (eventData & #metadata %~ injectMarker)+        injectMarker = \case+          Just (Aeson.Object fields) ->+            Just (Aeson.Object (KeyMap.insert "enriched" (Aeson.Bool True) fields))+          _ -> Just (object ["enriched" Aeson..= True])+        installHook = #storeSettings . #enrichEvent ?~ addMarker+        hasMarker = \case+          Just (Aeson.Object fields) ->+            KeyMap.lookup "enriched" fields == Just (Aeson.Bool True)+          _ -> False+    around (withFreshResourceStoreWith fixture installHook) $+      it "applies the store enrichment hook to both command append paths" $ \(_storeHandle, StoreRunner runner) -> do+        let plainTarget = stream "enrich-plain" :: Stream CounterEventStream+            transactionalTarget = stream "enrich-transactional" :: Stream CounterEventStream+        Right (Right _) <-+          runner $+            runCommand defaultRunCommandOptions counterEventStream plainTarget (Add 1)+        Right (Right (_, Just callbackRecordeds)) <-+          runner $+            runCommandWithSqlEvents+              defaultRunCommandOptions+              counterEventStream+              transactionalTarget+              (Add 1)+              (\pairs _ -> pure (fmap snd pairs))+        Right plainEvents <-+          runner $+            Store.readStreamForward (StreamName "enrich-plain") (StreamVersion 0) 10+        Right transactionalEvents <-+          runner $+            Store.readStreamForward (StreamName "enrich-transactional") (StreamVersion 0) 10+        for_ (Vector.toList plainEvents <> Vector.toList transactionalEvents) $ \recorded ->+          recorded ^. #metadata `shouldSatisfy` hasMarker+        for_ callbackRecordeds $ \recorded ->+          recorded ^. #metadata `shouldSatisfy` hasMarker++  describe "Keiro.Snapshot" $ around (withFreshStore fixture) $ do+    it "reports an ErrorCall when strict encoding reaches an empty register slot" $ \_storeHandle -> do+      result <-+        encodeSnapshotStrict+          (defaultStateCodec @SnapshotCounterRegs @CounterState 1)+          (Counting, emptyRegFile @SnapshotCounterRegs)+      case result of+        Left err -> displayException err `shouldSatisfy` isInfixOf "uninit: lastAmount"+        Right _ -> expectationFailure "expected strict snapshot encoding to fail on an empty register slot"++    it "writes a snapshot after policy threshold" $ \storeHandle -> do+      let target = stream "snapshot-write-threshold" :: Stream SnapshotCounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)+      Right snapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-write-threshold" snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 2)++    it "does not fail a committed command when the post-commit snapshot write fails" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-write-failure-swallowed" :: Stream SnapshotCounterEventStream+          options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 2)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_snapshots ADD CONSTRAINT keiro_snapshots_no_writes CHECK (false) NOT VALID"+      result <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 3)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 2+          commandResult ^. #eventsAppended `shouldBe` 1+        other -> expectationFailure ("expected committed command despite snapshot failure, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "snapshot-write-failure-swallowed") (StreamVersion 0) 10+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 2, CounterAdded 3]+      Right snapshotVersionDuringFailure <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-write-failure-swallowed" snapshotVersionForStreamStmt+      snapshotVersionDuringFailure `shouldBe` Nothing+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.write.failures" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_snapshots DROP CONSTRAINT keiro_snapshots_no_writes"+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 4)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 5)+      Right snapshotVersionAfterRecovery <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-write-failure-swallowed" snapshotVersionForStreamStmt+      snapshotVersionAfterRecovery `shouldBe` Just (StreamVersion 4)++    it "does not fail a committed command when strict snapshot encoding fails" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-encode-failure-swallowed" :: Stream PartialSnapshotEventStream+          options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+      result <-+        Store.runStoreIO storeHandle $+          runCommand options partialSnapshotEventStream target (Add 7)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 1+          commandResult ^. #eventsAppended `shouldBe` 1+        other -> expectationFailure ("expected committed command despite snapshot encode failure, got " <> show other)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "snapshot-encode-failure-swallowed") (StreamVersion 0) 10+      traverse (decodeRecorded counterCodec) (Vector.toList recorded)+        `shouldBe` Right [CounterAdded 7]+      Right snapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-encode-failure-swallowed" snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Nothing+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.snapshot.encode.failures" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.snapshot.write.failures" scalars `shouldBe` Nothing++    it "hydrates from snapshot and replays only the tail" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-tail-hydration" :: Stream SnapshotCounterEventStream+          options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 3)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement+              ( "snapshot-tail-hydration",+                (defaultStateCodec @SnapshotCounterRegs @CounterState 1 ^. #encode)+                  (Counting, RCons (Proxy @"lastAmount") 4 RNil)+              )+              corruptSnapshotStateStmt+      result <-+        Store.runStoreIO storeHandle $+          runCommand options guardedSnapshotCounterEventStream target (Add 4)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 3+        other -> expectationFailure ("expected snapshot-assisted command, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.read.hits" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)++    it "falls back when snapshot JSON is corrupt" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-corrupt-json" :: Stream SnapshotCounterEventStream+          options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 3)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("snapshot-corrupt-json", Aeson.String "bad") corruptSnapshotStateStmt+      result <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 4)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 3+        other -> expectationFailure ("expected corrupt snapshot fallback, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 3)++    it "falls back when shape hash mismatches" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-shape-mismatch" :: Stream SnapshotCounterEventStream+          options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 3)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("snapshot-shape-mismatch", "stale-shape") corruptSnapshotShapeStmt+      result <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 4)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 3+        other -> expectationFailure ("expected stale shape fallback, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 3)+      lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Nothing++    it "invalidates a snapshot when the control-state shape changes" $ \storeHandle -> do+      let targetStreamName = StreamName "snapshot-state-shape-change"+          target = stream "snapshot-state-shape-change" :: Stream SnapshotCounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)+      lookupResult <-+        Store.runStoreIO storeHandle $+          lookupSnapshotSeed+            targetStreamName+            (defaultStateCodec @SnapshotCounterRegs @CounterStateV2 1)+      case lookupResult of+        Right (SnapshotUnavailable SnapshotNotFound) -> pure ()+        _ -> expectationFailure "expected the changed control-state shape to miss the stored snapshot"++    it "uses the fold fingerprint as a snapshot discriminator" $ \storeHandle -> do+      let targetStreamName = StreamName "snapshot-fold-fingerprint-lookup"+          target = stream "snapshot-fold-fingerprint-lookup" :: Stream SnapshotCounterEventStream+          foldV1Codec =+            defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v1")+              1+          foldV2Codec =+            defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v2")+              1+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)+      sameFingerprint <- Store.runStoreIO storeHandle $ lookupSnapshotSeed targetStreamName foldV1Codec+      case sameFingerprint of+        Right (SnapshotHit seed) -> seed ^. #streamVersion `shouldBe` StreamVersion 2+        _ -> expectationFailure "expected an equal fold fingerprint to reuse the snapshot"+      changedFingerprint <- Store.runStoreIO storeHandle $ lookupSnapshotSeed targetStreamName foldV2Codec+      case changedFingerprint of+        Right (SnapshotUnavailable SnapshotNotFound) -> pure ()+        _ -> expectationFailure "expected a changed fold fingerprint to miss the snapshot"++    it "composes the hand-owned fold version into the state discriminator" $ \_storeHandle -> do+      let plain = defaultStateCodec @SnapshotCounterRegs @CounterState 1+          withFold =+            defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v1")+              1+      withFold ^. #stateShapeHash `shouldBe` (plain ^. #stateShapeHash <> ";fold=fold-v1")+      withFold ^. #stateCodecVersion `shouldBe` plain ^. #stateCodecVersion+      withFold ^. #shapeHash `shouldBe` plain ^. #shapeHash++    it "full-replays under a changed fold and persists the new discriminator" $ \storeHandle -> do+      let targetStreamName = "snapshot-fold-fingerprint-e2e"+          target = stream targetStreamName :: Stream SnapshotCounterEventStream+          candidateCodec =+            defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v2")+              1+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)+      case Keiki.applyEventsEither+        foldV2SnapshotCounterTransducer+        (Counting, RCons (Proxy @"lastAmount") 0 RNil)+        [CounterAdded 2, CounterAdded 3] of+        Right (_, RCons _ fullReplayLastAmount RNil) ->+          fullReplayLastAmount `shouldBe` 4+        Left failure ->+          expectationFailure ("expected full replay under fold v2, got " <> show failure)+      candidateResult <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV2SnapshotCounterEventStream target (Add 104)+      case candidateResult of+        Right (Right result) -> do+          result ^. #streamVersion `shouldBe` StreamVersion 3+          result ^. #eventsAppended `shouldBe` 1+        other -> expectationFailure ("expected changed-fold full replay to accept probe command, got " <> show other)+      Right storedStateShape <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement targetStreamName snapshotStateShapeForStreamStmt+      storedStateShape `shouldBe` Just (candidateCodec ^. #stateShapeHash)++    it "pins the manual-contract hazard when fold logic changes without a discriminator bump" $ \storeHandle -> do+      let targetStreamName = StreamName "snapshot-fold-manual-contract"+          target = stream "snapshot-fold-manual-contract" :: Stream SnapshotCounterEventStream+          unchangedCodec =+            defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v1")+              1+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)+      staleSeed <- Store.runStoreIO storeHandle $ lookupSnapshotSeed targetStreamName unchangedCodec+      case staleSeed of+        Right (SnapshotHit seed) ->+          case seed ^. #registers of+            RCons _ staleLastAmount RNil -> staleLastAmount `shouldBe` 3+        _ -> expectationFailure "expected the unchanged discriminator to serve the stale seed"+      residualResult <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV2WithoutFingerprintBumpEventStream target (Add 104)+      residualResult `shouldBe` Right (Left CommandRejected)++    it "samples a stale accepted seed without failing the command or writing a snapshot" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let targetName = "snapshot-seed-sampled-divergence"+          target = stream targetName :: Stream SnapshotCounterEventStream+          candidateStream :: ValidatedSnapshotCounterEventStream+          candidateStream =+            mkEventStreamOrThrow+              "snapshot-counter-fold-v2-sampled"+              (foldV2WithoutFingerprintBumpEventStreamDef & #snapshotPolicy .~ Never)+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #seedVerifySampleRate+              .~ 1+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)+      result <-+        Store.runStoreIO storeHandle $+          runCommand options candidateStream target (Add 4)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 3+          commandResult ^. #eventsAppended `shouldBe` 1+        other -> expectationFailure ("expected sampled verification to stay advisory, got " <> show other)+      observed <-+        timeout 5_000_000 $+          let awaitDivergence = do+                _ <- forceFlushMeterProvider provider Nothing+                exported <- readIORef metricsRef+                case lookup "keiro.snapshot.seed.divergence" (flattenScalarPoints exported) of+                  Just (IntNumber 1) -> pure ()+                  _ -> threadDelay 10_000 >> awaitDivergence+           in awaitDivergence+      observed `shouldBe` Just ()+      Right snapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement targetName snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 2)++    it "disables sampled seed verification at rate zero" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let targetName = "snapshot-seed-sampling-disabled"+          target = stream targetName :: Stream SnapshotCounterEventStream+          candidateStream :: ValidatedSnapshotCounterEventStream+          candidateStream =+            mkEventStreamOrThrow+              "snapshot-counter-fold-v2-sampling-disabled"+              (foldV2WithoutFingerprintBumpEventStreamDef & #snapshotPolicy .~ Never)+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #seedVerifySampleRate+              .~ 0+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)+      Right (Right commandResult) <-+        Store.runStoreIO storeHandle $+          runCommand options candidateStream target (Add 4)+      commandResult ^. #streamVersion `shouldBe` StreamVersion 3+      threadDelay 100_000+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.seed.divergence" (flattenScalarPoints exported) `shouldBe` Nothing+      Right snapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement targetName snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 2)++    it "falls back after operator truncation" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-operator-truncate" :: Stream SnapshotCounterEventStream+          options = defaultRunCommandOptions & #metrics ?~ keiroMetrics+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 3)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "TRUNCATE keiro.keiro_snapshots"+      result <-+        Store.runStoreIO storeHandle $+          runCommand options snapshotCounterEventStream target (Add 4)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 3+        other -> expectationFailure ("expected truncation fallback, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 3)+      lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Nothing++    it "writes snapshots after applying a complete multi-event command batch" $ \storeHandle -> do+      let target = stream "snapshot-multi-event-batch" :: Stream SnapshotCounterEventStream+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions multiSnapshotCounterEventStream target (Add 9)+      case result of+        Right (Right commandResult) -> do+          commandResult ^. #streamVersion `shouldBe` StreamVersion 2+          commandResult ^. #eventsAppended `shouldBe` 2+        other -> expectationFailure ("expected multi-event snapshot command, got " <> show other)+      Right snapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-multi-event-batch" snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 2)++    it "writes a snapshot when a multi-event append crosses an Every boundary" $ \storeHandle -> do+      let target = stream "snapshot-multi-event-crosses-boundary" :: Stream SnapshotCounterEventStream+          boundaryEventStream :: SnapshotCounterEventStream+          boundaryEventStream =+            snapshotCounterEventStreamDef+              & #transducer+              .~ multiSnapshotCounterTransducer+              & #snapshotPolicy+              .~ Every 3+          validatedBoundaryEventStream = mkEventStreamOrThrow "snapshot-multi-event-crosses-boundary" boundaryEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions validatedBoundaryEventStream target (Add 2)+      Right firstSnapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-multi-event-crosses-boundary" snapshotVersionForStreamStmt+      firstSnapshotVersion `shouldBe` Nothing+      result <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions validatedBoundaryEventStream target (Add 3)+      case result of+        Right (Right commandResult) ->+          commandResult ^. #streamVersion `shouldBe` StreamVersion 4+        other -> expectationFailure ("expected successful boundary-crossing command, got " <> show other)+      Right snapshotVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-multi-event-crosses-boundary" snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 4)++    it "allows an incompatible snapshot codec to replace a higher-version row" $ \storeHandle -> do+      let target = stream "snapshot-codec-rollback-overwrite" :: Stream SnapshotCounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 1)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 2)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 3)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add 4)+      Right snapshotVersionBefore <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-codec-rollback-overwrite" snapshotVersionForStreamStmt+      snapshotVersionBefore `shouldBe` Just (StreamVersion 4)+      let rollbackCodec = defaultStateCodec @SnapshotCounterRegs @CounterState 2+      streamId <-+        Store.runStoreIO storeHandle (Store.lookupStreamId (StreamName "snapshot-codec-rollback-overwrite")) >>= \case+          Right (Just sid) -> pure sid+          other -> expectationFailure ("expected stream id, got " <> show other) *> error "unreachable"+      Right () <-+        Store.runStoreIO storeHandle $+          writeSnapshotRow+            SnapshotWrite+              { streamId = streamId,+                streamVersion = StreamVersion 2,+                state = (rollbackCodec ^. #encode) (Counting, RCons (Proxy @"lastAmount") 2 RNil),+                stateCodecVersion = rollbackCodec ^. #stateCodecVersion,+                regfileShapeHash = rollbackCodec ^. #shapeHash,+                stateShapeHash = rollbackCodec ^. #stateShapeHash+              }+      Right snapshotVersionAfter <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "snapshot-codec-rollback-overwrite" snapshotVersionForStreamStmt+      snapshotVersionAfter `shouldBe` Just (StreamVersion 2)++  describe "Keiro.ReplayAudit" $ around (withFreshStore fixture) $ do+    it "accepts only stream names in the configured category" $ \_ -> do+      ReplayAudit.streamInCategory "counter" (StreamName "counter-one")+        `shouldBe` (Just (Stream.Stream (StreamName "counter-one")) :: Maybe (Stream ()))+      ReplayAudit.streamInCategory "counter" (StreamName "other-one")+        `shouldBe` (Nothing :: Maybe (Stream ()))++    it "catches a removed inverting edge while skipping unaffected streams" $ \storeHandle -> do+      let affectedTarget =+            stream "auditremove-affected" :: Stream CounterEventStream+          unaffectedTarget =+            stream "auditremove-unaffected" :: Stream CounterEventStream+          affected =+            ReplayAudit.AffectedSet+              { affectedEventTypes = Set.singleton (EventType "CounterAdded"),+                includeSnapshotStreams = False+              }+          budget = ReplayAudit.defaultAuditBudget & #parallelism .~ 2+          candidateTarget =+            ReplayAudit.AuditTarget+              { eventStream = auditedCounterEventStream,+                category = "auditremove",+                mkStream = Just . Stream.Stream+              }+          deployedTarget =+            ReplayAudit.AuditTarget+              { eventStream = counterEventStream,+                category = "auditremove",+                mkStream = Just . Stream.Stream+              }+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream affectedTarget (Add 7)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions auditedCounterEventStream unaffectedTarget (Add 9)++      Right candidateReport <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams+            (ReplayAudit.AuditTargeted affected)+            budget+            candidateTarget+      candidateReport ^. #streamsSelected `shouldBe` 1+      candidateReport ^. #streamsSkipped `shouldBe` 1+      candidateReport ^. #failures `shouldBe` 1+      candidateReport ^. #divergences `shouldBe` 0+      candidateReport ^. #rejectedStreams `shouldBe` []+      case candidateReport ^. #results of+        [ ReplayAudit.StreamAuditResult+            _+            ( ReplayAudit.ReplayFailed+                (HydrationReplayFailed _ HydrationNoInvertingEdge)+              )+          ] -> pure ()+        other ->+          expectationFailure+            ("expected a no-inverting-edge audit failure, got " <> show other)++      Right deployedReport <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams+            (ReplayAudit.AuditTargeted affected)+            budget+            deployedTarget+      ReplayAudit.auditExitCode [deployedReport] `shouldBe` 0++      Right eventsAfterAudit <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward+            (StreamName "auditremove-affected")+            (StreamVersion 0)+            10+      Vector.length eventsAfterAudit `shouldBe` 1++    it "proves a replay-only twin preserves the stored guard-tightening history" $ \storeHandle -> do+      let target = stream "divert-audit-replay-only" :: Stream DivertEventStream+          affected =+            ReplayAudit.AffectedSet+              { affectedEventTypes = Set.singleton (EventType "DivertConfirmed"),+                includeSnapshotStreams = False+              }+          budget = ReplayAudit.defaultAuditBudget & #parallelism .~ 1+          auditWith candidate =+            ReplayAudit.auditStreams+              (ReplayAudit.AuditTargeted affected)+              budget+              ReplayAudit.AuditTarget+                { eventStream = candidate,+                  category = "divert",+                  mkStream = Just . Stream.Stream+                }+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions permissiveDivertEventStream target (ConfirmDivert True)++      Right withoutTwin <-+        Store.runStoreIO storeHandle $+          auditWith tightenedDivertEventStream+      withoutTwin ^. #results+        `shouldBe` [ ReplayAudit.StreamAuditResult+                       (StreamName "divert-audit-replay-only")+                       ( ReplayAudit.ReplayFailed+                           (HydrationReplayFailed (StreamVersion 1) HydrationNoInvertingEdge)+                       )+                   ]+      ReplayAudit.auditExitCode [withoutTwin] `shouldBe` 1++      Right withTwin <-+        Store.runStoreIO storeHandle $+          auditWith twinDivertEventStream+      withTwin ^. #results+        `shouldBe` [ ReplayAudit.StreamAuditResult+                       (StreamName "divert-audit-replay-only")+                       ReplayAudit.ReplayOk+                         { ReplayAudit.streamVersion = StreamVersion 1,+                           ReplayAudit.digest = Nothing+                         }+                   ]+      ReplayAudit.auditExitCode [withTwin] `shouldBe` 0++    it "reports a stale accepted snapshot seed as a divergence" $ \storeHandle -> do+      let target =+            stream "auditfold-stale" :: Stream SnapshotCounterEventStream+          auditTarget =+            ReplayAudit.AuditTarget+              { eventStream = foldV2WithoutFingerprintBumpEventStream,+                category = "auditfold",+                mkStream = Just . Stream.Stream+              }+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 7)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 8)++      Right outcome <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStream auditTarget target+      case outcome of+        ReplayAudit.SeedDivergence+          { seedVersion = StreamVersion 2,+            seededDigest,+            fullDigest+          } ->+            seededDigest `shouldNotBe` fullDigest+        other ->+          expectationFailure+            ("expected a stale-seed divergence, got " <> show other)++    it "keeps clean digests stable and resumes without re-auditing" $ \storeHandle -> do+      let targets =+            [ stream "auditclean-one" :: Stream SnapshotCounterEventStream,+              stream "auditclean-two" :: Stream SnapshotCounterEventStream+            ]+          affected =+            ReplayAudit.AffectedSet+              { affectedEventTypes = Set.singleton (EventType "CounterAdded"),+                includeSnapshotStreams = False+              }+          auditTarget =+            ReplayAudit.AuditTarget+              { eventStream = snapshotCounterEventStream,+                category = "auditclean",+                mkStream = Just . Stream.Stream+              }+          unbounded = ReplayAudit.defaultAuditBudget & #parallelism .~ 2+      for_ (zip targets [10, 20]) $ \(target, amount) -> do+        Right (Right _) <-+          Store.runStoreIO storeHandle $+            runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add amount)+        Right (Right _) <-+          Store.runStoreIO storeHandle $+            runCommand defaultRunCommandOptions snapshotCounterEventStream target (Add (amount + 1))+        pure ()++      Right firstFull <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams ReplayAudit.AuditFull unbounded auditTarget+      Right secondFull <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams ReplayAudit.AuditFull unbounded auditTarget+      firstFull ^. #streamsSelected `shouldBe` 2+      firstFull ^. #streamsSkipped `shouldBe` 0+      firstFull ^. #results `shouldBe` secondFull ^. #results++      Right firstPage <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams+            (ReplayAudit.AuditTargeted affected)+            (unbounded & #maxStreams ?~ 1)+            auditTarget+      firstPage ^. #streamsSelected `shouldBe` 1+      firstPage ^. #checkpoint `shouldSatisfy` isJust+      Right secondPage <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams+            (ReplayAudit.AuditTargeted affected)+            ( unbounded+                & #maxStreams+                ?~ 1+                & #resumeFrom+                .~ (firstPage ^. #checkpoint)+            )+            auditTarget+      secondPage ^. #streamsSelected `shouldBe` 1+      let firstNames = Set.fromList ((^. #streamName) <$> firstPage ^. #results)+          secondNames = Set.fromList ((^. #streamName) <$> secondPage ^. #results)+      Set.disjoint firstNames secondNames `shouldBe` True+      firstNames <> secondNames+        `shouldBe` Set.fromList (Stream.streamName <$> targets)++      Right targeted <-+        Store.runStoreIO storeHandle $+          ReplayAudit.auditStreams+            (ReplayAudit.AuditTargeted affected)+            unbounded+            auditTarget+      targeted ^. #results `shouldBe` firstFull ^. #results++  describe "Keiro.Connection projection schema" $+    around (withFreshResourceStoreWith fixture (withProjectionSchema "app_reads")) $ do+      it "places a read-model table in a configured schema, separate from keiro metadata" $ \(storeHandle, StoreRunner runner) -> do+        -- qualifiedTableName builds the app's fully-qualified data table ref.+        qualifiedTableName placedReadModel `shouldBe` "\"app_reads\".\"placed_counter\""++        -- Create the app schema (opt-in) and the qualified read-model table.+        Right () <-+          Store.runStoreIO storeHandle $ do+            ensureProjectionSchema "app_reads"+            initializeRegisteredReadModel placedReadModel initializePlacedTable++        -- Drive a command with the inline projection that writes the app table.+        let target = stream "placed-in-app-reads" :: Stream CounterEventStream+        result <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              target+              (Add 7)+              [placedInlineProjection]+        case result of+          Right (Right _) -> pure ()+          other -> expectationFailure ("expected placed inline projection command, got " <> show other)++        -- Read it back through the configured-schema read model.+        queryResult <-+          Store.runStoreIO storeHandle $+            runQuery Nothing placedReadModel "placed"+        queryResult `shouldBe` Right (Right 7)++        -- Prove placement: the app table is in app_reads, NOT in kiroku, and+        -- Keiro's own metadata (keiro_read_models) is in the keiro schema.+        Right (inApp, inKiroku, keiroMeta) <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              (,,)+                <$> Tx.statement ("app_reads", "placed_counter") pgTableCountStmt+                <*> Tx.statement ("kiroku", "placed_counter") pgTableCountStmt+                <*> Tx.statement ("keiro", "keiro_read_models") pgTableCountStmt+        inApp `shouldBe` (1 :: Int)+        inKiroku `shouldBe` (0 :: Int)+        keiroMeta `shouldBe` (1 :: Int)++  describe "Keiro.ReadModel" $ around (withFreshStore fixture) $ do+    it "queries inline projection with Eventual consistency" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        let target = stream "read-model-inline" :: Stream CounterEventStream+        result <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              target+              (Add 5)+              [counterInlineProjection]+        case result of+          Right (Right commandResult) ->+            commandResult ^. #globalPosition `shouldSatisfy` isJust+          other -> expectationFailure ("expected inline projection command, got " <> show other)+        queryResult <-+          Store.runStoreIO storeHandle $+            runQuery Nothing counterReadModel "inline"+        queryResult `shouldBe` Right (Right 5)++    it "reads the minimum checkpoint across consumer-group subscription members" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Tx.statement ("counter-read-model-sub", 1, 7) upsertSubscriptionCursorMemberStmt+            Tx.statement ("counter-read-model-sub", 2, 3) upsertSubscriptionCursorMemberStmt+      position <-+        Store.runStoreIO storeHandle $+          readSubscriptionPosition "counter-read-model-sub"+      position `shouldBe` Right (Just (GlobalPosition 3))++    it "Strong returns immediately on an empty log" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      queryResult <-+        Store.runStoreIO storeHandle $+          runQueryWith Nothing Strong counterReadModel "empty"+      queryResult `shouldBe` Right (Right 0)++    it "Strong returns immediately when the subscription is already at the store head" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        let target = stream "read-model-strong-at-head" :: Stream CounterEventStream+        Right (Right commandResult) <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              target+              (Add 5)+              [counterInlineProjection]+        globalPosition <- case commandResult ^. #globalPosition of+          Just position -> pure position+          Nothing -> expectationFailure "expected command global position" *> error "unreachable"+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement ("counter-read-model-sub", globalPositionToInt globalPosition) upsertSubscriptionCursorStmt+        queryResult <-+          Store.runStoreIO storeHandle $+            runQueryWith Nothing Strong counterReadModel "inline"+        queryResult `shouldBe` Right (Right 5)++    it "Strong blocks until the subscription reaches the store head captured at query start" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        let target = stream "read-model-strong-blocking" :: Stream CounterEventStream+        Right (Right commandResult) <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              target+              (Add 6)+              [counterInlineProjection]+        globalPosition <- case commandResult ^. #globalPosition of+          Just position -> pure position+          Nothing -> expectationFailure "expected command global position" *> error "unreachable"+        _ <- forkIO $ do+          threadDelay 20000+          advanced <-+            Store.runStoreIO storeHandle $+              Store.runTransaction $+                Tx.statement ("counter-read-model-sub", globalPositionToInt globalPosition) upsertSubscriptionCursorStmt+          case advanced of+            Right () -> pure ()+            Left err -> expectationFailure ("failed to advance subscription cursor: " <> show err)+        queryResult <-+          Store.runStoreIO storeHandle $+            runQueryWith Nothing Strong counterReadModel "inline"+        queryResult `shouldBe` Right (Right 6)++    it "Strong returns when its category is caught up despite another active category" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        let counterTarget = stream "counter-strong-scope" :: Stream CounterEventStream+            otherTarget = stream "otherload-1" :: Stream CounterEventStream+        Right (Right counterResult) <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              counterTarget+              (Add 8)+              [counterInlineProjection]+        counterPosition <- case counterResult ^. #globalPosition of+          Just position -> pure position+          Nothing -> expectationFailure "expected counter global position" *> error "unreachable"+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement+                ("counter-read-model-sub", globalPositionToInt counterPosition)+                upsertSubscriptionCursorStmt+        Right (Right _) <-+          Store.runStoreIO storeHandle $+            runCommand defaultRunCommandOptions counterEventStream otherTarget (Add 1)+        queryResult <-+          Store.runStoreIO storeHandle $+            runQueryWith Nothing Strong counterCategoryReadModel "inline"+        queryResult `shouldBe` Right (Right 8)++    it "inline projection populates actor and source_event_id from command metadata" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        let target = stream "read-model-inline-metadata" :: Stream CounterEventStream+            opts =+              defaultRunCommandOptions+                & #metadata+                ?~ object ["actor" Aeson..= ("agent-7" :: Text)]+        Right (Right _) <-+          runner $+            runCommandWithProjections opts counterEventStream target (Add 5) [counterInlineProjection]+        Right row <-+          Store.runStoreIO storeHandle $+            Store.runTransaction (Tx.statement "inline" selectCounterMetaStmt)+        -- selectCounterMetaStmt returns (amount, actor, source_event_id).+        row `shouldSatisfy` \(amount, actor, srcId) ->+          amount == 5 && actor == Just "agent-7" && isJust srcId++    it "waits for async projection cursor with PositionWait" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        let target = stream "read-model-position-wait" :: Stream CounterEventStream+        Right (Right commandResult) <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              target+              (Add 3)+              [counterInlineProjection]+        globalPosition <- case commandResult ^. #globalPosition of+          Just position -> pure position+          Nothing -> expectationFailure "expected command global position" *> error "unreachable"+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement ("counter-read-model-sub", globalPositionToInt globalPosition) upsertSubscriptionCursorStmt+        queryResult <-+          Store.runStoreIO storeHandle $+            runQueryWith+              Nothing+              (PositionWait (fastWaitOptions & #target .~ Just globalPosition))+              counterReadModel+              "inline"+        queryResult `shouldBe` Right (Right 3)++    it "times out when PositionWait target is not reached" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("counter-read-model-sub", 1) upsertSubscriptionCursorStmt+      queryResult <-+        Store.runStoreIO storeHandle $+          runQueryWith+            Nothing+            (PositionWait (fastWaitOptions & #target .~ Just (GlobalPosition 5)))+            counterReadModel+            "timeout"+      queryResult+        `shouldBe` Right+          (Left (ReadModelWaitTimeout "counter-read-model" (GlobalPosition 5) (GlobalPosition 1)))++    it "does not write the registry row on repeated read-model queries" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      Right (Right 0) <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "no-churn"+      Right xminBefore <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "counter-read-model" readModelXminStmt+      Right (Right 0) <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "no-churn"+      Right xminAfter <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "counter-read-model" readModelXminStmt+      xminAfter `shouldBe` xminBefore++    it "rejects an unregistered model without creating a registry row" $ \storeHandle -> do+      let unregistered :: ReadModel Text Int+          unregistered = counterReadModel & #name .~ ("never-registered" :: Text)+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing unregistered "missing"+      queryResult `shouldBe` Right (Left (ReadModelUnregistered "never-registered"))+      found <-+        Store.runStoreIO storeHandle $+          lookupReadModel "never-registered"+      found `shouldBe` Right Nothing++    it "handles concurrent explicit read-model registration" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction initializeCounterReadModelTable+      resultA <- newEmptyMVar+      resultB <- newEmptyMVar+      _ <-+        forkIO $+          Store.runStoreIO storeHandle (registerReadModelDefinition counterReadModel)+            >>= putMVar resultA+      _ <-+        forkIO $+          Store.runStoreIO storeHandle (registerReadModelDefinition counterReadModel)+            >>= putMVar resultB+      first <- takeMVar resultA+      second <- takeMVar resultB+      first `shouldBe` Right ()+      second `shouldBe` Right ()+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "concurrent"+      queryResult `shouldBe` Right (Right 0)++    it "rejects stale read-model schema" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      Right (Right 0) <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "stale"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("counter-read-model", 99) updateReadModelVersionStmt+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "stale"+      queryResult+        `shouldBe` Right+          (Left (ReadModelStaleSchema "counter-read-model" 1 99 "counter-read-model-v1" "counter-read-model-v1"))++    it "surfaces unknown read-model statuses with the raw status text" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      Right (Right 0) <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "unknown-status"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("counter-read-model", "wedged") updateReadModelStatusStmt+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "unknown-status"+      queryResult+        `shouldBe` Right+          (Left (ReadModelNotLive "counter-read-model" (UnknownStatus "wedged")))++    it "ignores duplicate async event by source_event_id" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      let target = stream "read-model-async-idempotent" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 7)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "read-model-async-idempotent") (StreamVersion 0) 10+      event <- case Vector.toList recorded of+        [onlyEvent] -> pure onlyEvent+        other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"+      Right outcomes <- Store.runStoreIO storeHandle $+        Store.runTransaction $ do+          first <- applyAsyncProjection counterAsyncProjection event+          second <- applyAsyncProjection counterAsyncProjection event+          pure (first, second)+      outcomes `shouldBe` (AsyncApplied, AsyncDuplicate)+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "async-idempotent"+      queryResult `shouldBe` Right (Right 7)++    it "deduplicates async projection application across transactions and reopens after pruning" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction initializeProjectionDedupCounterTable+      Right _ <-+        Store.runStoreIO storeHandle $+          registerReadModel "projection-dedup-counter-model" 1 "projection-dedup-counter-v1"+      let target = stream "read-model-async-dedup-window" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 7)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "read-model-async-dedup-window") (StreamVersion 0) 10+      event <- case Vector.toList recorded of+        [onlyEvent] -> pure onlyEvent+        other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"+      let incrementingProjection =+            AsyncProjection+              { name = "incrementing-async-projection",+                readModelName = "projection-dedup-counter-model",+                subscriptionName = "incrementing-async-projection-sub",+                applyRecorded = \_ -> Tx.statement () incrementProjectionDedupCounterStmt,+                idempotencyKey = \recordedEvent -> recordedEvent ^. #eventId+              }+      Right AsyncApplied <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjection incrementingProjection event+      Right AsyncDuplicate <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjection incrementingProjection event+      Right countAfterDuplicate <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement () selectProjectionDedupCounterStmt+      countAfterDuplicate `shouldBe` 1+      cutoff <- addUTCTime 1 <$> getCurrentTime+      pruned <- Store.runStoreIO storeHandle $ pruneAsyncProjectionDedupBefore cutoff+      pruned `shouldBe` Right 1+      Right AsyncApplied <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjection incrementingProjection event+      Right countAfterPrune <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement () selectProjectionDedupCounterStmt+      countAfterPrune `shouldBe` 2++    it "rebuild repopulates the projection table through the supported workflow" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      let target = stream "read-model-rebuild-runbook" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 7)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "read-model-rebuild-runbook") (StreamVersion 0) 10+      event <- case Vector.toList recorded of+        [onlyEvent] -> pure onlyEvent+        other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"+      Right AsyncApplied <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjection counterAsyncProjection event+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement+              ( "counter-read-model-sub",+                globalPositionToInt (event ^. #globalPosition)+              )+              upsertSubscriptionCursorStmt+      beforeRebuild <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "async-idempotent"+      beforeRebuild `shouldBe` Right (Right 7)++      Right rebuilding <-+        Store.runStoreIO storeHandle $+          Rebuild.startRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      rebuilding ^. #status `shouldBe` Rebuilding+      checkpointAfterReset <-+        Store.runStoreIO storeHandle $+          readSubscriptionPosition "counter-read-model-sub"+      checkpointAfterReset `shouldBe` Right (Just (GlobalPosition 0))+      Right AsyncApplied <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjectionUnfenced counterAsyncProjection event+      Right (Right live) <-+        Store.runStoreIO storeHandle $+          Rebuild.finishRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      live ^. #status `shouldBe` Live++      afterRebuild <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "async-idempotent"+      afterRebuild `shouldBe` Right (Right 7)++    it "keeps a non-empty-log rebuild offline when replay applies nothing" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      let target = stream "read-model-rebuild-empty-replay" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 7)+      Right _ <-+        Store.runStoreIO storeHandle $+          Rebuild.startRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      finishResult <-+        Store.runStoreIO storeHandle $+          Rebuild.finishRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      case finishResult of+        Right (Left (Rebuild.RebuildProducedNoApplies modelName headPosition)) -> do+          modelName `shouldBe` "counter-read-model"+          headPosition `shouldSatisfy` (> GlobalPosition 0)+        other -> expectationFailure ("expected zero-apply guard, got " <> show other)+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "async-idempotent"+      queryResult+        `shouldBe` Right+          (Left (ReadModelNotLive "counter-read-model" Rebuilding))++    it "fences live async application while a model is rebuilding" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      let target = stream "read-model-fenced-apply" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 7)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "read-model-fenced-apply") (StreamVersion 0) 10+      event <- case Vector.toList recorded of+        [onlyEvent] -> pure onlyEvent+        other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"+      Right _ <-+        Store.runStoreIO storeHandle $+          Rebuild.startRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      outcome <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjection counterAsyncProjection event+      outcome `shouldBe` Right AsyncFenced+      Right dedupCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement (counterAsyncProjection ^. #name) projectionDedupCountStmt+      dedupCount `shouldBe` 0+      Right amount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "async-idempotent" selectCounterReadModelStmt+      amount `shouldBe` 0++    it "keeps a live applier out of the rebuild window and reopens it after promotion" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      let target = stream "read-model-fence-race" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 7)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "read-model-fence-race") (StreamVersion 0) 10+      event <- case Vector.toList recorded of+        [onlyEvent] -> pure onlyEvent+        other -> expectationFailure ("expected one event, got " <> show other) *> error "unreachable"+      enterRebuildWindow <- newEmptyMVar+      liveApplyResult <- newEmptyMVar+      _ <-+        forkIO $ do+          takeMVar enterRebuildWindow+          Store.runStoreIO+            storeHandle+            (Store.runTransaction (applyAsyncProjection counterAsyncProjection event))+            >>= putMVar liveApplyResult+      Right _ <-+        Store.runStoreIO storeHandle $+          Rebuild.startRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      putMVar enterRebuildWindow ()+      takeMVar liveApplyResult `shouldReturn` Right AsyncFenced++      Right AsyncApplied <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjectionUnfenced counterAsyncProjection event+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          Rebuild.finishRebuild+            counterReadModel+            [counterAsyncProjection ^. #name]+            (GlobalPosition 0)+      cutoff <- addUTCTime 1 <$> getCurrentTime+      pruned <- Store.runStoreIO storeHandle $ pruneAsyncProjectionDedupBefore cutoff+      pruned `shouldBe` Right 1+      reapplied <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            applyAsyncProjection counterAsyncProjection event+      reapplied `shouldBe` Right AsyncApplied+      queryResult <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterReadModel "async-idempotent"+      queryResult `shouldBe` Right (Right 7)++    it "tracks rebuild state transitions" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          registerReadModelDefinition counterReadModel+      Right rebuilding <-+        Store.runStoreIO storeHandle $+          Rebuild.rebuild counterReadModel+      rebuilding ^. #status `shouldBe` Rebuilding+      Right live <-+        Store.runStoreIO storeHandle $+          Rebuild.promote counterReadModel+      live ^. #status `shouldBe` Live+      Right abandoned <-+        Store.runStoreIO storeHandle $+          Rebuild.abandonRebuild counterReadModel+      abandoned ^. #status `shouldBe` Abandoned++    it "records projection lag behind the log head" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      let target = stream "read-model-lag" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 1)+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 1)+      -- The subscription cursor is never advanced, so the read model is behind+      -- the head by every appended event: the lag gauge records that gap.+      Right () <-+        Store.runStoreIO storeHandle $+          recordProjectionLag (Just keiroMetrics) counterAsyncProjection+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      case lookup "keiro.projection.lag" scalars of+        Just (IntNumber n) -> n `shouldSatisfy` (>= 1)+        other -> expectationFailure ("expected an integer projection lag, got " <> show other)++    it "counts a position-wait timeout in the timeout counter" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("counter-read-model-sub", 1) upsertSubscriptionCursorStmt+      queryResult <-+        Store.runStoreIO storeHandle $+          runQueryWith+            (Just keiroMetrics)+            (PositionWait (fastWaitOptions & #target .~ Just (GlobalPosition 5)))+            counterReadModel+            "timeout"+      queryResult+        `shouldBe` Right+          (Left (ReadModelWaitTimeout "counter-read-model" (GlobalPosition 5) (GlobalPosition 1)))+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      -- The single give-up bumped the counter exactly once.+      lookup "keiro.projection.wait.timeouts" scalars `shouldBe` Just (IntNumber 1)++  describe "Keiro.ProcessManager" $ around (withFreshResourceStore fixture) $ do+    it "advances manager state, emits a deterministic target command once, and schedules a timer" $ \(_storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+      result <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions counterProcessManager sourceEvent (CounterAdded 9)+      case result of+        Right (Right pmResult) -> do+          case pmResult ^. #managerResult of+            PMStateAppended managerResult ->+              managerResult ^. #streamVersion `shouldBe` StreamVersion 1+            other -> expectationFailure ("expected appended manager state, got " <> show other)+          case pmResult ^. #commandResults of+            [PMCommandAppended commandResult] ->+              commandResult ^. #eventsAppended `shouldBe` 1+            other -> expectationFailure ("expected one emitted command, got " <> show other)+          pmResult ^. #timersScheduled `shouldBe` 1+        other -> expectationFailure ("expected process-manager success, got " <> show other)+      Right managerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10+      Right targetEvents <-+        _runner $+          Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10+      Vector.length managerEvents `shouldBe` 1+      Vector.length targetEvents `shouldBe` 1+      timer <-+        _runner $+          claimDueTimer dueTimerTime+      case timer of+        Right (Just row) -> do+          row ^. #processManagerName `shouldBe` "counter-pm"+          row ^. #correlationId `shouldBe` "order-1"+        other -> expectationFailure ("expected scheduled timer row, got " <> show other)++    it "schedules timers when the manager command emits no events" $ \(_storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+      result <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions timerOnlyProcessManager sourceEvent (CounterAdded 9)+      case result of+        Right (Right pmResult) -> do+          case pmResult ^. #managerResult of+            PMStateAppended managerResult -> do+              managerResult ^. #streamVersion `shouldBe` StreamVersion 0+              managerResult ^. #eventsAppended `shouldBe` 0+            other -> expectationFailure ("expected no-op manager state, got " <> show other)+          pmResult ^. #commandResults `shouldBe` []+          pmResult ^. #timersScheduled `shouldBe` 1+        other -> expectationFailure ("expected process-manager success, got " <> show other)+      dueCount <-+        _runner $+          countDueTimers dueTimerTime+      dueCount `shouldBe` Right 1+      timer <-+        _runner $+          claimDueTimer dueTimerTime+      case timer of+        Right (Just row) -> do+          row ^. #processManagerName `shouldBe` "timer-only-pm"+          row ^. #correlationId `shouldBe` "order-1"+        other -> expectationFailure ("expected scheduled timer row, got " <> show other)++    it "treats duplicate input delivery as idempotent state and command dispatch" $ \(_storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid2) (CounterAdded 4)+      Right (Right _) <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions counterProcessManager sourceEvent (CounterAdded 4)+      duplicate <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions counterProcessManager sourceEvent (CounterAdded 4)+      case duplicate of+        Right (Right pmResult) -> do+          pmResult ^. #managerResult `shouldSatisfy` \case+            PMStateDuplicate {} -> True+            _ -> False+          pmResult ^. #commandResults `shouldSatisfy` \case+            [PMCommandDuplicate {}] -> True+            _ -> False+        other -> expectationFailure ("expected idempotent duplicate handling, got " <> show other)+      Right managerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10+      Right targetEvents <-+        _runner $+          Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10+      Vector.length managerEvents `shouldBe` 1+      Vector.length targetEvents `shouldBe` 1++    it "replays a Kiroku dead letter freshly and deduplicates a second replay" $ \(_storeHandle, StoreRunner _runner) -> do+      let subName = SubscriptionName "counter-pm-replay-fresh"+          replayHandler recorded =+            case decodeRecorded counterCodec recorded of+              Left err -> pure (Left (Text.pack (show err)))+              Right input -> do+                outcome <-+                  runProcessManagerOnce+                    defaultRunCommandOptions+                    counterProcessManager+                    recorded+                    input+                pure $+                  case outcome of+                    Left err -> Left (Text.pack (show err))+                    Right result -> Right (classifyProcessManagerReplay result)+      source <- deadLetterCounterSource _storeHandle subName (CounterAdded 7)+      Right listed <- _runner (listSubscriptionDeadLetters subName 0)+      Vector.length listed `shouldBe` 1++      Right firstPass <-+        _runner $+          replaySubscriptionDeadLetters subName 0 replayHandler+      firstPass+        `shouldBe` [ ReplayOutcome+                       { replayGlobalPosition = source ^. #globalPosition,+                         replayEventId = source ^. #eventId,+                         replayResult = ReplayedFresh+                       }+                   ]+      processManagerReplayCounts _storeHandle `shouldReturn` (1, 1)++      Right secondPass <-+        _runner $+          replaySubscriptionDeadLetters subName 0 replayHandler+      secondPass+        `shouldBe` [ ReplayOutcome+                       { replayGlobalPosition = source ^. #globalPosition,+                         replayEventId = source ^. #eventId,+                         replayResult = ReplayedDuplicate+                       }+                   ]+      processManagerReplayCounts _storeHandle `shouldReturn` (1, 1)+      Right retained <- _runner (listSubscriptionDeadLetters subName 0)+      Vector.length retained `shouldBe` 1++    it "reports an already-processed Kiroku dead letter without appending" $ \(_storeHandle, StoreRunner _runner) -> do+      let subName = SubscriptionName "counter-pm-replay-duplicate"+          replayHandler recorded =+            case decodeRecorded counterCodec recorded of+              Left err -> pure (Left (Text.pack (show err)))+              Right input -> do+                outcome <-+                  runProcessManagerOnce+                    defaultRunCommandOptions+                    counterProcessManager+                    recorded+                    input+                pure $+                  case outcome of+                    Left err -> Left (Text.pack (show err))+                    Right result -> Right (classifyProcessManagerReplay result)+      source <- deadLetterCounterSource _storeHandle subName (CounterAdded 8)+      Right (Right _) <-+        _runner $+          runProcessManagerOnce+            defaultRunCommandOptions+            counterProcessManager+            source+            (CounterAdded 8)+      countsBefore <- processManagerReplayCounts _storeHandle++      Right outcomes <-+        _runner $+          replaySubscriptionDeadLetters subName 0 replayHandler+      outcomes+        `shouldBe` [ ReplayOutcome+                       { replayGlobalPosition = source ^. #globalPosition,+                         replayEventId = source ^. #eventId,+                         replayResult = ReplayedDuplicate+                       }+                   ]+      processManagerReplayCounts _storeHandle `shouldReturn` countsBefore++    it "keeps multiple workflow process managers isolated by configured streams and categories" $ \(_storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 6)+          fulfillmentManager =+            workflowProcessManager+              "fulfillment-pm"+              "pm:fulfillment"+              "fulfillment-target-order-1"+          billingManager =+            workflowProcessManager+              "billing-pm"+              "pm:billing"+              "billing-target-order-1"+      fulfillmentResult <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions fulfillmentManager sourceEvent (CounterAdded 6)+      billingResult <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions billingManager sourceEvent (CounterAdded 6)+      assertWorkflowProcessManagerAppended fulfillmentResult+      assertWorkflowProcessManagerAppended billingResult++      Right fulfillmentManagerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:fulfillment-order-1") (StreamVersion 0) 10+      Right billingManagerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:billing-order-1") (StreamVersion 0) 10+      Right fulfillmentTargetEvents <-+        _runner $+          Store.readStreamForward (StreamName "fulfillment-target-order-1") (StreamVersion 0) 10+      Right billingTargetEvents <-+        _runner $+          Store.readStreamForward (StreamName "billing-target-order-1") (StreamVersion 0) 10+      Vector.length fulfillmentManagerEvents `shouldBe` 1+      Vector.length billingManagerEvents `shouldBe` 1+      Vector.length fulfillmentTargetEvents `shouldBe` 1+      Vector.length billingTargetEvents `shouldBe` 1++      Right fulfillmentCategoryEvents <-+        _runner $+          Store.readCategory (CategoryName "pm:fulfillment") (GlobalPosition 0) 10+      Right billingCategoryEvents <-+        _runner $+          Store.readCategory (CategoryName "pm:billing") (GlobalPosition 0) 10+      Right sharedPmCategoryEvents <-+        _runner $+          Store.readCategory (CategoryName "pm") (GlobalPosition 0) 10+      Right sharedPmNamespaceEvents <-+        _runner $+          Store.readCategory (CategoryName "pm:") (GlobalPosition 0) 10+      Vector.length fulfillmentCategoryEvents `shouldBe` 1+      Vector.length billingCategoryEvents `shouldBe` 1+      sharedPmCategoryEvents `shouldBe` Vector.empty+      sharedPmNamespaceEvents `shouldBe` Vector.empty++    it "worker finalizes AckOk through the ack handle on success" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          messages = [(sourceEvent, CounterAdded 9)]+          adapter = inMemoryAdapter decisionsRef messages+      Right () <-+        _runner $+          runProcessManagerWorker defaultRunCommandOptions counterProcessManager adapter Just+      decisions <- readIORef decisionsRef+      decisions `shouldBe` [AckOk]++    it "worker halts instead of acking when a target dispatch is rejected" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          messages = [(sourceEvent, CounterAdded 9)]+          adapter = inMemoryAdapter decisionsRef messages+          rejectingPm =+            (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)+              { targetEventStream = rejectingEventStream+              }+      Right () <-+        _runner $+          runProcessManagerWorker defaultRunCommandOptions rejectingPm adapter Just+      decisions <- readIORef decisionsRef+      decisions `shouldSatisfy` \case+        [AckHalt (HaltFatal _)] -> True+        _ -> False+      Right targetEvents <-+        _runner $+          Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10+      Right managerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10+      Vector.length targetEvents `shouldBe` 0+      Vector.length managerEvents `shouldBe` 1++    it "dead-letters a rejected dispatch and continues to the next event" $ \(_storeHandle, StoreRunner _runner) -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      decisionsRef <- newIORef []+      let first = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          second = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)+          messages = [(first, CounterAdded 9), (second, CounterAdded 1)]+          adapter = inMemoryAdapter decisionsRef messages+          policyPm =+            (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)+              { targetEventStream = rejectNineEventStream+              }+          workerOptions =+            defaultWorkerOptions+              & #rejectedCommandPolicy+              .~ RejectedDeadLetter+              & #metrics+              ?~ keiroMetrics+      Right () <-+        _runner $+          runProcessManagerWorkerWith workerOptions defaultRunCommandOptions policyPm adapter Just+      readIORef decisionsRef `shouldReturn` [AckOk, AckOk]+      Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")+      case deadLetters of+        [row] -> do+          row ^. #dispatcherKind `shouldBe` DispatcherProcessManager+          row ^. #correlationId `shouldBe` "order-1"+          row ^. #sourceEventId `shouldBe` EventId sampleUuid+          row ^. #emitIndex `shouldBe` 0+          row ^. #targetStreamName `shouldBe` StreamName "counter-target-order-1"+          row ^. #errorClass `shouldBe` "command_rejected"+          row ^. #attemptCount `shouldBe` 1+        other -> expectationFailure ("expected one rejected dispatch dead letter, got " <> show other)+      Right targetEvents <-+        _runner $+          Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10+      Right managerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10+      Vector.length targetEvents `shouldBe` 1+      Vector.length managerEvents `shouldBe` 2+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.dispatch.deadlettered" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)++    it "skips a rejected dispatch without writing a dead-letter row" $ \(_storeHandle, StoreRunner _runner) -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          adapter = inMemoryAdapter decisionsRef [(sourceEvent, CounterAdded 9)]+          rejectingPm =+            (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)+              { targetEventStream = rejectingEventStream+              }+          workerOptions =+            defaultWorkerOptions+              & #rejectedCommandPolicy+              .~ RejectedSkip+              & #metrics+              ?~ keiroMetrics+      Right () <-+        _runner $+          runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingPm adapter Just+      readIORef decisionsRef `shouldReturn` [AckOk]+      Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")+      deadLetters `shouldBe` []+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.dispatch.deadlettered" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)++    it "dead-letters a manager-state rejection at emit index minus one" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          adapter = inMemoryAdapter decisionsRef [(sourceEvent, CounterAdded 9)]+          rejectingManager =+            (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)+              { eventStream = rejectingEventStream+              }+          workerOptions = defaultWorkerOptions & #rejectedCommandPolicy .~ RejectedDeadLetter+      Right () <-+        _runner $+          runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingManager adapter Just+      readIORef decisionsRef `shouldReturn` [AckOk]+      Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")+      case deadLetters of+        [row] -> do+          row ^. #emitIndex `shouldBe` (-1)+          row ^. #targetStreamName `shouldBe` StreamName "pm:counter-order-1"+          row ^. #errorClass `shouldBe` "command_rejected"+        other -> expectationFailure ("expected one manager-state dead letter, got " <> show other)+      Right managerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10+      managerEvents `shouldBe` Vector.empty++    it "keeps rejected-dispatch dead letters idempotent on source redelivery" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          adapter = inMemoryAdapter decisionsRef [(sourceEvent, CounterAdded 9), (sourceEvent, CounterAdded 9)]+          rejectingPm =+            (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)+              { targetEventStream = rejectingEventStream+              }+          workerOptions = defaultWorkerOptions & #rejectedCommandPolicy .~ RejectedDeadLetter+      Right () <-+        _runner $+          runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingPm adapter Just+      readIORef decisionsRef `shouldReturn` [AckOk, AckOk]+      Right deadLetters <- _runner (listDispatchDeadLetters "counter-pm")+      Prelude.length deadLetters `shouldBe` 1++    it "records dispatch failures through worker metrics" $ \(_storeHandle, StoreRunner _runner) -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          messages = [(sourceEvent, CounterAdded 9)]+          adapter = inMemoryAdapter decisionsRef messages+          rejectingPm =+            (counterProcessManager :: ProcessManager CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent)+              { targetEventStream = rejectingEventStream+              }+          workerOptions = defaultWorkerOptions & #metrics ?~ keiroMetrics+      Right () <-+        _runner $+          runProcessManagerWorkerWith workerOptions defaultRunCommandOptions rejectingPm adapter Just+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.dispatch.failed" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 1)++    it "classifies transient store failures as retry and deterministic command failures as halt" $ \(_storeHandle, StoreRunner _runner) -> do+      isRejectionClass CommandRejected `shouldBe` True+      isRejectionClass (CommandAmbiguous [0, 1]) `shouldBe` True+      isRejectionClass (EncodeFailed (NonObjectCallerMetadata Aeson.Null)) `shouldBe` False+      ackForCommandError (RetryDelay 5) (StoreFailed (Store.ConnectionLost "boom"))+        `shouldBe` AckRetry (RetryDelay 5)+      ackForCommandError (RetryDelay 5) CommandRejected `shouldSatisfy` \case+        AckHalt (HaltFatal _) -> True+        _ -> False+      ackForCommandError (RetryDelay 5) (CommandAmbiguous [0, 1]) `shouldSatisfy` \case+        AckHalt (HaltFatal _) -> True+        _ -> False++    it "worker applies poison-message policy on decode failure" $ \(_storeHandle, StoreRunner _runner) -> do+      let badMessages = ["not-decodable" :: Text]+      defaultDecisions <- newIORef []+      Right () <-+        _runner $+          runProcessManagerWorker+            defaultRunCommandOptions+            counterProcessManager+            (inMemoryAdapter defaultDecisions badMessages)+            (const Nothing)+      defaultObserved <- readIORef defaultDecisions+      defaultObserved `shouldSatisfy` \case+        [AckHalt (HaltFatal _)] -> True+        _ -> False++      skippedRef <- newIORef []+      skipDecisions <- newIORef []+      let skipOptions =+            defaultWorkerOptions+              & #poisonPolicy+              .~ PoisonSkip (\env -> liftIO (modifyIORef' skippedRef (<> [env ^. #payload])))+      Right () <-+        _runner $+          runProcessManagerWorkerWith+            skipOptions+            defaultRunCommandOptions+            counterProcessManager+            (inMemoryAdapter skipDecisions badMessages)+            (const Nothing)+      readIORef skipDecisions `shouldReturn` [AckOk]+      readIORef skippedRef `shouldReturn` badMessages++      deadLetterDecisions <- newIORef []+      deadLetterRef <- newIORef []+      let deadLetterOptions =+            defaultWorkerOptions+              & #poisonPolicy+              .~ PoisonDeadLetter (\env -> liftIO (modifyIORef' deadLetterRef (<> [env ^. #payload])))+      Right () <-+        _runner $+          runProcessManagerWorkerWith+            deadLetterOptions+            defaultRunCommandOptions+            counterProcessManager+            (inMemoryAdapter deadLetterDecisions badMessages)+            (const Nothing)+      deadLetterObserved <- readIORef deadLetterDecisions+      deadLetterObserved `shouldSatisfy` \case+        [AckDeadLetter (InvalidPayload _)] -> True+        _ -> False+      readIORef deadLetterRef `shouldReturn` badMessages++    it "folds a concurrent duplicate target dispatch to PMCommandDuplicate" $ \(_storeHandle, StoreRunner _runner) -> do+      insertCount <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          commandId = deterministicCommandId "counter-pm" "order-1" (sourceEvent ^. #eventId) 0+          targetStreamName = StreamName "counter-target-order-1"+          insertConcurrentTarget = do+            callNo <- atomicModifyIORef' insertCount (\n -> (n + 1, n))+            when (callNo == 1) $ appendCounterEventWithId _storeHandle targetStreamName commandId (CounterAdded 9)+          options =+            defaultRunCommandOptions+              & #beforeAppend+              .~ insertConcurrentTarget+              & #retryBackoffMicros+              .~ 0+      result <-+        _runner $+          runProcessManagerOnce options counterProcessManager sourceEvent (CounterAdded 9)+      case result of+        Right (Right pmResult) ->+          pmResult ^. #commandResults `shouldSatisfy` \case+            [PMCommandDuplicate duplicateId] -> duplicateId == commandId+            _ -> False+        other -> expectationFailure ("expected duplicate target dispatch fold, got " <> show other)+      Right targetEvents <-+        _runner $+          Store.readStreamForward targetStreamName (StreamVersion 0) 10+      Vector.length targetEvents `shouldBe` 1++    it "folds a concurrent duplicate manager-state append to PMStateDuplicate" $ \(_storeHandle, StoreRunner _runner) -> do+      insertCount <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          managerId = deterministicCommandId "counter-pm" "order-1" (sourceEvent ^. #eventId) (-1)+          managerStreamName = StreamName "pm:counter-order-1"+          insertConcurrentManager = do+            callNo <- atomicModifyIORef' insertCount (\n -> (n + 1, n))+            when (callNo == 0) $ appendCounterEventWithId _storeHandle managerStreamName managerId (CounterAdded 9)+          options =+            defaultRunCommandOptions+              & #beforeAppend+              .~ insertConcurrentManager+              & #retryBackoffMicros+              .~ 0+      result <-+        _runner $+          runProcessManagerOnce options counterProcessManager sourceEvent (CounterAdded 9)+      case result of+        Right (Right pmResult) -> do+          pmResult ^. #managerResult `shouldSatisfy` \case+            PMStateDuplicate duplicateId -> duplicateId == managerId+            _ -> False+          pmResult ^. #commandResults `shouldSatisfy` \case+            [PMCommandAppended {}] -> True+            _ -> False+        other -> expectationFailure ("expected duplicate manager-state fold, got " <> show other)++  describe "Keiro.ProcessManager duplicate confirmation" $ around (withFreshResourceStore fixture) $ do+    it "rejects a duplicate report carrying a different id" $ \(_storeHandle, StoreRunner _runner) -> do+      let targetStreamName = StreamName "duplicate-confirmation-mismatch"+          ourId = EventId sampleUuid+          otherId = EventId sampleUuid2+      appendCounterEventWithId _storeHandle targetStreamName otherId (CounterAdded 1)+      outcome <-+        _runner $+          confirmBenignDuplicate+            targetStreamName+            ourId+            (StoreFailed (Store.DuplicateEvent (Just otherId)))+      outcome `shouldBe` Right False++    it "rejects a matching id that exists only in another stream" $ \(_storeHandle, StoreRunner _runner) -> do+      let targetStreamName = StreamName "duplicate-confirmation-target"+          otherStreamName = StreamName "duplicate-confirmation-other"+          ourId = EventId sampleUuid+          targetEventId = EventId sampleUuid2+      appendCounterEventWithId _storeHandle targetStreamName targetEventId (CounterAdded 1)+      appendCounterEventWithId _storeHandle otherStreamName ourId (CounterAdded 1)+      outcome <-+        _runner $+          confirmBenignDuplicate+            targetStreamName+            ourId+            (StoreFailed (Store.DuplicateEvent (Just ourId)))+      outcome `shouldBe` Right False++    it "confirms matching and id-less duplicate reports when the id is in the target stream" $ \(_storeHandle, StoreRunner _runner) -> do+      let targetStreamName = StreamName "duplicate-confirmation-present"+          ourId = EventId sampleUuid+      appendCounterEventWithId _storeHandle targetStreamName ourId (CounterAdded 1)+      matchingOutcome <-+        _runner $+          confirmBenignDuplicate+            targetStreamName+            ourId+            (StoreFailed (Store.DuplicateEvent (Just ourId)))+      missingDetailOutcome <-+        _runner $+          confirmBenignDuplicate+            targetStreamName+            ourId+            (StoreFailed (Store.DuplicateEvent Nothing))+      matchingOutcome `shouldBe` Right True+      missingDetailOutcome `shouldBe` Right True++    it "rejects non-duplicate command failures" $ \(_storeHandle, StoreRunner _runner) -> do+      let targetStreamName = StreamName "duplicate-confirmation-non-duplicate"+          ourId = EventId sampleUuid+      appendCounterEventWithId _storeHandle targetStreamName ourId (CounterAdded 1)+      outcome <-+        _runner $+          confirmBenignDuplicate+            targetStreamName+            ourId+            (StoreFailed (Store.ConnectionLost "boom"))+      outcome `shouldBe` Right False++  describe "Keiro.ProcessManager snapshots" $ around (withFreshResourceStore fixture) $ do+    it "writes a snapshot of the manager state stream after the policy threshold" $ \(_storeHandle, StoreRunner _runner) -> do+      -- Two distinct source events, both correlating to "order-1", drive the one+      -- manager instance to manager-stream version 2, which Every 2 snapshots.+      let sourceA = recordedFromEventId (EventId sampleUuid) (CounterAdded 2)+          sourceB = recordedFromEventId (EventId sampleUuid2) (CounterAdded 3)+      Right (Right _) <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceA (CounterAdded 2)+      Right (Right _) <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceB (CounterAdded 3)+      Right managerEvents <-+        _runner $+          Store.readStreamForward (StreamName "pm:counter-snap-order-1") (StreamVersion 0) 10+      Vector.length managerEvents `shouldBe` 2+      Right snapshotVersion <-+        _runner $+          Store.runTransaction $+            Tx.statement "pm:counter-snap-order-1" snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 2)++    it "hydrates the manager from its snapshot and replays only the tail" $ \(_storeHandle, StoreRunner _runner) -> do+      -- After the threshold snapshot exists, a third reaction should land on top of+      -- the snapshot at version 3 rather than replaying from version 0.+      let sourceA = recordedFromEventId (EventId sampleUuid) (CounterAdded 2)+          sourceB = recordedFromEventId (EventId sampleUuid2) (CounterAdded 3)+          sourceC = recordedFromEventId (EventId sampleUuid3) (CounterAdded 4)+      Right (Right _) <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceA (CounterAdded 2)+      Right (Right _) <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceB (CounterAdded 3)+      -- Confirm the snapshot is present before the tail-replay reaction.+      Right snapshotVersion <-+        _runner $+          Store.runTransaction $+            Tx.statement "pm:counter-snap-order-1" snapshotVersionForStreamStmt+      snapshotVersion `shouldBe` Just (StreamVersion 2)+      result <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions pmSnapshotProcessManager sourceC (CounterAdded 4)+      case result of+        Right (Right pmResult) ->+          case pmResult ^. #managerResult of+            PMStateAppended managerResult ->+              managerResult ^. #streamVersion `shouldBe` StreamVersion 3+            other -> expectationFailure ("expected appended manager state, got " <> show other)+        other -> expectationFailure ("expected snapshot-assisted PM reaction, got " <> show other)++  describe "Keiro.Router" $ around (withFreshResourceStore fixture) $ do+    it "encodes colon-bearing and non-ASCII id components without collisions" $ \(_storeHandle, StoreRunner _runner) -> do+      let sourceEventId = EventId sampleUuid+          colonLeft =+            deterministicRouterCommandId+              "router:a"+              "key"+              sourceEventId+              (StreamName "target")+              0+          colonRight =+            deterministicRouterCommandId+              "router"+              "a:key"+              sourceEventId+              (StreamName "target")+              0+          unicodeLeft =+            deterministicRouterCommandId+              "router"+              "key"+              sourceEventId+              (StreamName ("target-" <> Text.singleton '\x101'))+              0+          unicodeRight =+            deterministicRouterCommandId+              "router"+              "key"+              sourceEventId+              (StreamName ("target-" <> Text.singleton '\x201'))+              0+      colonLeft `shouldNotBe` colonRight+      unicodeLeft `shouldNotBe` unicodeRight++    it "resolves targets effectfully and fans out one command per target" $ \(_storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      Right () <- _runner $+        Store.runTransaction $ do+          Tx.statement ("g1", "router-target-a") insertRouterTargetStmt+          Tx.statement ("g1", "router-target-b") insertRouterTargetStmt+          Tx.statement ("g1", "router-target-c") insertRouterTargetStmt+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+      Right (RouterResult rs1) <-+        _runner $+          runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")+      length rs1 `shouldBe` 3+      rs1 `shouldSatisfy` all isAppended+      -- Data-dependence is load-bearing: an unseeded group resolves to no+      -- targets, so the count tracks the read model, not a fixed list.+      Right (RouterResult rsEmpty) <-+        _runner $+          runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "no-such-group")+      length rsEmpty `shouldBe` 0+      -- Each resolved target stream received exactly one command.+      Right targetA <-+        _runner $+          Store.readStreamForward (StreamName "router-target-a") (StreamVersion 0) 10+      Right targetB <-+        _runner $+          Store.readStreamForward (StreamName "router-target-b") (StreamVersion 0) 10+      Right targetC <-+        _runner $+          Store.readStreamForward (StreamName "router-target-c") (StreamVersion 0) 10+      Vector.length targetA `shouldBe` 1+      Vector.length targetB `shouldBe` 1+      Vector.length targetC `shouldBe` 1++    it "reports every dispatch as a duplicate on replay, writing no new events" $ \(_storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      Right () <- _runner $+        Store.runTransaction $ do+          Tx.statement ("g1", "router-target-a") insertRouterTargetStmt+          Tx.statement ("g1", "router-target-b") insertRouterTargetStmt+          Tx.statement ("g1", "router-target-c") insertRouterTargetStmt+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+      Right (RouterResult rs1) <-+        _runner $+          runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")+      rs1 `shouldSatisfy` all isAppended+      Right (RouterResult rs2) <-+        _runner $+          runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")+      length rs2 `shouldBe` 3+      rs2 `shouldSatisfy` all isDuplicate+      -- Replay added nothing: each target stream still holds exactly one event.+      Right targetA <-+        _runner $+          Store.readStreamForward (StreamName "router-target-a") (StreamVersion 0) 10+      Right targetB <-+        _runner $+          Store.readStreamForward (StreamName "router-target-b") (StreamVersion 0) 10+      Right targetC <-+        _runner $+          Store.readStreamForward (StreamName "router-target-c") (StreamVersion 0) 10+      Vector.length targetA `shouldBe` 1+      Vector.length targetB `shouldBe` 1+      Vector.length targetC `shouldBe` 1++    it "dedups by target identity when a redelivered resolve reorders targets after a partial dispatch" $ \(_storeHandle, StoreRunner _runner) -> do+      attemptsRef <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          router = unstableRouter attemptsRef $ \case+            0 -> ["swap-a"]+            _ -> ["swap-b", "swap-a"]+      Right (RouterResult firstAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      firstAttempt `shouldSatisfy` all isAppended+      Right (RouterResult secondAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      secondAttempt `shouldSatisfy` \case+        [swapB, swapA] -> isAppended swapB && isDuplicate swapA+        _ -> False+      Right swapAEvents <-+        _runner $+          Store.readStreamForward (StreamName "swap-a") (StreamVersion 0) 10+      Right swapBEvents <-+        _runner $+          Store.readStreamForward (StreamName "swap-b") (StreamVersion 0) 10+      Vector.length swapAEvents `shouldBe` 1+      Vector.length swapBEvents `shouldBe` 1++    it "dispatches a target added by resolve drift instead of misreading it as a duplicate" $ \(_storeHandle, StoreRunner _runner) -> do+      attemptsRef <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          router = unstableRouter attemptsRef $ \case+            0 -> ["growth-a", "growth-b"]+            _ -> ["growth-a", "growth-c"]+      Right (RouterResult firstAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      firstAttempt `shouldSatisfy` all isAppended+      Right (RouterResult secondAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      secondAttempt `shouldSatisfy` \case+        [growthA, growthC] -> isDuplicate growthA && isAppended growthC+        _ -> False+      Right growthAEvents <-+        _runner $+          Store.readStreamForward (StreamName "growth-a") (StreamVersion 0) 10+      Right growthBEvents <-+        _runner $+          Store.readStreamForward (StreamName "growth-b") (StreamVersion 0) 10+      Right growthCEvents <-+        _runner $+          Store.readStreamForward (StreamName "growth-c") (StreamVersion 0) 10+      Vector.length growthAEvents `shouldBe` 1+      Vector.length growthBEvents `shouldBe` 1+      Vector.length growthCEvents `shouldBe` 1++    it "keeps full-completion order swaps idempotent" $ \(_storeHandle, StoreRunner _runner) -> do+      attemptsRef <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          router = unstableRouter attemptsRef $ \case+            0 -> ["order-a", "order-b"]+            _ -> ["order-b", "order-a"]+      Right (RouterResult firstAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      firstAttempt `shouldSatisfy` all isAppended+      Right (RouterResult secondAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      secondAttempt `shouldSatisfy` all isDuplicate+      Right orderAEvents <-+        _runner $+          Store.readStreamForward (StreamName "order-a") (StreamVersion 0) 10+      Right orderBEvents <-+        _runner $+          Store.readStreamForward (StreamName "order-b") (StreamVersion 0) 10+      Vector.length orderAEvents `shouldBe` 1+      Vector.length orderBEvents `shouldBe` 1++    it "keeps dispatches to targets dropped by a later resolve attempt" $ \(_storeHandle, StoreRunner _runner) -> do+      attemptsRef <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          router = unstableRouter attemptsRef $ \case+            0 -> ["drop-a", "drop-b"]+            _ -> ["drop-b"]+      Right (RouterResult firstAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      firstAttempt `shouldSatisfy` all isAppended+      Right (RouterResult secondAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      secondAttempt `shouldSatisfy` \case+        [dropB] -> isDuplicate dropB+        _ -> False+      -- Resolve is authoritative per attempt. Across redeliveries, the+      -- dispatched set is the union of each attempt's resolved targets.+      Right dropAEvents <-+        _runner $+          Store.readStreamForward (StreamName "drop-a") (StreamVersion 0) 10+      Right dropBEvents <-+        _runner $+          Store.readStreamForward (StreamName "drop-b") (StreamVersion 0) 10+      Vector.length dropAEvents `shouldBe` 1+      Vector.length dropBEvents `shouldBe` 1++    it "keeps repeated commands to one target distinct within a resolve batch" $ \(_storeHandle, StoreRunner _runner) -> do+      attemptsRef <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          router = unstableRouter attemptsRef (const ["twin", "twin"])+      Right (RouterResult firstAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      firstAttempt `shouldSatisfy` all isAppended+      Right twinEventsAfterFirstAttempt <-+        _runner $+          Store.readStreamForward (StreamName "twin") (StreamVersion 0) 10+      Vector.length twinEventsAfterFirstAttempt `shouldBe` 2+      Right (RouterResult secondAttempt) <-+        _runner $+          runRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1")+      secondAttempt `shouldSatisfy` all isDuplicate+      Right twinEventsAfterSecondAttempt <-+        _runner $+          Store.readStreamForward (StreamName "twin") (StreamVersion 0) 10+      Vector.length twinEventsAfterSecondAttempt `shouldBe` 2++    it "drains an adapter, dispatching one command per resolved target for every message" $ \(_storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      Right () <- _runner $+        Store.runTransaction $ do+          Tx.statement ("g1", "worker-a") insertRouterTargetStmt+          Tx.statement ("g1", "worker-b") insertRouterTargetStmt+          Tx.statement ("g2", "worker-c") insertRouterTargetStmt+      decisionsRef <- newIORef []+      let sourceEvent1 = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          sourceEvent2 = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)+          messages =+            [ (sourceEvent1, RouteGroup "g1"),+              (sourceEvent2, RouteGroup "g2")+            ]+          adapter = inMemoryAdapter decisionsRef messages+      Right () <-+        _runner $+          runRouterWorker defaultRunCommandOptions demoRouter adapter Just+      decisions <- readIORef decisionsRef+      decisions `shouldBe` [AckOk, AckOk]+      Right wa <-+        _runner $+          Store.readStreamForward (StreamName "worker-a") (StreamVersion 0) 10+      Right wb <-+        _runner $+          Store.readStreamForward (StreamName "worker-b") (StreamVersion 0) 10+      Right wc <-+        _runner $+          Store.readStreamForward (StreamName "worker-c") (StreamVersion 0) 10+      Vector.length wa `shouldBe` 1+      Vector.length wb `shouldBe` 1+      Vector.length wc `shouldBe` 1++    it "finalizes AckHalt rather than AckOk when a dispatched command fails" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          messages = [(sourceEvent, RouteGroup "g1")]+          adapter = inMemoryAdapter decisionsRef messages+      Right () <-+        _runner $+          runRouterWorker defaultRunCommandOptions failingRouter adapter Just+      decisions <- readIORef decisionsRef+      decisions `shouldSatisfy` \case+        [AckHalt (HaltFatal _)] -> True+        _ -> False++    it "dead-letters a rejected router dispatch and acknowledges the source event" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          adapter = inMemoryAdapter decisionsRef [(sourceEvent, RouteGroup "g1")]+          workerOptions = defaultWorkerOptions & #rejectedCommandPolicy .~ RejectedDeadLetter+      Right () <-+        _runner $+          runRouterWorkerWith workerOptions defaultRunCommandOptions failingRouter adapter Just+      readIORef decisionsRef `shouldReturn` [AckOk]+      Right deadLetters <- _runner (listDispatchDeadLetters "failing-router")+      case deadLetters of+        [row] -> do+          row ^. #dispatcherKind `shouldBe` DispatcherRouter+          row ^. #correlationId `shouldBe` "g1"+          row ^. #targetStreamName `shouldBe` StreamName "failing-target"+          row ^. #errorClass `shouldBe` "command_rejected"+        other -> expectationFailure ("expected one router dead letter, got " <> show other)++    it "finalizes AckRetry for a transient thrown resolver error and continues" $ \(_storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      Right () <-+        _runner $+          Store.runTransaction (Tx.statement ("g2", "worker-after-retry") insertRouterTargetStmt)+      decisionsRef <- newIORef []+      attemptsRef <- newIORef (0 :: Int)+      let sourceEvent1 = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          sourceEvent2 = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)+          messages = [(sourceEvent1, RouteGroup "g1"), (sourceEvent2, RouteGroup "g2")]+          adapter = inMemoryAdapter decisionsRef messages+          flakyRouter ::+            (IOE :> es, Store :> es, Error Store.StoreError :> es) =>+            Router RouteGroup (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent es+          flakyRouter =+            Router+              { name = "flaky-router",+                key = \(RouteGroup g) -> g,+                resolve = \(RouteGroup g) -> do+                  attempt <- liftIO (atomicModifyIORef' attemptsRef (\n -> (n + 1, n)))+                  if attempt == 0+                    then throwError (Store.ConnectionLost "injected")+                    else do+                      result <- runQuery Nothing routerTargetsReadModel g+                      pure $ case result of+                        Right targetIds ->+                          [ PMCommand {target = stream targetId, command = Add 1}+                          | targetId <- targetIds+                          ]+                        Left _ -> [],+                targetEventStream = counterEventStream,+                targetProjections = const []+              }+      Right () <-+        _runner $+          runRouterWorker defaultRunCommandOptions flakyRouter adapter Just+      decisions <- readIORef decisionsRef+      decisions `shouldSatisfy` \case+        [AckRetry {}, AckOk] -> True+        _ -> False++    it "finalizes AckHalt for a deterministic thrown resolver error" $ \(_storeHandle, StoreRunner _runner) -> do+      decisionsRef <- newIORef []+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          messages = [(sourceEvent, RouteGroup "g1")]+          adapter = inMemoryAdapter decisionsRef messages+          failingResolveRouter ::+            (Error Store.StoreError :> es) =>+            Router RouteGroup (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent es+          failingResolveRouter =+            Router+              { name = "failing-resolve-router",+                key = \(RouteGroup g) -> g,+                resolve = \_ -> throwError (Store.UnexpectedServerError "XX000" "boom"),+                targetEventStream = counterEventStream,+                targetProjections = const []+              }+      Right () <-+        _runner $+          runRouterWorker defaultRunCommandOptions failingResolveRouter adapter Just+      decisions <- readIORef decisionsRef+      decisions `shouldSatisfy` \case+        [AckHalt (HaltFatal _)] -> True+        _ -> False++    it "folds a concurrent duplicate router dispatch to PMCommandDuplicate" $ \(_storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      Right () <-+        _runner $+          Store.runTransaction (Tx.statement ("g1", "router-duplicate-target") insertRouterTargetStmt)+      insertCount <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          targetStreamName = StreamName "router-duplicate-target"+          commandId =+            deterministicRouterCommandId+              "demo-router"+              "g1"+              (sourceEvent ^. #eventId)+              targetStreamName+              0+          insertConcurrentTarget = do+            callNo <- atomicModifyIORef' insertCount (\n -> (n + 1, n))+            when (callNo == 0) $ appendCounterEventWithId _storeHandle targetStreamName commandId (CounterAdded 1)+          options =+            defaultRunCommandOptions+              & #beforeAppend+              .~ insertConcurrentTarget+              & #retryBackoffMicros+              .~ 0+      result <-+        _runner $+          runRouterOnce options demoRouter sourceEvent (RouteGroup "g1")+      case result of+        Right (RouterResult [PMCommandDuplicate duplicateId]) ->+          duplicateId `shouldBe` commandId+        other -> expectationFailure ("expected duplicate router dispatch fold, got " <> show other)+      Right targetEvents <-+        _runner $+          Store.readStreamForward targetStreamName (StreamVersion 0) 10+      Vector.length targetEvents `shouldBe` 1++    it "dedups a pre-upgrade positional router dispatch during the transition" $ \(_storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      Right () <-+        _runner $+          Store.runTransaction (Tx.statement ("g1", "transition-target") insertRouterTargetStmt)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          legacyId = deterministicCommandId "demo-router" "g1" (sourceEvent ^. #eventId) 0+          targetStreamName = StreamName "transition-target"+      appendCounterEventWithId _storeHandle targetStreamName legacyId (CounterAdded 1)+      result <-+        _runner $+          runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup "g1")+      case result of+        Right (RouterResult [PMCommandDuplicate duplicateId]) ->+          duplicateId `shouldBe` legacyId+        other -> expectationFailure ("expected transition duplicate, got " <> show other)+      Right targetEvents <-+        _runner $+          Store.readStreamForward targetStreamName (StreamVersion 0) 10+      Vector.length targetEvents `shouldBe` 1++  describe "Keiro.Timer" $ around (withFreshStore fixture) $ do+    it "validates worker options before startup" $ \_storeHandle -> do+      shouldBeRight_ (mkTimerWorkerOptions defaultTimerWorkerOptions)+      mkTimerWorkerOptions (defaultTimerWorkerOptions & #maxAttempts ?~ (-1))+        `shouldBeLeft` InvalidTimerMaxAttempts (-1)+      mkTimerWorkerOptions (defaultTimerWorkerOptions & #requeueStuckAfter ?~ 0)+        `shouldBeLeft` InvalidTimerRequeueStuckAfter 0++    it "claims a due timer, fires a command, and marks it complete once" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      let firedEventId = EventId sampleUuid2+      workerResult <- Store.runStoreIO storeHandle $+        runTimerWorker Nothing dueTimerTime $ \_ -> do+          fired <-+            runCommand+              (defaultRunCommandOptions & #eventIds .~ [firedEventId])+              counterEventStream+              (stream "timer-target")+              (Add 11)+          case fired of+            Right _ -> pure (Just firedEventId)+            Left err -> liftIO (expectationFailure ("expected timer command to fire, got " <> show err)) *> pure Nothing+      case workerResult of+        Right (Just timer) ->+          timer ^. #status `shouldBe` Firing+        other -> expectationFailure ("expected fired timer, got " <> show other)+      secondWorkerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker Nothing dueTimerTime (\_ -> pure (Just firedEventId))+      secondWorkerResult `shouldBe` Right Nothing+      Right targetEvents <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "timer-target") (StreamVersion 0) 10+      fmap (^. #eventId) (Vector.toList targetEvents) `shouldBe` [firedEventId]++    it "records timer backlog, fire lag, attempts, and stuck count" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      let firedEventId = EventId sampleUuid2+      workerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker (Just keiroMetrics) dueTimerTime (\_ -> pure (Just firedEventId))+      case workerResult of+        Right (Just _) -> pure ()+        other -> expectationFailure ("expected a fired timer, got " <> show other)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+          hists = flattenHistogramPoints exported+      -- One scheduled+due row at the start of the pass: backlog gauge holds 1.+      lookup "keiro.timer.backlog" scalars `shouldBe` Just (IntNumber 1)+      -- Nothing was stranded in 'firing' before this pass: stuck gauge holds 0.+      lookup "keiro.timer.stuck" scalars `shouldBe` Just (IntNumber 0)+      -- The claimed timer was due exactly at 'now' and is on its first attempt:+      -- one fire.lag observation of 0 ms and one attempts observation of 1.+      [(c, s) | (n, c, s) <- hists, n == "keiro.timer.fire.lag"] `shouldBe` [(1, 0.0)]+      [(c, s) | (n, c, s) <- hists, n == "keiro.timer.attempts"] `shouldBe` [(1, 1.0)]++    it "finds a firing timer with findStuckTimers and requeues it for re-firing" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      -- Strand it in Firing by claiming without firing.+      claimed <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      case claimed of+        Right (Just timer) -> timer ^. #status `shouldBe` Firing+        other -> expectationFailure ("expected a claimed timer, got " <> show other)+      -- It surfaces as stuck under the permissive filter.+      Right stuck <-+        Store.runStoreIO storeHandle $+          findStuckTimers dueTimerTime anyStuckTimer+      fmap (^. #timerId) stuck `shouldBe` [counterTimerRequest ^. #timerId]+      -- A bound it does not meet (only one attempt) excludes it.+      Right unmatched <-+        Store.runStoreIO storeHandle $+          findStuckTimers dueTimerTime (StuckTimerFilter Nothing (Just 5))+      unmatched `shouldBe` []+      -- Requeue is idempotent: True the first time, False once it is scheduled.+      requeued <-+        Store.runStoreIO storeHandle $+          requeueStuckTimer (counterTimerRequest ^. #timerId)+      requeued `shouldBe` Right True+      requeuedAgain <-+        Store.runStoreIO storeHandle $+          requeueStuckTimer (counterTimerRequest ^. #timerId)+      requeuedAgain `shouldBe` Right False+      -- The ordinary loop re-claims and fires it exactly once.+      let firedEventId = EventId sampleUuid2+      workerResult <- Store.runStoreIO storeHandle $+        runTimerWorker Nothing dueTimerTime $ \_ -> do+          fired <-+            runCommand+              (defaultRunCommandOptions & #eventIds .~ [firedEventId])+              counterEventStream+              (stream "timer-target")+              (Add 7)+          case fired of+            Right _ -> pure (Just firedEventId)+            Left err -> liftIO (expectationFailure ("expected timer command to fire, got " <> show err)) *> pure Nothing+      case workerResult of+        Right (Just timer) ->+          timer ^. #status `shouldBe` Firing+        other -> expectationFailure ("expected re-fired timer, got " <> show other)+      secondWorkerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker Nothing dueTimerTime (\_ -> pure (Just firedEventId))+      secondWorkerResult `shouldBe` Right Nothing+      Right targetEvents <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "timer-target") (StreamVersion 0) 10+      fmap (^. #eventId) (Vector.toList targetEvents) `shouldBe` [firedEventId]++    it "re-fires a timer stranded by a crashed worker" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      Right (Just claimed) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      claimed ^. #status `shouldBe` Firing+      realNow <- getCurrentTime+      firedRef <- newIORef []+      let futureNow = addUTCTime 400 realNow+          firedEventId = EventId sampleUuid2+      workerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker Nothing futureNow $ \timer -> do+            liftIO (modifyIORef' firedRef (<> [timer ^. #timerId]))+            pure (Just firedEventId)+      case workerResult of+        Right (Just timer) -> timer ^. #timerId `shouldBe` counterTimerRequest ^. #timerId+        other -> expectationFailure ("expected stale timer to be requeued and claimed, got " <> show other)+      firedTimers <- readIORef firedRef+      firedTimers `shouldBe` [counterTimerRequest ^. #timerId]+      Right statusRow <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement sampleUuid timerStatusAndErrorStmt+      statusRow `shouldBe` Just ("fired", Nothing)+      secondWorkerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker Nothing futureNow (\_ -> pure (Just firedEventId))+      secondWorkerResult `shouldBe` Right Nothing++    it "does not requeue a fresh firing row" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      realNow <- getCurrentTime+      firedRef <- newIORef False+      workerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker Nothing realNow $ \_ -> do+            liftIO (writeIORef firedRef True)+            pure (Just (EventId sampleUuid2))+      workerResult `shouldBe` Right Nothing+      didFire <- readIORef firedRef+      didFire `shouldBe` False+      Right statusRow <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement sampleUuid timerStatusAndErrorStmt+      statusRow `shouldBe` Just ("firing", Nothing)++    it "requeueStuckAfter = Nothing preserves a stranded firing row" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      realNow <- getCurrentTime+      firedRef <- newIORef False+      let opts = defaultTimerWorkerOptions & #requeueStuckAfter .~ Nothing+      workerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorkerWith Nothing opts (addUTCTime 400 realNow) $ \_ -> do+            liftIO (writeIORef firedRef True)+            pure (Just (EventId sampleUuid2))+      workerResult `shouldBe` Right Nothing+      didFire <- readIORef firedRef+      didFire `shouldBe` False+      Right statusRow <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement sampleUuid timerStatusAndErrorStmt+      statusRow `shouldBe` Just ("firing", Nothing)++    it "does not claim a cancelled timer" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      cancelled <-+        Store.runStoreIO storeHandle $+          cancelTimer (counterTimerRequest ^. #timerId)+      cancelled `shouldBe` Right True+      claimed <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      claimed `shouldBe` Right Nothing+      cancelledAgain <-+        Store.runStoreIO storeHandle $+          cancelTimer (counterTimerRequest ^. #timerId)+      cancelledAgain `shouldBe` Right False++    it "dead-letters a timer that exceeds the attempt ceiling and never reclaims it" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      firedRef <- newIORef False+      let firedEventId = EventId sampleUuid2+      -- maxAttempts = Just 0: the first claim sets attempts = 1 > 0, so the+      -- worker dead-letters instead of firing.+      result <- Store.runStoreIO storeHandle $+        runTimerWorkerWith Nothing (defaultTimerWorkerOptions & #maxAttempts .~ Just 0) dueTimerTime $ \_ -> do+          liftIO (writeIORef firedRef True)+          pure (Just firedEventId)+      case result of+        Right (Just timer) ->+          timer ^. #status `shouldBe` Firing+        other -> expectationFailure ("expected a claimed timer, got " <> show other)+      -- The fire action never ran.+      didFire <- readIORef firedRef+      didFire `shouldBe` False+      -- The row landed in 'dead' with the expected reason in last_error.+      Right statusRow <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement sampleUuid timerStatusAndErrorStmt+      statusRow `shouldBe` Just ("dead", Just "timer exceeded attempt ceiling of 0")+      -- A dead row is never re-claimed.+      secondWorkerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker Nothing dueTimerTime (\_ -> pure (Just firedEventId))+      secondWorkerResult `shouldBe` Right Nothing++    it "markTimerFired does not resurrect a dead timer" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      deadened <-+        Store.runStoreIO storeHandle $+          deadLetterTimer (counterTimerRequest ^. #timerId) "operator dead-letter"+      deadened `shouldBe` Right True+      marked <-+        Store.runStoreIO storeHandle $+          markTimerFired (counterTimerRequest ^. #timerId) (EventId sampleUuid2)+      marked `shouldBe` Right False+      Right statusRow <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement sampleUuid timerStatusAndErrorStmt+      statusRow `shouldBe` Just ("dead", Just "operator dead-letter")++    it "records a row stranded in Firing in the stuck gauge" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            scheduleTimerTx counterTimerRequest+      -- Strand it in Firing by claiming without firing (a crashed worker).+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      -- A later pass finds nothing scheduled and due, but sees the stranded row.+      workerResult <-+        Store.runStoreIO storeHandle $+          runTimerWorker (Just keiroMetrics) dueTimerTime (\_ -> pure Nothing)+      workerResult `shouldBe` Right Nothing+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      -- The one firing row is counted as stuck.+      lookup "keiro.timer.stuck" scalars `shouldBe` Just (IntNumber 1)+      -- It is not 'scheduled', so it does not show up as backlog.+      lookup "keiro.timer.backlog" scalars `shouldBe` Just (IntNumber 0)++  describe "Keiro.Outbox.Kafka" $ do+    it "converts an outbox row to a Kafka producer record" $ do+      let envelope = sampleIntegrationEnvelope+          row = sampleOutboxRow envelope+          record = OutboxKafka.outboxRowToKafkaRecord row+      record ^. #topic `shouldBe` envelope ^. #destination+      record ^. #key `shouldBe` Just "order-123"+      record ^. #payload `shouldBe` envelope ^. #payloadBytes+      -- Headers include identity fields and content type.+      let headers = record ^. #headers+          messageIdHeader = Prelude.lookup "keiro-message-id" headers+      messageIdHeader `shouldBe` Just "018f0f18-17aa-7000-8000-0000000000aa"++    it "drops the partition key when the envelope has no key" $ do+      let envelope = sampleIntegrationEnvelope & #key .~ Nothing+          record = OutboxKafka.integrationEventToKafkaRecord envelope+      record ^. #key `shouldBe` Nothing++  describe "Keiro.Outbox" $ around (withFreshStore fixture) $ do+    it "validates publisher options before startup" $ \_storeHandle -> do+      shouldBeRight_ (mkOutboxPublishOptions defaultPublishOptions)+      mkOutboxPublishOptions (defaultPublishOptions & #batchSize .~ 0)+        `shouldBeLeft` InvalidOutboxBatchSize 0+      mkOutboxPublishOptions (defaultPublishOptions & #maxAttempts .~ 0)+        `shouldBeLeft` InvalidOutboxMaxAttempts 0+      mkOutboxPublishOptions (defaultPublishOptions & #publishingTimeout .~ 0)+        `shouldBeLeft` InvalidOutboxPublishingTimeout 0+      mkOutboxPublishOptions (defaultPublishOptions & #backoff .~ ConstantBackoff (-1))+        `shouldBeLeft` InvalidConstantBackoff (-1)+      mkOutboxPublishOptions+        ( defaultPublishOptions+            & #backoff+            .~ ExponentialBackoff+              ExponentialBackoffOptions+                { initial = 0,+                  maxDelay = 1,+                  multiplier = 2+                }+        )+        `shouldBeLeft` InvalidExponentialBackoffInitial 0+      mkOutboxPublishOptions+        ( defaultPublishOptions+            & #backoff+            .~ ExponentialBackoff+              ExponentialBackoffOptions+                { initial = 1,+                  maxDelay = 10,+                  multiplier = 0.5+                }+        )+        `shouldBeLeft` InvalidExponentialBackoffMultiplier 0.5+      mkOutboxPublishOptions+        ( defaultPublishOptions+            & #backoff+            .~ ExponentialBackoff+              ExponentialBackoffOptions+                { initial = 5,+                  maxDelay = 4,+                  multiplier = 2+                }+        )+        `shouldBeLeft` InvalidExponentialBackoffMaxDelay 5 4++    it "enqueues and looks up an outbox row" $ \storeHandle -> do+      let envelope = sampleIntegrationEnvelope+          oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid envelope)+      lookedUp <- Store.runStoreIO storeHandle (lookupOutbox oid)+      case lookedUp of+        Right (Just row) -> do+          row ^. #outboxId `shouldBe` oid+          row ^. #status `shouldBe` OutboxPending+          row ^. #attemptCount `shouldBe` 0+          row ^. #event . #messageId `shouldBe` envelope ^. #messageId+          row ^. #event . #destination `shouldBe` envelope ^. #destination+          row ^. #event . #payloadBytes `shouldBe` envelope ^. #payloadBytes+        other -> expectationFailure ("expected enqueued row, got " <> show other)++    it "claims a pending row, transitions it to publishing, and increments attempt count" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      now <- getCurrentTime+      Right rows <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      case rows of+        [row] -> do+          row ^. #outboxId `shouldBe` oid+          row ^. #status `shouldBe` OutboxPublishing+          row ^. #attemptCount `shouldBe` 1+        other -> expectationFailure ("expected one claimed row, got " <> show other)++    it "claims contiguous per-key runs in one pass" $ \storeHandle -> do+      let keyedRows =+            [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "run-a1" & #key .~ Just "A"),+              (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "run-a2" & #key .~ Just "A"),+              (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "run-a3" & #key .~ Just "A"),+              (outboxIdFromOrdinal 4, sampleIntegrationEnvelope & #messageId .~ "run-a4" & #key .~ Just "A"),+              (outboxIdFromOrdinal 5, sampleIntegrationEnvelope & #messageId .~ "run-a5" & #key .~ Just "A"),+              (outboxIdFromOrdinal 6, sampleIntegrationEnvelope & #messageId .~ "run-b1" & #key .~ Just "B"),+              (outboxIdFromOrdinal 7, sampleIntegrationEnvelope & #messageId .~ "run-b2" & #key .~ Just "B"),+              (outboxIdFromOrdinal 8, sampleIntegrationEnvelope & #messageId .~ "run-b3" & #key .~ Just "B")+            ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) keyedRows+      now <- getCurrentTime+      Right rows <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      fmap (^. #outboxId) rows `shouldBe` fmap fst keyedRows+      fmap (^. #attemptCount) rows `shouldBe` replicate 8 1++    it "does not let a backoff head starve other keys" $ \storeHandle -> do+      let a1Id = outboxIdFromOrdinal 1+          a2Id = outboxIdFromOrdinal 2+          b1Id = outboxIdFromOrdinal 3+          b2Id = outboxIdFromOrdinal 4+          rows =+            [ (a1Id, sampleIntegrationEnvelope & #messageId .~ "backoff-a1" & #key .~ Just "A"),+              (a2Id, sampleIntegrationEnvelope & #messageId .~ "backoff-a2" & #key .~ Just "A"),+              (b1Id, sampleIntegrationEnvelope & #messageId .~ "backoff-b1" & #key .~ Just "B"),+              (b2Id, sampleIntegrationEnvelope & #messageId .~ "backoff-b2" & #key .~ Just "B")+            ]+          failA1 row+            | row ^. #outboxId == a1Id = pure (PublishFailed "wait")+            | otherwise = pure PublishSucceeded+          opts =+            defaultPublishOptions+              & #batchSize+              .~ 1+              & #backoff+              .~ ConstantBackoff 3600+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      Right failedPass <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow failA1) opts Nothing)+      failedPass ^. #retried `shouldBe` 1+      now <- getCurrentTime+      Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      fmap (^. #outboxId) claimed `shouldBe` [b1Id, b2Id]+      Right (Just a2Row) <- Store.runStoreIO storeHandle (lookupOutbox a2Id)+      a2Row ^. #status `shouldBe` OutboxPending++    it "claims contiguous per-source runs in one pass" $ \storeHandle -> do+      let rows =+            [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "source-a1" & #key .~ Just "A"),+              (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "source-b1" & #key .~ Just "B"),+              (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "source-a2" & #key .~ Just "A"),+              (outboxIdFromOrdinal 4, sampleIntegrationEnvelope & #messageId .~ "source-b2" & #key .~ Just "B")+            ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      now <- getCurrentTime+      Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerSourceStream 10 now)+      fmap (^. #outboxId) claimed `shouldBe` fmap fst rows++    it "claims null-keyed rows freely alongside keyed runs" $ \storeHandle -> do+      let rows =+            [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "null-1" & #key .~ Nothing),+              (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "keyed-1" & #key .~ Just "A"),+              (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "null-2" & #key .~ Nothing),+              (outboxIdFromOrdinal 4, sampleIntegrationEnvelope & #messageId .~ "keyed-2" & #key .~ Just "A")+            ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      now <- getCurrentTime+      Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      fmap (^. #outboxId) claimed `shouldBe` fmap fst rows++    it "does not claim a tail while the previous run is still publishing" $ \storeHandle -> do+      let rows =+            [ (outboxIdFromOrdinal 1, sampleIntegrationEnvelope & #messageId .~ "publishing-a1" & #key .~ Just "A"),+              (outboxIdFromOrdinal 2, sampleIntegrationEnvelope & #messageId .~ "publishing-a2" & #key .~ Just "A"),+              (outboxIdFromOrdinal 3, sampleIntegrationEnvelope & #messageId .~ "publishing-a3" & #key .~ Just "A")+            ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      now <- getCurrentTime+      Right firstClaim <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      fmap (^. #outboxId) firstClaim `shouldBe` fmap fst rows+      Right secondClaim <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      secondClaim `shouldBe` []++    it "marks a claimed row as sent with published_at set" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      now <- getCurrentTime+      Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      Right True <- Store.runStoreIO storeHandle (markOutboxSent oid now)+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxSent+      row ^. #publishedAt `shouldSatisfy` isJust+      row ^. #lastError `shouldBe` Nothing++    it "reclaims a row stranded in publishing by a crashed worker through maintenance" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      now <- getCurrentTime+      let pastNow = addUTCTime (-3600) now+      Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      Right () <- Store.runStoreIO storeHandle (backdateOutboxUpdatedAt oid pastNow)+      Right (Just stranded) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      stranded ^. #status `shouldBe` OutboxPublishing+      publishedRef <- newIORef (0 :: Int)+      let publish _ = do+            liftIO (modifyIORef' publishedRef (+ 1))+            pure PublishSucceeded+      Right noPublish <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)+      noPublish ^. #claimed `shouldBe` 0+      Right (Just stillStranded) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      stillStranded ^. #status `shouldBe` OutboxPublishing+      Right maintenance <- Store.runStoreIO storeHandle (outboxMaintenancePass defaultMaintenanceOptions Nothing)+      maintenance ^. #requeued `shouldBe` 1+      maintenance ^. #deadLettered `shouldBe` 0+      Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)+      summary ^. #published `shouldBe` 1+      published <- readIORef publishedRef+      published `shouldBe` 1+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxSent++    it "head-of-line traffic unwedges after reclaim" $ \storeHandle -> do+      let firstId = OutboxId outboxUuid1+          secondId = OutboxId outboxUuid2+          first = sampleIntegrationEnvelope & #messageId .~ "stuck-first" & #key .~ Just "same-key"+          second = sampleIntegrationEnvelope & #messageId .~ "stuck-second" & #key .~ Just "same-key"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx firstId first)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx secondId second)+      now <- getCurrentTime+      let pastNow = addUTCTime (-3600) now+      Right [claimedFirst] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 1 now)+      claimedFirst ^. #outboxId `shouldBe` firstId+      Right () <- Store.runStoreIO storeHandle (backdateOutboxUpdatedAt firstId pastNow)+      publishedRef <- newIORef []+      let publish row = do+            liftIO (modifyIORef' publishedRef (<> [row ^. #outboxId]))+            pure PublishSucceeded+      Right maintenance <- Store.runStoreIO storeHandle (outboxMaintenancePass defaultMaintenanceOptions Nothing)+      maintenance ^. #requeued `shouldBe` 1+      Right firstPass <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)+      firstPass ^. #published `shouldBe` 2+      Right secondPass <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)+      secondPass ^. #published `shouldBe` 0+      published <- readIORef publishedRef+      published `shouldBe` [firstId, secondId]+      Right (Just secondRow) <- Store.runStoreIO storeHandle (lookupOutbox secondId)+      secondRow ^. #status `shouldBe` OutboxSent++    it "does not reclaim a recently claimed row" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      now <- getCurrentTime+      Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      publishedRef <- newIORef (0 :: Int)+      let publish _ = do+            liftIO (modifyIORef' publishedRef (+ 1))+            pure PublishSucceeded+      Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)+      summary ^. #claimed `shouldBe` 0+      published <- readIORef publishedRef+      published `shouldBe` 0+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxPublishing++    it "a throwing batch publish callback fails every row in that publish call" $ \storeHandle -> do+      let throwId = OutboxId outboxUuid1+          okId = OutboxId outboxUuid2+          throwEvent = sampleIntegrationEnvelope & #messageId .~ "throwing-publish" & #key .~ Just "throw-key"+          okEvent = sampleIntegrationEnvelope & #messageId .~ "ok-after-throw" & #key .~ Just "ok-key"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx throwId throwEvent)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx okId okEvent)+      let publish row+            | row ^. #outboxId == throwId = liftIO (throwIO (userError "kafka exploded"))+            | otherwise = pure PublishSucceeded+      Right summary <-+        Store.runStoreIO storeHandle $+          publishClaimedOutbox (perRow publish) (defaultPublishOptions & #backoff .~ ConstantBackoff 0) Nothing+      summary ^. #retried `shouldBe` 2+      summary ^. #published `shouldBe` 0+      Right (Just throwRow) <- Store.runStoreIO storeHandle (lookupOutbox throwId)+      throwRow ^. #status `shouldBe` OutboxFailed+      throwRow ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "kafka exploded")+      Right (Just okRow) <- Store.runStoreIO storeHandle (lookupOutbox okId)+      okRow ^. #status `shouldBe` OutboxFailed+      okRow ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "kafka exploded")++    it "a row that exhausts attempts while crash-looping is dead-lettered by maintenance" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+          opts = defaultMaintenanceOptions & #maxAttempts .~ 1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      now <- getCurrentTime+      let pastNow = addUTCTime (-3600) now+      Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      Right () <- Store.runStoreIO storeHandle (backdateOutboxUpdatedAt oid pastNow)+      Right summary <- Store.runStoreIO storeHandle (outboxMaintenancePass opts Nothing)+      summary ^. #requeued `shouldBe` 0+      summary ^. #deadLettered `shouldBe` 1+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxDead++    it "markOutboxSent does not resurrect a dead row" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+          opts = defaultPublishOptions & #maxAttempts .~ 1 & #backoff .~ ConstantBackoff 0+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      let publish _ = pure (PublishFailed "boom")+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)+      now <- getCurrentTime+      Right marked <- Store.runStoreIO storeHandle (markOutboxSent oid now)+      marked `shouldBe` False+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxDead++    it "publishClaimedOutbox marks success and records failures with last_error" $ \storeHandle -> do+      let okId = OutboxId outboxUuid1+          failId = OutboxId outboxUuid2+          okEvent = sampleIntegrationEnvelope+          failEvent =+            sampleIntegrationEnvelope+              & #messageId+              .~ "msg-fail-1"+              & #key+              .~ Just "order-789"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx okId okEvent)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx failId failEvent)+      let publish row+            | row ^. #outboxId == okId = pure PublishSucceeded+            | otherwise = pure (PublishFailed "broker unreachable")+      Right summary <-+        Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) defaultPublishOptions Nothing)+      summary ^. #claimed `shouldBe` 2+      summary ^. #published `shouldBe` 1+      summary ^. #retried `shouldBe` 1+      summary ^. #dead `shouldBe` 0+      Right (Just okRow) <- Store.runStoreIO storeHandle (lookupOutbox okId)+      okRow ^. #status `shouldBe` OutboxSent+      Right (Just failRow) <- Store.runStoreIO storeHandle (lookupOutbox failId)+      failRow ^. #status `shouldBe` OutboxFailed+      failRow ^. #lastError `shouldBe` Just "broker unreachable"++    it "publishClaimedOutbox hands a same-key run to one batch publish call" $ \storeHandle -> do+      let rows =+            [ (outboxIdFromOrdinal (fromIntegral i), sampleIntegrationEnvelope & #messageId .~ ("batch-ok-" <> Text.pack (show i)) & #key .~ Just "batch-key")+            | i <- [1 .. 10 :: Int]+            ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      invocationRef <- newIORef (0 :: Int)+      let publish claimed = do+            liftIO (modifyIORef' invocationRef (+ 1))+            pure [(row ^. #outboxId, PublishSucceeded) | row <- claimed]+      Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox publish defaultPublishOptions Nothing)+      summary ^. #claimed `shouldBe` 10+      summary ^. #published `shouldBe` 10+      invocations <- readIORef invocationRef+      invocations `shouldBe` 1+      for_ (fmap fst rows) $ \oid -> do+        Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+        row ^. #status `shouldBe` OutboxSent++    it "publishClaimedOutbox skips the same-key suffix after a mid-run failure" $ \storeHandle -> do+      let row1Id = outboxIdFromOrdinal 1+          row2Id = outboxIdFromOrdinal 2+          row3Id = outboxIdFromOrdinal 3+          row4Id = outboxIdFromOrdinal 4+          row5Id = outboxIdFromOrdinal 5+          ids = [row1Id, row2Id, row3Id, row4Id, row5Id]+          rows =+            [ (oid, sampleIntegrationEnvelope & #messageId .~ ("batch-fail-" <> Text.pack (show i)) & #key .~ Just "batch-fail-key")+            | (i, oid) <- zip [1 .. 5 :: Int] ids+            ]+          publish claimed =+            pure+              [ ( row ^. #outboxId,+                  if row ^. #outboxId == row3Id+                    then PublishFailed "pivot failed"+                    else PublishSucceeded+                )+              | row <- claimed+              ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      Right summary <-+        Store.runStoreIO storeHandle $+          publishClaimedOutbox publish (defaultPublishOptions & #backoff .~ ConstantBackoff 0) Nothing+      summary ^. #published `shouldBe` 2+      summary ^. #retried `shouldBe` 3+      Right (Just row1) <- Store.runStoreIO storeHandle (lookupOutbox row1Id)+      Right (Just row2) <- Store.runStoreIO storeHandle (lookupOutbox row2Id)+      Right (Just row3) <- Store.runStoreIO storeHandle (lookupOutbox row3Id)+      Right (Just row4) <- Store.runStoreIO storeHandle (lookupOutbox row4Id)+      Right (Just row5) <- Store.runStoreIO storeHandle (lookupOutbox row5Id)+      row1 ^. #status `shouldBe` OutboxSent+      row2 ^. #status `shouldBe` OutboxSent+      row3 ^. #status `shouldBe` OutboxFailed+      row3 ^. #attemptCount `shouldBe` 1+      row3 ^. #lastError `shouldBe` Just "pivot failed"+      row4 ^. #status `shouldBe` OutboxFailed+      row4 ^. #attemptCount `shouldBe` 0+      row4 ^. #lastError `shouldBe` Just "skipped: earlier record for the same key failed"+      row5 ^. #status `shouldBe` OutboxFailed+      row5 ^. #attemptCount `shouldBe` 0++    it "PerSourceStream keeps one source's failure from skipping another source's rows" $ \storeHandle -> do+      let rowA1 = outboxIdFromOrdinal 1+          rowB1 = outboxIdFromOrdinal 2+          rowA2 = outboxIdFromOrdinal 3+          rowB2 = outboxIdFromOrdinal 4+          mkRow oid src msgId =+            (oid, sampleIntegrationEnvelope & #messageId .~ msgId & #source .~ src & #key .~ Nothing)+          rows =+            [ mkRow rowA1 "per-source-a" "ps-a1",+              mkRow rowB1 "per-source-b" "ps-b1",+              mkRow rowA2 "per-source-a" "ps-a2",+              mkRow rowB2 "per-source-b" "ps-b2"+            ]+          publish claimed =+            pure+              [ ( row ^. #outboxId,+                  if row ^. #outboxId == rowA2+                    then PublishFailed "source-a pivot failed"+                    else PublishSucceeded+                )+              | row <- claimed+              ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      Right summary <-+        Store.runStoreIO storeHandle $+          publishClaimedOutbox publish (defaultPublishOptions & #orderingPolicy .~ PerSourceStream & #backoff .~ ConstantBackoff 0) Nothing+      summary ^. #claimed `shouldBe` 4+      summary ^. #published `shouldBe` 3+      summary ^. #retried `shouldBe` 1+      Right (Just a1) <- Store.runStoreIO storeHandle (lookupOutbox rowA1)+      Right (Just a2) <- Store.runStoreIO storeHandle (lookupOutbox rowA2)+      Right (Just b1) <- Store.runStoreIO storeHandle (lookupOutbox rowB1)+      Right (Just b2) <- Store.runStoreIO storeHandle (lookupOutbox rowB2)+      a1 ^. #status `shouldBe` OutboxSent+      a2 ^. #status `shouldBe` OutboxFailed+      a2 ^. #attemptCount `shouldBe` 1+      a2 ^. #lastError `shouldBe` Just "source-a pivot failed"+      b1 ^. #status `shouldBe` OutboxSent+      b2 ^. #status `shouldBe` OutboxSent++    it "a late failure mark does not clobber a row that already reached a terminal state" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid sampleIntegrationEnvelope)+      now <- getCurrentTime+      Right [_] <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      Right True <- Store.runStoreIO storeHandle (markOutboxSent oid now)+      Right _ <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (markOutboxFailedTx oid "late failure from a timed-out worker" 5 60 now)+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxSent+      row ^. #lastError `shouldBe` Nothing++    it "claims nothing while another transaction holds an uncommitted claim on a key's head" $ \storeHandle -> do+      let headId = outboxIdFromOrdinal 1+          tailId = outboxIdFromOrdinal 2+          rows =+            [ (headId, sampleIntegrationEnvelope & #messageId .~ "claim-race-1" & #key .~ Just "claim-race-key"),+              (tailId, sampleIntegrationEnvelope & #messageId .~ "claim-race-2" & #key .~ Just "claim-race-key")+            ]+          OutboxId headUuid = headId+          holdClaimSql =+            TE.encodeUtf8 $+              "UPDATE keiro.keiro_outbox SET status = 'publishing', attempt_count = attempt_count + 1, updated_at = now() WHERE outbox_id = '"+                <> UUID.toText headUuid+                <> "'"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      holderDone <- newEmptyMVar+      _ <- forkIO $ do+        holder <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $ do+              Tx.sql holdClaimSql+              Tx.sql "SELECT pg_sleep(2)"+        putMVar holderDone holder+      -- Let the holder acquire its uncommitted row lock, then race a claim.+      threadDelay 500000+      now <- getCurrentTime+      Right claimed <- Store.runStoreIO storeHandle (claimOutboxBatch PerKeyHeadOfLine 10 now)+      fmap (^. #outboxId) claimed `shouldBe` []+      Right () <- takeMVar holderDone+      pure ()++    it "StopTheLine publishes singleton batches and skips the unattempted suffix" $ \storeHandle -> do+      let row1Id = outboxIdFromOrdinal 1+          row2Id = outboxIdFromOrdinal 2+          row3Id = outboxIdFromOrdinal 3+          row4Id = outboxIdFromOrdinal 4+          ids = [row1Id, row2Id, row3Id, row4Id]+          rows =+            [ (oid, sampleIntegrationEnvelope & #messageId .~ ("stop-line-" <> Text.pack (show i)) & #key .~ Just "stop-key")+            | (i, oid) <- zip [1 .. 4 :: Int] ids+            ]+          publishRef = fmap (^. #outboxId)+          publish claimed =+            pure+              [ ( row ^. #outboxId,+                  if row ^. #outboxId == row2Id+                    then PublishFailed "stop here"+                    else PublishSucceeded+                )+              | row <- claimed+              ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            traverse_ (uncurry enqueueIntegrationEventTx) rows+      seenRef <- newIORef []+      let trackedPublish claimed = do+            liftIO (modifyIORef' seenRef (<> publishRef claimed))+            publish claimed+          opts = defaultPublishOptions & #orderingPolicy .~ StopTheLine & #backoff .~ ConstantBackoff 0+      Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox trackedPublish opts Nothing)+      summary ^. #published `shouldBe` 1+      summary ^. #retried `shouldBe` 3+      summary ^. #haltedOn `shouldBe` Just row2Id+      seen <- readIORef seenRef+      seen `shouldBe` take 2 ids+      Right (Just row3) <- Store.runStoreIO storeHandle (lookupOutbox row3Id)+      Right (Just row4) <- Store.runStoreIO storeHandle (lookupOutbox row4Id)+      row3 ^. #status `shouldBe` OutboxFailed+      row3 ^. #attemptCount `shouldBe` 0+      row4 ^. #status `shouldBe` OutboxFailed+      row4 ^. #attemptCount `shouldBe` 0++    it "publishClaimedOutbox treats a missing batch outcome as a failed row" $ \storeHandle -> do+      let okId = outboxIdFromOrdinal 1+          missingId = outboxIdFromOrdinal 2+          okEvent = sampleIntegrationEnvelope & #messageId .~ "missing-outcome-ok" & #key .~ Just "ok-key"+          missingEvent = sampleIntegrationEnvelope & #messageId .~ "missing-outcome-fail" & #key .~ Just "missing-key"+          publish claimed =+            pure+              [ (row ^. #outboxId, PublishSucceeded)+              | row <- claimed,+                row ^. #outboxId == okId+              ]+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            enqueueIntegrationEventTx okId okEvent+            enqueueIntegrationEventTx missingId missingEvent+      Right summary <- Store.runStoreIO storeHandle (publishClaimedOutbox publish defaultPublishOptions Nothing)+      summary ^. #published `shouldBe` 1+      summary ^. #retried `shouldBe` 1+      Right (Just missingRow) <- Store.runStoreIO storeHandle (lookupOutbox missingId)+      missingRow ^. #status `shouldBe` OutboxFailed+      missingRow ^. #lastError `shouldBe` Just "publisher returned no outcome"++    it "auto-dead-letters a row after maxAttempts consecutive failures" $ \storeHandle -> do+      let oid = OutboxId outboxUuid1+          event = sampleIntegrationEnvelope & #key .~ Nothing+          opts =+            defaultPublishOptions+              & #batchSize+              .~ 10+              & #maxAttempts+              .~ 3+              & #backoff+              .~ ConstantBackoff 0+              & #orderingPolicy+              .~ BestEffort+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oid event)+      let publish _ = pure (PublishFailed "broker exploded")+      -- First two failures retain Failed status.+      Right s1 <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)+      s1 ^. #retried `shouldBe` 1+      s1 ^. #dead `shouldBe` 0+      Right s2 <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)+      s2 ^. #retried `shouldBe` 1+      s2 ^. #dead `shouldBe` 0+      -- Third failure crosses the threshold.+      Right s3 <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)+      s3 ^. #dead `shouldBe` 1+      Right (Just row) <- Store.runStoreIO storeHandle (lookupOutbox oid)+      row ^. #status `shouldBe` OutboxDead+      -- A dead row is not claimable.+      now <- getCurrentTime+      Right reclaimed <- Store.runStoreIO storeHandle (claimOutboxBatch BestEffort 10 now)+      reclaimed `shouldBe` []++    it "garbageCollectSent deletes only old sent rows" $ \storeHandle -> do+      let oldSentId = OutboxId outboxUuid1+          recentSentId = OutboxId outboxUuid2+          failedId = OutboxId outboxUuid3+          deadId = OutboxId outboxUuid4+          base = sampleIntegrationEnvelope & #key .~ Nothing+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx oldSentId (base & #messageId .~ "gc-old-sent"))+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx recentSentId (base & #messageId .~ "gc-recent-sent"))+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx failedId (base & #messageId .~ "gc-failed"))+      let firstPass row+            | row ^. #outboxId == failedId = pure (PublishFailed "keep failed")+            | otherwise = pure PublishSucceeded+          firstPassOpts =+            defaultPublishOptions+              & #batchSize+              .~ 10+              & #orderingPolicy+              .~ BestEffort+              & #backoff+              .~ ConstantBackoff 3600+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow firstPass) firstPassOpts Nothing)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx deadId (base & #messageId .~ "gc-dead"))+      let deadPass row+            | row ^. #outboxId == deadId = pure (PublishFailed "keep dead")+            | otherwise = pure PublishSucceeded+          deadPassOpts =+            defaultPublishOptions+              & #batchSize+              .~ 10+              & #maxAttempts+              .~ 1+              & #orderingPolicy+              .~ BestEffort+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow deadPass) deadPassOpts Nothing)+      now <- getCurrentTime+      Right () <- Store.runStoreIO storeHandle (backdateOutboxPublishedAt oldSentId (addUTCTime (-3600) now))+      Right deleted <- Store.runStoreIO storeHandle (garbageCollectSent 300 now)+      deleted `shouldBe` 1+      Right oldRow <- Store.runStoreIO storeHandle (lookupOutbox oldSentId)+      oldRow `shouldBe` Nothing+      Right (Just recentRow) <- Store.runStoreIO storeHandle (lookupOutbox recentSentId)+      recentRow ^. #status `shouldBe` OutboxSent+      Right (Just failedRow) <- Store.runStoreIO storeHandle (lookupOutbox failedId)+      failedRow ^. #status `shouldBe` OutboxFailed+      Right (Just deadRow) <- Store.runStoreIO storeHandle (lookupOutbox deadId)+      deadRow ^. #status `shouldBe` OutboxDead++    it "enforces per-key head-of-line blocking and unblocks once the predecessor reaches a terminal state" $ \storeHandle -> do+      let a1Id = OutboxId outboxUuid1+          a2Id = OutboxId outboxUuid2+          b1Id = OutboxId outboxUuid3+          a1 = sampleIntegrationEnvelope & #messageId .~ "a1" & #key .~ Just "k1"+          a2 = sampleIntegrationEnvelope & #messageId .~ "a2" & #key .~ Just "k1"+          b1 = sampleIntegrationEnvelope & #messageId .~ "b1" & #key .~ Just "k2"+      -- Insert in created_at order (a1 first, then a2, then b1).+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx a1Id a1)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx a2Id a2)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx b1Id b1)+      claimed <- newIORef []+      let publish row = do+            liftIO (atomicModifyIORef' claimed (\xs -> ((row ^. #outboxId) : xs, ())))+            if row ^. #outboxId == a1Id+              then pure (PublishFailed "broker hiccup")+              else pure PublishSucceeded+      -- First pass: with a one-row batch, a1 fails and both later rows remain pending.+      let firstPassOpts =+            defaultPublishOptions+              & #batchSize+              .~ 1+              & #backoff+              .~ ConstantBackoff 0+      Right summary1 <-+        Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) firstPassOpts Nothing)+      summary1 ^. #claimed `shouldBe` 1+      claimedIds <- readIORef claimed+      claimedIds `shouldSatisfy` (a2Id `notElem`)+      claimedIds `shouldSatisfy` (a1Id `elem`)+      claimedIds `shouldSatisfy` (b1Id `notElem`)+      Right (Just a1Row) <- Store.runStoreIO storeHandle (lookupOutbox a1Id)+      a1Row ^. #status `shouldBe` OutboxFailed+      Right (Just b1Row) <- Store.runStoreIO storeHandle (lookupOutbox b1Id)+      b1Row ^. #status `shouldBe` OutboxPending+      Right (Just a2Row) <- Store.runStoreIO storeHandle (lookupOutbox a2Id)+      a2Row ^. #status `shouldBe` OutboxPending+      -- Drive a1 to terminal sent state so a2 can move. One pass claims a1+      -- (now that next_attempt_at has passed). A second pass claims a2,+      -- which becomes head-of-line once a1 reaches `sent`.+      writeIORef claimed []+      let publishOk row = do+            liftIO (atomicModifyIORef' claimed (\xs -> ((row ^. #outboxId) : xs, ())))+            pure PublishSucceeded+          retryOpts =+            defaultPublishOptions+              & #batchSize+              .~ 1+              & #backoff+              .~ ConstantBackoff 0+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publishOk) retryOpts Nothing)+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publishOk) retryOpts Nothing)+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publishOk) retryOpts Nothing)+      claimedIds2 <- readIORef claimed+      claimedIds2 `shouldSatisfy` (a1Id `elem`)+      claimedIds2 `shouldSatisfy` (a2Id `elem`)+      claimedIds2 `shouldSatisfy` (b1Id `elem`)+      Right (Just a2Row') <- Store.runStoreIO storeHandle (lookupOutbox a2Id)+      a2Row' ^. #status `shouldBe` OutboxSent++    it "allows null-keyed rows to publish independently" $ \storeHandle -> do+      let n1 = OutboxId outboxUuid1+          n2 = OutboxId outboxUuid2+          e = sampleIntegrationEnvelope & #key .~ Nothing+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx n1 (e & #messageId .~ "n1"))+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx n2 (e & #messageId .~ "n2"))+      let publish row+            | row ^. #outboxId == n1 = pure (PublishFailed "transient")+            | otherwise = pure PublishSucceeded+      Right summary <-+        Store.runStoreIO storeHandle $+          publishClaimedOutbox (perRow publish) (defaultPublishOptions & #backoff .~ ConstantBackoff 0) Nothing+      summary ^. #claimed `shouldBe` 2+      summary ^. #published `shouldBe` 1+      summary ^. #retried `shouldBe` 1++    it "mints message ids with the configured TypeID prefix" $ \storeHandle -> do+      Right minted <-+        Store.runStoreIO storeHandle (mintIntegrationEvent sampleProducer sampleDraft)+      minted ^. #source `shouldBe` "ordering"+      minted ^. #destination `shouldBe` "billing.orders.v1"+      Text.isPrefixOf "msg_" (minted ^. #messageId) `shouldBe` True++    it "validates integration producer message id prefixes before startup" $ \_storeHandle -> do+      shouldBeRight_ (mkIntegrationProducer sampleProducer)+      case mkIntegrationProducer (sampleProducer & #messageIdPrefix .~ "Bad-Prefix") of+        Left (InvalidMessageIdPrefix prefix reason) -> do+          prefix `shouldBe` "Bad-Prefix"+          reason `shouldSatisfy` (not . Text.null)+        other -> expectationFailure ("expected invalid prefix, got " <> show (void other))++    it "draftToEvent stamps source and messageId without minting" $ \_storeHandle -> do+      let event = draftToEvent "ordering" "msg-fixed-1" sampleDraft+      event ^. #messageId `shouldBe` "msg-fixed-1"+      event ^. #source `shouldBe` "ordering"+      event ^. #destination `shouldBe` "billing.orders.v1"++    it "freshOutboxId returns distinct UUIDv7 ids" $ \storeHandle -> do+      Right ids <-+        Store.runStoreIO storeHandle (traverse (\_ -> freshOutboxId) [1 .. 4 :: Int])+      length ids `shouldBe` 4+      length (uniqueIds ids) `shouldBe` 4++    it "publishClaimedOutbox emits a Producer span with messaging semconv attributes" $ \storeHandle -> do+      (processor, spansRef) <- inMemoryListExporter+      provider <- createTracerProvider [processor] emptyTracerProviderOptions+      let tracer = makeTracer provider "keiro-test" tracerOptions+          okId = OutboxId outboxUuid1+          failId = OutboxId outboxUuid2+          okEvent = sampleIntegrationEnvelope+          failEvent =+            sampleIntegrationEnvelope+              & #messageId+              .~ "msg-fail-otel-1"+              & #key+              .~ Just "order-otel-fail"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx okId okEvent)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx failId failEvent)+      let publish row+            | row ^. #outboxId == okId = pure PublishSucceeded+            | otherwise = pure (PublishFailed "broker unreachable")+          opts = defaultPublishOptions & #tracer ?~ tracer+      Right _ <- Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) opts Nothing)+      _ <- shutdownTracerProvider provider Nothing+      spans <- traverse captureSpan =<< readIORef spansRef+      length spans `shouldBe` 1+      case spans of+        [batchSpan] -> do+          csName batchSpan `shouldBe` ("send " <> (okEvent ^. #destination))+          show (csKind batchSpan) `shouldBe` "Producer"+          textAttr (csAttributes batchSpan) "messaging.system" `shouldBe` Just "kafka"+          textAttr (csAttributes batchSpan) "messaging.operation.type" `shouldBe` Just "publish"+          textAttr (csAttributes batchSpan) "messaging.operation.name" `shouldBe` Just "send"+          textAttr (csAttributes batchSpan) "messaging.destination.name"+            `shouldBe` Just (okEvent ^. #destination)+          textAttr (csAttributes batchSpan) "messaging.kafka.message.key"+            `shouldBe` (okEvent ^. #key)+          intAttr (csAttributes batchSpan) "keiro.outbox.batch.size" `shouldBe` Just 2+          textAttr (csAttributes batchSpan) "error.type" `shouldBe` Just "publish_failed"+          case csStatus batchSpan of+            Error msg -> msg `shouldBe` "broker unreachable"+            other -> expectationFailure ("expected Error \"broker unreachable\", got " <> show other)+        other -> expectationFailure ("expected one batch span, got " <> show (length other))++    it "publishClaimedOutbox records counters and sampleOutboxBacklog records the gauge" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let okId = OutboxId outboxUuid1+          failId = OutboxId outboxUuid2+          okEvent = sampleIntegrationEnvelope & #messageId .~ "metrics-ok" & #key .~ Nothing+          failEvent = sampleIntegrationEnvelope & #messageId .~ "metrics-fail" & #key .~ Nothing+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx okId okEvent)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (enqueueIntegrationEventTx failId failEvent)+      let publish row+            | row ^. #outboxId == okId = pure PublishSucceeded+            | otherwise = pure (PublishFailed "broker down")+          retryPassOpts =+            defaultPublishOptions+              & #batchSize+              .~ 10+              & #maxAttempts+              .~ 5+              & #backoff+              .~ ConstantBackoff 0+              & #orderingPolicy+              .~ BestEffort+          deadPassOpts = retryPassOpts & #maxAttempts .~ 1+      -- Pass 1 (maxAttempts = 5): ok publishes, the fail row retries.+      Right summary1 <-+        Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) retryPassOpts (Just keiroMetrics))+      summary1 ^. #published `shouldBe` 1+      summary1 ^. #retried `shouldBe` 1+      -- Pass 2 (maxAttempts = 1): the failed row crosses the ceiling and dies.+      Right summary2 <-+        Store.runStoreIO storeHandle (publishClaimedOutbox (perRow publish) deadPassOpts (Just keiroMetrics))+      summary2 ^. #dead `shouldBe` 1+      -- Flush so the in-memory exporter receives the aggregates.+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      -- Counters are cumulative across both passes.+      lookup "keiro.outbox.published" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.outbox.retried" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.outbox.deadlettered" scalars `shouldBe` Just (IntNumber 1)+      -- Publish passes no longer run the backlog COUNT(*) on the hot path.+      lookup "keiro.outbox.backlog" scalars `shouldBe` Nothing++      Store.runStoreIO storeHandle (sampleOutboxBacklog (Just keiroMetrics)) `shouldReturn` Right ()+      _ <- forceFlushMeterProvider provider Nothing+      sampled <- readIORef metricsRef+      let sampledScalars = flattenScalarPoints sampled+      lookup "keiro.outbox.backlog" sampledScalars `shouldBe` Just (IntNumber 0)++  describe "Keiro.Inbox" $ around (withFreshStore fixture) $ do+    it "runs the handler once and records the row as completed" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-1"+              & #source+              .~ "ordering"+          handler ev =+            Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right result1 <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      case result1 of+        Right (InboxProcessed ()) -> pure ()+        other -> expectationFailure ("expected InboxProcessed, got " <> show other)+      Right rowCount1 <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount1 `shouldBe` 1+      Right (Just inboxRow) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-1")+      inboxRow ^. #status `shouldBe` InboxCompleted+      inboxRow ^. #completedAt `shouldSatisfy` isJust++    it "treats a redelivery with the same messageId as a duplicate" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-dup"+              & #source+              .~ "ordering"+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      Right result2 <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      result2 `shouldBe` Right InboxDuplicate+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 1++    it "records inbox counters and samples backlog separately under the in-memory exporter" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event = sampleIntegrationEnvelope & #messageId .~ "inbox-metrics-dup" & #source .~ "ordering"+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      -- First delivery runs the handler: processed.+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction (Just keiroMetrics) PreferIntegrationMessageId event Nothing handler+      -- Second delivery of the same (source, message_id): duplicate.+      Right result2 <-+        Store.runStoreIO storeHandle $+          runInboxTransaction (Just keiroMetrics) PreferIntegrationMessageId event Nothing handler+      result2 `shouldBe` Right InboxDuplicate+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.inbox.processed" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.inbox.duplicates" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.inbox.backlog" scalars `shouldBe` Nothing+      Store.runStoreIO storeHandle (sampleInboxBacklog (Just keiroMetrics)) `shouldReturn` Right ()+      _ <- forceFlushMeterProvider provider Nothing+      sampled <- readIORef metricsRef+      let sampledScalars = flattenScalarPoints sampled+      lookup "keiro.inbox.backlog" sampledScalars `shouldBe` Just (IntNumber 0)+      -- The handler ran exactly once (the duplicate path does not re-run it).+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 1++    it "deduplicates via PreferSourceEventIdentity even when messageId differs" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let shared = sampleIntegrationEnvelope & #source .~ "ordering"+          first = shared & #messageId .~ "republish-1"+          second = shared & #messageId .~ "republish-2"+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferSourceEventIdentity first Nothing handler+      Right result2 <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferSourceEventIdentity second Nothing handler+      result2 `shouldBe` Right InboxDuplicate++    it "uses KafkaDeliveryIdentity when supplied" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event = sampleIntegrationEnvelope & #source .~ "ordering"+          kafka = KafkaDeliveryRef "billing.orders.v1" 0 17+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing KafkaDeliveryIdentity event (Just kafka) handler+      Right (Right InboxDuplicate) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing KafkaDeliveryIdentity event (Just kafka) handler+      Right (Just row) <-+        Store.runStoreIO storeHandle $+          lookupInbox "ordering" "billing.orders.v1:0:17"+      row ^. #status `shouldBe` InboxCompleted++    it "reports DedupePolicyUnsatisfied when the envelope lacks the required field" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #source+              .~ "ordering"+              & #sourceEventId+              .~ Nothing+              & #sourceGlobalPosition+              .~ Nothing+      Right result <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferSourceEventIdentity event Nothing (\_ -> pure ())+      result `shouldBe` Left (DedupePolicyUnsatisfied PreferSourceEventIdentity)++    it "leaves no inbox row when the handler condemns the transaction" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-rollback"+              & #source+              .~ "ordering"+          handler _ = do+            Tx.condemn+            pure ()+      _ <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      Right row <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-rollback")+      row `shouldBe` Nothing++    it "leaves no inbox row when the plain handler throws" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-throw-plain"+              & #source+              .~ "ordering"+          handler _ = (pure $! error "plain inbox handler failed") :: Tx.Transaction ()+      thrown <-+        try $+          Store.runStoreIO storeHandle $+            runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      case thrown of+        Left (_ :: SomeException) -> pure ()+        Right other -> expectationFailure ("expected handler exception, got " <> show (void other))+      Right row <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-throw-plain")+      row `shouldBe` Nothing++    it "exports markFailedTx from the public inbox module and preserves explicit failure marks" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-public-failed"+              & #source+              .~ "ordering"+          handler _ = do+            markFailedTx "ordering" "inbox-msg-public-failed" "operator failed" (event ^. #occurredAt)+            pure ()+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-public-failed")+      row ^. #status `shouldBe` InboxFailed+      row ^. #lastError `shouldBe` Just "operator failed"++    it "a throwing handler records a failed attempt instead of looping" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-poison-1"+              & #source+              .~ "ordering"+          handler _ = (pure $! error "inbox exploded") :: Tx.Transaction ()+      Right result <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetries Nothing 3 PreferIntegrationMessageId event Nothing handler+      case result of+        Right (InboxHandlerFailed err attempts) -> do+          Text.isInfixOf "inbox exploded" err `shouldBe` True+          attempts `shouldBe` 1+        other -> expectationFailure ("expected InboxHandlerFailed, got " <> show other)+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-poison-1")+      row ^. #status `shouldBe` InboxFailed+      row ^. #attemptCount `shouldBe` 1+      row ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "inbox exploded")++    it "a transient poison message succeeds on retry" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-poison-transient"+              & #source+              .~ "ordering"+          failOnce _ = (pure $! error "temporary inbox failure") :: Tx.Transaction ()+          succeeding ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right result1 <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetries Nothing 3 PreferIntegrationMessageId event Nothing failOnce+      case result1 of+        Right (InboxHandlerFailed _ 1) -> pure ()+        other -> expectationFailure ("expected first failed attempt, got " <> show other)+      Right result2 <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetries Nothing 3 PreferIntegrationMessageId event Nothing succeeding+      result2 `shouldBe` Right (InboxProcessed ())+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-poison-transient")+      row ^. #status `shouldBe` InboxCompleted+      row ^. #attemptCount `shouldBe` 1+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 1++    it "an unrecoverable message dead-letters at the ceiling" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-poison-dead"+              & #source+              .~ "ordering"+          handler _ = (pure $! error "always broken") :: Tx.Transaction ()+      Right result1 <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetries Nothing 2 PreferIntegrationMessageId event Nothing handler+      Right result2 <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetries Nothing 2 PreferIntegrationMessageId event Nothing handler+      Right result3 <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetries Nothing 2 PreferIntegrationMessageId event Nothing handler+      case (result1, result2, result3) of+        ( Right (InboxHandlerFailed _ 1),+          Right (InboxHandlerFailed _ 2),+          Right (InboxPreviouslyFailed _)+          ) -> pure ()+        other -> expectationFailure ("unexpected poison lifecycle: " <> show other)+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-msg-poison-dead")+      row ^. #status `shouldBe` InboxFailed+      row ^. #attemptCount `shouldBe` 2++    it "processes a batch of distinct messages in one transaction" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let events =+            [ sampleIntegrationEnvelope+                & #messageId+                .~ ("inbox-batch-msg-" <> Text.pack (show n))+                & #source+                .~ "batch-ordering"+            | n <- [1 .. 50 :: Int]+            ]+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right results <-+        Store.runStoreIO storeHandle $+          runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope ((,Nothing) <$> events) handler+      results `shouldBe` replicate 50 (Right (InboxProcessed ()))+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 50+      Right inboxRows <- Store.runStoreIO storeHandle (listInbox "batch-ordering")+      length inboxRows `shouldBe` 50+      all ((== InboxCompleted) . (^. #status)) inboxRows `shouldBe` True++    it "deduplicates repeated messages within one batch" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-batch-dup"+              & #source+              .~ "batch-ordering"+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right results <-+        Store.runStoreIO storeHandle $+          runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope [(event, Nothing), (event, Nothing)] handler+      results `shouldBe` [Right (InboxProcessed ()), Right InboxDuplicate]+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 1++    it "falls back per message when one batch handler throws" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let events =+            [ sampleIntegrationEnvelope+                & #messageId+                .~ ("inbox-batch-poison-" <> Text.pack (show n))+                & #source+                .~ "batch-ordering"+            | n <- [1 .. 5 :: Int]+            ]+          handler ev+            | ev ^. #messageId == "inbox-batch-poison-3" =+                (pure $! error "batch poison") :: Tx.Transaction ()+            | otherwise =+                Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right results <-+        Store.runStoreIO storeHandle $+          runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope ((,Nothing) <$> events) handler+      case results of+        [ Right (InboxProcessed ()),+          Right (InboxProcessed ()),+          Right (InboxHandlerFailed err 1),+          Right (InboxProcessed ()),+          Right (InboxProcessed ())+          ] ->+            Text.isInfixOf "batch poison" err `shouldBe` True+        other -> expectationFailure ("unexpected batch fallback results: " <> show other)+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 4+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-poison-3")+      row ^. #status `shouldBe` InboxFailed+      row ^. #attemptCount `shouldBe` 1+      row ^. #lastError `shouldSatisfy` maybe False (Text.isInfixOf "batch poison")++    it "reports duplicates across batch calls" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-batch-existing-dup"+              & #source+              .~ "batch-ordering"+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right first <-+        Store.runStoreIO storeHandle $+          runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope [(event, Nothing)] handler+      first `shouldBe` [Right (InboxProcessed ())]+      Right second <-+        Store.runStoreIO storeHandle $+          runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope [(event, Nothing)] handler+      second `shouldBe` [Right InboxDuplicate]+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 1++    it "falls back per message when one batch handler condemns the transaction" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let events =+            [ sampleIntegrationEnvelope+                & #messageId+                .~ ("inbox-batch-condemn-" <> Text.pack (show n))+                & #source+                .~ "batch-ordering"+            | n <- [1 .. 3 :: Int]+            ]+          handler ev+            | ev ^. #messageId == "inbox-batch-condemn-2" = Tx.condemn+            | otherwise = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right results <-+        Store.runStoreIO storeHandle $+          runInboxTransactionBatch Nothing 3 PreferIntegrationMessageId PersistFullEnvelope ((,Nothing) <$> events) handler+      -- The condemned single-message retry reports processed by the+      -- documented single-path contract; what matters is that the+      -- innocent batch mates actually committed.+      results `shouldBe` replicate 3 (Right (InboxProcessed ()))+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 2+      Right (Just mate1) <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-condemn-1")+      Right (Just mate3) <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-condemn-3")+      mate1 ^. #status `shouldBe` InboxCompleted+      mate3 ^. #status `shouldBe` InboxCompleted+      Right condemned <- Store.runStoreIO storeHandle (lookupInbox "batch-ordering" "inbox-batch-condemn-2")+      condemned `shouldBe` Nothing++    it "classifies a legacy processing row as InboxInProgress without running the handler" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-legacy-processing"+              & #source+              .~ "ordering"+          handler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "INSERT INTO keiro.keiro_inbox (source, dedupe_key, content_type, payload_bytes, status) VALUES ('ordering', 'inbox-legacy-processing', 'application/json', ''::bytea, 'processing')"+      Right result <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      result `shouldBe` Right InboxInProgress+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 0+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-legacy-processing")+      row ^. #status `shouldBe` InboxProcessing++    it "runs the handler once when two workers race the same dedupe key" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS inbox_test_counter (message_id TEXT PRIMARY KEY)")+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-race-dup"+              & #source+              .~ "ordering"+          slowHandler ev = do+            Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+            Tx.sql "SELECT pg_sleep(1.5)"+          fastHandler ev = Tx.statement (ev ^. #messageId) inboxTestCounterInsertStmt+      firstDone <- newEmptyMVar+      _ <- forkIO $ do+        first <-+          Store.runStoreIO storeHandle $+            runInboxTransaction Nothing PreferIntegrationMessageId event Nothing slowHandler+        putMVar firstDone first+      -- Let the slow worker insert its uncommitted row, then race the+      -- same dedupe key: the second insert must block on the unique+      -- constraint until the first commits, then classify as duplicate.+      threadDelay 400000+      Right second <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing fastHandler+      Right first <- takeMVar firstDone+      first `shouldBe` Right (InboxProcessed ())+      second `shouldBe` Right InboxDuplicate+      Right rowCount <-+        Store.runStoreIO storeHandle $+          Store.runTransaction (Tx.statement () inboxTestCounterCountStmt)+      rowCount `shouldBe` 1++    it "can persist only dedupe columns for successful rows" $ \storeHandle -> do+      let kafka = KafkaDeliveryRef "billing.orders.v1" 1 42+          event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-slim-success"+              & #source+              .~ "ordering"+              & #payloadBytes+              .~ "full success payload"+              & #attributes+              ?~ object ["source" Aeson..= ("slim-test" :: Text)]+          handler _ = pure ()+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWith Nothing PersistDedupeOnly PreferIntegrationMessageId event (Just kafka) handler+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-slim-success")+      row ^. #event . #payloadBytes `shouldBe` ""+      row ^. #event . #attributes `shouldBe` Nothing+      row ^. #event . #traceContext `shouldBe` Nothing+      row ^. #event . #schemaReference `shouldBe` Nothing+      row ^. #event . #messageId `shouldBe` "inbox-slim-success"+      row ^. #event . #sourceEventId `shouldBe` event ^. #sourceEventId+      row ^. #event . #sourceGlobalPosition `shouldBe` event ^. #sourceGlobalPosition+      row ^. #event . #causationId `shouldBe` event ^. #causationId+      row ^. #event . #correlationId `shouldBe` event ^. #correlationId+      row ^. #event . #occurredAt `shouldBe` event ^. #occurredAt+      row ^. #kafka `shouldBe` Just kafka+      Right redelivery <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWith Nothing PersistDedupeOnly PreferIntegrationMessageId event (Just kafka) handler+      redelivery `shouldBe` Right InboxDuplicate++    it "keeps full failed rows even when successful rows are dedupe-only" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-slim-failed"+              & #source+              .~ "ordering"+              & #payloadBytes+              .~ "full failed payload"+              & #attributes+              ?~ object ["source" Aeson..= ("failed-slim-test" :: Text)]+          handler _ = (pure $! error "slim failure") :: Tx.Transaction ()+      Right result <-+        Store.runStoreIO storeHandle $+          runInboxTransactionWithRetriesWith Nothing 3 PersistDedupeOnly PreferIntegrationMessageId event Nothing handler+      case result of+        Right (InboxHandlerFailed err 1) ->+          Text.isInfixOf "slim failure" err `shouldBe` True+        other -> expectationFailure ("expected InboxHandlerFailed, got " <> show other)+      Right (Just row) <- Store.runStoreIO storeHandle (lookupInbox "ordering" "inbox-slim-failed")+      row ^. #status `shouldBe` InboxFailed+      row ^. #event . #payloadBytes `shouldBe` event ^. #payloadBytes+      row ^. #event . #attributes `shouldBe` event ^. #attributes+      row ^. #event . #traceContext `shouldBe` event ^. #traceContext+      row ^. #event . #schemaReference `shouldBe` event ^. #schemaReference++    it "garbage-collects completed rows older than the retention window" $ \storeHandle -> do+      let event =+            sampleIntegrationEnvelope+              & #messageId+              .~ "inbox-msg-gc"+              & #source+              .~ "ordering"+          handler _ = pure ()+      Right (Right (InboxProcessed ())) <-+        Store.runStoreIO storeHandle $+          runInboxTransaction Nothing PreferIntegrationMessageId event Nothing handler+      -- Backdate the row so it falls outside the retention window.+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql+              "UPDATE keiro.keiro_inbox SET completed_at = now() - interval '40 days' WHERE message_id = 'inbox-msg-gc'"+      now <- getCurrentTime+      Right deleted <- Store.runStoreIO storeHandle (garbageCollectCompleted (nominalDays 30) now)+      deleted `shouldBe` 1+      Right rows <- Store.runStoreIO storeHandle (listInbox "ordering")+      rows `shouldBe` []++  describe "Keiro.Inbox.Kafka" $ do+    it "reconstructs an integration event from headers and payload" $ do+      let envelope = sampleIntegrationEnvelope+          headers = integrationHeaders envelope+          receivedAt = addUTCTime 60 (envelope ^. #occurredAt)+          record =+            InboxKafka.KafkaInboundRecord+              { topic = "billing.orders.v1",+                partition = 2,+                offset = 113,+                key = Just "order-123",+                payload = envelope ^. #payloadBytes,+                headers,+                receivedAt+              }+      case InboxKafka.integrationEventFromKafka record of+        Right (rebuilt, kafkaRef) -> do+          rebuilt ^. #messageId `shouldBe` envelope ^. #messageId+          rebuilt ^. #source `shouldBe` envelope ^. #source+          rebuilt ^. #destination `shouldBe` envelope ^. #destination+          rebuilt ^. #eventType `shouldBe` envelope ^. #eventType+          rebuilt ^. #schemaVersion `shouldBe` envelope ^. #schemaVersion+          rebuilt ^. #sourceEventId `shouldBe` envelope ^. #sourceEventId+          rebuilt ^. #sourceGlobalPosition `shouldBe` envelope ^. #sourceGlobalPosition+          rebuilt ^. #payloadBytes `shouldBe` envelope ^. #payloadBytes+          rebuilt ^. #occurredAt `shouldBe` envelope ^. #occurredAt+          rebuilt ^. #attributes `shouldBe` envelope ^. #attributes+          kafkaRef ^. #topic `shouldBe` "billing.orders.v1"+          kafkaRef ^. #partition `shouldBe` 2+          kafkaRef ^. #offset `shouldBe` 113+        Left err -> expectationFailure ("expected Right, got Left " <> show err)++    it "falls back to receivedAt when the occurredAt header is absent" $ do+      let envelope = sampleIntegrationEnvelope+          receivedAt = addUTCTime 60 (envelope ^. #occurredAt)+          headers = filter ((/= "keiro-occurred-at") . Prelude.fst) (integrationHeaders envelope)+          record =+            InboxKafka.KafkaInboundRecord+              { topic = "billing.orders.v1",+                partition = 2,+                offset = 113,+                key = Just "order-123",+                payload = envelope ^. #payloadBytes,+                headers,+                receivedAt+              }+      case InboxKafka.integrationEventFromKafka record of+        Right (rebuilt, _) -> rebuilt ^. #occurredAt `shouldBe` receivedAt+        Left err -> expectationFailure ("expected Right, got Left " <> show err)++    it "rejects malformed occurredAt headers" $ do+      let envelope = sampleIntegrationEnvelope+          headers = ("keiro-occurred-at", "not-a-time") : filter ((/= "keiro-occurred-at") . Prelude.fst) (integrationHeaders envelope)+          record =+            InboxKafka.KafkaInboundRecord+              { topic = "billing.orders.v1",+                partition = 2,+                offset = 113,+                key = Just "order-123",+                payload = envelope ^. #payloadBytes,+                headers,+                receivedAt = envelope ^. #occurredAt+              }+      InboxKafka.integrationEventFromKafka record+        `shouldBe` Left (InboxKafka.InvalidTimeHeader "keiro-occurred-at" "not-a-time")++    it "reports MissingHeader for an essential header" $ do+      let envelope = sampleIntegrationEnvelope+          headers = filter ((/= "keiro-message-id") . Prelude.fst) (integrationHeaders envelope)+          record =+            InboxKafka.KafkaInboundRecord+              { topic = "billing.orders.v1",+                partition = 0,+                offset = 0,+                key = Nothing,+                payload = envelope ^. #payloadBytes,+                headers,+                receivedAt = envelope ^. #occurredAt+              }+      InboxKafka.integrationEventFromKafka record+        `shouldBe` Left (InboxKafka.MissingHeader "keiro-message-id")++    it "withConsumerSpan parents the consumer span under an upstream producer span via W3C headers" $ do+      (processor, spansRef) <- inMemoryListExporter+      provider <- createTracerProvider [processor] emptyTracerProviderOptions+      let tracer = makeTracer provider "keiro-test" tracerOptions+          -- Clear the baked-in TraceContext on the sample so the only+          -- `traceparent` on the wire comes from the active producer+          -- span (via `injectTraceContext`).+          envelope = sampleIntegrationEnvelope & #traceContext .~ Nothing+          producerRecord = OutboxKafka.integrationEventToKafkaRecord envelope+      producerHeadersText <-+        Telemetry.withProducerSpan (Just tracer) envelope producerRecord $ \_ -> do+          let baseHeaders =+                [(TE.decodeUtf8 n, TE.decodeUtf8 v) | (n, v) <- producerRecord ^. #headers]+          Telemetry.injectTraceContext baseHeaders+      -- Build the inbound record the consumer would receive and open the+      -- consumer span around a no-op body.+      now <- getCurrentTime+      let inbound =+            InboxKafka.KafkaInboundRecord+              { topic = envelope ^. #destination,+                partition = 7,+                offset = 42,+                key = envelope ^. #key,+                payload = envelope ^. #payloadBytes,+                headers = producerHeadersText,+                receivedAt = now+              }+      Telemetry.withConsumerSpan (Just tracer) (Just "billing-cg") inbound (Just envelope) $ \_ ->+        pure ()+      _ <- shutdownTracerProvider provider Nothing+      spans <- traverse captureSpan =<< readIORef spansRef+      length spans `shouldBe` 2+      let findByName needle = case [s | s <- spans, csName s == needle] of+            (s : _) -> s+            [] -> error ("no span captured with name=" <> Text.unpack needle)+          producerSp = findByName ("send " <> envelope ^. #destination)+          consumerSp = findByName ("process " <> envelope ^. #destination)+      -- Same trace id end-to-end (cross-process parenting).+      traceId (csContext producerSp) `shouldBe` traceId (csContext consumerSp)+      -- Consumer's parent is the producer span.+      case csParent consumerSp of+        Nothing -> expectationFailure "consumer span has no parent"+        Just parent -> do+          parentCtx <- getSpanContext parent+          spanId parentCtx `shouldBe` spanId (csContext producerSp)+      -- Consumer span carries the expected attributes.+      show (csKind consumerSp) `shouldBe` "Consumer"+      textAttr (csAttributes consumerSp) "messaging.system" `shouldBe` Just "kafka"+      textAttr (csAttributes consumerSp) "messaging.operation.type" `shouldBe` Just "process"+      textAttr (csAttributes consumerSp) "messaging.destination.name"+        `shouldBe` Just (envelope ^. #destination)+      textAttr (csAttributes consumerSp) "messaging.destination.partition.id"+        `shouldBe` Just "7"+      textAttr (csAttributes consumerSp) "messaging.consumer.group.name"+        `shouldBe` Just "billing-cg"+      textAttr (csAttributes consumerSp) "messaging.message.id"+        `shouldBe` Just (envelope ^. #messageId)++  describe "Keiro cross-context Kafka integration" $ around (withFreshStores2 fixture) $ do+    it "publishes an Ordering integration event and runs the Billing handler exactly once across duplicate deliveries" $ \(ordering, billing) -> do+      Right () <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_received_orders (order_id TEXT PRIMARY KEY, quantity BIGINT NOT NULL)")+      topic <- newKafkaTopic+      -- Ordering side: enqueue an outbox row representing a published event.+      let orderingEvent = orderSubmittedEnvelope "order-aaa" 7 "msg-aaa"+          oid = OutboxId outboxUuid1+      Right () <-+        Store.runStoreIO ordering $+          Store.runTransaction (enqueueIntegrationEventTx oid orderingEvent)+      -- Run the publisher worker: push records to the in-process topic.+      Right pubSummary1 <-+        Store.runStoreIO ordering $+          publishClaimedOutbox (perRow (kafkaTopicPublish topic)) defaultPublishOptions Nothing+      pubSummary1 ^. #published `shouldBe` 1+      -- Billing side: consume from the topic.+      records1 <- drainKafkaTopic topic+      record1 <- case records1 of+        [r] -> pure r+        other -> expectationFailure ("expected 1 record, got " <> show (length other)) *> error "unreachable"+      Right consumed1 <-+        Store.runStoreIO billing $+          consumeAndApply record1 billingReactionHandler+      consumed1 `shouldBe` ConsumeApplied (InboxProcessed ())+      Right rowCount1 <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.statement () billingReceivedOrdersCountStmt)+      rowCount1 `shouldBe` 1++      -- Simulate Kafka redelivery: pretend the same Kafka record was+      -- delivered again at a different offset. The producer also retries+      -- (the outbox flips back to pending and the worker republishes).+      let redelivered = redeliverWithDifferentOffset record1+      Right consumed2 <-+        Store.runStoreIO billing $+          consumeAndApply redelivered billingReactionHandler+      consumed2 `shouldBe` ConsumeApplied InboxDuplicate+      Right rowCount2 <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.statement () billingReceivedOrdersCountStmt)+      rowCount2 `shouldBe` 1++    it "preserves per-partition ordering for two events sharing a Kafka key" $ \(ordering, billing) -> do+      Right () <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_received_orders (order_id TEXT PRIMARY KEY, quantity BIGINT NOT NULL)")+      Right () <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_event_log (seq BIGSERIAL PRIMARY KEY, source TEXT NOT NULL, event_type TEXT NOT NULL, order_id TEXT NOT NULL)")+      topic <- newKafkaTopic+      -- Two events for the same order key.+      let submittedEnv = orderSubmittedEnvelope "order-bbb" 4 "msg-bbb-1"+          cancelledEnv = orderCancelledEnvelope "order-bbb" "msg-bbb-2"+          submittedId = OutboxId outboxUuid1+          cancelledId = OutboxId outboxUuid2+      Right () <-+        Store.runStoreIO ordering $+          Store.runTransaction (enqueueIntegrationEventTx submittedId submittedEnv)+      Right () <-+        Store.runStoreIO ordering $+          Store.runTransaction (enqueueIntegrationEventTx cancelledId cancelledEnv)+      -- Run-claiming lets a same-key contiguous run drain in one pass.+      let drainOnce =+            publishClaimedOutbox+              (perRow (kafkaTopicPublish topic))+              (defaultPublishOptions & #backoff .~ ConstantBackoff 0)+              Nothing+      Right s1 <- Store.runStoreIO ordering drainOnce+      Right s2 <- Store.runStoreIO ordering drainOnce+      (s1 ^. #published) + (s2 ^. #published) `shouldBe` 2+      records <- drainKafkaTopic topic+      length records `shouldBe` 2+      -- Apply both records to billing in delivery order.+      for_ records $ \record -> do+        Right consumed <-+          Store.runStoreIO billing $+            consumeAndApply record (loggingReactionHandler "billing")+        case consumed of+          ConsumeApplied (InboxProcessed ()) -> pure ()+          other -> expectationFailure ("expected processed, got " <> show other)+      Right events <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.statement () billingEventLogStmt)+      events `shouldBe` [("OrderSubmitted", "order-bbb"), ("OrderCancelled", "order-bbb")]++    it "head-of-line blocks a same-key successor when the first send fails repeatedly until the first row reaches dead status" $ \(ordering, billing) -> do+      Right () <-+        Store.runStoreIO billing $+          Store.runTransaction (Tx.sql "CREATE TABLE IF NOT EXISTS billing_received_orders (order_id TEXT PRIMARY KEY, quantity BIGINT NOT NULL)")+      topic <- newKafkaTopic+      let submittedEnv = orderSubmittedEnvelope "order-ccc" 1 "msg-ccc-1"+          cancelledEnv = orderCancelledEnvelope "order-ccc" "msg-ccc-2"+          firstId = OutboxId outboxUuid1+          secondId = OutboxId outboxUuid2+      Right () <-+        Store.runStoreIO ordering $+          Store.runTransaction (enqueueIntegrationEventTx firstId submittedEnv)+      Right () <-+        Store.runStoreIO ordering $+          Store.runTransaction (enqueueIntegrationEventTx secondId cancelledEnv)+      -- Failing publish for the first row, success for any other.+      let publish row+            | row ^. #outboxId == firstId =+                pure (PublishFailed "simulated broker reject")+            | otherwise = do+                kafkaTopicAccept topic row+                pure PublishSucceeded+          deadOpts =+            defaultPublishOptions+              & #batchSize+              .~ 1+              & #backoff+              .~ ConstantBackoff 0+              & #maxAttempts+              .~ 2+      -- This test drives the pre-M3 sequential failure/dead-letter path+      -- with one-row batches. M3 adds suffix skipping for larger claimed+      -- same-key runs.+      -- First pass: the first row attempts once and fails; the second is+      -- outside the one-row claim window.+      Right pass1 <- Store.runStoreIO ordering (publishClaimedOutbox (perRow publish) deadOpts Nothing)+      pass1 ^. #retried `shouldBe` 1+      pass1 ^. #published `shouldBe` 0+      -- Second pass crosses maxAttempts and dead-letters the first row.+      Right pass2 <- Store.runStoreIO ordering (publishClaimedOutbox (perRow publish) deadOpts Nothing)+      pass2 ^. #dead `shouldBe` 1+      Right (Just firstRow) <- Store.runStoreIO ordering (lookupOutbox firstId)+      firstRow ^. #status `shouldBe` OutboxDead+      -- With the first row dead, the second becomes claimable and publishes.+      Right pass3 <- Store.runStoreIO ordering (publishClaimedOutbox (perRow publish) deadOpts Nothing)+      pass3 ^. #published `shouldBe` 1+      Right (Just secondRow) <- Store.runStoreIO ordering (lookupOutbox secondId)+      secondRow ^. #status `shouldBe` OutboxSent+      -- Billing only sees the second event.+      records <- drainKafkaTopic topic+      record <- case records of+        [r] -> pure r+        other -> expectationFailure ("expected 1 record, got " <> show (length other)) *> error "unreachable"+      Right consumed <-+        Store.runStoreIO billing $+          consumeAndApply record billingReactionHandler+      consumed `shouldBe` ConsumeApplied (InboxProcessed ())++  describe "Keiro.Integration.Event" $ do+    it "round-trips a JSON envelope through encode and decode" $ do+      let envelope = sampleIntegrationEnvelope+          payload = OrderSubmittedPayload "order-123" 5+          encoded = encodeJsonIntegrationEvent envelope payload+      decodeJsonIntegrationEvent encoded `shouldBe` Right payload++    it "preserves identity and routing through encode" $ do+      let envelope = sampleIntegrationEnvelope+          encoded = encodeJsonIntegrationEvent envelope (OrderSubmittedPayload "order-123" 5)+      encoded ^. #messageId `shouldBe` envelope ^. #messageId+      encoded ^. #source `shouldBe` "ordering"+      encoded ^. #destination `shouldBe` "billing.orders.v1"+      encoded ^. #key `shouldBe` Just "order-123"+      encoded ^. #eventType `shouldBe` "OrderSubmitted"+      encoded ^. #schemaVersion `shouldBe` 1+      encoded ^. #contentType `shouldBe` ApplicationJson++    it "emits the canonical wire headers" $ do+      let envelope = sampleIntegrationEnvelope+          headers = integrationHeaders envelope+      Prelude.lookup headerMessageId headers `shouldBe` Just (envelope ^. #messageId)+      Prelude.lookup headerSchemaVersion headers `shouldBe` Just "1"+      Prelude.lookup headerContentType headers `shouldBe` Just "application/json"+      Prelude.lookup headerSchemaSubject headers `shouldBe` Just "billing.orders.v1.OrderSubmitted"+      Prelude.lookup headerSourceEventId headers `shouldBe` Just "018f0f18-17aa-7000-8000-000000000003"+      Prelude.lookup headerSourceGlobalPosition headers `shouldBe` Just "42"+      Prelude.lookup headerTraceParent headers+        `shouldBe` Just "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"++    it "preserves a different content type without claiming JSON" $ do+      let envelope =+            sampleIntegrationEnvelope+              & #contentType+              .~ OtherContentType "application/vnd.apache.avro.binary"+              & #payloadBytes+              .~ "\x00\x01\x02"+          headers = integrationHeaders envelope+      Prelude.lookup headerContentType headers+        `shouldBe` Just "application/vnd.apache.avro.binary"+      decodeJsonIntegrationEvent envelope+        `shouldBe` ( Left (IntegrationEvent.UnsupportedContentType "application/vnd.apache.avro.binary") ::+                       Either IntegrationEvent.IntegrationEventError OrderSubmittedPayload+                   )++    it "reports malformed JSON payloads as decode errors instead of throwing" $ do+      let envelope =+            sampleIntegrationEnvelope+              & #payloadBytes+              .~ "{not-json"+      case decodeJsonIntegrationEvent envelope :: Either IntegrationEvent.IntegrationEventError OrderSubmittedPayload of+        Left (IntegrationEvent.MalformedPayload _) -> pure ()+        other -> expectationFailure ("expected MalformedPayload, got " <> show other)++    it "reports a JSON value that does not satisfy the target type as DecodeFailed" $ do+      let envelope =+            sampleIntegrationEnvelope+              & #payloadBytes+              .~ "{\"orderId\":\"order-123\"}"+      case decodeJsonIntegrationEvent envelope :: Either IntegrationEvent.IntegrationEventError OrderSubmittedPayload of+        Left (IntegrationEvent.DecodeFailed _) -> pure ()+        other -> expectationFailure ("expected DecodeFailed, got " <> show other)++    it "parses content-type headers back to the canonical type" $ do+      parseContentType "application/json" `shouldBe` ApplicationJson+      parseContentType "Application/JSON" `shouldBe` ApplicationJson+      parseContentType "application/json; charset=utf-8" `shouldBe` ApplicationJson+      parseContentType "APPLICATION/JSON ; CHARSET=UTF-8" `shouldBe` ApplicationJson+      parseContentType "application/vnd.apache.avro.binary"+        `shouldBe` OtherContentType "application/vnd.apache.avro.binary"++    it "preserves the payload bytes through integrationPayload" $ do+      let envelope = sampleIntegrationEnvelope+          encoded = encodeJsonIntegrationEvent envelope (OrderSubmittedPayload "order-123" 5)+      integrationPayload encoded `shouldBe` (encoded ^. #payloadBytes)++  describe "Keiro.Telemetry" $ do+    it "is a pass-through under a noop (Nothing) tracer" $ do+      counter <- newIORef (0 :: Int)+      let envelope = sampleIntegrationEnvelope+          record = OutboxKafka.integrationEventToKafkaRecord envelope+      result <-+        Telemetry.withProducerSpan Nothing envelope record $ \mSpan -> do+          atomicModifyIORef' counter (\n -> (n + 1, ()))+          pure (mSpan, "ok" :: Text)+      callsAfter <- readIORef counter+      callsAfter `shouldBe` (1 :: Int)+      snd result `shouldBe` "ok"+      fst result `shouldSatisfy` isNothing++    it "re-exports AttributeKeys whose textual payload matches the spec name" $ do+      attrKeyText Telemetry.messaging_operation_type `shouldBe` "messaging.operation.type"+      attrKeyText Telemetry.messaging_operation_name `shouldBe` "messaging.operation.name"+      attrKeyText Telemetry.messaging_destination_partition_id `shouldBe` "messaging.destination.partition.id"+      attrKeyText Telemetry.messaging_consumer_group_name `shouldBe` "messaging.consumer.group.name"+      attrKeyText Telemetry.messaging_client_id `shouldBe` "messaging.client.id"+      attrKeyTextInt64 Telemetry.messaging_kafka_offset `shouldBe` "messaging.kafka.offset"+      attrKeyText Telemetry.db_system_name `shouldBe` "db.system.name"+      attrKeyText Telemetry.db_namespace `shouldBe` "db.namespace"+      attrKeyText Telemetry.db_collection_name `shouldBe` "db.collection.name"+      attrKeyText Telemetry.db_operation_name `shouldBe` "db.operation.name"+      attrKeyText Telemetry.keiro_stream_name `shouldBe` "keiro.stream.name"+      attrKeyTextInt64 Telemetry.keiro_retry_attempt `shouldBe` "keiro.retry.attempt"+      attrKeyTextInt64 Telemetry.keiro_events_appended `shouldBe` "keiro.events.appended"+      attrKeyText Telemetry.keiro_replay_divergence `shouldBe` "keiro.replay.divergence"++    it "extracts a TraceContext from a W3C traceparent header pair" $ do+      let traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"+          tracestate = "vendor1=value1"+          hs = [(headerTraceParent, traceparent), ("tracestate", tracestate)]+      Telemetry.traceContextFromHeaders hs+        `shouldBe` Just (TraceContext traceparent (Just tracestate))++    it "returns Nothing when the traceparent header is missing" $ do+      Telemetry.traceContextFromHeaders [("content-type", "application/json")]+        `shouldBe` Nothing++    it "injectTraceContext is a no-op when no span is active on the thread" $ do+      let baseline = [("content-type", "application/json")]+      injected <- Telemetry.injectTraceContext baseline+      injected `shouldBe` baseline++    it "traceContextFromCurrentSpan returns Nothing outside any span" $ do+      tc <- Telemetry.traceContextFromCurrentSpan+      tc `shouldBe` Nothing++  describe "Keiro.Workflow" $ around (withFreshStore fixture) $ do+    it "journals each step once, returns Completed, and runs each side effect once" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "demo"+          wid = WorkflowId "demo-1"+      result <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)+      result `shouldBe` Right (Completed (1, 2))+      sideEffects <- readIORef counter+      sideEffects `shouldBe` 2+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:demo-demo-1") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 3+      traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)+        `shouldSatisfy` \case+          Right [StepRecorded "first" _ _, StepRecorded "second" _ _, WorkflowCompleted _] -> True+          _ -> False++    it "replays recorded steps without re-running their side effects" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "replay"+          wid = WorkflowId "r-1"+      first <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)+      first `shouldBe` Right (Completed (1, 2))+      afterFirst <- readIORef counter+      afterFirst `shouldBe` 2+      -- A second run with the same id is exactly the crash-restart scenario.+      second <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)+      second `shouldBe` Right (Completed (1, 2))+      afterSecond <- readIORef counter+      afterSecond `shouldBe` 2+      -- The deterministic ids and pre-load gating leave the journal at 3 events.+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:replay-r-1") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 3++    it "reuses the recorded result for a repeated step name in one run" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "samename"+          wid = WorkflowId "s-1"+          duplicateStepWorkflow = do+            a <- step (StepName "dup") (liftIO (incrementAndRead counter))+            b <- step (StepName "dup") (liftIO (incrementAndRead counter))+            pure (a, b)+      result <- Store.runStoreIO storeHandle $ runWorkflow name wid duplicateStepWorkflow+      result `shouldBe` Right (Completed (1, 1))+      sideEffects <- readIORef counter+      sideEffects `shouldBe` 1++    it "suspends on an unresolved awaitStep, journaling no completion" $ \storeHandle -> do+      let name = WorkflowName "awaiter"+          wid = WorkflowId "a-1"+      result <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow+      result `shouldBe` Right Suspended+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:awaiter-a-1") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 0++    it "resumes and completes once an awaited step is externally completed" $ \storeHandle -> do+      let name = WorkflowName "awaiter2"+          wid = WorkflowId "a-2"+      suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow+      suspended `shouldBe` Right Suspended+      -- Simulate a wake source recording the awaited step's resolution.+      Right () <- Store.runStoreIO storeHandle $ do+        now <- liftIO getCurrentTime+        appendJournalEntry name wid (StepRecorded "awk:test" (toJSON (42 :: Int)) now)+      resumed <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow+      resumed `shouldBe` Right (Completed 42)+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:awaiter2-a-2") (StreamVersion 0) 10+      traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)+        `shouldSatisfy` \case+          Right [StepRecorded "awk:test" _ _, WorkflowCompleted _] -> True+          _ -> False++    it "treats a duplicate external journal append as idempotent" $ \storeHandle -> do+      let name = WorkflowName "duplicate-append"+          wid = WorkflowId "da-1"+          stepKey = "awk:test"+          eventAt t = StepRecorded stepKey (toJSON (42 :: Int)) t+      now <- getCurrentTime+      Right firstId <-+        Store.runStoreIO storeHandle $+          appendJournalEntryReturningId name wid (eventAt now)+      secondResult <-+        Store.runStoreIO storeHandle $+          appendJournalEntryReturningId name wid (eventAt now)+      secondId <- case secondResult of+        Right value -> pure value+        Left err -> expectationFailure ("expected idempotent duplicate append, got " <> show err) *> error "unreachable"+      secondId `shouldBe` firstId+      Right indexed <- Store.runStoreIO storeHandle $ loadStepIndex name wid 0+      Map.lookup stepKey indexed `shouldBe` Just (toJSON (42 :: Int))+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:duplicate-append-da-1") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 1++    it "returns the journaled value when another writer records the same step mid-flight" $ \storeHandle -> do+      let name = WorkflowName "journal-race"+          wid = WorkflowId "jr-1"+          body =+            step (StepName "raced") $ do+              now <- liftIO getCurrentTime+              appendJournalEntry name wid (StepRecorded "raced" (toJSON ("winner" :: Text)) now)+              pure ("loser" :: Text)+      outcome <- Store.runStoreIO storeHandle $ runWorkflow name wid body+      outcome `shouldBe` Right (Completed "winner")+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:journal-race-jr-1") (StreamVersion 0) 10+      traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)+        `shouldSatisfy` \case+          Right [StepRecorded "raced" value _, WorkflowCompleted _] -> value == toJSON ("winner" :: Text)+          _ -> False++    it "returns the JSON round-trip of a fresh step result" $ \storeHandle -> do+      let name = WorkflowName "roundtrip-step"+          wid = WorkflowId "rs-1"+          body = step (StepName "approx") (pure (Approx 1.7))+      first <- Store.runStoreIO storeHandle $ runWorkflow name wid body+      first `shouldBe` Right (Completed (Approx 2.0))+      replay <- Store.runStoreIO storeHandle $ runWorkflow name wid body+      replay `shouldBe` Right (Completed (Approx 2.0))++    it "throws WorkflowStepDecodeError on the first run when the recorded result cannot decode" $ \storeHandle -> do+      let name = WorkflowName "bad-roundtrip"+          wid = WorkflowId "br-1"+          body = step (StepName "bad") (pure RejectingRoundTrip)+      Store.runStoreIO storeHandle (runWorkflow name wid body)+        `shouldThrow` \case+          WorkflowStepDecodeError key _ -> key == "bad"+          _ -> False+      Store.runStoreIO storeHandle (stepExists name wid 0 "bad")+        `shouldReturn` Right True++    it "discovers unfinished workflows via the step index" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      Right (Completed _) <-+        Store.runStoreIO storeHandle $+          runWorkflow (WorkflowName "done") (WorkflowId "d-1") (demoWorkflow counter)+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow (WorkflowName "pending") (WorkflowId "p-1") (stepThenAwaitWorkflow counter)+      now <- getCurrentTime+      Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      unfinished `shouldBe` [("p-1", "pending")]++  describe "Keiro.Workflow instance table" $ around (withFreshStore fixture) $ do+    it "creates and completes a workflow instance row transactionally with the journal" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "inst-complete"+          wid = WorkflowId "ic-1"+      Right (Completed _) <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #workflowId `shouldBe` "ic-1"+      row ^. #workflowName `shouldBe` "inst-complete"+      row ^. #generation `shouldBe` 0+      row ^. #status `shouldBe` Instance.WfCompleted+      row ^. #completedAt `shouldSatisfy` isJust++    it "records suspended status for workflows that park before journaling" $ \storeHandle -> do+      let name = WorkflowName "inst-suspended"+          wid = WorkflowId "is-1"+      Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid neverArmingWorkflow+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #status `shouldBe` Instance.WfSuspended+      row ^. #generation `shouldBe` 0+      row ^. #completedAt `shouldBe` Nothing++    it "creates child instance rows at spawn time and flips them to cancelled" $ \storeHandle -> do+      let childWid = WorkflowId "inst-child"+          childName = WorkflowName "ship"+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow (WorkflowName "inst-parent") (WorkflowId "ip-1") (parentWorkflow childWid)+      Right (Just spawned) <- Store.runStoreIO storeHandle $ Instance.lookupInstance childName childWid+      spawned ^. #status `shouldBe` Instance.WfRunning+      Right True <- Store.runStoreIO storeHandle $ cancelChild (ChildHandle childName childWid)+      Right (Just cancelledRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance childName childWid+      cancelledRow ^. #status `shouldBe` Instance.WfCancelled+      cancelledRow ^. #completedAt `shouldSatisfy` isJust++    it "bumps the instance generation when continueAsNew rotates" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "inst-rotate"+          wid = WorkflowId "ir-1"+      Right ContinuedAsNew <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (rollingTotal counter 1 2)+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #generation `shouldBe` 1+      row ^. #status `shouldBe` Instance.WfRunning++    it "does not let a late append resurrect a terminal instance row" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "inst-terminal"+          wid = WorkflowId "it-1"+      Right (Completed _) <- Store.runStoreIO storeHandle $ runWorkflow name wid (demoWorkflow counter)+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "late" (toJSON True) now)+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #status `shouldBe` Instance.WfCompleted++    it "discovers unfinished workflows from the instance table" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let completedName = WorkflowName "discover-completed"+          cancelledName = WorkflowName "discover-cancelled"+          crashedName = WorkflowName "discover-crashed"+          rotatedName = WorkflowName "discover-rotated"+      Right (Completed _) <-+        Store.runStoreIO storeHandle $+          runWorkflow completedName (WorkflowId "done") (demoWorkflow counter)+      cancelledAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry cancelledName (WorkflowId "cancelled") (WorkflowCancelled cancelledAt)+      Left (_ :: SimulatedCrash) <-+        try $+          Store.runStoreIO storeHandle $+            runWorkflow crashedName (WorkflowId "crashed") (crashAfterStep1 counter)+      Right ContinuedAsNew <-+        Store.runStoreIO storeHandle $+          runWorkflow rotatedName (WorkflowId "rotated") (rollingTotal counter 1 2)+      now <- getCurrentTime+      Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      unfinished+        `shouldBe` [ ("crashed", "discover-crashed"),+                     ("rotated", "discover-rotated")+                   ]++  describe "Keiro.Workflow snapshots" $ around (withFreshStore fixture) $ do+    it "does not fail committed workflow steps when snapshot writes fail" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let opts =+            defaultWorkflowRunOptions+              & #snapshotPolicy+              .~ Every 2+              & #metrics+              ?~ keiroMetrics+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_snapshots ADD CONSTRAINT keiro_snapshots_no_writes CHECK (false) NOT VALID"+      counter <- newIORef (0 :: Int)+      result <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts (WorkflowName "snap-write-failure") (WorkflowId "wf1") (countingSixSteps counter)+      result `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      Right journal <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:snap-write-failure-wf1") (StreamVersion 0) 100+      Vector.length journal `shouldBe` 7+      Right snapshotVersionDuringFailure <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "wf:snap-write-failure-wf1" snapshotVersionForStreamStmt+      snapshotVersionDuringFailure `shouldBe` Nothing+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.write.failures" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 3)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_snapshots DROP CONSTRAINT keiro_snapshots_no_writes"+      recoveryCounter <- newIORef (0 :: Int)+      recovery <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts (WorkflowName "snap-write-recovery") (WorkflowId "wf2") (countingSixSteps recoveryCounter)+      recovery `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      Right snapshotVersionAfterRecovery <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "wf:snap-write-recovery-wf2" snapshotVersionForStreamStmt+      snapshotVersionAfterRecovery `shouldBe` Just (StreamVersion 6)++    -- Validation (a): a snapshot row appears at the expected version and+    -- decodes to the full accumulated step map.+    it "writes a snapshot of the accumulated step map after Every 2 fires" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "snap"+          wid = WorkflowId "w1"+      result <-+        Store.runStoreIO storeHandle $+          runWorkflowWith+            (defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2)+            name+            wid+            (countingSixSteps counter)+      result `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      -- Every 2 fired at versions 2, 4, 6; the upsert keeps the highest (6).+      Right snapVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "wf:snap-w1" snapshotVersionForStreamStmt+      snapVersion `shouldBe` Just (StreamVersion 6)+      -- and the row decodes to the six-entry accumulated map.+      Right mSeed <- Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:snap-w1")+      case mSeed of+        Just (m, v) -> do+          v `shouldBe` StreamVersion 6+          Map.keys m `shouldBe` ["s1", "s2", "s3", "s4", "s5", "s6"]+        Nothing -> expectationFailure "expected a workflow snapshot row"++    -- The OnTerminal completion-site wiring: only the final WorkflowCompleted+    -- append (version 7) triggers the snapshot.+    it "writes a terminal snapshot under OnTerminal at the completion version" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "term"+          wid = WorkflowId "tm1"+      result <-+        Store.runStoreIO storeHandle $+          runWorkflowWith+            (defaultWorkflowRunOptions & #snapshotPolicy .~ OnTerminal)+            name+            wid+            (countingSixSteps counter)+      result `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      Right snapVersion <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement "wf:term-tm1" snapshotVersionForStreamStmt+      snapVersion `shouldBe` Just (StreamVersion 7)++    -- Validation (b): re-hydration reads only the tail after the snapshot+    -- version, and the journaled steps short-circuit (the counter stays put).+    it "reads only the tail after the snapshot version on re-hydration" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "tail"+          wid = WorkflowId "t1"+          opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2+      first <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)+      first `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      afterFirst <- readIORef counter+      afterFirst `shouldBe` 6+      -- A full version-0 replay would read every journal event...+      Right full <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:tail-t1") (StreamVersion 0) 100+      Vector.length full `shouldBe` 7 -- six StepRecorded + one WorkflowCompleted+      -- ...whereas the runtime seeds from the snapshot and reads only the tail.+      Right (Just (seedMap, StreamVersion sv)) <-+        Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:tail-t1")+      Map.size seedMap `shouldBe` 6+      Right tailEvents <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:tail-t1") (StreamVersion sv) 100+      Vector.length tailEvents `shouldSatisfy` (< Vector.length full)+      Vector.length tailEvents `shouldBe` 1 -- only the WorkflowCompleted at v7+      -- Re-hydration completes from the seed without re-running any step.+      second <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)+      second `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      afterSecond <- readIORef counter+      afterSecond `shouldBe` 6++    -- Validation (c): a Never run and an Every 2 run produce identical results+    -- and identical journals, and the snapshot seed equals a full replay.+    it "produces identical results and journals under Never and Every 2" $ \storeHandle -> do+      counterN <- newIORef (0 :: Int)+      counterE <- newIORef (0 :: Int)+      neverRes <-+        Store.runStoreIO storeHandle $+          runWorkflowWith+            (defaultWorkflowRunOptions & #snapshotPolicy .~ Never)+            (WorkflowName "corr-never")+            (WorkflowId "c1")+            (countingSixSteps counterN)+      everyRes <-+        Store.runStoreIO storeHandle $+          runWorkflowWith+            (defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2)+            (WorkflowName "corr-every")+            (WorkflowId "c1")+            (countingSixSteps counterE)+      neverRes `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      everyRes `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      Right neverEvents <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:corr-never-c1") (StreamVersion 0) 100+      Right everyEvents <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:corr-every-c1") (StreamVersion 0) 100+      let stepResults evs =+            [ (k, v)+            | Right (StepRecorded k v _) <- decodeRecorded workflowJournalCodec <$> Vector.toList evs+            ]+      stepResults neverEvents `shouldBe` stepResults everyEvents+      -- The snapshot seed equals the map a full version-0 replay would fold.+      Right (Just (seedMap, _)) <-+        Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:corr-every-c1")+      seedMap `shouldBe` Map.fromList (stepResults everyEvents)++    -- Validation (d): an advisory snapshot whose discriminant no longer matches+    -- is ignored and the workflow hydrates via full replay.+    it "hydrates via full replay when the snapshot discriminant mismatches" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "dmiss"+          wid = WorkflowId "d1"+          opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 2+      _ <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("wf:dmiss-d1", "stale-shape") corruptSnapshotShapeStmt+      Right mSeed <- Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:dmiss-d1")+      mSeed `shouldBe` Nothing+      resumed <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)+      resumed `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])++    -- Validation (d), second arm: corrupt snapshot JSON is treated as a miss.+    it "hydrates via full replay when the snapshot JSON is corrupt" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "cjson"+          wid = WorkflowId "d2"+          opts =+            defaultWorkflowRunOptions+              & #snapshotPolicy+              .~ Every 2+              & #metrics+              ?~ keiroMetrics+      _ <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("wf:cjson-d2", Aeson.String "bad") corruptSnapshotStateStmt+      Right mSeed <- Store.runStoreIO storeHandle $ loadWorkflowSnapshot (StreamName "wf:cjson-d2")+      mSeed `shouldBe` Nothing+      resumed <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (countingSixSteps counter)+      resumed `shouldBe` Right (Completed [1, 2, 3, 4, 5, 6])+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.snapshot.decode.failures" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.snapshot.read.misses" scalars `shouldBe` Just (IntNumber 2)++  describe "Keiro.Workflow snapshot wake-safety" $ around (withFreshStore fixture) $ do+    it "keeps a genuinely unresolved awakeable pending under Every 1" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "snapshot-unsignalled"+          wid = WorkflowId "wf1"+          opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1+          run = Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (snapshotUnsignalledAwakeable aidRef)+      first <- run+      first `shouldBe` Right Suspended+      aid <- readRequiredAwakeableId aidRef+      Right (Just rowAfterFirst) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)+      rowAfterFirst ^. #status `shouldBe` Awk.Pending+      rowAfterFirst ^. #payload `shouldBe` Nothing+      second <- run+      second `shouldBe` Right Suspended+      Right (Just rowAfterSecond) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)+      rowAfterSecond ^. #status `shouldBe` Awk.Pending+      rowAfterSecond ^. #payload `shouldBe` Nothing++    it "delivers an awakeable signalled mid-run despite the stale in-memory map" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "snapshot-midrun-awakeable"+          wid = WorkflowId "wf1"+          opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1+          run = Store.runStoreIO storeHandle $ runWorkflowWith opts name wid snapshotShadowedAwakeable+      armed <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts name wid (snapshotUnsignalledAwakeable aidRef)+      armed `shouldBe` Right Suspended+      first <- run+      first `shouldBe` Right (Completed "payload")+      second <- run+      second `shouldBe` Right (Completed "payload")++    it "delivers an awakeable shadowed by a snapshot on a later run" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "snapshot-stale-awakeable"+          wid = WorkflowId "wf1"+          opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1+      armed <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts name wid (snapshotUnsignalledAwakeable aidRef)+      armed `shouldBe` Right Suspended+      first <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts name wid (snapshotStaleAwakeablePhaseOne aidRef)+      first `shouldBe` Right Suspended+      aid <- readRequiredAwakeableId aidRef+      Right (Just (staleSeed, _)) <-+        Store.runStoreIO storeHandle $+          loadWorkflowSnapshot (workflowGenerationStreamName name wid 0)+      staleSeed `shouldSatisfy` Map.notMember ("awk:" <> awakeableIdText aid)+      second <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts name wid snapshotStaleAwakeablePhaseTwo+      second `shouldBe` Right (Completed "payload")++    it "delivers a child completion shadowed by a snapshot on a later run" $ \storeHandle -> do+      let name = WorkflowName "snapshot-stale-child-parent"+          wid = WorkflowId "wf1"+          childWid = WorkflowId "child1"+          opts = defaultWorkflowRunOptions & #snapshotPolicy .~ Every 1+      first <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts name wid (snapshotStaleChildPhaseOne childWid)+      first `shouldBe` Right Suspended+      Right (Just (staleSeed, _)) <-+        Store.runStoreIO storeHandle $+          loadWorkflowSnapshot (workflowGenerationStreamName name wid 0)+      staleSeed `shouldSatisfy` Map.notMember (childResultStepName childWid)+      second <-+        Store.runStoreIO storeHandle $+          runWorkflowWith opts name wid (snapshotStaleChildPhaseTwo childWid)+      second `shouldBe` Right (Completed "packed+labelled")++  describe "Keiro.Workflow.Resume" $ around (withFreshStore fixture) $ do+    -- M2: crash mid-run, then a resume pass drives the workflow to Completed+    -- without re-running the already-journaled step.+    it "resumes a crashed mid-run workflow, running only the un-journaled tail" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "crash-demo"+          wid = WorkflowId "cd-1"+      -- Simulate a crash after step 1's append has committed.+      crashed <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflow name wid (crashAfterStep1 counter)+          ) ::+          IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))+      case crashed of+        Left _ -> pure () -- the SimulatedCrash unwound the run, as intended+        Right other -> expectationFailure ("expected a simulated crash, got " <> show other)+      readIORef counter >>= \c -> c `shouldBe` 1+      -- Resume with a registry mapping the name to the FULL definition.+      let registry = Map.singleton name (WorkflowDef (\_wid -> threeStep counter))+      Right summary <-+        Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry+      summary+        `shouldBe` ResumeSummary+          { discovered = 1,+            resumed = 1,+            completed = 1,+            stillSuspended = 0,+            unknownName = 0,+            failed = 0,+            transientErrors = 0,+            leaseSkipped = 0+          }+      -- Step 1 short-circuited; steps 2 and 3 ran exactly once.+      readIORef counter >>= \c -> c `shouldBe` 3+      -- The journal now holds s1, s2, s3, WorkflowCompleted.+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:crash-demo-cd-1") (StreamVersion 0) 10+      traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)+        `shouldSatisfy` \case+          Right [StepRecorded "s1" _ _, StepRecorded "s2" _ _, StepRecorded "s3" _ _, WorkflowCompleted _] -> True+          _ -> False+      -- A second pass discovers nothing — the workflow is finished.+      Right summary2 <-+        Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry+      summary2 `shouldBe` emptyResumeSummary++    -- M3: a workflow suspended on an awaited step is driven to Completed once+    -- that step is journaled (here simulated; an EP-39/EP-40 wake source would+    -- journal the same StepRecorded end to end).+    it "resumes a suspended workflow once its awaited step is journaled" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "await-demo"+          wid = WorkflowId "ad-1"+      suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (awaitingThenStep counter)+      suspended `shouldBe` Right Suspended+      -- Simulate the wake source resolving the await.+      Right () <- Store.runStoreIO storeHandle $ do+        now <- liftIO getCurrentTime+        appendJournalEntry name wid (StepRecorded "awk:approval" (toJSON ("ok" :: Text)) now)+      let registry = Map.singleton name (WorkflowDef (\_wid -> awaitingThenStep counter))+      Right summary <-+        Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry+      summary+        `shouldBe` ResumeSummary+          { discovered = 1,+            resumed = 1,+            completed = 1,+            stillSuspended = 0,+            unknownName = 0,+            failed = 0,+            transientErrors = 0,+            leaseSkipped = 0+          }+      readIORef counter >>= \c -> c `shouldBe` 1+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:await-demo-ad-1") (StreamVersion 0) 10+      traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)+        `shouldSatisfy` \case+          Right [StepRecorded "awk:approval" _ _, StepRecorded "use" _ _, WorkflowCompleted _] -> True+          _ -> False++    -- M4: a discovered workflow whose name is absent from the registry is+    -- skipped and counted, never silently dropped or fatal.+    it "skips and counts a workflow whose name is absent from the registry" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "orphan"+          wid = WorkflowId "or-1"+      crashed <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflow name wid (crashAfterStep1 counter)+          ) ::+          IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))+      case crashed of+        Left _ -> pure ()+        Right other -> expectationFailure ("expected a simulated crash, got " <> show other)+      -- Empty registry: the orphan is surfaced via unknownName, not completed.+      Right summary <-+        Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions Map.empty+      summary+        `shouldBe` ResumeSummary+          { discovered = 1,+            resumed = 0,+            completed = 0,+            stillSuspended = 0,+            unknownName = 1,+            failed = 0,+            transientErrors = 0,+            leaseSkipped = 0+          }+      -- The journal is unchanged: still one step, no completion.+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:orphan-or-1") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 1++    it "isolates a poison workflow so a healthy workflow still completes" $ \storeHandle -> do+      healthyCounter <- newIORef (0 :: Int)+      let poisonName = WorkflowName "poison"+          poisonId = WorkflowId "poison-1"+          healthyName = WorkflowName "healthy"+          healthyId = WorkflowId "healthy-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 1+              & #logEvent+              .~ const (pure ())+          registry =+            Map.fromList+              [ (poisonName, WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int))),+                (healthyName, WorkflowDef (\_ -> threeStep healthyCounter))+              ]+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry poisonName poisonId (StepRecorded "seed" (toJSON True) now)+      crashed <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflow healthyName healthyId (crashAfterStep1 healthyCounter)+          ) ::+          IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))+      case crashed of+        Left _ -> pure ()+        Right other -> expectationFailure ("expected a simulated crash, got " <> show other)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      summary+        `shouldBe` emptyResumeSummary+          { discovered = 2,+            resumed = 2,+            completed = 1,+            failed = 1+          }+      readIORef healthyCounter >>= \c -> c `shouldBe` 3+      Right (Just poisonRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance poisonName poisonId+      poisonRow ^. #status `shouldBe` Instance.WfFailed++    it "marks a crashing workflow failed and short-circuits later direct runs" $ \storeHandle -> do+      let name = WorkflowName "terminal-poison"+          wid = WorkflowId "tp-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 1+              & #logEvent+              .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      failed summary `shouldBe` 1+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #status `shouldBe` Instance.WfFailed+      row ^. #attempts `shouldBe` 1+      direct <- Store.runStoreIO storeHandle $ runWorkflow name wid (step (StepName "never") (pure (1 :: Int)))+      direct `shouldBe` Right Failed+      Right recordedFailed <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:terminal-poison-tp-1") (StreamVersion 0) 10+      traverse (decodeRecorded workflowJournalCodec) (Vector.toList recordedFailed)+        `shouldSatisfy` \case+          Right events -> any (\case WorkflowFailed {} -> True; _ -> False) events+          _ -> False++    it "resurrects a failed workflow and completes without rerunning its journaled prefix" $ \storeHandle -> do+      shouldCrash <- newIORef True+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "resurrect-complete"+          wid = WorkflowId "rc-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 1+              & #logEvent+              .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> recoverableWorkflow shouldCrash counter))+      crashed <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflow name wid (recoverableWorkflow shouldCrash counter)+          ) ::+          IO (Either SomeException (Either Store.StoreError (WorkflowOutcome Int)))+      case crashed of+        Left _ -> pure ()+        Right other -> expectationFailure ("expected a simulated crash, got " <> show other)+      readIORef counter `shouldReturn` 1++      Right failedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      failed failedPass `shouldBe` 1+      Right (Just failedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      failedRow ^. #status `shouldBe` Instance.WfFailed++      writeIORef shouldCrash False+      resurrected <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid+      resurrected `shouldBe` Right Instance.WorkflowResurrected+      Right (Just revivedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      revivedRow ^. #status `shouldBe` Instance.WfRunning+      revivedRow ^. #attempts `shouldBe` 0+      revivedRow ^. #lastError `shouldBe` Nothing+      revivedRow ^. #nextAttemptAt `shouldBe` Nothing++      Right completedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      completed completedPass `shouldBe` 1+      readIORef counter `shouldReturn` 2+      Right (Just completedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      completedRow ^. #status `shouldBe` Instance.WfCompleted++    it "can fail again in the same generation after resurrection" $ \storeHandle -> do+      shouldCrash <- newIORef True+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "resurrect-refail"+          wid = WorkflowId "rr-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 1+              & #logEvent+              .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> recoverableWorkflow shouldCrash counter))+      crashed <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflow name wid (recoverableWorkflow shouldCrash counter)+          ) ::+          IO (Either SomeException (Either Store.StoreError (WorkflowOutcome Int)))+      case crashed of+        Left _ -> pure ()+        Right other -> expectationFailure ("expected a simulated crash, got " <> show other)++      Right firstFailedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      failed firstFailedPass `shouldBe` 1+      firstRevival <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid+      firstRevival `shouldBe` Right Instance.WorkflowResurrected+      Right secondFailedPass <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      failed secondFailedPass `shouldBe` 1+      Right (Just refailedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      refailedRow ^. #status `shouldBe` Instance.WfFailed++      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward+            (workflowGenerationStreamName name wid 0)+            (StreamVersion 0)+            10+      let failureIds =+            [ event ^. #eventId+            | event <- Vector.toList recorded,+              Right decoded <- [decodeRecorded workflowJournalCodec event],+              WorkflowFailed {} <- [decoded]+            ]+      case failureIds of+        [firstFailureId, secondFailureId] ->+          firstFailureId `shouldNotBe` secondFailureId+        other ->+          expectationFailure ("expected two failure events, got " <> show other)++      secondRevival <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid+      secondRevival `shouldBe` Right Instance.WorkflowResurrected++    it "guards resurrection and revives a failed child link transactionally" $ \storeHandle -> do+      let runningName = WorkflowName "resurrect-running"+          runningId = WorkflowId "running-1"+          missingName = WorkflowName "resurrect-missing"+          missingId = WorkflowId "missing-1"+          childName = WorkflowName "resurrect-child"+          childId = WorkflowId "child-1"+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry runningName runningId (StepRecorded "seed" (toJSON True) now)+      runningOutcome <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow runningName runningId+      runningOutcome `shouldBe` Right Instance.WorkflowNotFailed+      missingOutcome <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow missingName missingId+      missingOutcome `shouldBe` Right Instance.WorkflowNotFound++      Right childMarkedFailed <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Child.registerChildTx+              "child-1"+              "resurrect-child"+              "parent-1"+              "resurrect-parent"+              "child:child-1:result"+            Child.markChildFailedTx "child-1" "resurrect-child" "simulated terminal failure"+      childMarkedFailed `shouldBe` True+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry childName childId (WorkflowFailed "simulated terminal failure" now)++      childOutcome <- Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow childName childId+      childOutcome `shouldBe` Right Instance.WorkflowResurrected+      Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "child-1" "resurrect-child"+      childRow ^. #status `shouldBe` Child.Running+      childRow ^. #result `shouldBe` Nothing+      childRow ^. #failureReason `shouldBe` Nothing+      childRow ^. #completedAt `shouldBe` Nothing++    it "classifies thrown store errors as transient without consuming attempts" $ \storeHandle -> do+      let name = WorkflowName "transient"+          wid = WorkflowId "tr-1"+          opts = defaultWorkflowResumeOptions & #logEvent .~ const (pure ())+          registry =+            Map.singleton name $+              WorkflowDef+                ( \_ -> do+                    _ <- throwError (Store.ConnectionLost "boom")+                    pure (0 :: Int)+                )+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      transientErrors summary `shouldBe` 1+      failed summary `shouldBe` 0+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #attempts `shouldBe` 0+      row ^. #status `shouldBe` Instance.WfRunning++    it "keeps the fixed-poll loop alive when one pass contains a poison workflow" $ \storeHandle -> do+      done <- newEmptyMVar+      healthyCounter <- newIORef (0 :: Int)+      let poisonName = WorkflowName "fixed-loop-poison"+          poisonId = WorkflowId "flp-1"+          healthyName = WorkflowName "fixed-loop-healthy"+          healthyId = WorkflowId "flh-1"+          opts =+            defaultWorkflowResumeOptions+              & #pollInterval+              .~ 50_000+              & #maxAttempts+              .~ 1+              & #logEvent+              .~ const (pure ())+          healthyBody = threeStepThenSignal healthyCounter done+          registry =+            Map.fromList+              [ (poisonName, WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int))),+                (healthyName, WorkflowDef (\_ -> healthyBody))+              ]+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry poisonName poisonId (StepRecorded "seed" (toJSON True) now)+      crashed <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflow healthyName healthyId (crashAfterStep1 healthyCounter)+          ) ::+          IO (Either SomeException (Either Store.StoreError (WorkflowOutcome (Int, Int, Int))))+      case crashed of+        Left _ -> pure ()+        Right other -> expectationFailure ("expected a simulated crash, got " <> show other)+      worker <- forkIO (void (Store.runStoreIO storeHandle (runWorkflowResumeWorkerWith opts registry)))+      completed <- timeout 5_000_000 (takeMVar done)+      status <- threadStatus worker+      killThread worker+      completed `shouldBe` Just ()+      status `shouldSatisfy` \case+        ThreadFinished -> False+        ThreadDied -> False+        _ -> True++    it "claims one workflow instance for a single live owner and releases it" $ \storeHandle -> do+      let name = WorkflowName "lease-claim"+          wid = WorkflowId "lc-1"+      Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 30 name wid+      claimedA `shouldBe` True+      Right claimedB <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid+      claimedB `shouldBe` False+      Right () <- Store.runStoreIO storeHandle $ Instance.releaseInstance "owner-a" False name wid+      Right claimedBAfterRelease <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid+      claimedBAfterRelease `shouldBe` True++    it "lets an expired workflow lease be taken and resets attempts on progressed release" $ \storeHandle -> do+      let name = WorkflowName "lease-expire"+          wid = WorkflowId "le-1"+      Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 30 name wid+      claimedA `shouldBe` True+      Right attempt <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Instance.recordCrashTx "le-1" "lease-expire" "boom"+      attempt `shouldBe` 1+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql "UPDATE keiro.keiro_workflows SET lease_expires_at = now() - interval '1 second', next_attempt_at = now() - interval '1 second' WHERE workflow_id = 'le-1' AND workflow_name = 'lease-expire'"+      Right claimedB <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid+      claimedB `shouldBe` True+      Right () <- Store.runStoreIO storeHandle $ Instance.releaseInstance "owner-b" True name wid+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #attempts `shouldBe` 0+      row ^. #lastError `shouldBe` Nothing+      row ^. #nextAttemptAt `shouldBe` Nothing+      row ^. #leasedBy `shouldBe` Nothing++    it "skips a resume candidate held by another live lease owner" $ \storeHandle -> do+      ran <- newIORef False+      let name = WorkflowName "lease-skip"+          wid = WorkflowId "ls-1"+          registry =+            Map.singleton name $+              WorkflowDef+                ( \_ -> do+                    liftIO (writeIORef ran True)+                    pure (0 :: Int)+                )+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)+      Right foreignClaim <- Store.runStoreIO storeHandle $ Instance.claimInstance "foreign-owner" 30 name wid+      foreignClaim `shouldBe` True+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry+      summary+        `shouldBe` emptyResumeSummary+          { discovered = 1,+            leaseSkipped = 1+          }+      readIORef ran `shouldReturn` False++    -- M4: resume on an already-completed workflow is a genuine no-op.+    it "discovers nothing for an already-completed workflow and is stable" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "done-demo"+          wid = WorkflowId "dd-1"+      done <- Store.runStoreIO storeHandle $ runWorkflow name wid (threeStep counter)+      done `shouldBe` Right (Completed (1, 2, 3))+      readIORef counter >>= \c -> c `shouldBe` 3+      let registry = Map.singleton name (WorkflowDef (\_wid -> threeStep counter))+      Right summary1 <-+        Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry+      summary1 `shouldBe` emptyResumeSummary+      Right summary2 <-+        Store.runStoreIO storeHandle $ resumeWorkflowsOnce defaultWorkflowResumeOptions registry+      summary2 `shouldBe` emptyResumeSummary+      readIORef counter >>= \c -> c `shouldBe` 3++  describe "Keiro.Workflow lease renewal" $ around (withFreshStore fixture) $ do+    it "renews before a slow fresh step so the original lease cannot be stolen" $ \storeHandle -> do+      attemptedClaim <- newIORef Nothing+      let name = WorkflowName "lease-heartbeat"+          wid = WorkflowId "heartbeat-1"+          runOpts =+            defaultWorkflowRunOptions+              & #leaseHeartbeat+              .~ Just LeaseHeartbeat {owner = "owner-a", ttl = 60}+          body =+            step (StepName "slow-boundary") $ do+              liftIO (threadDelay 300_000)+              claimed <-+                Instance.claimInstance+                  "owner-b"+                  60+                  name+                  wid+              liftIO (writeIORef attemptedClaim (Just claimed))+              pure claimed+      Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 0.2 name wid+      claimedA `shouldBe` True+      outcome <- Store.runStoreIO storeHandle $ runWorkflowWith runOpts name wid body+      outcome `shouldBe` Right (Completed False)+      readIORef attemptedClaim `shouldReturn` Just False+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #leasedBy `shouldBe` Just "owner-a"++    it "stops at a lost lease boundary and the resume worker records no crash" $ \storeHandle -> do+      let directName = WorkflowName "lease-lost-direct"+          directId = WorkflowId "lost-direct-1"+          directOpts =+            defaultWorkflowRunOptions+              & #leaseHeartbeat+              .~ Just LeaseHeartbeat {owner = "owner-a", ttl = 60}+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry directName directId (StepRecorded "seed" (toJSON True) now)+      Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 60 directName directId+      claimedA `shouldBe` True+      leaseUntil <- addUTCTime 60 <$> getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement+              ("lost-direct-1", "lease-lost-direct", "owner-b", leaseUntil)+              forceWorkflowLeaseStmt+      firstDirectEffect <- newIORef (0 :: Int)+      secondDirectEffect <- newIORef (0 :: Int)+      direct <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflowWith directOpts directName directId $ do+                _ <- step (StepName "first") (liftIO (incrementAndRead firstDirectEffect))+                step (StepName "second") (liftIO (incrementAndRead secondDirectEffect))+          ) ::+          IO+            ( Either+                WorkflowLeaseLost+                (Either Store.StoreError (WorkflowOutcome Int))+            )+      direct `shouldBe` Left WorkflowLeaseLost+      readIORef firstDirectEffect `shouldReturn` 0+      readIORef secondDirectEffect `shouldReturn` 0+      directFinishedAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry directName directId (WorkflowCompleted directFinishedAt)++      firstWorkerEffect <- newIORef (0 :: Int)+      secondWorkerEffect <- newIORef (0 :: Int)+      let workerName = WorkflowName "lease-lost-worker"+          workerId = WorkflowId "lost-worker-1"+          workerOpts =+            defaultWorkflowResumeOptions+              & #logEvent+              .~ const (pure ())+          registry =+            Map.singleton workerName $+              WorkflowDef $ \_ -> do+                _ <-+                  step (StepName "first") $ do+                    value <- liftIO (incrementAndRead firstWorkerEffect)+                    expires <- liftIO (addUTCTime 60 <$> getCurrentTime)+                    Store.runTransaction $+                      Tx.statement+                        ("lost-worker-1", "lease-lost-worker", "owner-b", expires)+                        forceWorkflowLeaseStmt+                    pure value+                step (StepName "second") (liftIO (incrementAndRead secondWorkerEffect))+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry workerName workerId (StepRecorded "seed" (toJSON True) now)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce workerOpts registry+      summary+        `shouldBe` emptyResumeSummary+          { discovered = 1,+            leaseSkipped = 1+          }+      readIORef firstWorkerEffect `shouldReturn` 1+      readIORef secondWorkerEffect `shouldReturn` 0+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance workerName workerId+      row ^. #attempts `shouldBe` 0+      row ^. #leasedBy `shouldBe` Just "owner-b"++  describe "Keiro.Workflow continue-as-new" $ around (withFreshStore fixture) $ do+    -- EP-48 headline proof (Checks 1 & 2): a 300-step rolling-total workflow that+    -- rotates every 50 steps keeps each physical generation journal bounded by+    -- K = rotateEvery + 2 (at most rotateEvery work steps + the one seed step that+    -- opened the generation + the one terminal marker), yet returns the correct+    -- final total. A single non-rotating run would put all 300 steps on one+    -- journal and the per-generation `<= K` bound would fail.+    it "rotates a long workflow, bounds each generation, and returns the correct total" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "roller"+          wid = WorkflowId "r-1"+          rotateEvery = 50 :: Int+          total = 300 :: Int+          k = rotateEvery + 2+          body = rollingTotal counter rotateEvery total+          -- Re-invoke runWorkflow until it Completes; each call resolves and+          -- advances the current generation, exactly as the resume worker does.+          drive :: Int -> IO Int+          drive budget+            | budget <= 0 =+                expectationFailure "workflow did not complete within the rotation budget" >> pure (-1)+            | otherwise = do+                outcome <- Store.runStoreIO storeHandle (runWorkflow name wid body)+                case outcome of+                  Right ContinuedAsNew -> drive (budget - 1)+                  Right (Completed t) -> pure t+                  other -> expectationFailure ("unexpected outcome: " <> show other) >> pure (-1)+      -- The first invocation rotates (generation 0 did rotateEvery steps).+      firstOutcome <- Store.runStoreIO storeHandle (runWorkflow name wid body)+      firstOutcome `shouldBe` Right ContinuedAsNew+      -- Drive the remaining generations to completion (bounded passes).+      finalTotal <- drive (total `div` rotateEvery + 3)+      -- Check 2: correct result, and each side effect ran exactly once.+      finalTotal `shouldBe` total+      readIORef counter >>= (`shouldBe` total)+      -- The workflow rotated to its final generation (300/50 = 6 generations: 0..5).+      Right gen <- Store.runStoreIO storeHandle (currentGeneration name wid)+      gen `shouldBe` (total `div` rotateEvery - 1)+      -- Check 1: every generation's physical journal is bounded by K, and the+      -- total is split ACROSS generations (bounded per generation, not in+      -- aggregate). Each generation holds exactly 1 seed + rotateEvery work + 1+      -- marker = K events, so the sum is total + 2 per generation.+      lengths <-+        traverse+          ( \g -> do+              let streamName = workflowGenerationStreamName name wid g+              Right evs <- Store.runStoreIO storeHandle (Store.readStreamForward streamName (StreamVersion 0) 1000)+              pure (Vector.length evs)+          )+          [0 .. gen]+      for_ lengths (`shouldSatisfy` (<= k))+      sum lengths `shouldBe` (total + 2 * (gen + 1))+      -- The first generation ends with a rotation marker; the last with a+      -- completion marker.+      Right gen0evs <- Store.runStoreIO storeHandle (Store.readStreamForward (workflowGenerationStreamName name wid 0) (StreamVersion 0) 1000)+      (decodeRecorded workflowJournalCodec <$> Vector.toList gen0evs)+        `shouldSatisfy` any+          ( \case+              Right (WorkflowContinuedAsNew 1 _) -> True+              _ -> False+          )+      Right lastEvs <- Store.runStoreIO storeHandle (Store.readStreamForward (workflowGenerationStreamName name wid gen) (StreamVersion 0) 1000)+      (decodeRecorded workflowJournalCodec <$> Vector.toList lastEvs)+        `shouldSatisfy` any+          ( \case+              Right (WorkflowCompleted _) -> True+              _ -> False+          )++    -- EP-48 Check 3: discovery and resume follow the CURRENT generation. After a+    -- rotation the rotated (newer) generation is unfinished and discoverable —+    -- the older generation's WorkflowContinuedAsNew marker does NOT mask it — and+    -- the resume worker drives the rotated generation forward to completion.+    it "rediscovers and resumes a rotated workflow on its current generation" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "roller2"+          wid = WorkflowId "r-2"+          rotateEvery = 50 :: Int+          total = 150 :: Int+          registry = Map.singleton name (WorkflowDef (\_ -> rollingTotal counter rotateEvery total))+          resumeUntilDone :: Int -> IO ()+          resumeUntilDone budget+            | budget <= 0 = expectationFailure "resume did not complete the rotated workflow"+            | otherwise = do+                Right summary <-+                  Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+                if completed summary == 1 then pure () else resumeUntilDone (budget - 1)+      -- First run rotates onto generation 1.+      firstOutcome <- Store.runStoreIO storeHandle (runWorkflow name wid (rollingTotal counter rotateEvery total))+      firstOutcome `shouldBe` Right ContinuedAsNew+      -- The rotated current generation (1) is unfinished and discoverable.+      now <- getCurrentTime+      Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      unfinished `shouldBe` [("r-2", "roller2")]+      -- The resume worker drives the rotated generation(s) to completion.+      resumeUntilDone (total `div` rotateEvery + 3)+      readIORef counter >>= (`shouldBe` total)+      -- Finished: discovery now reports nothing for it.+      finalNow <- getCurrentTime+      Right finalUnfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds finalNow)+      finalUnfinished `shouldBe` []++  describe "Keiro.Workflow patch API" $ around (withFreshStore fixture) $ do+    it "an in-flight instance observes the OLD branch; a fresh instance the NEW branch; the decision is journaled once and stable" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "patchwf"+          inflight = WorkflowId "inflight-1"+          fresh = WorkflowId "fresh-1"+          patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId++      -- 1. Run the in-flight instance to a suspension under the PRE-patch code.+      pre <- Store.runStoreIO storeHandle $ runWorkflow name inflight (prePatchWorkflow counter)+      pre `shouldBe` Right Suspended++      -- 2. Redeploy: re-run the SAME instance id under the POST-patch code. It+      --    already journaled reserve-inventory, so it is in flight -> False.+      r1 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name inflight (postPatchWorkflow counter)+      r1 `shouldBe` Right (Completed "old-branch")++      -- 3. Replay the in-flight instance again: same OLD branch, every time.+      r2 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name inflight (postPatchWorkflow counter)+      r2 `shouldBe` Right (Completed "old-branch")++      -- 4. A fresh instance under the POST-patch code takes the NEW branch.+      f1 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name fresh (postPatchWorkflow counter)+      f1 `shouldBe` Right (Completed "new-branch")+      -- and stays on the new branch on replay.+      f2 <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name fresh (postPatchWorkflow counter)+      f2 `shouldBe` Right (Completed "new-branch")++      -- 5. The patch decision is journaled exactly once per instance, with the+      --    expected Bool, on the patch:<id> key.+      Right inflightJournal <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:patchwf-inflight-1") (StreamVersion 0) 20+      let inflightDecisions =+            [ v+            | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList inflightJournal),+              StepRecorded k v _ <- [ev],+              k == patchStepName fraudPatchId+            ]+      inflightDecisions `shouldBe` [toJSON False]++      Right freshJournal <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:patchwf-fresh-1") (StreamVersion 0) 20+      let freshDecisions =+            [ v+            | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList freshJournal),+              StepRecorded k v _ <- [ev],+              k == patchStepName fraudPatchId+            ]+      freshDecisions `shouldBe` [toJSON True]+      let freshPatchSets =+            [ v+            | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList freshJournal),+              StepRecorded k v _ <- [ev],+              k == patchSetStepName+            ]+      freshPatchSets `shouldBe` [toJSON [unPatchId fraudPatchId]]++    it "a fresh instance suspended before its patch call still takes the NEW branch" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "patch-after-suspend"+          wid = WorkflowId "pas-1"+          patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflowWith patchOptions name wid (postPatchAfterSuspendWorkflow counter)+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "awk:gate" Aeson.Null now)+      resumed <-+        Store.runStoreIO storeHandle $+          runWorkflowWith patchOptions name wid (postPatchAfterSuspendWorkflow counter)+      resumed `shouldBe` Right (Completed "new-branch")++    it "an in-flight instance with only wake-source completions stays on the OLD branch" $ \storeHandle -> do+      let name = WorkflowName "patch-wake-only"+          wid = WorkflowId "pwo-1"+          patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId+      Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid prePatchWakeOnlyWorkflow+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "awk:gate" Aeson.Null now)+      resumed <-+        Store.runStoreIO storeHandle $+          runWorkflowWith patchOptions name wid postPatchWakeOnlyWorkflow+      resumed `shouldBe` Right (Completed "old-branch")++    it "records the active patch set again for a fresh rotated generation" $ \storeHandle -> do+      let name = WorkflowName "patch-rotating"+          wid = WorkflowId "pr-1"+          patchOptions = defaultWorkflowRunOptions & #activePatches .~ Set.singleton fraudPatchId+      first <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name wid rotatingPatchWorkflow+      first `shouldBe` Right ContinuedAsNew+      second <- Store.runStoreIO storeHandle $ runWorkflowWith patchOptions name wid rotatingPatchWorkflow+      second `shouldBe` Right (Completed "new-branch")+      Right gen1Journal <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (workflowGenerationStreamName name wid 1) (StreamVersion 0) 20+      let gen1PatchSets =+            [ v+            | Right ev <- map (decodeRecorded workflowJournalCodec) (Vector.toList gen1Journal),+              StepRecorded k v _ <- [ev],+              k == patchSetStepName+            ]+      gen1PatchSets `shouldBe` [toJSON [unPatchId fraudPatchId]]++  describe "Keiro.Workflow patch recording at rotation" $ around (withFreshStore fixture) $ do+    it "keeps the active patch after a wake append lands before the first rotated run" $ \storeHandle -> do+      let name = WorkflowName "patch-rotation-race"+          wid = WorkflowId "prr-1"+          patchOptions =+            defaultWorkflowRunOptions+              & #activePatches+              .~ Set.singleton fraudPatchId+          generationOneStream = workflowGenerationStreamName name wid 1++      first <-+        Store.runStoreIO storeHandle $+          runWorkflowWith patchOptions name wid rotatingPatchWorkflow+      first `shouldBe` Right ContinuedAsNew+      Right patchSetRecorded <-+        Store.runStoreIO storeHandle $+          stepExists name wid 1 patchSetStepName+      patchSetRecorded `shouldBe` True++      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry+            name+            wid+            ( StepRecorded+                "awk:11111111-1111-1111-1111-111111111111"+                (toJSON True)+                now+            )++      second <-+        Store.runStoreIO storeHandle $+          runWorkflowWith patchOptions name wid rotatingPatchWorkflow+      second `shouldBe` Right (Completed "new-branch")+      replayed <-+        Store.runStoreIO storeHandle $+          runWorkflowWith patchOptions name wid rotatingPatchWorkflow+      replayed `shouldBe` Right (Completed "new-branch")++      Right generationOneJournal <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward generationOneStream (StreamVersion 0) 20+      let decoded =+            map (decodeRecorded workflowJournalCodec) (Vector.toList generationOneJournal)+          patchSets =+            [ value+            | Right (StepRecorded key value _) <- decoded,+              key == patchSetStepName+            ]+          decisions =+            [ value+            | Right (StepRecorded key value _) <- decoded,+              key == patchStepName fraudPatchId+            ]+      patchSets `shouldBe` [toJSON [unPatchId fraudPatchId]]+      decisions `shouldBe` [toJSON True]++  describe "Keiro.Wake" $ around (withFreshStore fixture) $ do+    -- EP-50: the wake primitive over kiroku's existing per-store notifier.+    it "returns WokenByTimeout when idle (no append)" $ \store -> do+      wake <- wakeSignalFromStore store+      reason <- waitForWake wake 200000 -- 200 ms+      reason `shouldBe` WokenByTimeout++    it "returns WokenByNotify promptly after a real append" $ \store -> do+      wake <- wakeSignalFromStore store+      -- A real append bumps the streams row and fires kiroku's NOTIFY on+      -- kiroku.events; the store's notifier ticks the broadcast channel.+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO store $+          appendJournalEntry (WorkflowName "wakedemo") (WorkflowId "w1") (StepRecorded "s" (toJSON True) now)+      reason <- waitForWake wake 5000000 -- generous 5 s ceiling; the round-trip is milliseconds+      reason `shouldBe` WokenByNotify++    it "neverWake always returns WokenByTimeout" $ \_store -> do+      reason <- waitForWake neverWake 100000+      reason `shouldBe` WokenByTimeout++  describe "Keiro.Workflow push latency (EP-50)" $ around (withFreshStore fixture) $ do+    -- The user-visible win: a gated workflow resumes within sub-second of the+    -- gate append, under a deliberately large (10 s) fallback — so a pass that+    -- resumes it sub-second can only have been woken by the NOTIFY, not the poll.+    it "resumes a gated workflow sub-second after the gate append (10s fallback)" $ \store -> do+      done <- newEmptyMVar+      let name = WorkflowName "pushwf"+          wid = WorkflowId "p-1"+          registry = Map.singleton name (WorkflowDef (\_ -> gateThenSignal done))+          opts = defaultWorkflowResumeOptions & #pollInterval .~ 10000000 -- 10 s fallback+      first <- Store.runStoreIO store (runWorkflow name wid (gateThenSignal done))+      first `shouldBe` Right Suspended+      worker <- forkIO (runWorkflowResumeWorkerPush store opts registry)+      -- Let the worker start, duplicate the tick channel, and park in its wait+      -- before we append, so the gate's NOTIFY cannot be missed.+      threadDelay 250000+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO store $+          appendJournalEntry name wid (StepRecorded "awk:gate" (toJSON ()) now)+      resumed <- timeout 5000000 (takeMVar done)+      t1 <- getCurrentTime+      killThread worker+      resumed `shouldBe` Just ()+      let latency = realToFrac (diffUTCTime t1 now) :: Double+      latency `shouldSatisfy` (< 1.0)++    it "logs a failed push pass and keeps draining after the store recovers" $ \store -> do+      done <- newEmptyMVar+      logs <- newIORef []+      let name = WorkflowName "push-recover"+          wid = WorkflowId "pr-1"+          registry = Map.singleton name (WorkflowDef (\_ -> gateThenSignal done))+          opts =+            defaultWorkflowResumeOptions+              & #pollInterval+              .~ 100_000+              & #logEvent+              .~ \event -> modifyIORef' logs (<> [event])+          waitForPassFailure = timeout 5_000_000 $ do+            let go = do+                  seen <- readIORef logs+                  if any isPassFailure seen+                    then pure ()+                    else threadDelay 20_000 >> go+            go+          isPassFailure = \case+            ResumePassFailed {} -> True+            _ -> False+      first <- Store.runStoreIO store (runWorkflow name wid (gateThenSignal done))+      first `shouldBe` Right Suspended+      Right () <-+        Store.runStoreIO store $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_workflow_steps RENAME TO keiro_workflow_steps_hidden"+      worker <- forkIO (runWorkflowResumeWorkerPush store opts registry)+      logged <- waitForPassFailure+      logged `shouldBe` Just ()+      Right () <-+        Store.runStoreIO store $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_workflow_steps_hidden RENAME TO keiro_workflow_steps"+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO store $+          appendJournalEntry name wid (StepRecorded "awk:gate" (toJSON ()) now)+      resumed <- timeout 5_000_000 (takeMVar done)+      status <- threadStatus worker+      killThread worker+      resumed `shouldBe` Just ()+      status `shouldSatisfy` \case+        ThreadFinished -> False+        ThreadDied -> False+        _ -> True++  describe "Keiro.Workflow push fallback (EP-50)" $ around (withFreshStore fixture) $ do+    -- Push is strictly an optimization: with the worker on 'neverWake' (every+    -- NOTIFY dropped) and a small fallback, the gated workflow still drains on+    -- the durable poll.+    it "still drains on the fallback timeout when no notification is delivered" $ \store -> do+      done <- newEmptyMVar+      let name = WorkflowName "fallbackwf"+          wid = WorkflowId "f-1"+          registry = Map.singleton name (WorkflowDef (\_ -> gateThenSignal done))+          onePass = void (Store.runStoreIO store (resumeWorkflowsOnce defaultWorkflowResumeOptions registry))+      first <- Store.runStoreIO store (runWorkflow name wid (gateThenSignal done))+      first `shouldBe` Right Suspended+      worker <- forkIO (runPollLoopWith neverWake 200000 onePass) -- 200 ms fallback, no notifications+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO store $+          appendJournalEntry name wid (StepRecorded "awk:gate" (toJSON ()) now)+      resumed <- timeout 5000000 (takeMVar done)+      killThread worker+      resumed `shouldBe` Just ()++  describe "Shard lease" $ around (withFreshStore fixture) $ do+    -- EP-51 M2: claim / renew / release / expiry at the SQL layer, with explicit+    -- `now` timestamps standing in for the passage of time (no workers yet). The+    -- exclusion guarantee is the FOR UPDATE SKIP LOCKED claim; disjointness and+    -- failover are both observable purely from the lease table.+    let subName = SubscriptionName "orders-shard"+        wA = WorkerId sampleUuid+        wB = WorkerId sampleUuid2+        ttl = 30 :: NominalDiffTime+        t0 = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)+        tExpired = addUTCTime 60 t0 -- past A's 30 s lease+        shardOpts = defaultShardedWorkerOptions (Category (CategoryName "orders")) 4+    it "validates sharded worker options before startup" $ \_store -> do+      shouldBeRight_ (mkShardedWorkerOptions shardOpts)+      mkShardedWorkerOptions (shardOpts & #shardCount .~ 0)+        `shouldBeLeft` InvalidShardCount 0+      mkShardedWorkerOptions (shardOpts & #leaseTtl .~ 0)+        `shouldBeLeft` InvalidShardLeaseTtl 0+      mkShardedWorkerOptions (shardOpts & #renewInterval .~ 0)+        `shouldBeLeft` InvalidShardRenewInterval 0+      mkShardedWorkerOptions (shardOpts & #leaseTtl .~ 10 & #renewInterval .~ 10)+        `shouldBeLeft` InvalidShardLeaseRenewInterval 10 10+      mkShardedWorkerOptions (shardOpts & #batchSize .~ 0)+        `shouldBeLeft` InvalidShardBatchSize 0+      mkShardedWorkerOptions (shardOpts & #bufferSize .~ 0)+        `shouldBeLeft` InvalidShardBufferSize 0+      mkShardedWorkerOptions (shardOpts & #handlerRetryDelay .~ KirokuSub.RetryDelay (-1))+        `shouldBeLeft` InvalidShardHandlerRetryDelay (KirokuSub.RetryDelay (-1))+      mkShardedWorkerOptions (shardOpts & #retryPolicy .~ KirokuSub.RetryPolicy 0)+        `shouldBeLeft` InvalidShardRetryMaxAttempts 0++    it "ensureShardRows populates N rows once (idempotent on re-run)" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction $ do+        ensureShardRows subName 4+        ensureShardRows subName 4+      Right rows <- Store.runStoreIO store $ Store.runTransaction (listShardOwnership subName)+      map (\(b, _, _) -> b) rows `shouldBe` [0, 1, 2, 3]+      all (\(_, o, _) -> isNothing o) rows `shouldBe` True++    it "worker A claims all N when free; B claims 0 while A holds valid leases" $ \store -> do+      Right claimedA <- Store.runStoreIO store $ Store.runTransaction $ do+        ensureShardRows subName 4+        claimShardsTx subName wA 4 t0 ttl+      claimedA `shouldBe` [0, 1, 2, 3]+      Right claimedB <- Store.runStoreIO store $ Store.runTransaction (claimShardsTx subName wB 4 t0 ttl)+      claimedB `shouldBe` []++    it "B claims A's buckets after A's lease expires; A then renews nothing" $ \store -> do+      Right _ <- Store.runStoreIO store $ Store.runTransaction $ do+        ensureShardRows subName 4+        claimShardsTx subName wA 4 t0 ttl+      Right claimedB <- Store.runStoreIO store $ Store.runTransaction (claimShardsTx subName wB 4 tExpired ttl)+      claimedB `shouldBe` [0, 1, 2, 3]+      -- A lost every bucket to B, so its renew returns the empty set: this is how+      -- a worker learns it no longer owns a bucket and stops reading it.+      Right heldA <- Store.runStoreIO store $ Store.runTransaction (renewLeaseTx subName wA tExpired ttl)+      heldA `shouldBe` []++    it "renewLease returns only still-held buckets" $ \store -> do+      Right held <- Store.runStoreIO store $ Store.runTransaction $ do+        ensureShardRows subName 4+        _ <- claimShardsTx subName wA 4 t0 ttl+        renewLeaseTx subName wA t0 ttl+      held `shouldBe` [0, 1, 2, 3]++    it "releaseShards: relinquished buckets are immediately claimable" $ \store -> do+      Right _ <- Store.runStoreIO store $ Store.runTransaction $ do+        ensureShardRows subName 4+        _ <- claimShardsTx subName wA 4 t0 ttl+        releaseShardsTx subName wA [0, 1]+      -- Even while A's lease over 2,3 is still valid, the released 0,1 are claimable.+      Right claimedB <- Store.runStoreIO store $ Store.runTransaction (claimShardsTx subName wB 4 t0 ttl)+      claimedB `shouldBe` [0, 1]++    it "fairShareTarget divides buckets evenly (ceil)" $ \_store -> do+      fairShareTarget 6 3 `shouldBe` 2+      fairShareTarget 6 4 `shouldBe` 2+      fairShareTarget 7 3 `shouldBe` 3+      fairShareTarget 4 0 `shouldBe` 4 -- a non-positive estimate claims everything+    it "acquireOutcome keeps previous ownership on acquire failure" $ \_store -> do+      let previous = Set.fromList [0, 2]+      acquireOutcome previous (Left "database unavailable")+        `shouldBe` (previous, Just (ShardAcquireFailed "database unavailable"))+      acquireOutcome previous (Right (Set.fromList [1, 3]))+        `shouldBe` (Set.fromList [1, 3], Nothing)++    it "ensureShards rejects a shardCount mismatch" $ \store -> do+      let lease4 =+            ShardLease+              { subscriptionName = subName,+                workerId = wA,+                shardCount = 4,+                leaseTtl = ttl+              }+          lease6 =+            ShardLease+              { subscriptionName = subName,+                workerId = wA,+                shardCount = 6,+                leaseTtl = ttl+              }+      Right () <- Store.runStoreIO store (ensureShards lease4)+      Store.runStoreIO store (ensureShards lease6)+        `shouldThrow` \case+          ShardCountMismatch name configured found ->+            name == "orders-shard" && configured == 6 && found == [4]++  describe "Sharded subscription single worker" $ around (withFreshStore fixture) $ do+    -- EP-51 M3: one process owning all N buckets drains a seeded category exactly+    -- once. The sink is idempotent on event_id, so "count == total" proves every+    -- event was delivered with none missing and none surviving as a duplicate row.+    it "one worker with N=4 buckets drains a seeded category exactly once" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      total <- seedOrders store 8 5 -- 40 events across 8 streams+      let opts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 4)+              { leaseTtl = 3,+                renewInterval = 0.3+              }+      w <- forkIO (runShardedSubscriptionGroup store (SubscriptionName "orders-sub") opts (sinkHandler store 1))+      drained <- waitUntilSinkCount store total 20_000_000+      killThread w+      drained `shouldBe` True+      count <- shardSinkCount store+      count `shouldBe` total+      maxW <- maxWorkersPerStream store+      maxW `shouldBe` 1++  describe "Sharded subscription drain and failover" $ around (withFreshStore fixture) $ do+    -- EP-51 M5: the behavioural acceptance. Three worker processes cooperatively+    -- partition a category; we let ownership converge on the *empty* category+    -- first (so the churn of cold-start rebalancing touches no events), then seed+    -- and drain under stable membership — so each stream is owned by exactly one+    -- worker throughout the drain. Then we kill a worker and prove its buckets are+    -- re-homed and the new events drain (failover via lease expiry).+    let sub = SubscriptionName "orders-failover"+        mkOpts = (defaultShardedWorkerOptions (Category (CategoryName "orders")) 6) {leaseTtl = 3, renewInterval = 0.3}+    it "three workers drain disjointly, then re-home a killed worker's buckets" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      w1 <- forkIO (runShardedSubscriptionGroup store sub mkOpts (sinkHandler store 1))+      w2 <- forkIO (runShardedSubscriptionGroup store sub mkOpts (sinkHandler store 2))+      w3 <- forkIO (runShardedSubscriptionGroup store sub mkOpts (sinkHandler store 3))+      -- Wait for cooperative balance on the empty category: all 6 buckets owned,+      -- spread across >= 2 workers, none holding more than its fair share.+      balanced <- waitShardsBalanced store sub 6 2 15_000_000+      balanced `shouldBe` True+      -- Now seed and drain under stable membership.+      total1 <- seedOrders store 12 5 -- 60 events+      ok1 <- waitUntilSinkCount store total1 25_000_000+      ok1 `shouldBe` True+      -- Disjoint: no stream key was processed by two workers (stable membership,+      -- so no re-homing split any stream).+      maxW <- maxWorkersPerStream store+      maxW `shouldBe` 1+      -- The work genuinely spread (not a monopoly): at least two workers participated.+      spread <- distinctWorkers store+      spread `shouldSatisfy` (>= 2)+      -- Counts sum to total with no duplicate event id (PK on event_id + count).+      c1 <- shardSinkCount store+      c1 `shouldBe` total1+      -- Kill worker 1 (its readers stop; it stops renewing, so its leases expire).+      killThread w1+      -- Seed more across all streams; some hash to worker 1's now-orphaned buckets.+      total2 <- seedOrders store 12 5 -- another 60+      -- Failover: a surviving worker re-claims the expired buckets and drains the+      -- new events. If re-homing did not happen, events on worker 1's buckets would+      -- never drain and this would time out.+      ok2 <- waitUntilSinkCount store (total1 + total2) 30_000_000+      killThread w2+      killThread w3+      ok2 `shouldBe` True+      c2 <- shardSinkCount store+      c2 `shouldBe` (total1 + total2)++    it "a killed worker relinquishes its leases immediately" $ \store -> do+      let subImmediate = SubscriptionName "orders-immediate-release"+          longTtlOpts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 4)+              { leaseTtl = 30,+                renewInterval = 0.2+              }+      w <- forkIO (runShardedSubscriptionGroup store subImmediate longTtlOpts (sinkHandler store 1))+      owned <- waitShardsBalanced store subImmediate 4 1 10_000_000+      owned `shouldBe` True+      killThread w+      released <- waitShardsUnowned store subImmediate 4 3_000_000+      released `shouldBe` True++    it "a handler exception is retried in place and drains" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      thrown <- newIORef False+      errors <- newIORef []+      let subRestart = SubscriptionName "orders-reader-restart"+          opts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 2)+              { leaseTtl = 3,+                renewInterval = 0.2,+                handlerRetryDelay = KirokuSub.RetryDelay 0.05,+                onShardError = Just (\err -> modifyIORef' errors (err :))+              }+          handler ev = do+            firstTime <-+              atomicModifyIORef'+                thrown+                ( \seen ->+                    if seen+                      then (seen, False)+                      else (True, True)+                )+            when firstTime (throwIO (userError "reader boom"))+            sinkHandler store 1 ev+      w <- forkIO (runShardedSubscriptionGroup store subRestart opts handler)+      balanced <- waitShardsBalanced store subRestart 2 1 10_000_000+      balanced `shouldBe` True+      total <- seedOrders store 4 2+      drained <- waitUntilSinkCount store total 20_000_000+      killThread w+      drained `shouldBe` True+      seenErrors <- readIORef errors+      seenErrors `shouldSatisfy` all (\case ShardReaderDied _ _ -> False; _ -> True)++  describe "Sharded subscription ack coupling" $ around (withFreshStore fixture) $ do+    it "redelivers a batch-tail event whose handler was killed mid-flight" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      total <- seedOrders store 1 5+      enteredTail <- newEmptyMVar+      holdTail <- newEmptyMVar+      let sub = SubscriptionName "orders-ack-tail"+          opts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 1)+              { leaseTtl = 3,+                renewInterval = 0.3+              }+          blockingHandler ev = do+            let orderNumber = parseEither (withObject "OrderPlaced" (.: "n")) (ev ^. #payload)+            when (orderNumber == Right (4 :: Int)) $ do+              putMVar enteredTail ()+              takeMVar holdTail+            sinkHandler store 1 ev+      first <- forkIO (runShardedSubscriptionGroup store sub opts blockingHandler)+      entered <- timeout 10_000_000 (takeMVar enteredTail)+      entered `shouldBe` Just ()+      -- The old pull bridge replies Continue before invoking the handler;+      -- leave enough time for its batch-tail checkpoint to commit while the+      -- handler remains blocked. The ack-coupled bridge introduced by EP-96+      -- remains blocked on the unfilled reply instead.+      threadDelay 200_000+      killThread first+      second <- forkIO (runShardedSubscriptionGroup store sub opts (sinkHandler store 2))+      drained <- waitUntilSinkCount store total 20_000_000+      killThread second+      drained `shouldBe` True+      shardSinkCount store `shouldReturn` total++    it "loses no events when a bucket is shed mid-drain during rebalance" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      total <- seedOrders store 24 5+      let sub = SubscriptionName "orders-ack-rebalance"+          opts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 4)+              { leaseTtl = 3,+                renewInterval = 0.3,+                batchSize = 1+              }+          slowHandler tag ev = do+            threadDelay 100_000+            sinkHandler store tag ev+      first <- forkIO (runShardedSubscriptionGroup store sub opts (slowHandler 1))+      -- acquireOwnedBuckets claims one bucket per pass. Starting the joiner+      -- while A owns three leaves one claimable bucket for B, making B visible;+      -- A's next pass then sheds its excess third bucket while its handler is+      -- deliberately slow and in flight.+      ownsThree <- waitUntilOwnedShardCount store sub 3 10_000_000+      ownsThree `shouldBe` True+      second <- forkIO (runShardedSubscriptionGroup store sub opts (slowHandler 2))+      drained <- waitUntilSinkCount store total 30_000_000+      killThread first+      killThread second+      drained `shouldBe` True+      shardSinkCount store `shouldReturn` total++    it "allows zombie overlap duplicates without losing an event" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      total <- seedOrders store 1 5+      entered <- newEmptyMVar+      release <- newEmptyMVar+      deliveries <- newIORef ([] :: [EventId])+      successor <- newIORef Nothing+      readersA <- newIORef Map.empty+      let sub = SubscriptionName "orders-ack-zombie"+          opts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 1)+              { leaseTtl = 2,+                renewInterval = 0.2+              }+          leaseA =+            ShardLease+              { subscriptionName = sub,+                workerId = WorkerId sampleUuid,+                shardCount = 1,+                leaseTtl = 2+              }+          handlerA delivery = do+            let ev = delivery ^. #event+            modifyIORef' deliveries ((ev ^. #eventId) :)+            putMVar entered ()+            takeMVar release+            sinkHandler store 1 ev+            pure ShardAckOk+          handlerB delivery = do+            let ev = delivery ^. #event+            modifyIORef' deliveries ((ev ^. #eventId) :)+            sinkHandler store 2 ev+            pure ShardAckOk+          cleanup = do+            void (tryPutMVar release ())+            mSuccessor <- readIORef successor+            for_ mSuccessor killThread+            now <- getCurrentTime+            let cleanupWorker = WorkerId sampleUuid2+            _ <- Store.runStoreIO store $ Store.runTransaction $ do+              releaseShardsTx sub (WorkerId sampleUuid) [0]+              claimShardsTx sub cleanupWorker 1 now 30+            void (reconcileShardsOnce store leaseA opts readersA handlerA)+      ( do+          Right () <- Store.runStoreIO store (ensureShards leaseA)+          void (reconcileShardsOnce store leaseA opts readersA handlerA)+          timeout 10_000_000 (takeMVar entered) `shouldReturn` Just ()+          -- A no longer renews, but its reader remains alive and blocked+          -- with one unacknowledged event. B can claim after expiry and+          -- must therefore receive that event again from the checkpoint.+          threadDelay 2_500_000+          workerB <- forkIO (runShardedSubscriptionGroupAck store sub opts handlerB)+          writeIORef successor (Just workerB)+          drained <- waitUntilSinkCount store total 20_000_000+          drained `shouldBe` True+          raw <- readIORef deliveries+          length raw `shouldSatisfy` (> total)+          shardSinkCount store `shouldReturn` total+        )+        `finally` cleanup++    it "dead-letters a poison event after bounded retries and keeps draining" $ \store -> do+      Right () <- Store.runStoreIO store $ Store.runTransaction (Tx.sql createShardSinkSql)+      total <- seedOrders store 1 4+      poisonDeliveries <- newIORef (0 :: Int)+      errors <- newIORef []+      let sub = SubscriptionName "orders-ack-poison"+          opts =+            (defaultShardedWorkerOptions (Category (CategoryName "orders")) 1)+              { leaseTtl = 3,+                renewInterval = 0.2,+                handlerRetryDelay = KirokuSub.RetryDelay 0.05,+                retryPolicy = KirokuSub.RetryPolicy 3,+                onShardError = Just (\err -> modifyIORef' errors (err :))+              }+          handler ev = do+            let orderNumber = parseEither (withObject "OrderPlaced" (.: "n")) (ev ^. #payload)+            if orderNumber == Right (1 :: Int)+              then do+                modifyIORef' poisonDeliveries (+ 1)+                throwIO (userError "poison order")+              else sinkHandler store 1 ev+      worker <- forkIO (runShardedSubscriptionGroup store sub opts handler)+      drained <- waitUntilSinkCount store (total - 1) 20_000_000+      details <- shardDeadLetterDetails store "orders-ack-poison"+      attempts <- readIORef poisonDeliveries+      seenErrors <- readIORef errors+      killThread worker+      drained `shouldBe` True+      attempts `shouldBe` 3+      details `shouldBe` (1, Just "max retry attempts exceeded (3)", Just 3)+      seenErrors `shouldSatisfy` all (\case ShardReaderDied _ _ -> False; _ -> True)++  describe "Keiro.Workflow observability" $ around (withFreshStore fixture) $ do+    -- The headline operability signal: executed (real work) vs replayed+    -- (recorded history), recorded by the runtime through an SDK meter and read+    -- back from the in-memory exporter — plus the active gauge and the+    -- journal-length histogram.+    it "records workflow instruments through an SDK meter" $ \storeHandle -> do+      (exporter, ref) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      metrics <- Telemetry.newKeiroMetrics meter+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "obs"+          wid = WorkflowId "obs-1"+          opts = defaultWorkflowRunOptions & #metrics .~ Just metrics+      -- First run: both steps miss → two executions.+      first <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (demoWorkflow counter)+      first `shouldBe` Right (Completed (1, 2))+      -- Second run, same id: both steps hit → two replays.+      second <- Store.runStoreIO storeHandle $ runWorkflowWith opts name wid (demoWorkflow counter)+      second `shouldBe` Right (Completed (1, 2))+      -- The side effects ran exactly twice across both runs (the replay run+      -- short-circuited every step).+      readIORef counter >>= \c -> c `shouldBe` 2+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef ref+      let scalars = flattenScalarPoints exported+          hists = flattenHistogramPoints exported+      lookup "keiro.workflow.steps.executed" scalars `shouldBe` Just (IntNumber 2)+      lookup "keiro.workflow.steps.replayed" scalars `shouldBe` Just (IntNumber 2)+      -- One journal-length observation per completed run (two completions).+      [c | (n, c, _) <- hists, n == "keiro.workflow.journal.length"] `shouldBe` [2]+      -- Both runs finished, so the live-run count returned to zero.+      lookup "keiro.workflow.active" scalars `shouldBe` Just (IntNumber 0)++    -- The resume worker increments keiro.workflow.resumed per re-invocation and+    -- samples keiro.workflow.awakeables.pending each pass.+    it "records a resume and the pending-awakeable count when the worker re-invokes" $ \storeHandle -> do+      (exporter, ref) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      metrics <- Telemetry.newKeiroMetrics meter+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "obs-resume"+          wid = WorkflowId "obs-r-1"+      -- Suspend a workflow so it has a step row but no completion: the resume+      -- worker will re-invoke it (and stay Suspended, which still counts as a+      -- re-invocation).+      suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (stepThenAwaitWorkflow counter)+      suspended `shouldBe` Right Suspended+      -- Register one pending awakeable (independent of the suspended workflow's+      -- own await) so the pending gauge has something to count.+      let aid = awakeableIdToUuid (deterministicAwakeableId (WorkflowName "ext") (WorkflowId "1") "cb")+      Right () <-+        Store.runStoreIO storeHandle $ Store.runTransaction $ Awk.registerAwakeableTx aid "ext" "1"+      -- One resume pass with metrics threaded through the run options.+      let registry = Map.singleton name (WorkflowDef (\_wid -> stepThenAwaitWorkflow counter))+          resumeOpts =+            defaultWorkflowResumeOptions+              & #runOptions+              .~ (defaultWorkflowRunOptions & #metrics .~ Just metrics)+      Right _summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce resumeOpts registry+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef ref+      let scalars = flattenScalarPoints exported+      lookup "keiro.workflow.resumed" scalars `shouldBe` Just (IntNumber 1)+      lookup "keiro.workflow.awakeables.pending" scalars `shouldBe` Just (IntNumber 1)++    -- The no-op idiom end to end: defaultWorkflowRunOptions carries metrics =+    -- Nothing, so a run on a dedicated provider exports no points at all.+    it "records nothing through a Nothing handle" $ \storeHandle -> do+      (exporter, ref) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      counter <- newIORef (0 :: Int)+      result <-+        Store.runStoreIO storeHandle $+          runWorkflow (WorkflowName "obs-noop") (WorkflowId "obs-n-1") (demoWorkflow counter)+      result `shouldBe` Right (Completed (1, 2))+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef ref+      flattenScalarPoints exported `shouldBe` []+      flattenHistogramPoints exported `shouldBe` []++  describe "Keiro.Workflow.Snapshot codec" $ do+    -- Pure (no-DB) round-trip of the workflow state codec.+    it "round-trips a non-trivial accumulated step map and carries the sentinel shape hash" $ do+      let m =+            Map.fromList+              [ ("first", toJSON (1 :: Int)),+                ("second", toJSON ["a", "b" :: Text]),+                ("sleep:42", Aeson.Null)+              ]+      (workflowStateCodec ^. #decode) ((workflowStateCodec ^. #encode) m) `shouldBe` Right m+      (workflowStateCodec ^. #shapeHash) `shouldBe` "keiro.workflow.stepmap.v1"+      (workflowStateCodec ^. #stateShapeHash) `shouldBe` "keiro.workflow.stepmap.v1"+      (workflowStateCodec ^. #stateCodecVersion) `shouldBe` 1++  describe "Keiro.Workflow.Types journal codec" $ do+    -- Pure (no-DB) round-trip of the EP-48 rotation marker, proving the+    -- additive WorkflowContinuedAsNew constructor encodes and decodes+    -- self-describingly within schemaVersion 1.+    it "round-trips a WorkflowContinuedAsNew rotation marker" $ do+      let t = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 3600)+          marker = WorkflowContinuedAsNew 3 t+      (workflowJournalCodec ^. #decode) ((workflowJournalCodec ^. #eventType) marker) ((workflowJournalCodec ^. #encode) marker)+        `shouldBe` Right marker+      (workflowJournalCodec ^. #schemaVersion) `shouldBe` 1+      EventType "WorkflowContinuedAsNew" `elem` (workflowJournalCodec ^. #eventTypes) `shouldBe` True++    it "validates workflow identity smart constructors" $ do+      mkWorkflowName "orderFulfillment" `shouldBe` Right (WorkflowName "orderFulfillment")+      mkWorkflowName "" `shouldBe` Left WorkflowNameEmpty+      mkWorkflowName "order-fulfillment" `shouldBe` Left (WorkflowNameInvalidChar '-' "order-fulfillment")+      mkWorkflowName "order:fulfillment" `shouldBe` Left (WorkflowNameInvalidChar ':' "order:fulfillment")+      mkWorkflowName "order#1" `shouldBe` Left (WorkflowNameInvalidChar '#' "order#1")+      mkWorkflowId "550e8400-e29b-41d4-a716-446655440000"+        `shouldBe` Right (WorkflowId "550e8400-e29b-41d4-a716-446655440000")+      mkWorkflowId "" `shouldBe` Left WorkflowIdEmpty+      mkWorkflowId "customer:42" `shouldBe` Left (WorkflowIdInvalidChar ':' "customer:42")+      mkWorkflowId "customer#42" `shouldBe` Left (WorkflowIdInvalidChar '#' "customer#42")++  describe "Keiro.Workflow.Sleep" $ do+    -- Pure (no-DB) checks of the id/payload/step-name helpers.+    it "derives a deterministic, distinct timer id" $ do+      let name = WorkflowName "wf"+          wid = WorkflowId "w-1"+          sleepGolden = uuidLiteral "a95d5e7f-a43d-5ee2-9243-8206f0d8734a"+      sleepTimerId name wid 0 "sleep:cool" `shouldBe` sleepTimerId name wid 0 "sleep:cool"+      (sleepTimerId name wid 0 "sleep:cool" == sleepTimerId name wid 0 "sleep:other")+        `shouldBe` False+      sleepTimerId name wid 0 "sleep:cool"+        `shouldBe` TimerId sleepGolden+      sleepTimerId name wid 1 "sleep:cool" `shouldNotBe` sleepTimerId name wid 0 "sleep:cool"+      sleepTimerId name wid 2 "sleep:cool" `shouldNotBe` sleepTimerId name wid 1 "sleep:cool"++    it "round-trips and recognises its timer payload" $ do+      parseSleepPayload (sleepTimerPayload 2 "sleep:cool")+        `shouldBe` Just ("sleep:cool", Just 2)+      parseSleepPayload+        ( object+            [ "kind" Aeson..= ("keiro.workflow.sleep" :: Text),+              "step" Aeson..= ("sleep:legacy" :: Text)+            ]+        )+        `shouldBe` Just ("sleep:legacy", Nothing)+      parseSleepPayload (object ["kind" Aeson..= ("counter-timeout" :: Text)])+        `shouldBe` Nothing++    it "recovers a legacy payload's generation from its deterministic timer id" $ do+      let name = WorkflowName "wf"+          wid = WorkflowId "w-legacy"+          full = "sleep:cool"+      for_ [0 .. 2] $ \gen ->+        matchSleepTimerGeneration name wid 2 full (sleepTimerId name wid gen full)+          `shouldBe` Just gen++    it "prefixes the journal step name with the reserved sleep prefix" $+      sleepStepName (StepName "cool") `shouldBe` "sleep:cool"++    around (withFreshStore fixture) $ do+      it "arms a timer and suspends, then a fired timer resumes the workflow" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleepdemo"+            wid = WorkflowId "sd-1"+            journalStream = StreamName "wf:sleepdemo-sd-1"+            TimerId timerUuid = sleepTimerId name wid 0 "sleep:cool"+        -- First run: 'a' runs, the sleep arms a timer, and the run suspends.+        outcome1 <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "cool") 0)+        outcome1 `shouldBe` Right Suspended+        afterFirst <- readIORef counter+        afterFirst `shouldBe` 1+        -- The journal holds only 'a' (no completion, no sleep:cool yet).+        Right recorded1 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward journalStream (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded1)+          `shouldSatisfy` \case+            Right [StepRecorded "a" _ _] -> True+            _ -> False+        -- The durable wait is a single Scheduled timer row carrying the+        -- workflow-sleep payload.+        Right timerRow <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement timerUuid sleepTimerStatusStmt+        timerRow `shouldSatisfy` \case+          Just (status, payload) ->+            status == "scheduled"+              && parseSleepPayload payload == Just ("sleep:cool", Just 0)+          Nothing -> False+        -- Fire the timer through the routing worker (no PM fallback needed).+        fireTime <- getCurrentTime+        fireResult <-+          Store.runStoreIO storeHandle $+            runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)+        case fireResult of+          Right (Just timer) -> timer ^. #status `shouldBe` Firing+          other -> expectationFailure ("expected a fired sleep timer, got " <> show other)+        -- The row is now Fired and the journal gained sleep:cool.+        Right afterFire <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement timerUuid sleepTimerStatusStmt+        fmap fst afterFire `shouldBe` Just "fired"+        Right recorded2 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward journalStream (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded2)+          `shouldSatisfy` \case+            Right [StepRecorded "a" _ _, StepRecorded "sleep:cool" _ _] -> True+            _ -> False+        -- Second run completes: 'a' and the sleep short-circuit, only 'b' runs.+        outcome2 <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "cool") 0)+        outcome2 `shouldBe` Right (Completed (1, 2))+        afterSecond <- readIORef counter+        afterSecond `shouldBe` 2+        Right recorded3 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward journalStream (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded3)+          `shouldSatisfy` \case+            Right [StepRecorded "a" _ _, StepRecorded "sleep:cool" _ _, StepRecorded "b" _ _, WorkflowCompleted _] -> True+            _ -> False++      it "respects a positive delay: not due before fire_at, fires after" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleepwait"+            wid = WorkflowId "rt-1"+            journalStream = StreamName "wf:sleepwait-rt-1"+        clockBeforeFire <- getCurrentTime+        outcome1 <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 1)+        outcome1 `shouldBe` Right Suspended+        afterFirst <- readIORef counter+        afterFirst `shouldBe` 1+        -- A worker whose clock is before fire_at claims nothing.+        notDue <-+          Store.runStoreIO storeHandle $+            runTimerWorker Nothing clockBeforeFire workflowSleepFireAction+        notDue `shouldBe` Right Nothing+        Right recordedMid <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward journalStream (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList recordedMid)+          `shouldSatisfy` \case+            Right [StepRecorded "a" _ _] -> True+            _ -> False+        -- Wait out the one-second delay, then the worker fires it.+        threadDelay 1_200_000+        afterDelay <- getCurrentTime+        fired <-+          Store.runStoreIO storeHandle $+            runTimerWorker Nothing afterDelay workflowSleepFireAction+        fired `shouldSatisfy` \case+          Right (Just _) -> True+          _ -> False+        Right recordedWoken <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward journalStream (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList recordedWoken)+          `shouldSatisfy` \case+            Right [StepRecorded "a" _ _, StepRecorded "sleep:wait" _ _] -> True+            _ -> False+        outcome2 <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 1)+        outcome2 `shouldBe` Right (Completed (1, 2))+        afterSecond <- readIORef counter+        afterSecond `shouldBe` 2++      it "does not postpone fire_at when a resume pass re-arms the sleep" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleeponce"+            wid = WorkflowId "so-1"+            TimerId timerUuid = sleepTimerId name wid 0 "sleep:cool"+            registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "cool") 300))+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "cool") 300)+        Right (Just firstFireAt) <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement timerUuid sleepTimerFireAtStmt+        Right summary <-+          Store.runStoreIO storeHandle $+            resumeWorkflowsOnce defaultWorkflowResumeOptions registry+        discovered summary `shouldBe` 0+        Right (Just secondFireAt) <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement timerUuid sleepTimerFireAtStmt+        secondFireAt `shouldBe` firstFireAt+        readIORef counter >>= (`shouldBe` 1)++      it "keeps a due wake_after stable on re-arm and clears it on fire" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleep-wake-stable"+            wid = WorkflowId "sws-1"+            registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") 0))+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 0)+        Right (Just firstWakeAfter) <-+          Store.runStoreIO storeHandle $+            workflowWakeAfter name wid++        Right rearmed <-+          Store.runStoreIO storeHandle $+            resumeWorkflowsOnce defaultWorkflowResumeOptions registry+        discovered rearmed `shouldBe` 1+        Right (Just secondWakeAfter) <-+          Store.runStoreIO storeHandle $+            workflowWakeAfter name wid+        secondWakeAfter `shouldBe` firstWakeAfter++        fireTime <- getCurrentTime+        Right (Just _) <-+          Store.runStoreIO storeHandle $+            runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)+        Right clearedWakeAfter <-+          Store.runStoreIO storeHandle $+            workflowWakeAfter name wid+        clearedWakeAfter `shouldBe` Nothing++        Right resumed <-+          Store.runStoreIO storeHandle $+            resumeWorkflowsOnce defaultWorkflowResumeOptions registry+        discovered resumed `shouldBe` 1+        completed resumed `shouldBe` 1+        readIORef counter >>= (`shouldBe` 2)++      it "skips a sleeping workflow until wake_after expires" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleepwakeafter"+            wid = WorkflowId "swa-1"+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 60)+        now <- getCurrentTime+        Right mWakeAfter <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid+        case mWakeAfter of+          Nothing -> expectationFailure "expected wake_after"+          Just wakeAfter -> wakeAfter `shouldSatisfy` (> now)+        Right early <- Store.runStoreIO storeHandle $ findUnfinishedWorkflowIds now+        early `shouldBe` []+        Right due <- Store.runStoreIO storeHandle $ findUnfinishedWorkflowIds (addUTCTime 61 now)+        due `shouldBe` [("swa-1", "sleepwakeafter")]++      it "does not re-invoke a parked sleeper before wake_after" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleepquiet"+            wid = WorkflowId "sq-1"+            registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") 60))+            pass = Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 60)+        Right s1 <- pass+        Right s2 <- pass+        Right s3 <- pass+        map discovered [s1, s2, s3] `shouldBe` [0, 0, 0]+        readIORef counter >>= (`shouldBe` 1)++      it "treats a missing instance row during sleep arm as a no-op wake hint update" $ \storeHandle -> do+        let name = WorkflowName "sleepmissingrow"+            wid = WorkflowId "smr-1"+            body = sleepNamed (StepName "wait") 60 >> pure ()+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement ("smr-1", "sleepmissingrow") deleteWorkflowInstanceStmt+        Store.runStoreIO storeHandle (runWorkflow name wid body)+          `shouldReturn` Right Suspended++      it "fires a sleep whose instance row is missing after an arm crash" $ \storeHandle -> do+        let name = WorkflowName "sleep-missing-fire"+            wid = WorkflowId "smf-1"+            body = sleepNamed (StepName "wait") 0+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Tx.statement ("smf-1", "sleep-missing-fire") deleteWorkflowInstanceStmt+        fireTime <- getCurrentTime+        Right (Just _) <-+          Store.runStoreIO storeHandle $+            runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)+        Right resolved <-+          Store.runStoreIO storeHandle $+            stepExists name wid 0 "sleep:wait"+        resolved `shouldBe` True+        Right (Just recovered) <-+          Store.runStoreIO storeHandle $+            Instance.lookupInstance name wid+        recovered ^. #status `shouldBe` Instance.WfRunning+        Store.runStoreIO storeHandle (runWorkflow name wid body)+          `shouldReturn` Right (Completed ())++      it "fires a sleep longer than the resume cadence under an active resume worker" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleepactive"+            wid = WorkflowId "sa-1"+            registry = Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") 1))+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 1)+        threadDelay 1_200_000+        Right boundaryPass <-+          Store.runStoreIO storeHandle $+            resumeWorkflowsOnce defaultWorkflowResumeOptions registry+        discovered boundaryPass `shouldBe` 1+        fireTime <- getCurrentTime+        Right (Just _) <-+          Store.runStoreIO storeHandle $+            runWorkflowTimerWorker Nothing fireTime (\_ -> pure Nothing)+        Right completionPass <-+          Store.runStoreIO storeHandle $+            resumeWorkflowsOnce defaultWorkflowResumeOptions registry+        discovered completionPass `shouldBe` 1+        completed completionPass `shouldBe` 1+        readIORef counter >>= (`shouldBe` 2)++      it "uses generation-namespaced timer ids after continueAsNew" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let name = WorkflowName "sleeproll"+            wid = WorkflowId "sr-1"+            registry = Map.singleton name (WorkflowDef (\_ -> rollingSleepWorkflow counter))+            drive 0 = expectationFailure "rolling sleep did not complete"+            drive n = do+              Right summary <-+                Store.runStoreIO storeHandle $+                  resumeWorkflowsOnce defaultWorkflowResumeOptions registry+              now <- getCurrentTime+              _ <-+                Store.runStoreIO storeHandle $+                  runWorkflowTimerWorker Nothing now (\_ -> pure Nothing)+              if completed summary == 1+                then pure ()+                else drive (n - 1)+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (rollingSleepWorkflow counter)+        drive (12 :: Int)+        readIORef counter >>= (`shouldBe` 3)++  describe "Keiro.Workflow sleep generation pinning" $ around (withFreshStore fixture) $ do+    it "keeps a stale re-fire on the generation that armed the sleep" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "sleep-generation-pin"+          wid = WorkflowId "sgp-1"+          full = "sleep:cool"+          TimerId generationZeroTimerId = sleepTimerId name wid 0 full+          TimerId generationOneTimerId = sleepTimerId name wid 1 full+          body = do+            seed <- restoreSeed (0 :: Int)+            _ <- step (StepName "work") (liftIO (incrementAndRead counter))+            if seed == 0+              then sleepNamed (StepName "cool") 0 >> continueAsNew (1 :: Int)+              else sleepNamed (StepName "cool") 3600 >> pure seed++      Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+      claimTime <- getCurrentTime+      Right (Just claimed) <- Store.runStoreIO storeHandle $ claimDueTimer claimTime+      claimed ^. #timerId `shouldBe` TimerId generationZeroTimerId+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          workflowSleepFireAction claimed++      Right ContinuedAsNew <- Store.runStoreIO storeHandle $ runWorkflow name wid body+      Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+      Right (Just generationOneFireAt) <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement generationOneTimerId sleepTimerFireAtStmt++      requeueTime <- getCurrentTime+      Right requeued <-+        Store.runStoreIO storeHandle $+          requeueStuckTimers 0 (addUTCTime 1 requeueTime)+      requeued `shouldBe` 1+      Right (Just staleFire) <-+        Store.runStoreIO storeHandle $+          runWorkflowTimerWorker Nothing (addUTCTime 2 requeueTime) (\_ -> pure Nothing)+      staleFire ^. #timerId `shouldBe` TimerId generationZeroTimerId++      Right generationOneResolved <-+        Store.runStoreIO storeHandle $+          stepExists name wid 1 full+      generationOneResolved `shouldBe` False+      Right (Just instanceRow) <-+        Store.runStoreIO storeHandle $+          Instance.lookupInstance name wid+      instanceRow ^. #status `shouldBe` Instance.WfSuspended+      Right generationZeroStatus <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement generationZeroTimerId sleepTimerStatusStmt+      fmap fst generationZeroStatus `shouldBe` Just "fired"+      Right generationOneStatus <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement generationOneTimerId sleepTimerStatusStmt+      fmap fst generationOneStatus `shouldBe` Just "scheduled"+      Right (Just generationOneFireAtAfter) <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement generationOneTimerId sleepTimerFireAtStmt+      generationOneFireAtAfter `shouldBe` generationOneFireAt+      readIORef counter >>= (`shouldBe` 2)++  describe "Keiro.Workflow.Awakeable" $ do+    -- Pure (no-DB) check of the deterministic id derivation.+    it "derives a deterministic AwakeableId, stable across calls and label-sensitive" $ do+      let aid1 = deterministicAwakeableId (WorkflowName "w") (WorkflowId "1") "approval"+          aid2 = deterministicAwakeableId (WorkflowName "w") (WorkflowId "1") "approval"+          aidOther = deterministicAwakeableId (WorkflowName "w") (WorkflowId "1") "other"+          awakeableGolden = uuidLiteral "ccaeaf74-3ffe-5ea5-a118-a3441a95c279"+      aid1 `shouldBe` aid2+      (aid1 == aidOther) `shouldBe` False+      aid1 `shouldBe` AwakeableId awakeableGolden++    around (withFreshStore fixture) $ do+      it "schema: registers, completes once (idempotent), cancels, and counts pending rows" $ \storeHandle -> do+        let aidA = awakeableIdToUuid (deterministicAwakeableId (WorkflowName "sch") (WorkflowId "1") "a")+            aidB = awakeableIdToUuid (deterministicAwakeableId (WorkflowName "sch") (WorkflowId "1") "b")+        now <- getCurrentTime+        Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ do+          Awk.registerAwakeableTx aidA "sch" "1"+          Awk.registerAwakeableTx aidB "sch" "1"+        Right pendingCount <- Store.runStoreIO storeHandle Awk.countPendingAwakeables+        pendingCount `shouldBe` 2+        Right (Just rowA) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable aidA+        rowA ^. #status `shouldBe` Awk.Pending+        rowA ^. #payload `shouldBe` Nothing+        -- Complete A once; the status-guarded UPDATE makes a re-complete a no-op.+        Right firstComplete <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.completeAwakeableTx aidA (toJSON ("done" :: Text)) now+        firstComplete `shouldBe` True+        Right secondComplete <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.completeAwakeableTx aidA (toJSON ("again" :: Text)) now+        secondComplete `shouldBe` False+        Right (Just rowA') <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable aidA+        rowA' ^. #status `shouldBe` Awk.Completed+        rowA' ^. #payload `shouldBe` Just (toJSON ("done" :: Text))+        -- Cancel the still-pending B; both rows are now resolved.+        Right cancelled <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.cancelAwakeableTx aidB+        cancelled `shouldBe` True+        Right pendingAfter <- Store.runStoreIO storeHandle Awk.countPendingAwakeables+        pendingAfter `shouldBe` 0++      it "suspends on an unsignalled awakeable, recording a pending row and no completion" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "approval"+            wid = WorkflowId "wf1"+        outcome1 <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        outcome1 `shouldBe` Right Suspended+        aid <- readRequiredAwakeableId aidRef+        Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)+        row ^. #status `shouldBe` Awk.Pending+        row ^. #payload `shouldBe` Nothing+        Right pendingNow <- Store.runStoreIO storeHandle Awk.countPendingAwakeables+        pendingNow `shouldBe` 1+        Right recorded <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:approval-wf1") (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded)+          `shouldSatisfy` \case+            Right [StepRecorded stepName value _] ->+              stepName == awakeableAllocStepPrefix <> "approval" && value == toJSON aid+            _ -> False++      it "resumes with the signalled payload after signalAwakeable" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "approval"+            wid = WorkflowId "wf1"+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        aid <- readRequiredAwakeableId aidRef+        let awkStep = "awk:" <> awakeableIdText aid+        Right signalled <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+        signalled `shouldBe` True+        Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)+        row ^. #status `shouldBe` Awk.Completed+        row ^. #payload `shouldBe` Just (toJSON ("ok" :: Text))+        Right afterSignal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:approval-wf1") (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterSignal)+          `shouldSatisfy` \case+            Right [StepRecorded allocStep _ _, StepRecorded s r _] ->+              allocStep == awakeableAllocStepPrefix <> "approval" && s == awkStep && r == toJSON ("ok" :: Text)+            _ -> False+        outcome2 <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        outcome2 `shouldBe` Right (Completed "ok!")+        Right afterResume <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:approval-wf1") (StreamVersion 0) 100+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterResume)+          `shouldSatisfy` \case+            Right [StepRecorded allocStep _ _, StepRecorded s1 _ _, StepRecorded "use" _ _, WorkflowCompleted _] ->+              allocStep == awakeableAllocStepPrefix <> "approval" && s1 == awkStep+            _ -> False++      it "is idempotent: a second signal returns False and does not change the value" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "idem"+            wid = WorkflowId "wf-i"+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        aid <- readRequiredAwakeableId aidRef+        let awkStep = "awk:" <> awakeableIdText aid+        Right True <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+        Right again <- Store.runStoreIO storeHandle $ signalAwakeable aid ("later" :: Text)+        again `shouldBe` False+        Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)+        row ^. #payload `shouldBe` Just (toJSON ("ok" :: Text))+        Right recorded <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:idem-wf-i") (StreamVersion 0) 100+        Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))+        [r | StepRecorded s r _ <- decoded, s == awkStep] `shouldBe` [toJSON ("ok" :: Text)]++      it "throws WorkflowAwakeableCancelled after cancelAwakeable" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "cancelwf"+            wid = WorkflowId "wf2"+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        aid <- readRequiredAwakeableId aidRef+        Right cancelled <- Store.runStoreIO storeHandle $ cancelAwakeable aid+        cancelled `shouldBe` True+        Right (Just row) <- Store.runStoreIO storeHandle $ Awk.lookupAwakeable (awakeableIdToUuid aid)+        row ^. #status `shouldBe` Awk.Cancelled+        Store.runStoreIO storeHandle (runWorkflow name wid (approvalFlowWithId aidRef))+          `shouldThrow` (== WorkflowAwakeableCancelled aid)+        Right recorded <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:cancelwf-wf2") (StreamVersion 0) 100+        Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))+        any (\case WorkflowCompleted {} -> True; _ -> False) decoded `shouldBe` False++      it "re-appends a missing journal entry when re-signalled (crash-safe)" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "crash"+            wid = WorkflowId "wf3"+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        aid <- readRequiredAwakeableId aidRef+        let awkStep = "awk:" <> awakeableIdText aid+        -- Simulate "row completed but the journal append did not happen" by+        -- completing the row directly, bypassing signalAwakeable's journal write.+        now <- getCurrentTime+        Right completedRow <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.completeAwakeableTx (awakeableIdToUuid aid) (toJSON ("ok" :: Text)) now+        completedRow `shouldBe` True+        Right beforeRepair <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:crash-wf3") (StreamVersion 0) 100+        Right beforeDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList beforeRepair))+        [() | StepRecorded s _ _ <- beforeDecoded, s == awkStep] `shouldBe` []+        -- A re-signal with the same payload returns False (already completed) but+        -- repairs the missing journal entry from the stored payload.+        Right repaired <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+        repaired `shouldBe` False+        Right afterRepair <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:crash-wf3") (StreamVersion 0) 100+        Right afterDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterRepair))+        [r | StepRecorded s r _ <- afterDecoded, s == awkStep] `shouldBe` [toJSON ("ok" :: Text)]++      it "repairs a completed awakeable row from the await arm without a second signal" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "crash-arm"+            wid = WorkflowId "wf4"+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        aid <- readRequiredAwakeableId aidRef+        let awkStep = "awk:" <> awakeableIdText aid+        now <- getCurrentTime+        Right True <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.completeAwakeableTx (awakeableIdToUuid aid) (toJSON ("ok" :: Text)) now+        repairedRun <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        repairedRun `shouldBe` Right Suspended+        Right repairedJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:crash-arm-wf4") (StreamVersion 0) 100+        Right repairedDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList repairedJournal))+        [r | StepRecorded s r _ <- repairedDecoded, s == awkStep] `shouldBe` [toJSON ("ok" :: Text)]+        completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        completed `shouldBe` Right (Completed "ok!")++      it "refuses a forged coordinate-derived id for a fresh awakeable" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "fresh-awake"+            wid = WorkflowId "fa-1"+            forged = deterministicAwakeableId name wid "approval"+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        real <- readRequiredAwakeableId aidRef+        real `shouldNotBe` forged+        Right forgedSignal <- Store.runStoreIO storeHandle $ signalAwakeable forged ("bad" :: Text)+        forgedSignal `shouldBe` False+        Right stillSuspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        stillSuspended `shouldBe` Suspended+        Right realSignal <- Store.runStoreIO storeHandle $ signalAwakeable real ("ok" :: Text)+        realSignal `shouldBe` True+        completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        completed `shouldBe` Right (Completed "ok!")++      it "adopts a generation-0 legacy deterministic row" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "legacy-awake"+            wid = WorkflowId "la-1"+            legacy = deterministicAwakeableId name wid "approval"+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.registerAwakeableTx (awakeableIdToUuid legacy) (unWorkflowName name) (unWorkflowId wid)+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        adopted <- readRequiredAwakeableId aidRef+        adopted `shouldBe` legacy+        Right True <- Store.runStoreIO storeHandle $ signalAwakeable legacy ("ok" :: Text)+        completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+        completed `shouldBe` Right (Completed "ok!")++      it "allocates a fresh awakeable for the same label after continueAsNew" $ \storeHandle -> do+        idsRef <- newIORef []+        let name = WorkflowName "awake-roll"+            wid = WorkflowId "ar-1"+            body = rollingAwakeableWorkflow idsRef+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+        ids1 <- readIORef idsRef+        [firstAid] <- pure ids1+        Right True <- Store.runStoreIO storeHandle $ signalAwakeable firstAid ("first" :: Text)+        Right ContinuedAsNew <- Store.runStoreIO storeHandle $ runWorkflow name wid body+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+        ids2 <- readIORef idsRef+        case ids2 of+          [firstAgain, secondAid] -> do+            firstAgain `shouldBe` firstAid+            secondAid `shouldNotBe` firstAid+            Right staleSignal <- Store.runStoreIO storeHandle $ signalAwakeable firstAid ("stale" :: Text)+            staleSignal `shouldBe` False+            Right stillSuspended <- Store.runStoreIO storeHandle $ runWorkflow name wid body+            stillSuspended `shouldBe` Suspended+            Right True <- Store.runStoreIO storeHandle $ signalAwakeable secondAid ("second" :: Text)+            completed <- Store.runStoreIO storeHandle $ runWorkflow name wid body+            completed `shouldBe` Right (Completed "second")+          other -> expectationFailure ("expected two awakeable ids, got " <> show other)++  describe "Keiro.Workflow awakeable registration" $ around (withFreshStore fixture) $ do+    it "registers the row before a journaled hand-off can expose the id" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "awakeable-signal-gap"+          wid = WorkflowId "asg-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (publishAwakeableBeforeAwait aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right (Just pendingRow) <-+        Store.runStoreIO storeHandle $+          Awk.lookupAwakeable (awakeableIdToUuid aid)+      pendingRow ^. #status `shouldBe` Awk.Pending++      Right signalled <-+        Store.runStoreIO storeHandle $+          signalAwakeable aid ("ok" :: Text)+      signalled `shouldBe` True+      Right (Just completedRow) <-+        Store.runStoreIO storeHandle $+          Awk.lookupAwakeable (awakeableIdToUuid aid)+      completedRow ^. #status `shouldBe` Awk.Completed++      let unknown =+            AwakeableId+              (uuidLiteral "00000000-0000-0000-0000-0000000002f2")+      Right unknownSignal <-+        Store.runStoreIO storeHandle $+          signalAwakeable unknown ("forged" :: Text)+      unknownSignal `shouldBe` False++      completed <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (awaitPublishedAwakeable aidRef)+      completed `shouldBe` Right (Completed "ok")++  describe "Keiro.Workflow awakeable signal race" $ around (withFreshStore fixture) $ do+    it "does not append a value when cancellation wins after the signal pre-read" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "awakeable-cancel-race"+          wid = WorkflowId "acr-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right (Just stalePendingRow) <-+        Store.runStoreIO storeHandle $+          Awk.lookupAwakeable (awakeableIdToUuid aid)+      Right cancelled <- Store.runStoreIO storeHandle $ cancelAwakeable aid+      cancelled `shouldBe` True+      Right signalled <-+        Store.runStoreIO storeHandle $+          signalAwakeableFrom stalePendingRow ("late" :: Text)+      signalled `shouldBe` False+      Right recorded <-+        Store.runStoreIO storeHandle $+          stepExists+            name+            wid+            0+            (awakeableStepPrefix <> awakeableIdText aid)+      recorded `shouldBe` False+      Store.runStoreIO storeHandle (runWorkflow name wid (approvalFlowWithId aidRef))+        `shouldThrow` (== WorkflowAwakeableCancelled aid)++  describe "Keiro.Workflow.Child" $ do+    -- M2: the reserved spawn/result step-name derivations are stable.+    it "derives the child spawn and result step names" $ do+      childSpawnStepName (WorkflowId "c1") `shouldBe` "child:c1"+      childResultStepName (WorkflowId "c1") `shouldBe` "child:c1:result"++    -- M3(a): the new terminal journal constructors round-trip through the codec.+    it "round-trips WorkflowCancelled and WorkflowFailed through the journal codec" $ do+      let t = UTCTime (ModifiedJulianDay 0) 0+          rt ev = (workflowJournalCodec ^. #decode) ((workflowJournalCodec ^. #eventType) ev) ((workflowJournalCodec ^. #encode) ev)+      rt (WorkflowCancelled t) `shouldBe` Right (WorkflowCancelled t)+      rt (WorkflowFailed "boom" t) `shouldBe` Right (WorkflowFailed "boom" t)++    around (withFreshStore fixture) $ do+      -- M1: the keiro_workflow_children table and its schema helpers.+      it "schema: registers, completes, cancels, and counts child links" $ \storeHandle -> do+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.registerChildTx "c-1" "ship" "p-1" "parent" "child:c-1:result"+        Right (Just row) <- Store.runStoreIO storeHandle $ Child.lookupChild "c-1" "ship"+        row ^. #status `shouldBe` Child.Running+        row ^. #parentId `shouldBe` "p-1"+        row ^. #parentName `shouldBe` "parent"+        row ^. #awaitStep `shouldBe` "child:c-1:result"+        now <- getCurrentTime+        Right firstComplete <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.markChildResultTx "c-1" "ship" (toJSON ("packed+labelled" :: Text)) now+        firstComplete `shouldBe` True+        Right secondComplete <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.markChildResultTx "c-1" "ship" (toJSON ("again" :: Text)) now+        secondComplete `shouldBe` False+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.registerChildTx "c-2" "ship" "p-1" "parent" "child:c-2:result"+        Right cancelled <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.markChildCancelledTx "c-2" "ship"+        cancelled `shouldBe` True+        Right kids <- Store.runStoreIO storeHandle $ Child.lookupChildrenOfParent "p-1" "parent"+        map (^. #childId) kids `shouldBe` ["c-1", "c-2"]+        Right active <- Store.runStoreIO storeHandle Child.countActiveChildren+        active `shouldBe` (0 :: Int)+        Right st <- Store.runStoreIO storeHandle $ Child.childStatus "c-1" "ship"+        st `shouldBe` Just Child.ChildCompleted++      -- M4: spawn -> drive the child (with the completion hook) -> resume parent.+      it "spawns a child, drives it, propagates its result, and resumes the parent to Completed" $ \storeHandle -> do+        let childWid = WorkflowId "ship-1"+        suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p1") (parentWorkflow childWid)+        suspended `shouldBe` Right Suspended+        Right parentJournal1 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p1") (StreamVersion 0) 10+        Right decoded1 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal1))+        decoded1 `shouldSatisfy` \case+          [StepRecorded "child:ship-1" _ _] -> True+          _ -> False+        Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-1" "ship"+        childRow ^. #status `shouldBe` Child.Running+        childRow ^. #parentId `shouldBe` "p1"+        childRow ^. #parentName `shouldBe` "parent"+        childRow ^. #awaitStep `shouldBe` "child:ship-1:result"+        -- 2) drive the child through runChildWorkflow (propagates on completion).+        childOutcome <-+          Store.runStoreIO storeHandle $+            runChildWorkflow defaultWorkflowRunOptions (WorkflowName "ship") childWid shipWorkflow+        childOutcome `shouldBe` Right (Completed "packed+labelled")+        Right childJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:ship-ship-1") (StreamVersion 0) 10+        traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal)+          `shouldSatisfy` \case+            Right [StepRecorded "pack" _ _, StepRecorded "label" _ _, WorkflowCompleted _] -> True+            _ -> False+        Right parentJournal2 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p1") (StreamVersion 0) 10+        Right decoded2 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal2))+        [r | StepRecorded "child:ship-1:result" r _ <- decoded2]+          `shouldBe` [object ["ok" Aeson..= ("packed+labelled" :: Text)]]+        Right (Just childRow2) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-1" "ship"+        childRow2 ^. #status `shouldBe` Child.ChildCompleted+        -- 3) resume the parent: it replays past awaitChild and completes.+        resumed <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p1") (parentWorkflow childWid)+        resumed `shouldBe` Right (Completed "done:packed+labelled")+        Right parentJournal3 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p1") (StreamVersion 0) 10+        Right decoded3 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal3))+        any (\case StepRecorded "notify" _ _ -> True; _ -> False) decoded3 `shouldBe` True+        any (\case WorkflowCompleted {} -> True; _ -> False) decoded3 `shouldBe` True++      it "repairs a completed child row from awaitChild without another completion hook" $ \storeHandle -> do+        let childWid = WorkflowId "ship-crash"+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-crash") (parentWorkflow childWid)+        now <- getCurrentTime+        Right transitioned <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.markChildResultTx "ship-crash" "ship" (toJSON ("packed+labelled" :: Text)) now+        transitioned `shouldBe` True+        Right beforeRepair <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p-crash") (StreamVersion 0) 10+        Right beforeDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList beforeRepair))+        [r | StepRecorded "child:ship-crash:result" r _ <- beforeDecoded] `shouldBe` []+        repaired <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-crash") (parentWorkflow childWid)+        repaired `shouldBe` Right Suspended+        Right afterRepair <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p-crash") (StreamVersion 0) 10+        Right afterDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList afterRepair))+        [r | StepRecorded "child:ship-crash:result" r _ <- afterDecoded]+          `shouldBe` [object ["ok" Aeson..= ("packed+labelled" :: Text)]]+        completed <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-crash") (parentWorkflow childWid)+        completed `shouldBe` Right (Completed "done:packed+labelled")++      -- M5: re-invoking the parent does not re-spawn the child (crash survival).+      it "does not re-spawn the child when the parent is re-invoked" $ \storeHandle -> do+        let childWid = WorkflowId "ship-2"+        s1 <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p2") (parentWorkflow childWid)+        s1 `shouldBe` Right Suspended+        Right (Just beforeRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-2" "ship"+        let createdAt0 = beforeRow ^. #createdAt+        s2 <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p2") (parentWorkflow childWid)+        s2 `shouldBe` Right Suspended+        Right parentJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p2") (StreamVersion 0) 10+        Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))+        length [() | StepRecorded "child:ship-2" _ _ <- decoded] `shouldBe` 1+        Right kids <- Store.runStoreIO storeHandle $ Child.lookupChildrenOfParent "p2" "parent"+        length kids `shouldBe` 1+        map (^. #createdAt) kids `shouldBe` [createdAt0]++      -- M5: cancelling a child stops it and makes the parent's awaitChild throw.+      it "cancels a child: the child stops and the parent's awaitChild throws" $ \storeHandle -> do+        let childWid = WorkflowId "cancel-child"+            h = ChildHandle (WorkflowName "ship") childWid+        s1 <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p3") (parentWorkflow childWid)+        s1 `shouldBe` Right Suspended+        Right cancelled <- Store.runStoreIO storeHandle $ cancelChild h+        cancelled `shouldBe` True+        Right childJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:ship-cancel-child") (StreamVersion 0) 10+        Right childDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal))+        any (\case WorkflowCancelled {} -> True; _ -> False) childDecoded `shouldBe` True+        Right st <- Store.runStoreIO storeHandle $ Child.childStatus "cancel-child" "ship"+        st `shouldBe` Just Child.ChildCancelled+        Right parentJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p3") (StreamVersion 0) 10+        Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))+        [r | StepRecorded "child:cancel-child:result" r _ <- parentDecoded]+          `shouldBe` [object ["cancelled" Aeson..= True]]+        -- driving the child returns Cancelled and runs none of its steps.+        childOutcome <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "ship") childWid shipWorkflow+        childOutcome `shouldBe` Right Keiro.Workflow.Cancelled+        Right childJournal2 <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:ship-cancel-child") (StreamVersion 0) 10+        Right childDecoded2 <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal2))+        any (\case StepRecorded "pack" _ _ -> True; _ -> False) childDecoded2 `shouldBe` False+        -- re-invoking the parent throws WorkflowChildCancelled.+        Store.runStoreIO+          storeHandle+          (runWorkflow (WorkflowName "parent") (WorkflowId "p3") (parentWorkflow childWid))+          `shouldThrow` (== WorkflowChildCancelled (WorkflowName "ship") childWid)++      it "repairs a cancelled child row when cancelChild is retried after the row flip" $ \storeHandle -> do+        let childWid = WorkflowId "cancel-child-crash"+            h = ChildHandle (WorkflowName "ship") childWid+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-cancel-crash") (parentWorkflow childWid)+        Right transitioned <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.markChildCancelledTx "cancel-child-crash" "ship"+        transitioned `shouldBe` True+        Right retried <- Store.runStoreIO storeHandle $ cancelChild h+        retried `shouldBe` False+        Right childJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:ship-cancel-child-crash") (StreamVersion 0) 10+        Right childDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal))+        any (\case WorkflowCancelled {} -> True; _ -> False) childDecoded `shouldBe` True+        Right parentJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p-cancel-crash") (StreamVersion 0) 10+        Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))+        [r | StepRecorded "child:cancel-child-crash:result" r _ <- parentDecoded]+          `shouldBe` [object ["cancelled" Aeson..= True]]++      it "heals a cancelled-but-unmarked child from runChildWorkflow" $ \storeHandle -> do+        let childWid = WorkflowId "cancel-child-drive"+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-cancel-drive") (parentWorkflow childWid)+        Right True <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Child.markChildCancelledTx "cancel-child-drive" "ship"+        childOutcome <-+          Store.runStoreIO storeHandle $+            runChildWorkflow defaultWorkflowRunOptions (WorkflowName "ship") childWid shipWorkflow+        childOutcome `shouldBe` Right Keiro.Workflow.Cancelled+        Right childJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:ship-cancel-child-drive") (StreamVersion 0) 10+        Right childDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList childJournal))+        any (\case WorkflowCancelled {} -> True; _ -> False) childDecoded `shouldBe` True+        Right parentJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p-cancel-drive") (StreamVersion 0) 10+        Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))+        [r | StepRecorded "child:cancel-child-drive:result" r _ <- parentDecoded]+          `shouldBe` [object ["cancelled" Aeson..= True]]++      it "delivers an honest child result equal to the old cancellation sentinel" $ \storeHandle -> do+        let childWid = WorkflowId "json-cancelled-object"+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "json-parent") (WorkflowId "jp1") (jsonObjectParentWorkflow childWid)+        childOutcome <-+          Store.runStoreIO storeHandle $+            runChildWorkflow defaultWorkflowRunOptions (WorkflowName "json-child") childWid jsonObjectChildWorkflow+        childOutcome `shouldBe` Right (Completed (object ["cancelled" Aeson..= True]))+        completed <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "json-parent") (WorkflowId "jp1") (jsonObjectParentWorkflow childWid)+        completed `shouldBe` Right (Completed (object ["cancelled" Aeson..= True]))+        Right parentJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:json-parent-jp1") (StreamVersion 0) 10+        Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))+        [r | StepRecorded "child:json-cancelled-object:result" r _ <- parentDecoded]+          `shouldBe` [object ["ok" Aeson..= object ["cancelled" Aeson..= True]]]++      it "throws WorkflowStepDecodeError when an enveloped child result has the wrong type" $ \storeHandle -> do+        let childWid = WorkflowId "decode-child"+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-decode") (parentWorkflow childWid)+        Store.runStoreIO+          storeHandle+          (childCompletionHook (WorkflowName "ship") childWid (toJSON (42 :: Int)))+          `shouldReturn` Right ()+        Store.runStoreIO+          storeHandle+          (runWorkflow (WorkflowName "parent") (WorkflowId "p-decode") (parentWorkflow childWid))+          `shouldThrow` \case+            WorkflowStepDecodeError key _ -> key == "child:decode-child:result"+            _ -> False++      it "wakes a parent with WorkflowChildFailed when a child reaches the failure ceiling" $ \storeHandle -> do+        let childWid = WorkflowId "failed-child"+            registry =+              Map.fromList+                [ (WorkflowName "parent", WorkflowDef (\_ -> parentWorkflow childWid)),+                  (WorkflowName "ship", WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure ("" :: Text)))+                ]+            opts = defaultWorkflowResumeOptions & #maxAttempts .~ 1+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p-failed-child") (parentWorkflow childWid)+        Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+        failed summary `shouldBe` 1+        Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "failed-child" "ship"+        childRow ^. #status `shouldBe` Child.ChildFailed+        Store.runStoreIO+          storeHandle+          (runWorkflow (WorkflowName "parent") (WorkflowId "p-failed-child") (parentWorkflow childWid))+          `shouldThrow` \case+            WorkflowChildFailed (WorkflowName "ship") (WorkflowId "failed-child") reason ->+              "SimulatedCrash" `Text.isInfixOf` reason+            _ -> False++      it "stops at the next step boundary when a workflow is cancelled mid-run" $ \storeHandle -> do+        counter <- newIORef 0+        let name = WorkflowName "self-cancel"+            wid = WorkflowId "sc1"+        outcome <-+          Store.runStoreIO storeHandle $+            runWorkflow name wid (selfCancellingWorkflow name wid counter)+        outcome `shouldBe` Right Keiro.Workflow.Cancelled+        readIORef counter `shouldReturn` 2+        Right recorded <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:self-cancel-sc1") (StreamVersion 0) 10+        Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))+        any (\case StepRecorded "three" _ _ -> True; _ -> False) decoded `shouldBe` False++      -- EP-42 worker-driven variant: the resume worker drives both parent and+      -- child from a registry, selecting childCompletionHook for the child and+      -- union-discovering the zero-step child.+      it "drives a parent and its child to completion through the resume worker" $ \storeHandle -> do+        let childWid = WorkflowId "ship-3"+            registry =+              Map.fromList+                [ (WorkflowName "parent", WorkflowDef (\_ -> parentWorkflow childWid)),+                  (WorkflowName "ship", WorkflowDef (\_ -> shipWorkflow))+                ]+        Right Suspended <-+          Store.runStoreIO storeHandle $+            runWorkflow (WorkflowName "parent") (WorkflowId "p4") (parentWorkflow childWid)+        let drive = Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+        Right _ <- drive+        Right _ <- drive+        Right _ <- drive+        Right parentJournal <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "wf:parent-p4") (StreamVersion 0) 10+        Right parentDecoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList parentJournal))+        any (\case WorkflowCompleted {} -> True; _ -> False) parentDecoded `shouldBe` True+        Right (Just childRow) <- Store.runStoreIO storeHandle $ Child.lookupChild "ship-3" "ship"+        childRow ^. #status `shouldBe` Child.ChildCompleted++      it "attaches to a completed child after continueAsNew" $ \storeHandle -> do+        let childWid = WorkflowId "ship-rotated"+            parentName = WorkflowName "parent-rotating"+            parentId = WorkflowId "p-rotating"+            body = rotatingParentWorkflow childWid+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body+        childOutcome <-+          Store.runStoreIO storeHandle $+            runChildWorkflow defaultWorkflowRunOptions (WorkflowName "ship") childWid shipWorkflow+        childOutcome `shouldBe` Right (Completed "packed+labelled")+        Right ContinuedAsNew <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body+        repair <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body+        repair `shouldBe` Right Suspended+        completed <- Store.runStoreIO storeHandle $ runWorkflow parentName parentId body+        completed `shouldBe` Right (Completed "packed+labelled")++  describe "Keiro.Workflow.Child durable failed delivery" $ around (withFreshStore fixture) $ do+    it "delivers a persisted child failure after the parent rotates past the failure journal" $ \storeHandle -> do+      let childWid = WorkflowId "failed-before-rotation"+          parentName = WorkflowName "parent-failure-rotation"+          parentId = WorkflowId "p-failure-rotation"+          registry =+            Map.fromList+              [ (parentName, WorkflowDef (\_ -> failedChildBeforeRotation childWid)),+                (WorkflowName "ship", WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure ("" :: Text)))+              ]+          opts = defaultWorkflowResumeOptions & #maxAttempts .~ 1+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow parentName parentId (failedChildBeforeRotation childWid)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      failed summary `shouldBe` 1+      Right (Just childRow) <-+        Store.runStoreIO storeHandle $+          Child.lookupChild "failed-before-rotation" "ship"+      childRow ^. #status `shouldBe` Child.ChildFailed+      childRow ^. #failureReason+        `shouldSatisfy` maybe False ("SimulatedCrash" `Text.isInfixOf`)+      Right failedOnGenerationZero <-+        Store.runStoreIO storeHandle $+          stepExists+            parentName+            parentId+            0+            (childResultStepName childWid)+      failedOnGenerationZero `shouldBe` True++      Right ContinuedAsNew <-+        Store.runStoreIO storeHandle $+          runWorkflow parentName parentId (rotatePastFailedChild childWid)+      Right generation <- Store.runStoreIO storeHandle $ currentGeneration parentName parentId+      generation `shouldBe` 1+      Right failedOnGenerationOne <-+        Store.runStoreIO storeHandle $+          stepExists+            parentName+            parentId+            1+            (childResultStepName childWid)+      failedOnGenerationOne `shouldBe` False++      delivered <-+        Store.runStoreIO storeHandle $+          runWorkflow parentName parentId (catchFailedChildAfterRotation childWid)+      delivered `shouldSatisfy` \case+        Right (Completed reason) -> "SimulatedCrash" `Text.isInfixOf` reason+        _ -> False++  describe "Keiro.Workflow.Gc" $ around (withFreshStore fixture) $ do+    it "deletes terminal workflow data after retention" $ \storeHandle -> do+      let name = WorkflowName "gc-basic"+          wid = WorkflowId "gb-1"+          gcStreamName = workflowGenerationStreamName name wid 0+          aid = fromMaybe (error "invalid gc awakeable uuid") (fromString "00000000-0000-0000-0000-0000000000a1")+          timerId = fromMaybe (error "invalid gc timer uuid") (fromString "00000000-0000-0000-0000-0000000000a2")+      counter <- newIORef (0 :: Int)+      Right (Completed _) <-+        Store.runStoreIO storeHandle $+          runWorkflowWith+            (defaultWorkflowRunOptions & #snapshotPolicy .~ OnTerminal)+            name+            wid+            (demoWorkflow counter)+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Awk.registerAwakeableTx aid "gc-basic" "gb-1"+            Tx.statement (timerId, "gc-basic", "gb-1", now, object ["kind" Aeson..= ("keiro.workflow.sleep" :: Text)], "fired") insertGcTimerStmt+      Right beforeCounts <- Store.runStoreIO storeHandle $ workflowOwnedRowCounts "gc-basic" "gb-1"+      beforeCounts `shouldBe` (1, 3, 1, 0, 1, 1)+      Right freshSummary <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce+            now+            WorkflowGc.WorkflowGcPolicy {retention = 3600, batchSize = 10}+      freshSummary `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 0, deleted = 0}+      Right (Just _) <- Store.runStoreIO storeHandle $ Store.lookupStreamId gcStreamName+      Right deletedSummary <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce+            (addUTCTime 1 now)+            WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+      deletedSummary `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 1, deleted = 1}+      Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId gcStreamName+      Right afterCounts <- Store.runStoreIO storeHandle $ workflowOwnedRowCounts "gc-basic" "gb-1"+      afterCounts `shouldBe` (0, 0, 0, 0, 0, 0)++    it "deletes scheduled sleep timers so a collected workflow cannot resurrect" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "gc-scheduled-sleep"+          wid = WorkflowId "gss-1"+          journalStream = workflowGenerationStreamName name wid 0+          TimerId timerUuid = sleepTimerId name wid 0 "sleep:wait"+          body = do+            _ <- step (StepName "before-sleep") (liftIO (incrementAndRead counter))+            sleepNamed (StepName "wait") 3600+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid body+      Right timerBeforeGc <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement timerUuid sleepTimerStatusStmt+      fmap fst timerBeforeGc `shouldBe` Just "scheduled"++      cancelledAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (WorkflowCancelled cancelledAt)+      gcClock <- getCurrentTime+      Right collected <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce+            (addUTCTime 1 gcClock)+            WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+      collected `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 1, deleted = 1}++      Right Nothing <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId journalStream+      Right timerAfterGc <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement timerUuid sleepTimerStatusStmt+      timerAfterGc `shouldBe` Nothing++      Right noClaim <-+        Store.runStoreIO storeHandle $+          runWorkflowTimerWorker Nothing (addUTCTime 7200 gcClock) (\_ -> pure Nothing)+      noClaim `shouldBe` Nothing+      Right Nothing <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId journalStream+      readIORef counter >>= (`shouldBe` 1)++    it "cancels a sleep fire when a terminal instance survives partial GC" $ \storeHandle -> do+      let name = WorkflowName "gc-terminal-fire"+          wid = WorkflowId "gtf-1"+          full = "sleep:wait"+          timerId@(TimerId timerUuid) = sleepTimerId name wid 0 full+          journalStream = workflowGenerationStreamName name wid 0+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Instance.upsertInstanceTx "gtf-1" "gc-terminal-fire" 0 Instance.WfCancelled Nothing+            void $+              scheduleTimerOnceTx+                TimerRequest+                  { timerId,+                    processManagerName = "gc-terminal-fire",+                    correlationId = "gtf-1",+                    fireAt = now,+                    payload = sleepTimerPayload 0 full+                  }+      Right (Just claimed) <-+        Store.runStoreIO storeHandle $+          runWorkflowTimerWorker Nothing now (\_ -> pure Nothing)+      claimed ^. #timerId `shouldBe` timerId+      Right terminalTimer <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement timerUuid sleepTimerStatusStmt+      fmap fst terminalTimer `shouldBe` Just "cancelled"+      Right Nothing <- Store.runStoreIO storeHandle $ Store.lookupStreamId journalStream+      Right resolved <-+        Store.runStoreIO storeHandle $+          stepExists name wid 0 full+      resolved `shouldBe` False++    it "keeps completed children while a parent is live and converges after partial cleanup" $ \storeHandle -> do+      let parentName = WorkflowName "gc-live-parent"+          parentId = WorkflowId "gp-1"+          childName = WorkflowName "gc-child"+          childId = WorkflowId "gc-1"+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Instance.upsertInstanceTx "gp-1" "gc-live-parent" 0 Instance.WfRunning Nothing+            Child.registerChildTx "gc-1" "gc-child" "gp-1" "gc-live-parent" "child:gc-1:result"+            void (Child.markChildResultTx "gc-1" "gc-child" (toJSON ("ok" :: Text)) now)+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry childName childId (WorkflowCompleted now)+      Right held <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce+            (addUTCTime 1 now)+            WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+      held `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 0, deleted = 0}+      Right childStillThere <- Store.runStoreIO storeHandle $ Store.lookupStreamId (workflowGenerationStreamName childName childId 0)+      childStillThere `shouldSatisfy` isJust+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry parentName parentId (WorkflowCompleted now)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("gc-1", "gc-child") deleteGcStepsStmt+      Right collected <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce+            (addUTCTime 1 now)+            WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+      collected `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 2, deleted = 2}+      Right parentGone <- Store.runStoreIO storeHandle $ Instance.lookupInstance parentName parentId+      parentGone `shouldBe` Nothing+      Right childGone <- Store.runStoreIO storeHandle $ Instance.lookupInstance childName childId+      childGone `shouldBe` Nothing+      Right childRows <- Store.runStoreIO storeHandle $ workflowOwnedChildCount "gc-child" "gc-1"+      childRows `shouldBe` 0++-- | Increment a shared counter and return its new value (the step's side+-- effect, so replay can be proven by watching the counter).+incrementAndRead :: IORef Int -> IO Int+incrementAndRead ref = atomicModifyIORef' ref (\n -> (n + 1, n + 1))++forceWorkflowLeaseStmt :: Statement (Text, Text, Text, UTCTime) ()+forceWorkflowLeaseStmt =+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET leased_by = $3,+        lease_expires_at = $4,+        updated_at = now()+    WHERE workflow_id = $1+      AND workflow_name = $2+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult++-- | Six numbered steps, each returning its index after bumping a shared+-- counter. The counter lets a re-hydration prove the steps short-circuit+-- (it stays at 6 when every step is replayed from the journal/snapshot).+countingSixSteps :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es [Int]+countingSixSteps counter =+  mapM+    (\i -> step (StepName ("s" <> Text.pack (show i))) (liftIO (incrementAndRead counter) >> pure i))+    [1 .. 6]++newtype Approx = Approx Double+  deriving stock (Eq, Show)++instance ToJSON Approx where+  toJSON (Approx d) = toJSON (round d :: Int)++instance FromJSON Approx where+  parseJSON value = do+    n <- Aeson.parseJSON value+    pure (Approx (fromIntegral (n :: Int)))++data RejectingRoundTrip = RejectingRoundTrip+  deriving stock (Eq, Show)++instance ToJSON RejectingRoundTrip where+  toJSON RejectingRoundTrip = Aeson.String "not-an-object"++instance FromJSON RejectingRoundTrip where+  parseJSON = Aeson.withObject "RejectingRoundTrip" $ \_ -> pure RejectingRoundTrip++-- | A distinguished exception used to simulate a process crash mid-workflow+-- (after a step has committed its journal append but before completion).+data SimulatedCrash = SimulatedCrash+  deriving stock (Show)++instance Exception SimulatedCrash++-- | A three-step workflow; each step bumps a shared counter so a resume can+-- prove steps short-circuit (the counter only advances for steps that run).+threeStep :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int, Int)+threeStep counter = do+  a <- step (StepName "s1") (liftIO (incrementAndRead counter))+  b <- step (StepName "s2") (liftIO (incrementAndRead counter))+  c <- step (StepName "s3") (liftIO (incrementAndRead counter))+  pure (a, b, c)++threeStepThenSignal :: (Workflow :> es, IOE :> es) => IORef Int -> MVar () -> Eff es (Int, Int, Int)+threeStepThenSignal counter done = do+  result <- threeStep counter+  liftIO (putMVar done ())+  pure result++-- | Runs step @"s1"@ (which commits its own journal append) then crashes, so+-- the journal is left with one StepRecorded and no WorkflowCompleted.+crashAfterStep1 :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int, Int)+crashAfterStep1 counter = do+  _ <- step (StepName "s1") (liftIO (incrementAndRead counter))+  _ <- liftIO (throwIO SimulatedCrash)+  pure (0, 0, 0)++-- | A workflow with one durable side effect before a switchable failure and+-- one durable side effect after it. Resurrection tests use the counter to prove+-- the recorded prefix never executes again.+recoverableWorkflow ::+  (Workflow :> es, IOE :> es) =>+  IORef Bool ->+  IORef Int ->+  Eff es Int+recoverableWorkflow shouldCrash counter = do+  _ <- step (StepName "durable-prefix") (liftIO (incrementAndRead counter))+  crashing <- liftIO (readIORef shouldCrash)+  when crashing (liftIO (throwIO SimulatedCrash))+  step (StepName "durable-tail") (liftIO (incrementAndRead counter))++-- | Awaits an external step, then runs a step that bumps the counter. Used to+-- prove the resume worker drives a suspended workflow to completion once its+-- awaited step is journaled.+awaitingThenStep :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text+awaitingThenStep counter = do+  decision <- awaitStep (StepName "awk:approval") (pure ())+  _ <- step (StepName "use") (liftIO (incrementAndRead counter) >> pure (decision <> "!"))+  pure (decision <> "-done")++-- | A rolling-total workflow (EP-48 continue-as-new acceptance). It adds @total@+-- unit-valued work steps to a running total, rotating its journal every+-- @rotateEvery@ steps via 'continueAsNew'. The carried seed is the pair+-- @(runningTotal, stepsDoneGlobally)@ so each generation knows the global+-- progress; @genDone@ counts steps within the /current/ generation to bound it.+-- Each work step bumps @counter@ exactly once (proving rotation neither drops+-- nor double-counts) and returns 1, so the final total equals @total@.+--+-- Step names are the global step index (@w0@, @w1@, …), so they are unique+-- within each generation's journal and replay-stable. Note the regression+-- direction: on a tree where 'continueAsNew' did not rotate, this body would put+-- all @total@ steps on generation 0's single journal and the per-generation+-- @<= K@ bound below would fail for @total > K@.+rollingTotal :: (Workflow :> es, IOE :> es) => IORef Int -> Int -> Int -> Eff es Int+rollingTotal counter rotateEvery total = do+  (acc0, done0) <- restoreSeed (0 :: Int, 0 :: Int)+  go acc0 done0 0+  where+    go acc done genDone+      | done >= total = pure acc -- all global work done: this generation completes+      | genDone >= rotateEvery = continueAsNew (acc, done) -- bound this generation; carry onward+      | otherwise = do+          n <-+            step+              (StepName ("w" <> Text.pack (show done)))+              (liftIO (modifyIORef' counter (+ 1) >> pure (1 :: Int)))+          go (acc + n) (done + 1) (genDone + 1)++-- The patch id under test (EP-49).+fraudPatchId :: PatchId+fraudPatchId = PatchId "fraud-check-v2"++-- | The workflow BEFORE the patch shipped: reserve, then await an external step+-- (so an instance can be left in flight, mid-journal, with one ordinary step+-- recorded and no completion). Used to create the in-flight instance.+prePatchWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text+prePatchWorkflow counter = do+  _ <- step (StepName "reserve-inventory") (liftIO (incrementAndRead counter) >> pure ())+  (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ()) -- park here, in flight+  pure "old-done"++-- | The workflow AFTER the patch shipped: the same first step, then a+-- patch-gated cross-cutting branch. The in-flight instance (which already+-- journaled reserve-inventory under the pre-patch code) must observe False and+-- take the OLD branch; a fresh instance must observe True and take the NEW branch.+postPatchWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text+postPatchWorkflow counter = do+  _ <- step (StepName "reserve-inventory") (liftIO (incrementAndRead counter) >> pure ())+  useNew <- patch fraudPatchId+  if useNew+    then step (StepName "new-charge") (pure "new-branch")+    else step (StepName "old-charge") (pure "old-branch")++postPatchAfterSuspendWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Text+postPatchAfterSuspendWorkflow counter = do+  _ <- step (StepName "reserve-inventory") (liftIO (incrementAndRead counter) >> pure ())+  (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())+  useNew <- patch fraudPatchId+  if useNew+    then step (StepName "new-charge") (pure "new-branch")+    else step (StepName "old-charge") (pure "old-branch")++prePatchWakeOnlyWorkflow :: (Workflow :> es) => Eff es Text+prePatchWakeOnlyWorkflow = do+  (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())+  pure "old-done"++postPatchWakeOnlyWorkflow :: (Workflow :> es) => Eff es Text+postPatchWakeOnlyWorkflow = do+  (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())+  useNew <- patch fraudPatchId+  if useNew+    then step (StepName "new-charge") (pure "new-branch")+    else step (StepName "old-charge") (pure "old-branch")++rotatingPatchWorkflow :: (Workflow :> es) => Eff es Text+rotatingPatchWorkflow = do+  seed <- restoreSeed (0 :: Int)+  if seed < 1+    then continueAsNew (seed + 1)+    else do+      useNew <- patch fraudPatchId+      if useNew+        then step (StepName "new-charge") (pure "new-branch")+        else step (StepName "old-charge") (pure "old-branch")++-- | A workflow (EP-50 push tests) that awaits an external "awk:gate" step, then+-- runs a step that fills @done@ — so a test can observe the exact moment the+-- workflow resumes to completion. Awaiting first means the journal is empty until+-- the external gate append, which is what makes the instance discoverable by the+-- resume worker (the gate's StepRecorded is the first index row).+gateThenSignal :: (Workflow :> es, IOE :> es) => MVar () -> Eff es Text+gateThenSignal done = do+  (_ :: ()) <- awaitStep (StepName "awk:gate") (pure ())+  _ <- step (StepName "after-gate") (liftIO (putMVar done ()) >> pure ())+  pure "resumed"++-- | A two-step workflow whose steps each bump a shared counter.+demoWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int)+demoWorkflow counter = do+  a <- step (StepName "first") (liftIO (incrementAndRead counter))+  b <- step (StepName "second") (liftIO (incrementAndRead counter))+  pure (a, b)++-- | A workflow that immediately awaits a step nothing ever arms — used to+-- exercise the suspend path and external completion.+neverArmingWorkflow :: (Workflow :> es) => Eff es Int+neverArmingWorkflow = awaitStep (StepName "awk:test") (pure ())++-- | The awakeable validation workflow: allocate a durable promise, suspend on+-- it, and (once signalled) append "!" to the payload through a recorded step.+approvalFlowWithId :: (Workflow :> es, Store :> es, IOE :> es) => IORef (Maybe AwakeableId) -> Eff es Text+approvalFlowWithId ref = do+  (aid, await) <- awakeableNamed (StepName "approval")+  liftIO (writeIORef ref (Just aid))+  v <- await+  step (StepName "use") (pure (v <> "!"))++publishAwakeableBeforeAwait ::+  forall es.+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef (Maybe AwakeableId) ->+  Eff es ()+publishAwakeableBeforeAwait ref = do+  (aid, _await :: Eff es Text) <- awakeableNamed (StepName "gate")+  _ <-+    step (StepName "publish") $ do+      liftIO (writeIORef ref (Just aid))+  (_ :: ()) <- awaitStep (StepName "hold") (pure ())+  pure ()++awaitPublishedAwakeable ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef (Maybe AwakeableId) ->+  Eff es Text+awaitPublishedAwakeable ref = do+  (aid, await) <- awakeableNamed (StepName "gate")+  _ <-+    step (StepName "publish") $ do+      liftIO (writeIORef ref (Just aid))+  await++snapshotUnsignalledAwakeable ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef (Maybe AwakeableId) ->+  Eff es Text+snapshotUnsignalledAwakeable ref = do+  (aid, await) <- awakeableNamed (StepName "gate")+  liftIO (writeIORef ref (Just aid))+  await++snapshotShadowedAwakeable :: (Workflow :> es, Store :> es, IOE :> es) => Eff es Text+snapshotShadowedAwakeable = do+  (aid, await) <- awakeableNamed (StepName "gate")+  _ <- step (StepName "mid") (void (signalAwakeable aid ("payload" :: Text)))+  await++snapshotStaleAwakeablePhaseOne ::+  forall es.+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef (Maybe AwakeableId) ->+  Eff es ()+snapshotStaleAwakeablePhaseOne ref = do+  (aid, _await :: Eff es Text) <- awakeableNamed (StepName "gate")+  liftIO (writeIORef ref (Just aid))+  _ <- step (StepName "mid") (void (signalAwakeable aid ("payload" :: Text)))+  (_ :: ()) <- awaitStep (StepName "hold") (pure ())+  pure ()++snapshotStaleAwakeablePhaseTwo :: (Workflow :> es, Store :> es, IOE :> es) => Eff es Text+snapshotStaleAwakeablePhaseTwo = do+  (_aid, await) <- awakeableNamed (StepName "gate")+  _ <- step (StepName "mid") (pure ())+  await++snapshotStaleChildPhaseOne ::+  (Workflow :> es, Store :> es, IOE :> es, Error Store.StoreError :> es) =>+  WorkflowId ->+  Eff es ()+snapshotStaleChildPhaseOne childWid = do+  _h <- spawnChild (WorkflowName "snapshot-child") childWid shipWorkflow+  _ <-+    step (StepName "drive") $+      void (runChildWorkflow defaultWorkflowRunOptions (WorkflowName "snapshot-child") childWid shipWorkflow)+  (_ :: ()) <- awaitStep (StepName "hold") (pure ())+  pure ()++snapshotStaleChildPhaseTwo ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  WorkflowId ->+  Eff es Text+snapshotStaleChildPhaseTwo childWid = do+  h <- spawnChild (WorkflowName "snapshot-child") childWid shipWorkflow+  _ <- step (StepName "drive") (pure ())+  awaitChild h++readRequiredAwakeableId :: IORef (Maybe AwakeableId) -> IO AwakeableId+readRequiredAwakeableId ref =+  readIORef ref >>= \case+    Just aid -> pure aid+    Nothing -> fail "workflow did not allocate an awakeable id"++uuidLiteral :: String -> UUID+uuidLiteral raw =+  case fromString raw of+    Just uuid -> uuid+    Nothing -> error ("invalid UUID literal in test: " <> raw)++-- | A two-step workflow with a durable sleep between the steps. The sleep's+-- name and delay are parameters so one helper drives both the zero-delta and+-- the real-time tests.+sleepDemoNamed ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef Int -> StepName -> NominalDiffTime -> Eff es (Int, Int)+sleepDemoNamed counter sName delta = do+  a <- step (StepName "a") (liftIO (incrementAndRead counter))+  sleepNamed sName delta+  b <- step (StepName "b") (liftIO (incrementAndRead counter))+  pure (a, b)++rollingSleepWorkflow ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef Int -> Eff es Int+rollingSleepWorkflow counter = do+  seed <- restoreSeed (0 :: Int)+  _ <- step (StepName "work") (liftIO (incrementAndRead counter))+  if seed < 2+    then sleepNamed (StepName "cool") 0 >> continueAsNew (seed + 1)+    else pure seed++rollingAwakeableWorkflow ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef [AwakeableId] -> Eff es Text+rollingAwakeableWorkflow idsRef = do+  seed <- restoreSeed (0 :: Int)+  (aid, await) <- awakeableNamed (StepName "gate")+  liftIO (modifyIORef' idsRef (\ids -> if aid `elem` ids then ids else ids <> [aid]))+  value <- await+  if seed < 1+    then continueAsNew (seed + 1)+    else step (StepName "use") (pure value)++rotatingParentWorkflow ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  WorkflowId -> Eff es Text+rotatingParentWorkflow childWid = do+  seed <- restoreSeed (0 :: Int)+  h <- spawnChild (WorkflowName "ship") childWid shipWorkflow+  result <- awaitChild h+  if seed < 1+    then continueAsNew (seed + 1)+    else pure result++failedChildBeforeRotation ::+  (Workflow :> es, Store :> es) =>+  WorkflowId ->+  Eff es Text+failedChildBeforeRotation childWid = do+  _ <- spawnChild (WorkflowName "ship") childWid shipWorkflow+  awaitStep (StepName "rotation-gate") (pure ())++rotatePastFailedChild ::+  (Workflow :> es, Store :> es) =>+  WorkflowId ->+  Eff es Text+rotatePastFailedChild childWid = do+  _ <- spawnChild (WorkflowName "ship") childWid shipWorkflow+  continueAsNew ()++catchFailedChildAfterRotation ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  WorkflowId ->+  Eff es Text+catchFailedChildAfterRotation childWid = do+  child <- spawnChild (WorkflowName "ship") childWid shipWorkflow+  EffException.catch+    (awaitChild child)+    (\(WorkflowChildFailed _ _ reason) -> pure reason)++-- | A workflow that records one step, then suspends on an await — so it has a+-- step row but no completion marker (the unfinished-discovery case).+stepThenAwaitWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int+stepThenAwaitWorkflow counter = do+  _ <- step (StepName "s1") (liftIO (incrementAndRead counter))+  awaitStep (StepName "awk:wait") (pure ())++-- | A two-step child workflow used in the child-workflow tests.+shipWorkflow :: (Workflow :> es) => Eff es Text+shipWorkflow = do+  a <- step (StepName "pack") (pure ("packed" :: Text))+  b <- step (StepName "label") (pure (a <> "+labelled"))+  pure b++-- | A parent that spawns a @"ship"@ child (id supplied), awaits its result, and+-- then records a @notify@ step. Parametrised by child id so each test isolates+-- its own child journal.+parentWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowId -> Eff es Text+parentWorkflow childWid = do+  h <- spawnChild (WorkflowName "ship") childWid shipWorkflow+  result <- awaitChild h+  _ <- step (StepName "notify") (pure ("done:" <> result))+  pure ("done:" <> result)++jsonObjectChildWorkflow :: Eff es Aeson.Value+jsonObjectChildWorkflow =+  pure (object ["cancelled" Aeson..= True])++jsonObjectParentWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowId -> Eff es Aeson.Value+jsonObjectParentWorkflow childWid = do+  h <- spawnChild (WorkflowName "json-child") childWid jsonObjectChildWorkflow+  result <- awaitChild h+  _ <- step (StepName "json-notify") (pure ())+  pure result++selfCancellingWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowName -> WorkflowId -> IORef Int -> Eff es Int+selfCancellingWorkflow name wid counter = do+  _ <- step (StepName "one") (liftIO (incrementAndRead counter))+  _ <-+    step (StepName "two") $ do+      now <- liftIO getCurrentTime+      appendJournalEntry name wid (WorkflowCancelled now)+      liftIO (incrementAndRead counter)+  step (StepName "three") (liftIO (incrementAndRead counter))++nominalDays :: Int -> NominalDiffTime+nominalDays n = fromIntegral n * 86400++attrKeyText :: AttributeKey Text -> Text+attrKeyText = unkey++attrKeyTextInt64 :: AttributeKey Int64 -> Text+attrKeyTextInt64 = unkey++textAttr :: Attributes -> Text -> Maybe Text+textAttr attrs name = case lookupAttribute attrs name of+  Just (AttributeValue (TextAttribute t)) -> Just t+  _ -> Nothing++intAttr :: Attributes -> Text -> Maybe Int64+intAttr attrs name = case lookupAttribute attrs name of+  Just (AttributeValue (IntAttribute n)) -> Just n+  _ -> Nothing++-- | A frozen snapshot of an 'ImmutableSpan'. In hs-opentelemetry 1.0 the+-- mutable span fields (name, attributes, status) live behind the+-- @spanHot :: IORef SpanHot@ field rather than directly on 'ImmutableSpan',+-- so the tests read that reference once after the span ends and assert on+-- this flat record.+data CapturedSpan = CapturedSpan+  { csName :: Text,+    csKind :: SpanKind,+    csAttributes :: Attributes,+    csStatus :: SpanStatus,+    csContext :: SpanContext,+    csParent :: Maybe Span+  }++captureSpan :: ImmutableSpan -> IO CapturedSpan+captureSpan sp = do+  hot <- readIORef (spanHot sp)+  pure+    CapturedSpan+      { csName = hotName hot,+        csKind = spanKind sp,+        csAttributes = hotAttributes hot,+        csStatus = hotStatus hot,+        csContext = spanContext sp,+        csParent = spanParent sp+      }++-- | Tiny in-process \"Kafka topic\": an MVar of consumed records plus an+-- incrementing offset. The publisher pushes records here; the consumer+-- drains the MVar. There is no real broker — the goal of the fixture is+-- to validate that the keiro envelope and outbox/inbox semantics+-- compose correctly across two isolated PostgreSQL contexts.+newtype KafkaTopic = KafkaTopic (MVar (Int64, [InboxKafka.KafkaInboundRecord]))++newKafkaTopic :: IO KafkaTopic+newKafkaTopic = KafkaTopic <$> newMVar (0, [])++kafkaTopicAccept :: (MonadIO m) => KafkaTopic -> OutboxRow -> m ()+kafkaTopicAccept (KafkaTopic ref) row = liftIO $ do+  let record = OutboxKafka.outboxRowToKafkaRecord row+      headersText =+        [ (TE.decodeUtf8 name, TE.decodeUtf8 value)+        | (name, value) <- record ^. #headers+        ]+  now <- getCurrentTime+  modifyMVar ref $ \(nextOffset, acc) ->+    let inbound =+          InboxKafka.KafkaInboundRecord+            { topic = record ^. #topic,+              partition = 0,+              offset = nextOffset,+              key = fmap TE.decodeUtf8 (record ^. #key),+              payload = record ^. #payload,+              headers = headersText,+              receivedAt = now+            }+     in pure ((nextOffset + 1, inbound : acc), ())++kafkaTopicPublish ::+  forall es.+  (IOE :> es) =>+  KafkaTopic ->+  OutboxRow ->+  Eff es PublishOutcome+kafkaTopicPublish topic row = do+  kafkaTopicAccept topic row+  pure PublishSucceeded++perRow ::+  (OutboxRow -> Eff es PublishOutcome) ->+  [OutboxRow] ->+  Eff es [(OutboxId, PublishOutcome)]+perRow publish rows =+  traverse publishOne rows+  where+    publishOne row = do+      outcome <- publish row+      pure (row ^. #outboxId, outcome)++drainKafkaTopic :: KafkaTopic -> IO [InboxKafka.KafkaInboundRecord]+drainKafkaTopic (KafkaTopic ref) = do+  (_, acc) <- readMVar ref+  pure (reverse acc)++redeliverWithDifferentOffset ::+  InboxKafka.KafkaInboundRecord ->+  InboxKafka.KafkaInboundRecord+redeliverWithDifferentOffset record = record & #offset .~ (record ^. #offset) + 1000++data ConsumeResult a+  = ConsumeDecodeFailed !InboxKafka.KafkaDecodeError+  | ConsumePolicyUnsatisfied !InboxError+  | ConsumeApplied !(InboxResult a)+  deriving stock (Eq, Show)++-- | A worker-shaped consumer: decode the Kafka record into an+-- IntegrationEvent and run it through the inbox.+consumeAndApply ::+  forall es.+  (IOE :> es, Store :> es) =>+  InboxKafka.KafkaInboundRecord ->+  (IntegrationEvent -> Tx.Transaction ()) ->+  Eff es (ConsumeResult ())+consumeAndApply record handler =+  case InboxKafka.integrationEventFromKafka record of+    Left err -> pure (ConsumeDecodeFailed err)+    Right (event, kafkaRef) -> do+      result <-+        runInboxTransaction Nothing PreferIntegrationMessageId event (Just kafkaRef) handler+      case result of+        Left err -> pure (ConsumePolicyUnsatisfied err)+        Right applied -> pure (ConsumeApplied applied)++billingReactionHandler :: IntegrationEvent -> Tx.Transaction ()+billingReactionHandler event = case decodeJsonIntegrationEvent event of+  Left _ -> Tx.condemn+  Right (OrderSubmittedPayload orderId quantity) ->+    Tx.statement (orderId, fromIntegral quantity :: Int64) insertReceivedOrderStmt++loggingReactionHandler :: Text -> IntegrationEvent -> Tx.Transaction ()+loggingReactionHandler _ event = do+  -- The cross-context test only needs the (eventType, key) pair, not+  -- the decoded payload.+  let key = fromMaybe "" (event ^. #key)+  Tx.statement (event ^. #source, event ^. #eventType, key) appendBillingEventLogStmt++insertReceivedOrderStmt :: Statement (Text, Int64) ()+insertReceivedOrderStmt =+  preparable+    """+    INSERT INTO billing_received_orders (order_id, quantity) VALUES ($1, $2)+    ON CONFLICT (order_id) DO NOTHING+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++billingReceivedOrdersCountStmt :: Statement () Int+billingReceivedOrdersCountStmt =+  preparable+    "SELECT COUNT(*)::bigint FROM billing_received_orders"+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))++appendBillingEventLogStmt :: Statement (Text, Text, Text) ()+appendBillingEventLogStmt =+  preparable+    "INSERT INTO billing_event_log (source, event_type, order_id) VALUES ($1, $2, $3)"+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++billingEventLogStmt :: Statement () [(Text, Text)]+billingEventLogStmt =+  preparable+    "SELECT event_type, order_id FROM billing_event_log ORDER BY seq"+    E.noParams+    ( D.rowList+        ( (,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+        )+    )++orderSubmittedEnvelope :: Text -> Int -> Text -> IntegrationEvent+orderSubmittedEnvelope orderId quantity messageId =+  encodeJsonIntegrationEvent+    ( sampleIntegrationEnvelope+        & #messageId+        .~ messageId+        & #eventType+        .~ "OrderSubmitted"+        & #key+        .~ Just orderId+    )+    (OrderSubmittedPayload orderId quantity)++orderCancelledEnvelope :: Text -> Text -> IntegrationEvent+orderCancelledEnvelope orderId messageId =+  sampleIntegrationEnvelope+    & #messageId+    .~ messageId+    & #eventType+    .~ "OrderCancelled"+    & #key+    .~ Just orderId+    & #payloadBytes+    .~ ("{\"orderId\":\"" <> TE.encodeUtf8 orderId <> "\"}")+    & #contentType+    .~ ApplicationJson++inboxTestCounterInsertStmt :: Statement Text ()+inboxTestCounterInsertStmt =+  preparable+    "INSERT INTO inbox_test_counter (message_id) VALUES ($1)"+    (E.param (E.nonNullable E.text))+    D.noResult++inboxTestCounterCountStmt :: Statement () Int+inboxTestCounterCountStmt =+  preparable+    "SELECT COUNT(*)::bigint FROM inbox_test_counter"+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))++sampleProducer :: IntegrationProducer ()+sampleProducer =+  IntegrationProducer+    { name = "ordering-integration-producer",+      source = "ordering",+      messageIdPrefix = "msg",+      mapEvent = \_recorded () -> Just sampleDraft+    }++sampleDraft :: IntegrationEventDraft+sampleDraft =+  IntegrationEventDraft+    { destination = "billing.orders.v1",+      key = Just "order-123",+      eventType = "OrderSubmitted",+      schemaVersion = 1,+      contentType = ApplicationJson,+      schemaReference = Nothing,+      sourceEventId = Nothing,+      sourceGlobalPosition = Nothing,+      payloadBytes = "{\"orderId\":\"order-123\",\"quantity\":5}",+      occurredAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0),+      causationId = Nothing,+      correlationId = Nothing,+      traceContext = Nothing,+      attributes = Just (object ["source" Aeson..= ("test-suite" :: Text)])+    }++sampleOutboxRow :: IntegrationEvent -> OutboxRow+sampleOutboxRow event =+  OutboxRow+    { outboxId = OutboxId outboxUuid1,+      event,+      status = OutboxPending,+      attemptCount = 0,+      nextAttemptAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0),+      lastError = Nothing,+      publishedAt = Nothing,+      createdAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0),+      updatedAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)+    }++backdateOutboxUpdatedAt :: (Store :> es) => OutboxId -> UTCTime -> Eff es ()+backdateOutboxUpdatedAt oid timestamp =+  Store.runTransaction $+    Tx.statement (unOutboxId oid, timestamp) backdateOutboxUpdatedAtStmt++backdateOutboxUpdatedAtStmt :: Statement (UUID, UTCTime) ()+backdateOutboxUpdatedAtStmt =+  preparable+    "UPDATE keiro.keiro_outbox SET updated_at = $2 WHERE outbox_id = $1"+    ( contrazip2+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult++backdateOutboxPublishedAt :: (Store :> es) => OutboxId -> UTCTime -> Eff es ()+backdateOutboxPublishedAt oid timestamp =+  Store.runTransaction $+    Tx.statement (unOutboxId oid, timestamp) backdateOutboxPublishedAtStmt++backdateOutboxPublishedAtStmt :: Statement (UUID, UTCTime) ()+backdateOutboxPublishedAtStmt =+  preparable+    "UPDATE keiro.keiro_outbox SET published_at = $2 WHERE outbox_id = $1"+    ( contrazip2+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.timestamptz))+    )+    D.noResult++outboxUuid1, outboxUuid2, outboxUuid3, outboxUuid4 :: UUID+outboxUuid1 = case fromString "018f0f18-0000-7000-8000-000000000a01" of+  Just uuid -> uuid+  Nothing -> error "invalid outbox uuid 1"+outboxUuid2 = case fromString "018f0f18-0000-7000-8000-000000000a02" of+  Just uuid -> uuid+  Nothing -> error "invalid outbox uuid 2"+outboxUuid3 = case fromString "018f0f18-0000-7000-8000-000000000a03" of+  Just uuid -> uuid+  Nothing -> error "invalid outbox uuid 3"+outboxUuid4 = case fromString "018f0f18-0000-7000-8000-000000000a04" of+  Just uuid -> uuid+  Nothing -> error "invalid outbox uuid 4"++outboxIdFromOrdinal :: Word64 -> OutboxId+outboxIdFromOrdinal n =+  OutboxId (fromWords64 0x018f0f1800007000 (0x8000000000000000 + n))++uniqueIds :: (Eq a) => [a] -> [a]+uniqueIds = foldr (\x xs -> if x `elem` xs then xs else x : xs) []++data OrderSubmittedPayload = OrderSubmittedPayload+  { orderId :: !Text,+    quantity :: !Int+  }+  deriving stock (Generic, Eq, Show)++instance ToJSON OrderSubmittedPayload where+  toJSON = genericToJSON (aesonPrefix camelCase)+  toEncoding = genericToEncoding (aesonPrefix camelCase)++instance FromJSON OrderSubmittedPayload where+  parseJSON = genericParseJSON (aesonPrefix camelCase)++sampleIntegrationEnvelope :: IntegrationEvent+sampleIntegrationEnvelope =+  IntegrationEvent+    { messageId = "018f0f18-17aa-7000-8000-0000000000aa",+      source = "ordering",+      destination = "billing.orders.v1",+      key = Just "order-123",+      eventType = "OrderSubmitted",+      schemaVersion = 1,+      contentType = ApplicationJson,+      schemaReference =+        Just+          SchemaReference+            { registry = Just "https://schemas.example/registry",+              subject = Just "billing.orders.v1.OrderSubmitted",+              version = Just 1,+              schemaId = Just 42,+              fingerprint = Just "sha256:abc123"+            },+      sourceEventId = Just (EventId integrationSourceEventUuid),+      sourceGlobalPosition = Just (GlobalPosition 42),+      payloadBytes = "{\"orderId\":\"order-123\",\"quantity\":5}",+      occurredAt = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0),+      causationId = Just (EventId integrationCausationUuid),+      correlationId = Just (EventId integrationCorrelationUuid),+      traceContext =+        Just+          TraceContext+            { traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",+              tracestate = Just "rojo=00f067aa0ba902b7"+            },+      attributes = Nothing+    }++integrationSourceEventUuid :: UUID+integrationSourceEventUuid =+  case fromString "018f0f18-17aa-7000-8000-000000000003" of+    Just uuid -> uuid+    Nothing -> error "invalid integration source event UUID"++integrationCausationUuid :: UUID+integrationCausationUuid =+  case fromString "018f0f18-17aa-7000-8000-000000000004" of+    Just uuid -> uuid+    Nothing -> error "invalid integration causation UUID"++integrationCorrelationUuid :: UUID+integrationCorrelationUuid =+  case fromString "018f0f18-17aa-7000-8000-000000000005" of+    Just uuid -> uuid+    Nothing -> error "invalid integration correlation UUID"++data OrderStream++data OrderEvent+  = OrderPlaced !Text !Int+  deriving stock (Generic, Eq, Show)++data OrderState+  = Idle+  deriving stock (Generic, Eq, Show)++data OrderCommand+  = PlaceOrder+  deriving stock (Generic, Eq, Show)++orderCodec :: Codec OrderEvent+orderCodec =+  Codec+    { eventTypes = EventType "OrderPlaced" :| [],+      eventType = \case+        OrderPlaced {} -> EventType "OrderPlaced",+      schemaVersion = 2,+      encode = \case+        OrderPlaced orderId quantity ->+          object ["orderId" Aeson..= orderId, "quantity" Aeson..= quantity],+      decode = parseOrderPlaced,+      upcasters = [(1, const upcastOrderPlacedV1)]+    }++gappyCodec :: Codec OrderEvent+gappyCodec =+  Codec+    { eventTypes = orderCodec ^. #eventTypes,+      eventType = orderCodec ^. #eventType,+      schemaVersion = 4,+      encode = orderCodec ^. #encode,+      decode = orderCodec ^. #decode,+      upcasters = [(1, const upcastOrderPlacedV1), (3, const Right)]+    }++parseOrderPlaced :: EventType -> Value -> Either Text OrderEvent+parseOrderPlaced _ value =+  case parseEither parser value of+    Right event -> Right event+    Left message -> Left (fromStringLiteral message)+  where+    parser = withObject "OrderPlaced" $ \objectValue ->+      OrderPlaced+        <$> objectValue .: "orderId"+        <*> objectValue .: "quantity"++upcastOrderPlacedV1 :: Value -> Either Text Value+upcastOrderPlacedV1 value =+  case parseEither parser value of+    Right migrated -> Right migrated+    Left message -> Left (fromStringLiteral message)+  where+    parser = withObject "OrderPlacedV1" $ \objectValue -> do+      orderId <- objectValue .: "orderId"+      quantity <- objectValue .: "qty"+      pure (object ["orderId" Aeson..= (orderId :: Text), "quantity" Aeson..= (quantity :: Int)])++metadataForOrDie :: Int -> Maybe Value -> Value+metadataForOrDie version existing =+  either (error . show) id (metadataFor version existing)++emptyTransducer :: SymTransducer () '[] OrderState OrderCommand OrderEvent+emptyTransducer =+  SymTransducer+    { edgesOut = \_ -> [],+      initial = Idle,+      initialRegs = RNil,+      isFinal = \_ -> True+    }++type CounterEventStream = EventStream (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent++type ValidatedCounterEventStream = ValidatedEventStream (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent++type SnapshotCounterRegs = '[ '("lastAmount", Int)]++type UninitializedSnapshotRegs = '[ '("initialized", Int), '("neverWritten", Int)]++type SnapshotCounterEventStream = EventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent++type PartialSnapshotEventStream = EventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs PartialSnapshotState CounterCommand CounterEvent++type ValidatedSnapshotCounterEventStream = ValidatedEventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent++type UninitializedSnapshotEventStream = EventStream (HsPred UninitializedSnapshotRegs CounterCommand) UninitializedSnapshotRegs CounterState CounterCommand CounterEvent++data CounterCommand+  = Add !Int+  deriving stock (Generic, Eq, Show)++data SkipCommand+  = SAdd !Int+  | SSkip+  deriving stock (Generic, Eq, Show)++data CounterEvent+  = CounterAdded !Int+  | CounterAudited !Int+  deriving stock (Generic, Eq, Show)++data CounterState+  = Counting+  deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)+  deriving anyclass (FromJSON, ToJSON)++instance CanonicalStateShape CounterState++data CounterStateV2+  = CountingV2+  | PausedV2+  deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)+  deriving anyclass (FromJSON, ToJSON)++instance CanonicalStateShape CounterStateV2++data DrainState+  = Draining+  | Drained+  deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)++data PartialSnapshotState+  = SnapshotEncodable+  | SnapshotEncodeBomb+  deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)++instance CanonicalStateShape PartialSnapshotState++instance ToJSON PartialSnapshotState where+  toJSON SnapshotEncodable = Aeson.String "encodable"+  toJSON SnapshotEncodeBomb = error "snapshot state encoder exploded"++instance FromJSON PartialSnapshotState where+  parseJSON = Aeson.withText "PartialSnapshotState" $ \case+    "encodable" -> pure SnapshotEncodable+    "bomb" -> pure SnapshotEncodeBomb+    other -> fail ("unknown partial snapshot state: " <> Text.unpack other)++counterEventStreamDef :: CounterEventStream+counterEventStreamDef =+  EventStream+    { transducer = counterTransducer,+      initialState = Counting,+      initialRegisters = RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++counterEventStream :: ValidatedCounterEventStream+counterEventStream = mkEventStreamOrThrow "counter" counterEventStreamDef++auditedCounterEventStream :: ValidatedCounterEventStream+auditedCounterEventStream =+  mkEventStreamOrThrow+    "counter-audited-only"+    (counterEventStreamDef & #transducer .~ auditedCounterTransducer)++auditedCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+auditedCounterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update = UKeep,+                output = [pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++noOpCounterEventStreamDef :: CounterEventStream+noOpCounterEventStreamDef =+  counterEventStreamDef & #transducer .~ noOpCounterTransducer++noOpCounterEventStream :: ValidatedCounterEventStream+noOpCounterEventStream = mkEventStreamOrThrow "counter-no-op" noOpCounterEventStreamDef++counterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+counterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update = UKeep,+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++noOpCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+noOpCounterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update = UKeep,+                output = [],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++multiCounterEventStreamDef :: CounterEventStream+multiCounterEventStreamDef =+  counterEventStreamDef & #transducer .~ multiCounterTransducer++multiCounterEventStream :: ValidatedCounterEventStream+multiCounterEventStream = mkEventStreamOrThrow "counter-multi" multiCounterEventStreamDef++multiCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+multiCounterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update = UKeep,+                output =+                  [ pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil),+                    pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)+                  ],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++-- | Both guards match at runtime but remain outside keiki's conservative pure+-- overlap fragment. Distinct head event constructors keep inversion unambiguous,+-- so this is a validated stream that exercises the runtime step witness.+ambiguousCounterEventStreamDef :: CounterEventStream+ambiguousCounterEventStreamDef =+  counterEventStreamDef & #transducer .~ ambiguousCounterTransducer++ambiguousCounterEventStream :: ValidatedCounterEventStream+ambiguousCounterEventStream =+  mkEventStreamOrThrow "counter-ambiguous" ambiguousCounterEventStreamDef++ambiguousCounterTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+ambiguousCounterTransducer =+  counterTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = ambiguousGuard,+                update = UKeep,+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              },+            Edge+              { guard = ambiguousGuard,+                update = UKeep,+                output = [pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ]+    }+  where+    ambiguousGuard = PAnd (matchInCtor addCtor) (PNot PBot)++snapshotCounterEventStreamDef :: SnapshotCounterEventStream+snapshotCounterEventStreamDef =+  EventStream+    { transducer = snapshotCounterTransducer,+      initialState = Counting,+      initialRegisters = RCons (Proxy @"lastAmount") 0 RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Every 2,+      stateCodec = Just (defaultStateCodec @SnapshotCounterRegs @CounterState 1)+    }++partialSnapshotEventStream :: ValidatedEventStream (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs PartialSnapshotState CounterCommand CounterEvent+partialSnapshotEventStream = mkEventStreamOrThrow "partial-snapshot" partialSnapshotEventStreamDef++partialSnapshotEventStreamDef :: PartialSnapshotEventStream+partialSnapshotEventStreamDef =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \_ ->+              [ Edge+                  { guard = matchInCtor addCtor,+                    update =+                      USet+                        (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                        (inpCtor addCtor #amount),+                    output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                    target = SnapshotEncodeBomb,+                    mode = Keiki.Live+                  }+              ],+            initial = SnapshotEncodable,+            initialRegs = RCons (Proxy @"lastAmount") 0 RNil,+            isFinal = \_ -> False+          },+      initialState = SnapshotEncodable,+      initialRegisters = RCons (Proxy @"lastAmount") 0 RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Every 1,+      stateCodec = Just (defaultStateCodec @SnapshotCounterRegs @PartialSnapshotState 1)+    }++uninitializedSnapshotEventStreamDef :: UninitializedSnapshotEventStream+uninitializedSnapshotEventStreamDef =+  initializedSnapshotEventStreamDef+    & #initialRegisters+    .~ RCons (Proxy @"initialized") 0 (emptyRegFile @'[ '("neverWritten", Int)])++initializedSnapshotEventStreamDef :: UninitializedSnapshotEventStream+initializedSnapshotEventStreamDef =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \case Counting -> [],+            initial = Counting,+            initialRegs = RCons (Proxy @"initialized") 0 (RCons (Proxy @"neverWritten") 0 RNil),+            isFinal = \_ -> False+          },+      initialState = Counting,+      initialRegisters = RCons (Proxy @"initialized") 0 (RCons (Proxy @"neverWritten") 0 RNil),+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Every 2,+      stateCodec = Just (defaultStateCodec @UninitializedSnapshotRegs @CounterState 1)+    }++snapshotCounterEventStream :: ValidatedSnapshotCounterEventStream+snapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter" snapshotCounterEventStreamDef++snapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+snapshotCounterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update =+                  USet+                    (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                    (inpCtor addCtor #amount),+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RCons (Proxy @"lastAmount") 0 RNil,+      isFinal = \_ -> False+    }++foldV1SnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream+foldV1SnapshotCounterEventStream =+  mkEventStreamOrThrow "snapshot-counter-fold-v1" foldV1SnapshotCounterEventStreamDef++foldV1SnapshotCounterEventStreamDef :: SnapshotCounterEventStream+foldV1SnapshotCounterEventStreamDef =+  snapshotCounterEventStreamDef+    { transducer = foldV1SnapshotCounterTransducer,+      stateCodec =+        Just+          ( defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v1")+              1+          )+    }++foldV2SnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream+foldV2SnapshotCounterEventStream =+  mkEventStreamOrThrow "snapshot-counter-fold-v2" foldV2SnapshotCounterEventStreamDef++foldV2SnapshotCounterEventStreamDef :: SnapshotCounterEventStream+foldV2SnapshotCounterEventStreamDef =+  foldV1SnapshotCounterEventStreamDef+    { transducer = foldV2SnapshotCounterTransducer,+      snapshotPolicy = Every 1,+      stateCodec =+        Just+          ( defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v2")+              1+          )+    }++foldV2WithoutFingerprintBumpEventStream :: ValidatedSnapshotCounterEventStream+foldV2WithoutFingerprintBumpEventStream =+  mkEventStreamOrThrow+    "snapshot-counter-fold-v2-without-fingerprint-bump"+    foldV2WithoutFingerprintBumpEventStreamDef++foldV2WithoutFingerprintBumpEventStreamDef :: SnapshotCounterEventStream+foldV2WithoutFingerprintBumpEventStreamDef =+  foldV2SnapshotCounterEventStreamDef+    { stateCodec =+        Just+          ( defaultStateCodecWithFold+              @SnapshotCounterRegs+              @CounterState+              (FoldVersion "fold-v1")+              1+          )+    }++foldV1SnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+foldV1SnapshotCounterTransducer =+  foldSnapshotCounterTransducer+    (inpCtor addCtor #amount)++foldV2SnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+foldV2SnapshotCounterTransducer =+  foldSnapshotCounterTransducer+    (inpCtor addCtor #amount K..+ lit 1)++foldSnapshotCounterTransducer ::+  Keiki.Term SnapshotCounterRegs CounterCommand AddFields Int ->+  SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+foldSnapshotCounterTransducer nextLastAmount =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard =+                  PAnd+                    (matchInCtor addCtor)+                    (inpCtor addCtor #amount K..< lit 100),+                update =+                  USet+                    (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                    nextLastAmount,+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              },+            Edge+              { guard =+                  PAnd+                    (matchInCtor addCtor)+                    ( PAnd+                        (inpCtor addCtor #amount K..>= lit 100)+                        ( inpCtor addCtor #amount+                            .== (proj (#lastAmount :: Keiki.Index SnapshotCounterRegs Int) K..+ lit 100)+                        )+                    ),+                update = UKeep,+                output = [pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RCons (Proxy @"lastAmount") 0 RNil,+      isFinal = \_ -> False+    }++multiSnapshotCounterEventStreamDef :: SnapshotCounterEventStream+multiSnapshotCounterEventStreamDef =+  snapshotCounterEventStreamDef+    & #transducer+    .~ multiSnapshotCounterTransducer+    & #snapshotPolicy+    .~ Every 1++multiSnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream+multiSnapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter-multi" multiSnapshotCounterEventStreamDef++multiSnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+multiSnapshotCounterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update =+                  USet+                    (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                    (inpCtor addCtor #amount),+                output =+                  [ pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil),+                    pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)+                  ],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RCons (Proxy @"lastAmount") 0 RNil,+      isFinal = \_ -> False+    }++guardedSnapshotCounterEventStreamDef :: SnapshotCounterEventStream+guardedSnapshotCounterEventStreamDef =+  snapshotCounterEventStreamDef & #transducer .~ guardedSnapshotCounterTransducer++guardedSnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream+guardedSnapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter-guarded" guardedSnapshotCounterEventStreamDef++guardedSnapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+guardedSnapshotCounterTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard =+                  PAnd+                    (matchInCtor addCtor)+                    (inpCtor addCtor #amount .== proj (#lastAmount :: Keiki.Index SnapshotCounterRegs Int)),+                update =+                  USet+                    (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                    (inpCtor addCtor #amount),+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RCons (Proxy @"lastAmount") 0 RNil,+      isFinal = \_ -> False+    }++-- | A deliberately replay-unsafe stream: its single edge is an ε-edge+-- (empty @output@) whose @update@ reads the command's @amount@. Because+-- the edge emits no event, that command field cannot be recovered on+-- replay, so keiki's hidden-input check flags it. Used to prove+-- 'validateEventStream' / 'mkEventStream' reject an unsafe stream.+brokenHiddenInputEventStream :: SnapshotCounterEventStream+brokenHiddenInputEventStream =+  snapshotCounterEventStreamDef & #transducer .~ brokenHiddenInputTransducer++brokenHiddenInputTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+brokenHiddenInputTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update =+                  USet+                    (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                    (inpCtor addCtor #amount),+                output = [],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RCons (Proxy @"lastAmount") 0 RNil,+      isFinal = \_ -> False+    }++-- | A multi-event edge whose tail carries the command field omitted from its+-- head. The union of the outputs covers @amount@, but replay commits to an edge+-- by inverting only the head, so the stored chain cannot reconstruct @Add@.+headUnrecoverableEventStreamDef :: CounterEventStream+headUnrecoverableEventStreamDef =+  counterEventStreamDef & #transducer .~ headUnrecoverableTransducer++headUnrecoverableEventStream :: ValidatedCounterEventStream+headUnrecoverableEventStream = mkEventStreamUnchecked headUnrecoverableEventStreamDef++headUnrecoverableTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+headUnrecoverableTransducer =+  counterTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update = UKeep,+                output =+                  [ pack addCtor counterAddedCtor (Keiki.lit 0 *: oNil),+                    pack addCtor counterAuditedCtor (inpCtor addCtor #amount *: oNil)+                  ],+                target = Counting,+                mode = Keiki.Live+              }+          ]+    }++-- | Two edges share a head wire constructor, so one stored event can invert+-- through both. The double-negated guard is true at runtime but deliberately+-- outside keiki's pure overlap fragment, isolating the inversion warning from+-- the separate conservative determinism check.+inversionAmbiguousEventStreamDef :: CounterEventStream+inversionAmbiguousEventStreamDef =+  counterEventStreamDef & #transducer .~ inversionAmbiguousTransducer++inversionAmbiguousEventStream :: ValidatedCounterEventStream+inversionAmbiguousEventStream =+  case mkEventStreamWith+    Keiki.defaultValidationOptions {Keiki.checkInversionAmbiguity = False}+    "counter-inversion-ambiguous"+    inversionAmbiguousEventStreamDef of+    Right validated -> validated+    Left warnings -> error ("expected inversion-ambiguity override to validate: " <> show warnings)++inversionAmbiguousTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+inversionAmbiguousTransducer =+  counterTransducer+    { edgesOut = \case+        Counting ->+          [ ambiguousEdge,+            ambiguousEdge+          ]+    }+  where+    ambiguousEdge =+      Edge+        { guard = PAnd (matchInCtor addCtor) (PNot PBot),+          update = UKeep,+          output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+          target = Counting,+          mode = Keiki.Live+        }++-- | This edge reads @Add.amount@ while guarded only by @PTop@. A different+-- command constructor would reach the partial projection and crash instead of+-- being rejected.+unguardedInputReadEventStreamDef :: CounterEventStream+unguardedInputReadEventStreamDef =+  counterEventStreamDef & #transducer .~ unguardedInputReadTransducer++unguardedInputReadTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+unguardedInputReadTransducer =+  counterTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = PTop,+                update = UKeep,+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ]+    }++-- | A silent self-loop that writes a register. With no emitted event the+-- write cannot be reconstructed from the durable log.+stateChangingEpsilonEventStreamDef :: SnapshotCounterEventStream+stateChangingEpsilonEventStreamDef =+  snapshotCounterEventStreamDef & #transducer .~ stateChangingEpsilonTransducer++stateChangingEpsilonTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent+stateChangingEpsilonTransducer =+  snapshotCounterTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor addCtor,+                update =+                  USet+                    (#lastAmount :: IndexN "lastAmount" SnapshotCounterRegs Int)+                    (Keiki.lit 0),+                output = [],+                target = Counting,+                mode = Keiki.Live+              }+          ]+    }++type SilentMoveEventStream = EventStream (HsPred '[] CounterCommand) '[] DrainState CounterCommand CounterEvent++silentMoveEventStreamDef :: SilentMoveEventStream+silentMoveEventStreamDef =+  EventStream+    { transducer = silentMoveTransducer,+      initialState = Draining,+      initialRegisters = RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++silentMoveTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] DrainState CounterCommand CounterEvent+silentMoveTransducer =+  SymTransducer+    { edgesOut = \case+        Draining ->+          [ Edge+              { guard = matchInCtor addCtor,+                update = UKeep,+                output = [],+                target = Drained,+                mode = Keiki.Live+              }+          ]+        Drained -> [],+      initial = Draining,+      initialRegs = RNil,+      isFinal = (== Drained)+    }++isStateChangingEpsilon :: Keiki.TransducerValidationWarning s -> Bool+isStateChangingEpsilon = \case+  Keiki.StateChangingEpsilon {} -> True+  _ -> False++expectValidationWarning ::+  (Bounded s, Enum s, Ord s, Show s) =>+  Text ->+  Text ->+  EventStream (HsPred rs ci) rs s ci co ->+  Expectation+expectValidationWarning label prefix eventStream =+  case mkEventStream label eventStream of+    Left warnings -> do+      map eswStreamLabel warnings `shouldSatisfy` all (== label)+      map eswReason warnings `shouldSatisfy` any (Text.isInfixOf prefix)+    Right _ ->+      expectationFailure+        ( "expected mkEventStream to reject "+            <> Text.unpack label+            <> " with warning prefix "+            <> Text.unpack prefix+        )++type AddFields = '[ '("amount", Int)]++type SkipEventStream = EventStream (HsPred '[] SkipCommand) '[] CounterState SkipCommand CounterEvent++type ValidatedSkipEventStream = ValidatedEventStream (HsPred '[] SkipCommand) '[] CounterState SkipCommand CounterEvent++skipEventStream :: ValidatedSkipEventStream+skipEventStream = mkEventStreamOrThrow "skip-command" skipEventStreamDef++skipEventStreamDef :: SkipEventStream+skipEventStreamDef =+  EventStream+    { transducer = skipTransducer,+      initialState = Counting,+      initialRegisters = RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++skipTransducer :: SymTransducer (HsPred '[] SkipCommand) '[] CounterState SkipCommand CounterEvent+skipTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor sAddCtor,+                update = UKeep,+                output = [pack sAddCtor counterAddedCtor (inpCtor sAddCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              },+            Edge+              { guard = matchInCtor sSkipCtor,+                update = UKeep,+                output = [],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++sAddCtor :: InCtor SkipCommand AddFields+sAddCtor =+  InCtor+    { icName = "SAdd",+      icMatch = \case+        SAdd amount -> Just (RCons Proxy amount RNil)+        SSkip -> Nothing,+      icBuild = \case+        RCons _ amount RNil -> SAdd amount+    }++sSkipCtor :: InCtor SkipCommand '[]+sSkipCtor =+  InCtor+    { icName = "SSkip",+      icMatch = \case+        SAdd {} -> Nothing+        SSkip -> Just RNil,+      icBuild = \case+        RNil -> SSkip+    }++addCtor :: InCtor CounterCommand AddFields+addCtor =+  InCtor+    { icName = "Add",+      icMatch = \case+        Add amount -> Just (RCons Proxy amount RNil),+      icBuild = \case+        RCons _ amount RNil -> Add amount+    }++counterAddedCtor :: WireCtor CounterEvent (Int, ())+counterAddedCtor =+  WireCtor+    { wcName = "CounterAdded",+      wcMatch = \case+        CounterAdded amount -> Just (amount, ())+        CounterAudited {} -> Nothing,+      wcBuild = \case+        (amount, ()) -> CounterAdded amount+    }++counterAuditedCtor :: WireCtor CounterEvent (Int, ())+counterAuditedCtor =+  WireCtor+    { wcName = "CounterAudited",+      wcMatch = \case+        CounterAudited amount -> Just (amount, ())+        CounterAdded {} -> Nothing,+      wcBuild = \case+        (amount, ()) -> CounterAudited amount+    }++counterCodec :: Codec CounterEvent+counterCodec =+  Codec+    { eventTypes = EventType "CounterAdded" :| [EventType "CounterAudited"],+      eventType = \case+        CounterAdded {} -> EventType "CounterAdded"+        CounterAudited {} -> EventType "CounterAudited",+      schemaVersion = 1,+      encode = \case+        CounterAdded amount -> object ["amount" Aeson..= amount]+        CounterAudited amount -> object ["amount" Aeson..= amount, "audited" Aeson..= True],+      decode = parseCounterEvent,+      upcasters = []+    }++parseCounterEvent :: EventType -> Value -> Either Text CounterEvent+parseCounterEvent (EventType tag) value =+  case parseEither parser value of+    Right event -> Right event+    Left message -> Left (fromStringLiteral message)+  where+    parser = withObject "CounterEvent" $ \objectValue -> do+      amount <- objectValue .: "amount"+      case tag of+        "CounterAdded" -> pure (CounterAdded amount)+        "CounterAudited" -> pure (CounterAudited amount)+        _ -> fail "unknown counter event type"++-- * Divert fixture (plan 143: replay-only transitions / black-acuity) -----++type DivertEventStream = EventStream (HsPred '[] DivertCommand) '[] DivertState DivertCommand DivertEvent++type ValidatedDivertEventStream = ValidatedEventStream (HsPred '[] DivertCommand) '[] DivertState DivertCommand DivertEvent++data DivertCommand+  = ConfirmDivert !Bool+  deriving stock (Generic, Eq, Show)++newtype DivertEvent+  = DivertConfirmed Bool+  deriving stock (Generic, Eq, Show)++data DivertState+  = DivertHeld+  deriving stock (Generic, Eq, Show, Enum, Bounded, Ord)++type DivertFields = '[ '("acuityBlack", Bool)]++confirmDivertCtor :: InCtor DivertCommand DivertFields+confirmDivertCtor =+  InCtor+    { icName = "ConfirmDivert",+      icMatch = \case+        ConfirmDivert acuityBlack -> Just (RCons Proxy acuityBlack RNil),+      icBuild = \case+        RCons _ acuityBlack RNil -> ConfirmDivert acuityBlack+    }++divertConfirmedCtor :: WireCtor DivertEvent (Bool, ())+divertConfirmedCtor =+  WireCtor+    { wcName = "DivertConfirmed",+      wcMatch = \case+        DivertConfirmed acuityBlack -> Just (acuityBlack, ()),+      wcBuild = \case+        (acuityBlack, ()) -> DivertConfirmed acuityBlack+    }++divertCodec :: Codec DivertEvent+divertCodec =+  Codec+    { eventTypes = EventType "DivertConfirmed" :| [],+      eventType = \_ -> EventType "DivertConfirmed",+      schemaVersion = 1,+      encode = \case+        DivertConfirmed acuityBlack -> object ["acuityBlack" Aeson..= acuityBlack],+      decode = parseDivertEvent,+      upcasters = []+    }++parseDivertEvent :: EventType -> Value -> Either Text DivertEvent+parseDivertEvent _ value =+  case parseEither parser value of+    Right event -> Right event+    Left message -> Left (fromStringLiteral message)+  where+    parser = withObject "DivertConfirmed" $ \objectValue ->+      DivertConfirmed <$> objectValue .: "acuityBlack"++-- | The old rule: confirm any reservation.+divertOldGuard :: HsPred '[] DivertCommand+divertOldGuard = matchInCtor confirmDivertCtor++-- | The tightened rule: confirm only non-black acuity.+divertNewGuard :: HsPred '[] DivertCommand+divertNewGuard =+  PAnd+    (matchInCtor confirmDivertCtor)+    (inpCtor confirmDivertCtor #acuityBlack .== Keiki.lit False)++-- | The removed region, @old ∧ ¬new@: exactly black acuity.+divertRemovedRegionGuard :: HsPred '[] DivertCommand+divertRemovedRegionGuard =+  PAnd+    (matchInCtor confirmDivertCtor)+    (inpCtor confirmDivertCtor #acuityBlack .== Keiki.lit True)++divertConfirmEdge ::+  HsPred '[] DivertCommand ->+  Keiki.EdgeMode ->+  Edge (HsPred '[] DivertCommand) '[] DivertCommand DivertEvent DivertState+divertConfirmEdge edgeGuard edgeMode =+  Edge+    { guard = edgeGuard,+      update = UKeep,+      output = [pack confirmDivertCtor divertConfirmedCtor (inpCtor confirmDivertCtor #acuityBlack *: oNil)],+      target = DivertHeld,+      mode = edgeMode+    }++divertEventStreamDef ::+  [Edge (HsPred '[] DivertCommand) '[] DivertCommand DivertEvent DivertState] ->+  DivertEventStream+divertEventStreamDef heldEdges =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \case+              DivertHeld -> heldEdges,+            initial = DivertHeld,+            initialRegs = RNil,+            isFinal = const False+          },+      initialState = DivertHeld,+      initialRegisters = RNil,+      eventCodec = divertCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++-- | Machine A: the original permissive rule.+permissiveDivertEventStream :: ValidatedDivertEventStream+permissiveDivertEventStream =+  mkEventStreamOrThrow+    "divert-permissive"+    (divertEventStreamDef [divertConfirmEdge divertOldGuard Keiki.Live])++-- | Machine B without the twin: the tightened rule alone.+tightenedDivertEventStream :: ValidatedDivertEventStream+tightenedDivertEventStream =+  mkEventStreamOrThrow+    "divert-tightened"+    (divertEventStreamDef [divertConfirmEdge divertNewGuard Keiki.Live])++-- | Machine B with the replay-only twin carrying the removed region:+-- the tightened rule governs new traffic; black-acuity history keeps+-- its inverting edge.+twinDivertEventStream :: ValidatedDivertEventStream+twinDivertEventStream =+  mkEventStreamOrThrow+    "divert-twin"+    ( divertEventStreamDef+        [ divertConfirmEdge divertNewGuard Keiki.Live,+          divertConfirmEdge divertRemovedRegionGuard Keiki.ReplayOnly+        ]+    )++counterProcessManager ::+  ProcessManager+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+counterProcessManager =+  ProcessManager+    { name = "counter-pm",+      correlate = \_ -> "order-1",+      eventStream = counterEventStream,+      streamFor = \correlationId -> stream ("pm:counter-" <> correlationId),+      targetEventStream = counterEventStream,+      targetProjections = const [],+      handle = \case+        CounterAdded amount ->+          ProcessManagerAction+            { command = Add amount,+              commands =+                [ PMCommand+                    { target = stream "counter-target-order-1",+                      command = Add amount+                    }+                ],+              timers = [counterTimerRequest]+            }+        CounterAudited amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [],+              timers = []+            }+    }++timerOnlyProcessManager ::+  ProcessManager+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+timerOnlyProcessManager =+  ProcessManager+    { name = "timer-only-pm",+      correlate = \_ -> "order-1",+      eventStream = noOpCounterEventStream,+      streamFor = \correlationId -> stream ("pm:timer-only-" <> correlationId),+      targetEventStream = counterEventStream,+      targetProjections = const [],+      handle = \case+        CounterAdded amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [],+              timers =+                [ counterTimerRequest+                    & #processManagerName+                    .~ "timer-only-pm"+                ]+            }+        CounterAudited amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [],+              timers = []+            }+    }++-- A process manager whose OWN state stream snapshots under Every 2.+-- This is the first PM fixture to exercise a state-stream snapshot: the only+-- difference from counterProcessManager is that its eventStream carries a+-- snapshotPolicy + stateCodec (it reuses snapshotCounterEventStream), so+-- runProcessManagerOnce's manager-state append (which goes through+-- runCommandWithSql) writes and reuses snapshots. The manager registers are+-- SnapshotCounterRegs because the eventStream is a SnapshotCounterEventStream;+-- the target side stays '[]/counterEventStream exactly as counterProcessManager.+pmSnapshotCounterEventStreamDef :: SnapshotCounterEventStream+pmSnapshotCounterEventStreamDef = snapshotCounterEventStreamDef++pmSnapshotCounterEventStream :: ValidatedSnapshotCounterEventStream+pmSnapshotCounterEventStream = mkEventStreamOrThrow "pm-snapshot-counter" pmSnapshotCounterEventStreamDef++pmSnapshotProcessManager ::+  ProcessManager+    CounterEvent+    (HsPred SnapshotCounterRegs CounterCommand)+    SnapshotCounterRegs+    CounterState+    CounterCommand+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+pmSnapshotProcessManager =+  ProcessManager+    { name = "counter-snap-pm",+      correlate = \_ -> "order-1",+      eventStream = pmSnapshotCounterEventStream,+      streamFor = \correlationId -> stream ("pm:counter-snap-" <> correlationId),+      targetEventStream = counterEventStream,+      targetProjections = const [],+      handle = \case+        CounterAdded amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [], -- keep the test focused on the manager state stream+              timers = []+            }+        CounterAudited amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [],+              timers = []+            }+    }++workflowProcessManager ::+  Text ->+  Text ->+  Text ->+  ProcessManager+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+workflowProcessManager managerName managerCategory targetStreamName =+  counterProcessManager+    { name = managerName,+      streamFor = \correlationId -> stream (managerCategory <> "-" <> correlationId),+      handle = \case+        CounterAdded amount ->+          ProcessManagerAction+            { command = Add amount,+              commands =+                [ PMCommand+                    { target = stream targetStreamName,+                      command = Add amount+                    }+                ],+              timers = []+            }+        CounterAudited amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [],+              timers = []+            }+    }++assertWorkflowProcessManagerAppended ::+  Either+    Store.StoreError+    ( Either+        CommandError+        (ProcessManagerResult CounterEventStream CounterEventStream)+    ) ->+  Expectation+assertWorkflowProcessManagerAppended = \case+  Right (Right pmResult) -> do+    pmResult ^. #managerResult `shouldSatisfy` \case+      PMStateAppended {} -> True+      _ -> False+    pmResult ^. #commandResults `shouldSatisfy` \case+      [PMCommandAppended {}] -> True+      _ -> False+  other -> expectationFailure ("expected workflow process-manager success, got " <> show other)++counterTimerRequest :: TimerRequest+counterTimerRequest =+  TimerRequest+    { timerId = TimerId sampleUuid,+      processManagerName = "counter-pm",+      correlationId = "order-1",+      fireAt = dueTimerTime,+      payload = object ["kind" Aeson..= ("counter-timeout" :: Text)]+    }++dueTimerTime :: UTCTime+dueTimerTime = UTCTime (ModifiedJulianDay 1) (secondsToDiffTime 0)++timerStatusAndErrorStmt :: Statement UUID (Maybe (Text, Maybe Text))+timerStatusAndErrorStmt =+  preparable+    """+    SELECT status, last_error+    FROM keiro.keiro_timers+    WHERE timer_id = $1+    """+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))++-- | Read a timer's status and JSON payload by id (for the workflow-sleep tests).+sleepTimerStatusStmt :: Statement UUID (Maybe (Text, Value))+sleepTimerStatusStmt =+  preparable+    """+    SELECT status, payload+    FROM keiro.keiro_timers+    WHERE timer_id = $1+    """+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.jsonb)))++-- | Read a timer's fire time by id (for workflow-sleep re-arm tests).+sleepTimerFireAtStmt :: Statement UUID (Maybe UTCTime)+sleepTimerFireAtStmt =+  preparable+    """+    SELECT fire_at+    FROM keiro.keiro_timers+    WHERE timer_id = $1+    """+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe (D.column (D.nonNullable D.timestamptz)))++recordedFrom :: EventData -> RecordedEvent+recordedFrom event =+  RecordedEvent+    { eventId = EventId sampleUuid,+      eventType = event ^. #eventType,+      streamVersion = StreamVersion 1,+      globalPosition = GlobalPosition 1,+      originalStreamId = StreamId 1,+      originalVersion = StreamVersion 1,+      payload = event ^. #payload,+      metadata = event ^. #metadata,+      causationId = Nothing,+      correlationId = Nothing,+      createdAt = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)+    }++recordedFromEventId :: EventId -> CounterEvent -> RecordedEvent+recordedFromEventId eventId event =+  case encodeForAppend counterCodec event of+    Right encoded -> recordedFrom encoded & #eventId .~ eventId+    Left err -> error ("test fixture failed to encode counter event: " <> show err)++appendCounterEventWithId :: Store.KirokuStore -> StreamName -> EventId -> CounterEvent -> IO ()+appendCounterEventWithId storeHandle streamName eventId event = do+  encoded <- shouldBeRight (encodeForAppend counterCodec event)+  outcome <-+    Store.runStoreIO storeHandle $+      Store.appendToStream streamName NoStream [encoded & #eventId ?~ eventId]+  case outcome of+    Right _ -> pure ()+    Left err -> expectationFailure ("failed to insert concurrent duplicate event: " <> show err)++appendCounterEvents :: Store.KirokuStore -> StreamName -> [CounterEvent] -> IO ()+appendCounterEvents storeHandle destinationStreamName events = do+  encoded <- traverse (shouldBeRight . encodeForAppend counterCodec) events+  outcome <-+    Store.runStoreIO storeHandle $+      Store.appendToStream destinationStreamName NoStream encoded+  case outcome of+    Right _ -> pure ()+    Left err -> expectationFailure ("failed to insert counter events: " <> show err)++-- Insert a real source event and drive Kiroku's acknowledgement bridge to park+-- it in kiroku.dead_letters. A second event lets the test observe that the+-- checkpoint advanced after the dead letter before stopping the subscription.+deadLetterCounterSource :: Store.KirokuStore -> SubscriptionName -> CounterEvent -> IO RecordedEvent+deadLetterCounterSource storeHandle subName sourceEvent = do+  appendCounterEvents+    storeHandle+    (StreamName "counter-replay-source")+    [sourceEvent, CounterAdded 0]+  let subConfig =+        ( KirokuSub.defaultSubscriptionConfig+            subName+            AllStreams+            (\_ -> pure KirokuSub.Continue)+        )+          { KirokuSub.retryPolicy = KirokuSub.RetryPolicy 1+          }+      pull label source = do+        result <- timeout 5_000_000 (Streamly.uncons source)+        case result of+          Just (Just itemAndRest) -> pure itemAndRest+          Just Nothing -> fail (label <> ": subscription ended early")+          Nothing -> fail (label <> ": timed out waiting for delivery")+  (stream0, cancelStream) <- subscriptionAckStream storeHandle subConfig 4+  ( do+      (first, stream1) <- pull "source delivery" stream0+      atomically $+        putTMVar+          (ackReply first)+          (KirokuSub.Retry (KirokuSub.RetryDelay 0))+      (next, stream2) <- pull "event after source dead letter" stream1+      ackEvent next ^. #eventId `shouldNotBe` ackEvent first ^. #eventId+      atomically (putTMVar (ackReply next) KirokuSub.Stop)+      ended <- timeout 5_000_000 (Streamly.uncons stream2)+      case ended of+        Just Nothing -> pure ()+        Just (Just _) -> expectationFailure "replay fixture delivered after Stop"+        Nothing -> expectationFailure "replay fixture did not stop"+      pure (ackEvent first)+    )+    `finally` cancelStream++classifyProcessManagerReplay :: ProcessManagerResult managerTarget commandTarget -> ReplayResult+classifyProcessManagerReplay result =+  case result ^. #managerResult of+    PMStateDuplicate {}+      | Prelude.all commandIsDuplicate (result ^. #commandResults) -> ReplayedDuplicate+    _ -> ReplayedFresh+  where+    commandIsDuplicate = \case+      PMCommandDuplicate {} -> True+      _ -> False++processManagerReplayCounts :: Store.KirokuStore -> IO (Int, Int)+processManagerReplayCounts storeHandle = do+  Right managerEvents <-+    Store.runStoreIO storeHandle $+      Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 10+  Right targetEvents <-+    Store.runStoreIO storeHandle $+      Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10+  pure (Vector.length managerEvents, Vector.length targetEvents)++sampleUuid :: UUID+sampleUuid =+  case fromString "018f0f18-17aa-7000-8000-000000000001" of+    Just uuid -> uuid+    Nothing -> error "invalid test UUID"++sampleUuid2 :: UUID+sampleUuid2 =+  case fromString "018f0f18-17aa-7000-8000-000000000002" of+    Just uuid -> uuid+    Nothing -> error "invalid test UUID"++sampleUuid3 :: UUID+sampleUuid3 =+  case fromString "018f0f18-17aa-7000-8000-000000000003" of+    Just uuid -> uuid+    Nothing -> error "invalid test UUID"++shouldBeRight :: (HasCallStack, Show e) => Either e a -> IO a+shouldBeRight = \case+  Right value -> pure value+  Left err -> expectationFailure ("expected Right, got Left " <> show err) *> error "unreachable"++shouldBeRight_ :: (HasCallStack, Show e) => Either e a -> Expectation+shouldBeRight_ = \case+  Right _ -> pure ()+  Left err -> expectationFailure ("expected Right, got Left " <> show err)++shouldBeLeft :: (HasCallStack, Eq e, Show e) => Either e a -> e -> Expectation+shouldBeLeft actual expected =+  case actual of+    Left err -> err `shouldBe` expected+    Right _ -> expectationFailure ("expected Left " <> show expected <> ", got Right")++fromStringLiteral :: String -> Text+fromStringLiteral = Text.pack++snapshotVersionForStreamStmt :: Statement Text (Maybe StreamVersion)+snapshotVersionForStreamStmt =+  preparable+    """+    SELECT ks.stream_version+    FROM keiro.keiro_snapshots ks+    JOIN streams s ON s.stream_id = ks.stream_id+    WHERE s.stream_name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (StreamVersion <$> D.column (D.nonNullable D.int8)))++snapshotStateShapeForStreamStmt :: Statement Text (Maybe Text)+snapshotStateShapeForStreamStmt =+  preparable+    """+    SELECT ks.state_shape_hash+    FROM keiro.keiro_snapshots ks+    JOIN streams s ON s.stream_id = ks.stream_id+    WHERE s.stream_name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (D.column (D.nonNullable D.text)))++corruptSnapshotStateStmt :: Statement (Text, Value) ()+corruptSnapshotStateStmt =+  preparable+    """+    UPDATE keiro.keiro_snapshots ks+    SET state = $2+    FROM streams s+    WHERE s.stream_id = ks.stream_id+      AND s.stream_name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.jsonb))+    )+    D.noResult++corruptSnapshotShapeStmt :: Statement (Text, Text) ()+corruptSnapshotShapeStmt =+  preparable+    """+    UPDATE keiro.keiro_snapshots ks+    SET regfile_shape_hash = $2+    FROM streams s+    WHERE s.stream_id = ks.stream_id+      AND s.stream_name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++counterReadModel :: ReadModel Text Int+counterReadModel =+  ReadModel+    { name = "counter-read-model",+      tableName = "counter_read_model",+      schema = "kiroku",+      subscriptionName = "counter-read-model-sub",+      version = 1,+      shapeHash = "counter-read-model-v1",+      defaultConsistency = Eventual,+      strongScope = EntireLog,+      query = \modelId -> Tx.statement modelId selectCounterReadModelStmt+    }++counterCategoryReadModel :: ReadModel Text Int+counterCategoryReadModel =+  counterReadModel & #strongScope .~ CategoryHead "counter"++registerReadModelDefinition :: (Store :> es) => ReadModel q r -> Eff es ()+registerReadModelDefinition readModel =+  void $+    registerReadModel+      (readModel ^. #name)+      (readModel ^. #version)+      (readModel ^. #shapeHash)++initializeRegisteredReadModel ::+  (Store :> es) =>+  ReadModel q r ->+  Tx.Transaction () ->+  Eff es ()+initializeRegisteredReadModel readModel initializeTable = do+  Store.runTransaction initializeTable+  registerReadModelDefinition readModel++counterInlineProjection :: InlineProjection CounterEvent+counterInlineProjection =+  InlineProjection+    { name = "counter-inline-projection",+      apply = \event recorded ->+        case event of+          CounterAdded amount ->+            Tx.statement+              ( "inline",+                Prelude.fromIntegral amount,+                globalPositionToInt (recorded ^. #globalPosition),+                Just (eventIdToUuid (recorded ^. #eventId)),+                metadataActor recorded+              )+              upsertCounterReadModelStmt+          CounterAudited {} -> pure ()+    }++counterAsyncProjection :: AsyncProjection+counterAsyncProjection =+  AsyncProjection+    { name = "counter-async-projection",+      readModelName = "counter-read-model",+      subscriptionName = "counter-read-model-sub",+      applyRecorded = \recorded ->+        case decodeRecorded counterCodec recorded of+          Right (CounterAdded amount) ->+            Tx.statement+              ( "async-idempotent",+                Prelude.fromIntegral amount,+                globalPositionToInt (recorded ^. #globalPosition),+                Just (eventIdToUuid (recorded ^. #eventId)),+                Nothing+              )+              upsertCounterReadModelStmt+          Right CounterAudited {} -> pure ()+          Left _ -> pure (),+      idempotencyKey = \recorded -> recorded ^. #eventId+    }++fastWaitOptions :: PositionWaitOptions+fastWaitOptions =+  PositionWaitOptions+    { target = Nothing,+      timeoutMicros = 50000,+      pollMicros = 5000+    }++initializeCounterReadModelTable :: Tx.Transaction ()+initializeCounterReadModelTable =+  Tx.sql+    """+    CREATE TABLE IF NOT EXISTS counter_read_model (+      model_id TEXT PRIMARY KEY,+      amount BIGINT NOT NULL,+      last_seen BIGINT NOT NULL,+      source_event_id UUID UNIQUE,+      actor TEXT+    )+    """++-- A read model whose data table lives in an application-configured schema+-- (@app_reads@), demonstrating EP-4's configurable projection schema. Its SQL is+-- fully qualified via 'placedTable'; Keiro's own metadata stays in @keiro@.+placedTable :: Text+placedTable = qualifyTable "app_reads" "placed_counter"++placedReadModel :: ReadModel Text Int+placedReadModel =+  ReadModel+    { name = "placed-counter-read-model",+      tableName = "placed_counter",+      schema = "app_reads",+      subscriptionName = "placed-counter-sub",+      version = 1,+      shapeHash = "placed-counter-v1",+      defaultConsistency = Eventual,+      strongScope = EntireLog,+      query = \modelId -> Tx.statement modelId selectPlacedStmt+    }++placedInlineProjection :: InlineProjection CounterEvent+placedInlineProjection =+  InlineProjection+    { name = "placed-inline-projection",+      apply = \event recorded ->+        case event of+          CounterAdded amount ->+            Tx.statement+              ( "placed",+                Prelude.fromIntegral amount,+                globalPositionToInt (recorded ^. #globalPosition)+              )+              upsertPlacedStmt+          CounterAudited {} -> pure ()+    }++initializePlacedTable :: Tx.Transaction ()+initializePlacedTable =+  Tx.sql $+    TE.encodeUtf8 $+      "CREATE TABLE IF NOT EXISTS "+        <> placedTable+        <> " (\n"+        <> "  model_id TEXT PRIMARY KEY,\n"+        <> "  amount BIGINT NOT NULL,\n"+        <> "  last_seen BIGINT NOT NULL\n"+        <> ")"++upsertPlacedStmt :: Statement (Text, Int64, Int64) ()+upsertPlacedStmt =+  preparable+    ( "INSERT INTO "+        <> placedTable+        <> " (model_id, amount, last_seen)\n"+        <> "VALUES ($1, $2, $3)\n"+        <> "ON CONFLICT (model_id) DO UPDATE\n"+        <> "  SET amount = EXCLUDED.amount, last_seen = EXCLUDED.last_seen"+    )+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++selectPlacedStmt :: Statement Text Int+selectPlacedStmt =+  preparable+    ("SELECT COALESCE((SELECT amount FROM " <> placedTable <> " WHERE model_id = $1), 0)")+    (E.param (E.nonNullable E.text))+    (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8)))++-- Count matching base tables in a given schema; proves table placement.+pgTableCountStmt :: Statement (Text, Text) Int+pgTableCountStmt =+  preparable+    "SELECT count(*)::int FROM pg_tables WHERE schemaname = $1 AND tablename = $2"+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int4)))++initializeProjectionDedupCounterTable :: Tx.Transaction ()+initializeProjectionDedupCounterTable =+  Tx.sql+    """+    CREATE TABLE IF NOT EXISTS projection_dedup_counter (+      id BOOLEAN PRIMARY KEY DEFAULT TRUE,+      amount BIGINT NOT NULL+    );++    INSERT INTO projection_dedup_counter (id, amount)+    VALUES (TRUE, 0)+    ON CONFLICT (id) DO NOTHING;+    """++upsertCounterReadModelStmt :: Statement (Text, Int64, Int64, Maybe UUID, Maybe Text) ()+upsertCounterReadModelStmt =+  preparable+    """+    INSERT INTO counter_read_model (model_id, amount, last_seen, source_event_id, actor)+    VALUES ($1, $2, $3, $4, $5)+    ON CONFLICT (source_event_id) DO NOTHING+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nullable E.uuid))+        (E.param (E.nullable E.text))+    )+    D.noResult++incrementProjectionDedupCounterStmt :: Statement () ()+incrementProjectionDedupCounterStmt =+  preparable+    """+    UPDATE projection_dedup_counter+    SET amount = amount + 1+    WHERE id = TRUE+    """+    E.noParams+    D.noResult++selectProjectionDedupCounterStmt :: Statement () Int+selectProjectionDedupCounterStmt =+  preparable+    """+    SELECT amount+    FROM projection_dedup_counter+    WHERE id = TRUE+    """+    E.noParams+    (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8)))++projectionDedupCountStmt :: Statement Text Int64+projectionDedupCountStmt =+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_projection_dedup+    WHERE projection_name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.int8)))++selectCounterMetaStmt :: Statement Text (Int64, Maybe Text, Maybe UUID)+selectCounterMetaStmt =+  preparable+    """+    SELECT amount, actor, source_event_id+    FROM counter_read_model+    WHERE model_id = $1+    """+    (E.param (E.nonNullable E.text))+    ( D.singleRow+        ( (,,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nullable D.uuid)+        )+    )++selectCounterReadModelStmt :: Statement Text Int+selectCounterReadModelStmt =+  preparable+    """+    SELECT COALESCE((SELECT amount FROM counter_read_model WHERE model_id = $1), 0)+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8)))++upsertSubscriptionCursorStmt :: Statement (Text, Int64) ()+upsertSubscriptionCursorStmt =+  preparable+    """+    INSERT INTO subscriptions (subscription_name, stream_name, last_seen)+    VALUES ($1, '$all', $2)+    ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE+      SET last_seen = EXCLUDED.last_seen,+          updated_at = now()+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++upsertSubscriptionCursorMemberStmt :: Statement (Text, Int32, Int64) ()+upsertSubscriptionCursorMemberStmt =+  preparable+    """+    INSERT INTO subscriptions (subscription_name, stream_name, consumer_group_member, consumer_group_size, last_seen)+    VALUES ($1, '$all', $2, 2, $3)+    ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE+      SET last_seen = EXCLUDED.last_seen,+          updated_at = now()+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++updateReadModelVersionStmt :: Statement (Text, Int64) ()+updateReadModelVersionStmt =+  preparable+    """+    UPDATE keiro.keiro_read_models+    SET version = $2+    WHERE name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++updateReadModelStatusStmt :: Statement (Text, Text) ()+updateReadModelStatusStmt =+  preparable+    """+    UPDATE keiro.keiro_read_models+    SET status = $2+    WHERE name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++readModelXminStmt :: Statement Text Text+readModelXminStmt =+  preparable+    """+    SELECT xmin::text+    FROM keiro.keiro_read_models+    WHERE name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.text)))++globalPositionToInt :: GlobalPosition -> Int64+globalPositionToInt (GlobalPosition value) = value++eventIdToUuid :: EventId -> UUID+eventIdToUuid (EventId value) = value++metadataActor :: RecordedEvent -> Maybe Text+metadataActor recorded = do+  Aeson.Object o <- recorded ^. #metadata+  Aeson.String s <- KeyMap.lookup "actor" o+  pure s++-- Router test fixtures: an effectful, data-dependent fan-out whose target set+-- is stored in a read-model table (router_targets) rather than computed purely.++newtype RouteGroup = RouteGroup Text+  deriving stock (Generic, Eq, Show)++-- | Maps a routing group to the list of target counter stream identifiers seeded+-- for it. The query is genuinely effectful: 'demoRouter' calls it via 'runQuery'.+routerTargetsReadModel :: ReadModel Text [Text]+routerTargetsReadModel =+  ReadModel+    { name = "router-targets-read-model",+      tableName = "router_targets",+      schema = "kiroku",+      subscriptionName = "router-targets-sub",+      version = 1,+      shapeHash = "router-targets-v1",+      defaultConsistency = Eventual,+      strongScope = EntireLog,+      query = \groupId -> Tx.statement groupId selectRouterTargetsStmt+    }++demoRouter ::+  (IOE :> es, Store :> es) =>+  Router+    RouteGroup+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    es+demoRouter =+  Router+    { name = "demo-router",+      key = \(RouteGroup g) -> g,+      resolve = \(RouteGroup g) -> do+        result <- runQuery Nothing routerTargetsReadModel g+        pure $ case result of+          Right targetIds ->+            [ PMCommand {target = stream targetId, command = Add 1}+            | targetId <- targetIds+            ]+          Left _ -> [],+      targetEventStream = counterEventStream,+      targetProjections = const []+    }++unstableRouter ::+  (IOE :> es) =>+  IORef Int ->+  (Int -> [Text]) ->+  Router+    RouteGroup+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    es+unstableRouter attemptsRef targetsFor =+  Router+    { name = "unstable-router",+      key = \(RouteGroup g) -> g,+      resolve = \_ -> do+        attempt <- liftIO (atomicModifyIORef' attemptsRef (\n -> (n + 1, n)))+        pure+          [ PMCommand {target = stream targetId, command = Add 1}+          | targetId <- targetsFor attempt+          ],+      targetEventStream = counterEventStream,+      targetProjections = const []+    }++isAppended :: PMCommandResult target -> Bool+isAppended = \case+  PMCommandAppended {} -> True+  _ -> False++isDuplicate :: PMCommandResult target -> Bool+isDuplicate = \case+  PMCommandDuplicate {} -> True+  _ -> False++initializeRouterTargetsTable :: Tx.Transaction ()+initializeRouterTargetsTable =+  Tx.sql+    """+    CREATE TABLE IF NOT EXISTS router_targets (+      group_id TEXT NOT NULL,+      target_id TEXT NOT NULL+    )+    """++insertRouterTargetStmt :: Statement (Text, Text) ()+insertRouterTargetStmt =+  preparable+    """+    INSERT INTO router_targets (group_id, target_id)+    VALUES ($1, $2)+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++selectRouterTargetsStmt :: Statement Text [Text]+selectRouterTargetsStmt =+  preparable+    """+    SELECT target_id+    FROM router_targets+    WHERE group_id = $1+    ORDER BY target_id+    """+    (E.param (E.nonNullable E.text))+    (D.rowList (D.column (D.nonNullable D.text)))++-- Router worker fixtures: an in-memory Shibuya adapter that records every+-- finalized AckDecision, plus a router whose dispatch always fails.++inMemoryAdapter ::+  (IOE :> es) =>+  IORef [AckDecision] ->+  [msg] ->+  Adapter es msg+inMemoryAdapter decisionsRef messages =+  Adapter+    { adapterName = "router-test-adapter",+      source = Streamly.fromList (fmap ingest messages),+      shutdown = pure ()+    }+  where+    ingest message =+      Ingested+        { envelope = routerTestEnvelope message,+          ack = AckHandle (\decision -> liftIO (modifyIORef' decisionsRef (<> [decision]))),+          lease = Nothing+        }++routerTestEnvelope :: msg -> Envelope msg+routerTestEnvelope message =+  Envelope+    { messageId = "router-test-message",+      cursor = Nothing,+      partition = Nothing,+      enqueuedAt = Nothing,+      traceContext = Nothing,+      headers = Nothing,+      attempt = Nothing,+      attributes = mempty,+      payload = message+    }++-- | A target aggregate with no outgoing edges: every command is rejected+-- (CommandRejected), so a dispatch through it surfaces as PMCommandFailed,+-- driving the worker's AckHalt branch.+rejectingEventStreamDef :: CounterEventStream+rejectingEventStreamDef =+  counterEventStreamDef & #transducer .~ rejectingTransducer++rejectingEventStream :: ValidatedCounterEventStream+rejectingEventStream = mkEventStreamOrThrow "rejecting-counter" rejectingEventStreamDef++-- | Accept every Add command except amount 9, which exercises a worker that+-- dead-letters one rejected dispatch and then successfully processes the next.+rejectNineEventStream :: ValidatedCounterEventStream+rejectNineEventStream = mkEventStreamOrThrow "reject-nine-counter" rejectNineEventStreamDef++rejectNineEventStreamDef :: CounterEventStream+rejectNineEventStreamDef =+  counterEventStreamDef & #transducer .~ rejectNineTransducer++rejectNineTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+rejectNineTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard =+                  PAnd+                    (matchInCtor addCtor)+                    (PNot (inpCtor addCtor #amount .== Keiki.lit 9)),+                update = UKeep,+                output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++rejectingTransducer :: SymTransducer (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent+rejectingTransducer =+  SymTransducer+    { edgesOut = \case+        Counting -> [],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++failingRouter ::+  Router+    RouteGroup+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    es+failingRouter =+  Router+    { name = "failing-router",+      key = \(RouteGroup g) -> g,+      resolve = \_ -> pure [PMCommand {target = stream "failing-target", command = Add 1}],+      targetEventStream = rejectingEventStream,+      targetProjections = const []+    }++-- Flatten exported counter/gauge points to (instrument name, value).+flattenScalarPoints :: [ResourceMetricsExport] -> [(Text, NumberValue)]+flattenScalarPoints rmes =+  [ (name, val)+  | rme <- rmes,+    scope <- Vector.toList (resourceMetricsScopes rme),+    export <- Vector.toList (scopeMetricsExports scope),+    (name, val) <- pointsOf export+  ]+  where+    pointsOf (MetricExportSum n _ _ _ _ _ _ pts) =+      [(n, sumDataPointValue p) | p <- Vector.toList pts]+    pointsOf (MetricExportGauge n _ _ _ _ pts) =+      [(n, gaugeDataPointValue p) | p <- Vector.toList pts]+    pointsOf _ = []++-- Flatten exported histogram points to (instrument name, count, sum).+flattenHistogramPoints :: [ResourceMetricsExport] -> [(Text, Word64, Double)]+flattenHistogramPoints rmes =+  [ (n, histogramDataPointCount p, histogramDataPointSum p)+  | rme <- rmes,+    scope <- Vector.toList (resourceMetricsScopes rme),+    export <- Vector.toList (scopeMetricsExports scope),+    MetricExportHistogram n _ _ _ _ pts <- [export],+    p <- Vector.toList pts+  ]++-- ===========================================================================+-- EP-51 sharded-subscription test helpers+-- ===========================================================================++-- A test sink the sharded handlers write to: one row per processed event,+-- idempotent on event_id (an at-least-once handler may redeliver during a+-- rebalance). worker_tag identifies which worker process handled it; stream_id+-- is the originating stream (the partition key kiroku hashes on).+createShardSinkSql :: ByteString+createShardSinkSql =+  "CREATE TABLE IF NOT EXISTS shard_sink \+  \(event_id uuid PRIMARY KEY, worker_tag int NOT NULL, stream_id bigint NOT NULL)"++-- Seed @nStreams@ category-@orders@ streams with @perStream@ events each+-- (upsert append, so it is safe to call twice in one test). Returns the total+-- number of events appended.+seedOrders :: Store.KirokuStore -> Int -> Int -> IO Int+seedOrders store nStreams perStream = do+  for_ [0 .. nStreams - 1] $ \i -> do+    let sname = StreamName ("orders-" <> Text.pack (show i))+        evs =+          [ EventData+              { eventId = Nothing,+                eventType = EventType "OrderPlaced",+                payload = object ["n" Aeson..= (j :: Int)],+                metadata = Nothing,+                causationId = Nothing,+                correlationId = Nothing+              }+          | j <- [0 .. perStream - 1]+          ]+    Right _ <- Store.runStoreIO store $ Store.appendToStream sname AnyVersion evs+    pure ()+  pure (nStreams * perStream)++-- A handler for worker @tag@: idempotently record (event_id, tag, stream_id).+sinkHandler :: Store.KirokuStore -> Int32 -> RecordedEvent -> IO ()+sinkHandler store tag ev =+  void $+    Store.runStoreIO store $+      Store.runTransaction $+        Tx.statement (eventUuid (ev ^. #eventId), tag, streamIdInt (ev ^. #originalStreamId)) insertShardSinkStmt+  where+    eventUuid (EventId u) = u+    streamIdInt (StreamId s) = s++insertShardSinkStmt :: Statement (UUID, Int32, Int64) ()+insertShardSinkStmt =+  preparable+    "INSERT INTO shard_sink (event_id, worker_tag, stream_id) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING"+    ( contrazip3+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++shardSinkCount :: Store.KirokuStore -> IO Int+shardSinkCount store =+  either (const 0) id+    <$> Store.runStoreIO store (Store.runTransaction (Tx.statement () countShardSinkStmt))++countShardSinkStmt :: Statement () Int+countShardSinkStmt =+  preparable+    "SELECT count(*) FROM shard_sink"+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))++shardDeadLetterDetails :: Store.KirokuStore -> Text -> IO (Int, Maybe Text, Maybe Int)+shardDeadLetterDetails store subscription =+  either (const (0, Nothing, Nothing)) id+    <$> Store.runStoreIO store (Store.runTransaction (Tx.statement subscription shardDeadLetterDetailsStmt))++shardDeadLetterDetailsStmt :: Statement Text (Int, Maybe Text, Maybe Int)+shardDeadLetterDetailsStmt =+  preparable+    "SELECT count(*)::bigint, max(reason_summary), max(attempt_count) \+    \FROM kiroku.dead_letters \+    \WHERE subscription_name = $1 AND consumer_group_member = 0"+    (E.param (E.nonNullable E.text))+    ( D.singleRow $+        (,,)+          <$> (fromIntegral <$> D.column (D.nonNullable D.int8))+          <*> D.column (D.nullable D.text)+          <*> (fmap fromIntegral <$> D.column (D.nullable D.int4))+    )++-- The largest number of distinct workers that processed any single stream. 1+-- means perfectly disjoint ownership (no stream split across workers).+maxWorkersPerStream :: Store.KirokuStore -> IO Int+maxWorkersPerStream store =+  either (const 0) id+    <$> Store.runStoreIO store (Store.runTransaction (Tx.statement () maxWorkersPerStreamStmt))++maxWorkersPerStreamStmt :: Statement () Int+maxWorkersPerStreamStmt =+  preparable+    "SELECT COALESCE(MAX(c), 0) FROM \+    \(SELECT count(DISTINCT worker_tag) AS c FROM shard_sink GROUP BY stream_id) s"+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))++-- How many distinct workers processed at least one event (proves the work+-- spread across the pool rather than monopolised by one worker).+distinctWorkers :: Store.KirokuStore -> IO Int+distinctWorkers store =+  either (const 0) id+    <$> Store.runStoreIO store (Store.runTransaction (Tx.statement () distinctWorkersStmt))++distinctWorkersStmt :: Statement () Int+distinctWorkersStmt =+  preparable+    "SELECT count(DISTINCT worker_tag) FROM shard_sink"+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int8)))++-- Poll the sink count until it reaches @target@ or the timeout elapses.+waitUntilSinkCount :: Store.KirokuStore -> Int -> Int -> IO Bool+waitUntilSinkCount store target timeoutMicros = go (max 1 (timeoutMicros `div` step))+  where+    step = 100_000+    go :: Int -> IO Bool+    go 0 = (>= target) <$> shardSinkCount store+    go n = do+      c <- shardSinkCount store+      if c >= target+        then pure True+        else threadDelay step >> go (n - 1)++-- Poll until at least @target@ shard rows have a live owner. Tests use this to+-- join a second worker at a precise point in the one-bucket-per-pass ramp-up.+waitUntilOwnedShardCount :: Store.KirokuStore -> SubscriptionName -> Int -> Int -> IO Bool+waitUntilOwnedShardCount store sub target timeoutMicros = go (max 1 (timeoutMicros `div` step))+  where+    step = 50_000+    go 0 = hasTarget+    go n = do+      reached <- hasTarget+      if reached then pure True else threadDelay step >> go (n - 1)+    hasTarget = do+      rows <- either (const []) id <$> Store.runStoreIO store (Store.runTransaction (listShardOwnership sub))+      pure (length [() | (_, Just _, _) <- rows] >= target)++-- Poll the lease table until cooperative ownership has converged: every bucket+-- owned, at least @minWorkers@ distinct owners, and no owner holding more than+-- its fair share. This is the "balanced on the empty category" gate the+-- failover test waits on before seeding, so the drain runs under stable+-- membership.+waitShardsBalanced :: Store.KirokuStore -> SubscriptionName -> Int -> Int -> Int -> IO Bool+waitShardsBalanced store sub n minWorkers timeoutMicros = go (max 1 (timeoutMicros `div` step))+  where+    step = 200_000+    go :: Int -> IO Bool+    go 0 = isBalanced+    go k = do+      ok <- isBalanced+      if ok then pure True else threadDelay step >> go (k - 1)+    isBalanced :: IO Bool+    isBalanced = do+      rows <- either (const []) id <$> Store.runStoreIO store (Store.runTransaction (listShardOwnership sub))+      let owners = [w | (_, Just w, _) <- rows]+          distinct = length (nubOrd owners)+          perOwner = [length g | g <- groupByOwner owners]+          fairShare = (n + max 1 distinct - 1) `div` max 1 distinct+      pure (length rows == n && length owners == n && distinct >= minWorkers && all (<= fairShare) perOwner)+    groupByOwner ws = [filter (== w) ws | w <- nubOrd ws]+    nubOrd = Set.toList . Set.fromList++waitShardsUnowned :: Store.KirokuStore -> SubscriptionName -> Int -> Int -> IO Bool+waitShardsUnowned store sub n timeoutMicros = go (max 1 (timeoutMicros `div` step))+  where+    step = 100_000+    go 0 = isUnowned+    go k = do+      ok <- isUnowned+      if ok then pure True else threadDelay step >> go (k - 1)+    isUnowned = do+      rows <- either (const []) id <$> Store.runStoreIO store (Store.runTransaction (listShardOwnership sub))+      pure (length rows == n && all (\(_, owner, _) -> isNothing owner) rows)++workflowOwnedRowCounts :: (Store :> es) => Text -> Text -> Eff es (Int64, Int64, Int64, Int64, Int64, Int64)+workflowOwnedRowCounts name wid =+  Store.runTransaction (Tx.statement (wid, name) workflowOwnedRowCountsStmt)++workflowOwnedChildCount :: (Store :> es) => Text -> Text -> Eff es Int64+workflowOwnedChildCount name wid =+  Store.runTransaction (Tx.statement (wid, name, wid, name) workflowOwnedChildCountStmt)++workflowWakeAfter :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es (Maybe UTCTime)+workflowWakeAfter (WorkflowName name) (WorkflowId wid) =+  Store.runTransaction (Tx.statement (wid, name) workflowWakeAfterStmt)++insertGcTimerStmt :: Statement (UUID, Text, Text, UTCTime, Value, Text) ()+insertGcTimerStmt =+  preparable+    """+    INSERT INTO keiro.keiro_timers+      (timer_id, process_manager_name, correlation_id, fire_at, payload, status)+    VALUES ($1, $2, $3, $4, $5, $6)+    """+    ( contrazip6+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+        (E.param (E.nonNullable E.jsonb))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++deleteGcStepsStmt :: Statement (Text, Text) ()+deleteGcStepsStmt =+  preparable+    """+    DELETE FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++deleteWorkflowInstanceStmt :: Statement (Text, Text) ()+deleteWorkflowInstanceStmt =+  preparable+    """+    DELETE FROM keiro.keiro_workflows+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++workflowWakeAfterStmt :: Statement (Text, Text) (Maybe UTCTime)+workflowWakeAfterStmt =+  preparable+    """+    SELECT wake_after+    FROM keiro.keiro_workflows+    WHERE workflow_id = $1 AND workflow_name = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (maybe Nothing id <$> D.rowMaybe (D.column (D.nullable D.timestamptz)))++workflowOwnedRowCountsStmt :: Statement (Text, Text) (Int64, Int64, Int64, Int64, Int64, Int64)+workflowOwnedRowCountsStmt =+  preparable+    """+    SELECT+      (SELECT count(*) FROM keiro.keiro_workflows WHERE workflow_id = $1 AND workflow_name = $2),+      (SELECT count(*) FROM keiro.keiro_workflow_steps WHERE workflow_id = $1 AND workflow_name = $2),+      (SELECT count(*) FROM keiro.keiro_awakeables WHERE owner_workflow_id = $1 AND owner_workflow_name = $2),+      (SELECT count(*) FROM keiro.keiro_workflow_children+        WHERE (parent_id = $1 AND parent_name = $2) OR (child_id = $1 AND child_name = $2)),+      (SELECT count(*) FROM keiro.keiro_timers+        WHERE correlation_id = $1 AND process_manager_name = $2 AND payload->>'kind' = 'keiro.workflow.sleep'),+      (SELECT count(*)+         FROM keiro.keiro_snapshots s+         JOIN streams st ON st.stream_id = s.stream_id+        WHERE st.stream_name = 'wf:' || $2 || '-' || $1)+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    ( D.singleRow $+        (,,,,,)+          <$> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.int8)+    )++workflowOwnedChildCountStmt :: Statement (Text, Text, Text, Text) Int64+workflowOwnedChildCountStmt =+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_workflow_children+    WHERE (parent_id = $1 AND parent_name = $2)+       OR (child_id = $3 AND child_name = $4)+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.singleRow (D.column (D.nonNullable D.int8)))