packages feed

keiro 0.11.0.0 → 0.12.0.0

raw patch · 64 files changed

+47157/−13774 lines, 64 filesdep ~aesondep ~keikidep ~keiro-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: aeson, keiki, keiro-core, kiroku-store, shibuya-core

API changes (from Hackage documentation)

- Keiro.Command: instance forall k (target :: k) (rs :: [Keiki.Core.Slot]) s co. GHC.Internal.Generics.Generic (Keiro.Command.CommandPlan target rs s co)
- Keiro.Projection: instance GHC.Internal.Generics.Generic (Keiro.Projection.InlineProjection co)
- Keiro.Projection: instance GHC.Internal.Generics.Generic Keiro.Projection.AsyncProjection
- Keiro.Workflow: WorkflowJournalAppendError :: !Text -> WorkflowError
- Keiro.Workflow: WorkflowJournalDecodeError :: !Text -> WorkflowError
- Keiro.Workflow: WorkflowJournalEncodeError :: !Text -> WorkflowError
- Keiro.Workflow: WorkflowStepDecodeError :: !Text -> !Text -> WorkflowError
- Keiro.Workflow: data WorkflowError
- Keiro.Workflow: instance GHC.Classes.Eq Keiro.Workflow.JournalAppendOutcome
- Keiro.Workflow: instance GHC.Classes.Eq Keiro.Workflow.WorkflowError
- Keiro.Workflow: instance GHC.Internal.Exception.Type.Exception Keiro.Workflow.WorkflowError
- Keiro.Workflow: instance GHC.Internal.Show.Show Keiro.Workflow.JournalAppendOutcome
- Keiro.Workflow: instance GHC.Internal.Show.Show Keiro.Workflow.WorkflowError
- Keiro.Workflow.Awakeable: deterministicAwakeableId :: WorkflowName -> WorkflowId -> Text -> AwakeableId
- Keiro.Workflow.Instance: instance GHC.Classes.Eq Keiro.Workflow.Instance.WorkflowStatus
- Keiro.Workflow.Instance: instance GHC.Internal.Generics.Generic Keiro.Workflow.Instance.WorkflowStatus
- Keiro.Workflow.Instance: instance GHC.Internal.Show.Show Keiro.Workflow.Instance.WorkflowStatus
- Keiro.Workflow.Instance: markInstanceSuspended :: forall (es :: [Effect]). Store :> es => WorkflowName -> WorkflowId -> Eff es ()
+ Keiro: AllRowsExternalRead :: !ExternalReadContractId -> !ExternalReadContractVersion -> !QueryModelId -> !QualifiedSqlType -> !Text -> !NonEmpty ProjectionRevisionId -> !Int -> !ClaimSite -> ExternalReadContract
+ Keiro: AmbiguousSourceOrdering :: CatalogDiagnosticCode
+ Keiro: AsyncHandler :: !AsyncProjection -> !SubscriptionId -> !DedupKeyId -> !ClaimSite -> ProjectionHandler event
+ Keiro: AsyncHandlerDedupMismatch :: CatalogDiagnosticCode
+ Keiro: AsyncHandlerSubscriptionMismatch :: CatalogDiagnosticCode
+ Keiro: AsyncProjectionRegistration :: !ProjectionId -> !Text -> !SubscriptionId -> !Text -> !MissingCheckpointPolicy -> !DedupKeyId -> !Text -> AsyncProjectionRegistration
+ Keiro: CatalogAsyncDedupSpec :: !DedupKeyId -> !Text -> !Text -> !SourceId -> !SourceScope -> !RecordedEvent -> EventId -> CatalogAsyncDedupSpec
+ Keiro: CatalogDiagnostic :: !CatalogDiagnosticCode -> !Text -> ![ClaimSite] -> !Text -> CatalogDiagnostic
+ Keiro: CatalogIdentityHasSurroundingWhitespace :: !Text -> CatalogIdentityError
+ Keiro: CatalogInventory :: ![InventorySource] -> ![InventoryTarget] -> ![InventoryGroup] -> ![InventoryProjection] -> ![InventoryProjectionRevision] -> ![InventoryExternalReadContract] -> ![InventoryQueryModel] -> ![InventorySubscription] -> ![InventoryDedupKey] -> CatalogInventory
+ Keiro: CatalogRegistration :: !QueryModelId -> !Text -> !Int -> !Text -> !RebuildGroupId -> CatalogRegistration
+ Keiro: CategorySource :: !CategoryName -> SourceScope
+ Keiro: ClearBeforeReplay :: TargetResetPolicy
+ Keiro: ClearTargetRequiresReplayableOwner :: CatalogDiagnosticCode
+ Keiro: DedupKeyDeclaration :: !DedupKeyId -> !Text -> !ClaimSite -> DedupKeyDeclaration
+ Keiro: DedupKeyRemoved :: !DedupKeyId -> CatalogEvolution
+ Keiro: DuplicateDedupKeyId :: CatalogDiagnosticCode
+ Keiro: DuplicateDedupName :: CatalogDiagnosticCode
+ Keiro: DuplicateExternalReadContractVersion :: CatalogDiagnosticCode
+ Keiro: DuplicateExternalReadFunctionName :: CatalogDiagnosticCode
+ Keiro: DuplicateGroupTarget :: CatalogDiagnosticCode
+ Keiro: DuplicateProjectionId :: CatalogDiagnosticCode
+ Keiro: DuplicateProjectionRevisionId :: CatalogDiagnosticCode
+ Keiro: DuplicateQualifiedTable :: CatalogDiagnosticCode
+ Keiro: DuplicateQueryModelId :: CatalogDiagnosticCode
+ Keiro: DuplicateQueryModelRegistryName :: CatalogDiagnosticCode
+ Keiro: DuplicateRebuildGroupId :: CatalogDiagnosticCode
+ Keiro: DuplicateRebuildVerificationId :: CatalogDiagnosticCode
+ Keiro: DuplicateSourceId :: CatalogDiagnosticCode
+ Keiro: DuplicateStreamScopedReplayProjection :: CatalogDiagnosticCode
+ Keiro: DuplicateSubscriptionId :: CatalogDiagnosticCode
+ Keiro: DuplicateSubscriptionName :: CatalogDiagnosticCode
+ Keiro: DuplicateTargetId :: CatalogDiagnosticCode
+ Keiro: EmptyCatalogIdentity :: CatalogIdentityError
+ Keiro: EmptyQueryObservedTargets :: CatalogDiagnosticCode
+ Keiro: EmptyRebuildGroup :: CatalogDiagnosticCode
+ Keiro: ExternalReadContractRemoved :: !ExternalReadContractId -> !ExternalReadContractVersion -> CatalogEvolution
+ Keiro: ExternalReadContractVersion :: Int -> ExternalReadContractVersion
+ Keiro: ExternalReadImmutableSignatureDrift :: CatalogDiagnosticCode
+ Keiro: ExternalReadImplementationCollision :: CatalogDiagnosticCode
+ Keiro: ExternalReadRevisionOwnershipMismatch :: CatalogDiagnosticCode
+ Keiro: ExternalReadShapeMismatch :: CatalogDiagnosticCode
+ Keiro: ExternalReadSurfaceGenerationRegression :: CatalogDiagnosticCode
+ Keiro: Failure :: !err -> Validation err value
+ Keiro: InlineCapability :: !Text -> ProjectionHandlerCapability
+ Keiro: InlineHandler :: !InlineProjection event -> !ClaimSite -> ProjectionHandler event
+ Keiro: InvalidExternalReadContractIdentity :: CatalogDiagnosticCode
+ Keiro: InvalidExternalReadSqlIdentifier :: CatalogDiagnosticCode
+ Keiro: InvalidExternalReadSqlType :: CatalogDiagnosticCode
+ Keiro: InvalidProjectionRevisionIdentity :: CatalogDiagnosticCode
+ Keiro: InvalidRebuildVerificationIdentity :: CatalogDiagnosticCode
+ Keiro: InvalidStreamScopedReplayIdentity :: CatalogDiagnosticCode
+ Keiro: InventoryAllRowsExternalRead :: ExternalReadContractKind
+ Keiro: InventoryAsyncHandler :: !Text -> !SubscriptionId -> !DedupKeyId -> InventoryHandler
+ Keiro: InventoryDedupKey :: !DedupKeyId -> !Text -> InventoryDedupKey
+ Keiro: InventoryExternalReadContract :: !ExternalReadContractId -> !ExternalReadContractVersion -> !QueryModelId -> !RebuildGroupId -> !Text -> !ExternalReadContractKind -> ![SqlFunctionArgument] -> !QualifiedSqlType -> !Maybe QualifiedFunction -> !Maybe Int -> !Text -> !NonEmpty ProjectionRevisionId -> !Int -> InventoryExternalReadContract
+ Keiro: InventoryGroup :: !RebuildGroupId -> ![TargetId] -> ![(Text, Text)] -> InventoryGroup
+ Keiro: InventoryImmediate :: InventoryQueryFreshness
+ Keiro: InventoryInlineHandler :: !Text -> InventoryHandler
+ Keiro: InventoryKeyedExternalRead :: ExternalReadContractKind
+ Keiro: InventoryProjection :: !ProjectionId -> !SourceId -> !RebuildGroupId -> ![TargetId] -> !Text -> ![InventoryHandler] -> InventoryProjection
+ Keiro: InventoryProjectionRevision :: !ProjectionRevisionId -> !RebuildGroupId -> ![InventoryTargetProvisioner] -> ![InventoryRevisionHandler] -> ![InventoryRevisionHandler] -> ![InventoryRevisionHandler] -> ![InventoryStreamScopedReplay] -> InventoryProjectionRevision
+ Keiro: InventoryQueryCursor :: !SubscriptionId -> !Text -> InventoryQueryCursor
+ Keiro: InventoryQueryModel :: !QueryModelId -> !Text -> !Int -> !Text -> !RebuildGroupId -> ![TargetId] -> !InventoryQueryFreshness -> !Maybe InventoryQueryCursor -> InventoryQueryModel
+ Keiro: InventoryRevisionHandler :: !Text -> !Int -> !Maybe RevisionLiveDelivery -> ![TargetId] -> InventoryRevisionHandler
+ Keiro: InventorySource :: !SourceId -> !SourceScope -> !Text -> InventorySource
+ Keiro: InventoryStreamScopedReplay :: !ProjectionId -> ![TargetId] -> !Text -> !Int -> !Text -> !Int -> !Text -> !Int -> ![DedupKeyId] -> InventoryStreamScopedReplay
+ Keiro: InventorySubscription :: !SubscriptionId -> !Text -> !SourceId -> !MissingCheckpointPolicy -> InventorySubscription
+ Keiro: InventoryTarget :: !TargetId -> !QualifiedTable -> !TargetResetPolicy -> ![TargetId] -> !ProjectionId -> InventoryTarget
+ Keiro: InventoryTargetProvisioner :: !TargetId -> !Text -> !Int -> !TargetSchemaVersion -> !Text -> !Text -> !Int -> ![PromotionObjectName] -> InventoryTargetProvisioner
+ Keiro: InventoryWaitForHead :: !HeadScope -> InventoryQueryFreshness
+ Keiro: InventoryWaitForPosition :: InventoryQueryFreshness
+ Keiro: KeyedExternalRead :: !ExternalReadContractId -> !ExternalReadContractVersion -> !QueryModelId -> ![SqlFunctionArgument] -> !QualifiedSqlType -> !QualifiedFunction -> !Int -> !Text -> !NonEmpty ProjectionRevisionId -> !Int -> !ClaimSite -> ExternalReadContract
+ Keiro: LiveOnly :: !LiveOnlyReason -> ProjectionReplayPolicy event
+ Keiro: LiveOnlyReason :: Text -> LiveOnlyReason
+ Keiro: MissingPhysicalTarget :: !TargetId -> PhysicalTargetMapError
+ Keiro: MixedResetGroupRequiresReplayAdapter :: CatalogDiagnosticCode
+ Keiro: PreserveAndReconcile :: TargetResetPolicy
+ Keiro: ProjectionCatalog :: ![SourceDeclaration] -> ![TargetDeclaration] -> ![RebuildGroupDeclaration] -> ![ProjectionRevision] -> ![ExternalReadContract] -> ![SubscriptionDeclaration] -> ![DedupKeyDeclaration] -> ![SomeQueryModelBinding] -> ![SomeProjectionSet] -> ProjectionCatalog
+ Keiro: ProjectionCrossesRebuildGroups :: CatalogDiagnosticCode
+ Keiro: ProjectionDefinition :: !ProjectionId -> !RebuildGroupId -> !NonEmpty TargetId -> !ProjectionReplayPolicy event -> !NonEmpty (ProjectionHandler event) -> !ClaimSite -> ProjectionDefinition event
+ Keiro: ProjectionRemoved :: !ProjectionId -> CatalogEvolution
+ Keiro: ProjectionRevision :: !ProjectionRevisionId -> !RebuildGroupId -> !Map TargetId TargetProvisioner -> ![RevisionLiveHandler] -> ![RevisionReplayAdapter] -> ![RevisionVerification] -> ![StreamScopedReplay] -> !ClaimSite -> ProjectionRevision
+ Keiro: ProjectionRevisionLiveCapabilityMismatch :: CatalogDiagnosticCode
+ Keiro: ProjectionRevisionLiveTargetOwnershipMismatch :: CatalogDiagnosticCode
+ Keiro: ProjectionRevisionMissingSchemaValidation :: CatalogDiagnosticCode
+ Keiro: ProjectionRevisionPhysicalTargetsNotTotal :: CatalogDiagnosticCode
+ Keiro: ProjectionRevisionRemoved :: !ProjectionRevisionId -> CatalogEvolution
+ Keiro: ProjectionRevisionTargetSetDrift :: CatalogDiagnosticCode
+ Keiro: ProjectionRevisionWithoutLiveHandler :: CatalogDiagnosticCode
+ Keiro: ProjectionRevisionWithoutReplayAdapter :: CatalogDiagnosticCode
+ Keiro: ProjectionSet :: !SourceId -> !NonEmpty (ProjectionDefinition event) -> !ClaimSite -> ProjectionSet event
+ Keiro: PromotionConstraint :: PromotionObjectKind
+ Keiro: PromotionIndex :: PromotionObjectKind
+ Keiro: PromotionObjectName :: !PromotionObjectKind -> !Text -> !Text -> PromotionObjectName
+ Keiro: PromotionOwnedSequence :: PromotionObjectKind
+ Keiro: QualifiedFunction :: !Text -> !Text -> QualifiedFunction
+ Keiro: QualifiedSqlType :: !Text -> !Text -> QualifiedSqlType
+ Keiro: QualifiedTable :: !Text -> !Text -> QualifiedTable
+ Keiro: QueryModelBinding :: !QueryModelId -> !ReadModel q r -> !RebuildGroupId -> ![TargetId] -> !ClaimSite -> QueryModelBinding q r
+ Keiro: QueryModelOutsideRebuildGroup :: CatalogDiagnosticCode
+ Keiro: QueryModelRemoved :: !QueryModelId -> CatalogEvolution
+ Keiro: QueryModelWithMultipleSuppliers :: CatalogDiagnosticCode
+ Keiro: QueryModelWithoutSupplier :: CatalogDiagnosticCode
+ Keiro: QueryWaitWithAmbiguousCursor :: CatalogDiagnosticCode
+ Keiro: QueryWaitWithoutCompatibleCursor :: CatalogDiagnosticCode
+ Keiro: RebuildGroupDeclaration :: !RebuildGroupId -> ![TargetId] -> ![RebuildVerification] -> !ClaimSite -> RebuildGroupDeclaration
+ Keiro: RebuildGroupRemoved :: !RebuildGroupId -> CatalogEvolution
+ Keiro: RebuildVerification :: !Text -> !Text -> !Transaction (Either Text ()) -> RebuildVerification
+ Keiro: ReplayAdapter :: !RecordedEvent -> ReplayDecodeResult event -> !event -> RecordedEvent -> Transaction () -> ReplayAdapter event
+ Keiro: ReplayAdapterMetadata :: !ProjectionId -> !SourceId -> !RebuildGroupId -> !Bool -> ReplayAdapterMetadata
+ Keiro: ReplayDecodeError :: Text -> ReplayDecodeError
+ Keiro: ReplayDecodeFailure :: !ReplayDecodeError -> ReplayDecodeResult event
+ Keiro: ReplayIrrelevant :: ReplayDecodeResult event
+ Keiro: ReplayRelevant :: !event -> ReplayDecodeResult event
+ Keiro: Replayable :: !ReplayAdapter event -> ProjectionReplayPolicy event
+ Keiro: ReplayableClearTargetStartsAtCurrentHead :: CatalogDiagnosticCode
+ Keiro: ResolvedQuerySupply :: !QueryModelId -> !ProjectionId -> !RebuildGroupId -> !NonEmpty TargetId -> !SourceId -> !NonEmpty ProjectionHandlerCapability -> !InventoryQueryFreshness -> !Maybe InventoryQueryCursor -> ResolvedQuerySupply
+ Keiro: RevisionInlineDelivery :: !ProjectionId -> !Text -> RevisionLiveDelivery
+ Keiro: RevisionLiveHandler :: !Text -> !Int -> !RevisionLiveDelivery -> ![TargetId] -> !PhysicalTargets -> RecordedEvent -> Transaction () -> RevisionLiveHandler
+ Keiro: RevisionReplayAdapter :: !Text -> !Int -> ![TargetId] -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool) -> RevisionReplayAdapter
+ Keiro: RevisionSubscriptionDelivery :: !ProjectionId -> !SubscriptionId -> !DedupKeyId -> RevisionLiveDelivery
+ Keiro: RevisionVerification :: !Text -> !Int -> ![TargetId] -> !PhysicalTargets -> Transaction (Either Text ()) -> RevisionVerification
+ Keiro: SomeProjectionSet :: ProjectionSet event -> SomeProjectionSet
+ Keiro: SomeQueryModelBinding :: QueryModelBinding q r -> SomeQueryModelBinding
+ Keiro: SourceDeclaration :: !SourceId -> !SourceScope -> !Text -> !ClaimSite -> SourceDeclaration
+ Keiro: SourceRemoved :: !SourceId -> CatalogEvolution
+ Keiro: SqlFunctionArgument :: !Text -> !QualifiedSqlType -> SqlFunctionArgument
+ Keiro: StreamClearCount :: !TargetId -> !Int64 -> StreamClearCount
+ Keiro: StreamScopedReplay :: !ProjectionId -> !NonEmpty TargetId -> !Text -> !Int -> !PhysicalTargets -> StreamName -> Transaction (Either Text [StreamClearCount]) -> !Text -> !Int -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool) -> !Text -> !Int -> !PhysicalTargets -> StreamName -> Transaction (Either Text ()) -> ![DedupKeyId] -> !ClaimSite -> StreamScopedReplay
+ Keiro: StreamScopedReplayDedupMismatch :: CatalogDiagnosticCode
+ Keiro: StreamScopedReplayGroupMismatch :: CatalogDiagnosticCode
+ Keiro: StreamScopedReplayTargetSetMismatch :: CatalogDiagnosticCode
+ Keiro: SubscriptionCapability :: !Text -> !SubscriptionId -> !Text -> !SourceId -> !MissingCheckpointPolicy -> !DedupKeyId -> !Text -> ProjectionHandlerCapability
+ Keiro: SubscriptionDeclaration :: !SubscriptionId -> !Text -> !SourceId -> !MissingCheckpointPolicy -> !ClaimSite -> SubscriptionDeclaration
+ Keiro: SubscriptionRemoved :: !SubscriptionId -> CatalogEvolution
+ Keiro: Success :: !value -> Validation err value
+ Keiro: TargetDeclaration :: !TargetId -> !QualifiedTable -> !TargetResetPolicy -> ![TargetId] -> !ClaimSite -> TargetDeclaration
+ Keiro: TargetDependencyCycle :: CatalogDiagnosticCode
+ Keiro: TargetGenerationId :: UUID -> TargetGenerationId
+ Keiro: TargetGroupChanged :: !TargetId -> !RebuildGroupId -> !RebuildGroupId -> CatalogEvolution
+ Keiro: TargetOrderViolatesDependency :: CatalogDiagnosticCode
+ Keiro: TargetOwnerChanged :: !TargetId -> !ProjectionId -> !ProjectionId -> CatalogEvolution
+ Keiro: TargetProvisioner :: !Text -> !Int -> !TargetSchemaVersion -> !Text -> !TargetProvisioningContext -> Transaction () -> !Text -> !Int -> !Maybe (TargetProvisioningContext -> Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence)) -> ![PromotionObjectName] -> TargetProvisioner
+ Keiro: TargetProvisioningContext :: !TargetId -> !TargetGenerationId -> !QualifiedTable -> !QualifiedTable -> TargetProvisioningContext
+ Keiro: TargetRemoved :: !TargetId -> CatalogEvolution
+ Keiro: TargetSchemaEvidence :: !Int64 -> !Text -> ![PromotionObjectName] -> !Text -> TargetSchemaEvidence
+ Keiro: TargetSchemaVersion :: Text -> TargetSchemaVersion
+ Keiro: TargetSchemaViolation :: !Text -> !Text -> TargetSchemaViolation
+ Keiro: TargetWithMultipleOwners :: CatalogDiagnosticCode
+ Keiro: TargetWithoutOwner :: CatalogDiagnosticCode
+ Keiro: UnexpectedPhysicalTarget :: !TargetId -> PhysicalTargetMapError
+ Keiro: UnknownDedupKeyReference :: CatalogDiagnosticCode
+ Keiro: UnknownExternalReadQueryModel :: CatalogDiagnosticCode
+ Keiro: UnknownGroupReference :: CatalogDiagnosticCode
+ Keiro: UnknownQueryModelReference :: CatalogDiagnosticCode
+ Keiro: UnknownRevisionReference :: CatalogDiagnosticCode
+ Keiro: UnknownSourceReference :: CatalogDiagnosticCode
+ Keiro: UnknownStreamScopedReplayProjection :: CatalogDiagnosticCode
+ Keiro: UnknownSubscriptionReference :: CatalogDiagnosticCode
+ Keiro: UnknownTargetDependency :: CatalogDiagnosticCode
+ Keiro: UnknownTargetProvisioner :: CatalogDiagnosticCode
+ Keiro: UnknownTargetReference :: CatalogDiagnosticCode
+ Keiro: [adapterId] :: RevisionReplayAdapter -> !Text
+ Keiro: [adapterVersion] :: RevisionReplayAdapter -> !Int
+ Keiro: [affectedAsyncDedup] :: StreamScopedReplay -> ![DedupKeyId]
+ Keiro: [applyForReplay] :: ReplayAdapter event -> !event -> RecordedEvent -> Transaction ()
+ Keiro: [argumentName] :: SqlFunctionArgument -> !Text
+ Keiro: [argumentType] :: SqlFunctionArgument -> !QualifiedSqlType
+ Keiro: [arguments] :: InventoryExternalReadContract -> ![SqlFunctionArgument]
+ Keiro: [canonicalName] :: PromotionObjectName -> !Text
+ Keiro: [capabilityCheckpointOnMissing] :: ProjectionHandlerCapability -> !MissingCheckpointPolicy
+ Keiro: [capabilityDedupKeyId] :: ProjectionHandlerCapability -> !DedupKeyId
+ Keiro: [capabilityDedupName] :: ProjectionHandlerCapability -> !Text
+ Keiro: [capabilityHandlerName] :: ProjectionHandlerCapability -> !Text
+ Keiro: [capabilitySourceId] :: ProjectionHandlerCapability -> !SourceId
+ Keiro: [capabilitySubscriptionId] :: ProjectionHandlerCapability -> !SubscriptionId
+ Keiro: [capabilitySubscriptionName] :: ProjectionHandlerCapability -> !Text
+ Keiro: [catalogSnapshot] :: TargetSchemaEvidence -> !Text
+ Keiro: [checkpointOnMissing] :: SubscriptionDeclaration -> !MissingCheckpointPolicy
+ Keiro: [claimSite] :: TargetDeclaration -> !ClaimSite
+ Keiro: [clearStreamRows] :: StreamScopedReplay -> !PhysicalTargets -> StreamName -> Transaction (Either Text [StreamClearCount])
+ Keiro: [clearedRows] :: StreamClearCount -> !Int64
+ Keiro: [clearerId] :: StreamScopedReplay -> !Text
+ Keiro: [clearerVersion] :: StreamScopedReplay -> !Int
+ Keiro: [codecFingerprint] :: SourceDeclaration -> !Text
+ Keiro: [compatibleRevisions] :: InventoryExternalReadContract -> !NonEmpty ProjectionRevisionId
+ Keiro: [contractKind] :: InventoryExternalReadContract -> !ExternalReadContractKind
+ Keiro: [contractVersion] :: InventoryExternalReadContract -> !ExternalReadContractVersion
+ Keiro: [decodeForReplay] :: ReplayAdapter event -> !RecordedEvent -> ReplayDecodeResult event
+ Keiro: [dedupKeyId] :: InventoryDedupKey -> !DedupKeyId
+ Keiro: [dedupKeys] :: ProjectionCatalog -> ![DedupKeyDeclaration]
+ Keiro: [dedupName] :: InventoryDedupKey -> !Text
+ Keiro: [delivery] :: RevisionLiveHandler -> !RevisionLiveDelivery
+ Keiro: [dependsOn] :: TargetDeclaration -> ![TargetId]
+ Keiro: [diagnosticCode] :: CatalogDiagnostic -> !CatalogDiagnosticCode
+ Keiro: [diagnosticIdentity] :: CatalogDiagnostic -> !Text
+ Keiro: [diagnosticMessage] :: CatalogDiagnostic -> !Text
+ Keiro: [diagnosticSites] :: CatalogDiagnostic -> ![ClaimSite]
+ Keiro: [expectedShapeId] :: TargetProvisioner -> !Text
+ Keiro: [externalReadContracts] :: ProjectionCatalog -> ![ExternalReadContract]
+ Keiro: [functionName] :: QualifiedFunction -> !Text
+ Keiro: [functionSchema] :: QualifiedFunction -> !Text
+ Keiro: [generationId] :: TargetProvisioningContext -> !TargetGenerationId
+ Keiro: [generationName] :: PromotionObjectName -> !Text
+ Keiro: [handlerId] :: RevisionLiveHandler -> !Text
+ Keiro: [handlerVersion] :: RevisionLiveHandler -> !Int
+ Keiro: [handlers] :: ProjectionDefinition event -> !NonEmpty (ProjectionHandler event)
+ Keiro: [inventoryDedupKeys] :: CatalogInventory -> ![InventoryDedupKey]
+ Keiro: [inventoryExternalReadContracts] :: CatalogInventory -> ![InventoryExternalReadContract]
+ Keiro: [inventoryGroups] :: CatalogInventory -> ![InventoryGroup]
+ Keiro: [inventoryProjectionRevisions] :: CatalogInventory -> ![InventoryProjectionRevision]
+ Keiro: [inventoryProjections] :: CatalogInventory -> ![InventoryProjection]
+ Keiro: [inventoryQueryModels] :: CatalogInventory -> ![InventoryQueryModel]
+ Keiro: [inventorySources] :: CatalogInventory -> ![InventorySource]
+ Keiro: [inventorySubscriptions] :: CatalogInventory -> ![InventorySubscription]
+ Keiro: [inventoryTargets] :: CatalogInventory -> ![InventoryTarget]
+ Keiro: [liveHandlers] :: ProjectionRevision -> ![RevisionLiveHandler]
+ Keiro: [objectKind] :: PromotionObjectName -> !PromotionObjectKind
+ Keiro: [observedPromotionObjects] :: TargetSchemaEvidence -> ![PromotionObjectName]
+ Keiro: [observedShapeFingerprint] :: TargetSchemaEvidence -> !Text
+ Keiro: [observedTargets] :: QueryModelBinding q r -> ![TargetId]
+ Keiro: [orderedTargets] :: RebuildGroupDeclaration -> ![TargetId]
+ Keiro: [ownedTargets] :: ProjectionDefinition event -> !NonEmpty TargetId
+ Keiro: [owner] :: InventoryTarget -> !ProjectionId
+ Keiro: [privateImplementationVersion] :: InventoryExternalReadContract -> !Maybe Int
+ Keiro: [privateImplementation] :: InventoryExternalReadContract -> !Maybe QualifiedFunction
+ Keiro: [projectionDefinitions] :: ProjectionSet event -> !NonEmpty (ProjectionDefinition event)
+ Keiro: [projectionId] :: ReplayAdapterMetadata -> !ProjectionId
+ Keiro: [projectionName] :: AsyncProjectionRegistration -> !Text
+ Keiro: [projectionRevisions] :: ProjectionCatalog -> ![ProjectionRevision]
+ Keiro: [projectionSets] :: ProjectionCatalog -> ![SomeProjectionSet]
+ Keiro: [projectionSource] :: ProjectionSet event -> !SourceId
+ Keiro: [promotionObjectNames] :: TargetProvisioner -> ![PromotionObjectName]
+ Keiro: [provisionTarget] :: TargetProvisioner -> !TargetProvisioningContext -> Transaction ()
+ Keiro: [provisionerId] :: TargetProvisioner -> !Text
+ Keiro: [provisionerVersion] :: TargetProvisioner -> !Int
+ Keiro: [qualifiedTable] :: TargetDeclaration -> !QualifiedTable
+ Keiro: [queryModelId] :: QueryModelBinding q r -> !QueryModelId
+ Keiro: [queryModels] :: ProjectionCatalog -> ![SomeQueryModelBinding]
+ Keiro: [readContractId] :: InventoryExternalReadContract -> !ExternalReadContractId
+ Keiro: [readModel] :: QueryModelBinding q r -> !ReadModel q r
+ Keiro: [rebuildGroupId] :: ReplayAdapterMetadata -> !RebuildGroupId
+ Keiro: [rebuildGroup] :: QueryModelBinding q r -> !RebuildGroupId
+ Keiro: [rebuildGroups] :: ProjectionCatalog -> ![RebuildGroupDeclaration]
+ Keiro: [relationOid] :: TargetSchemaEvidence -> !Int64
+ Keiro: [replayAdapters] :: ProjectionRevision -> ![RevisionReplayAdapter]
+ Keiro: [replayDisposition] :: InventoryProjection -> !Text
+ Keiro: [replayId] :: InventoryStreamScopedReplay -> !Text
+ Keiro: [replayPolicy] :: ProjectionDefinition event -> !ProjectionReplayPolicy event
+ Keiro: [replayStreamEvent] :: StreamScopedReplay -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool)
+ Keiro: [replayVersion] :: InventoryStreamScopedReplay -> !Int
+ Keiro: [replayable] :: ReplayAdapterMetadata -> !Bool
+ Keiro: [requiredTargets] :: RevisionVerification -> ![TargetId]
+ Keiro: [resetPolicy] :: TargetDeclaration -> !TargetResetPolicy
+ Keiro: [resolvedHandlerCapabilities] :: ResolvedQuerySupply -> !NonEmpty ProjectionHandlerCapability
+ Keiro: [resolvedObservedTargets] :: ResolvedQuerySupply -> !NonEmpty TargetId
+ Keiro: [resolvedProjectionId] :: ResolvedQuerySupply -> !ProjectionId
+ Keiro: [resolvedQueryCursor] :: ResolvedQuerySupply -> !Maybe InventoryQueryCursor
+ Keiro: [resolvedQueryFreshness] :: ResolvedQuerySupply -> !InventoryQueryFreshness
+ Keiro: [resolvedQueryModelId] :: ResolvedQuerySupply -> !QueryModelId
+ Keiro: [resolvedRebuildGroupId] :: ResolvedQuerySupply -> !RebuildGroupId
+ Keiro: [resolvedSourceId] :: ResolvedQuerySupply -> !SourceId
+ Keiro: [resultContractType] :: InventoryExternalReadContract -> !QualifiedSqlType
+ Keiro: [resultShapeHash] :: InventoryExternalReadContract -> !Text
+ Keiro: [revisionId] :: ProjectionRevision -> !ProjectionRevisionId
+ Keiro: [revisionVerificationId] :: RevisionVerification -> !Text
+ Keiro: [revisionVerificationVersion] :: RevisionVerification -> !Int
+ Keiro: [revisionVerifications] :: ProjectionRevision -> ![RevisionVerification]
+ Keiro: [runRevisionLive] :: RevisionLiveHandler -> !PhysicalTargets -> RecordedEvent -> Transaction ()
+ Keiro: [runRevisionReplay] :: RevisionReplayAdapter -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool)
+ Keiro: [runRevisionVerification] :: RevisionVerification -> !PhysicalTargets -> Transaction (Either Text ())
+ Keiro: [schemaName] :: QualifiedTable -> !Text
+ Keiro: [servingTable] :: TargetProvisioningContext -> !QualifiedTable
+ Keiro: [sourceId] :: SourceDeclaration -> !SourceId
+ Keiro: [sourceScope] :: SourceDeclaration -> !SourceScope
+ Keiro: [sources] :: ProjectionCatalog -> ![SourceDeclaration]
+ Keiro: [specDedupKeyId] :: CatalogAsyncDedupSpec -> !DedupKeyId
+ Keiro: [specDedupName] :: CatalogAsyncDedupSpec -> !Text
+ Keiro: [specIdempotencyKey] :: CatalogAsyncDedupSpec -> !RecordedEvent -> EventId
+ Keiro: [specSourceId] :: CatalogAsyncDedupSpec -> !SourceId
+ Keiro: [specSourceScope] :: CatalogAsyncDedupSpec -> !SourceScope
+ Keiro: [specSubscriptionName] :: CatalogAsyncDedupSpec -> !Text
+ Keiro: [stagingTable] :: TargetProvisioningContext -> !QualifiedTable
+ Keiro: [streamOwnedTargets] :: StreamScopedReplay -> !NonEmpty TargetId
+ Keiro: [streamProjectionId] :: StreamScopedReplay -> !ProjectionId
+ Keiro: [streamReplayId] :: StreamScopedReplay -> !Text
+ Keiro: [streamReplayVersion] :: StreamScopedReplay -> !Int
+ Keiro: [streamScopedReplays] :: ProjectionRevision -> ![StreamScopedReplay]
+ Keiro: [streamVerificationId] :: StreamScopedReplay -> !Text
+ Keiro: [streamVerificationVersion] :: StreamScopedReplay -> !Int
+ Keiro: [subscriptionId] :: SubscriptionDeclaration -> !SubscriptionId
+ Keiro: [subscriptionName] :: SubscriptionDeclaration -> !Text
+ Keiro: [subscriptionSource] :: SubscriptionDeclaration -> !SourceId
+ Keiro: [subscriptions] :: ProjectionCatalog -> ![SubscriptionDeclaration]
+ Keiro: [surfaceGeneration] :: InventoryExternalReadContract -> !Int
+ Keiro: [tableName] :: QualifiedTable -> !Text
+ Keiro: [targetId] :: TargetProvisioningContext -> !TargetId
+ Keiro: [targetProvisioners] :: ProjectionRevision -> !Map TargetId TargetProvisioner
+ Keiro: [targets] :: ProjectionCatalog -> ![TargetDeclaration]
+ Keiro: [typeName] :: QualifiedSqlType -> !Text
+ Keiro: [typeSchema] :: QualifiedSqlType -> !Text
+ Keiro: [validateTarget] :: TargetProvisioner -> !Maybe (TargetProvisioningContext -> Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence))
+ Keiro: [validatorId] :: TargetProvisioner -> !Text
+ Keiro: [validatorVersion] :: TargetProvisioner -> !Int
+ Keiro: [verificationHooks] :: RebuildGroupDeclaration -> ![RebuildVerification]
+ Keiro: [verificationId] :: RebuildVerification -> !Text
+ Keiro: [verificationVersion] :: RebuildVerification -> !Text
+ Keiro: [verifications] :: InventoryProjectionRevision -> ![InventoryRevisionHandler]
+ Keiro: [verifyRebuild] :: RebuildVerification -> !Transaction (Either Text ())
+ Keiro: [verifyStreamRows] :: StreamScopedReplay -> !PhysicalTargets -> StreamName -> Transaction (Either Text ())
+ Keiro: [violationCode] :: TargetSchemaViolation -> !Text
+ Keiro: [violationDetail] :: TargetSchemaViolation -> !Text
+ Keiro: asyncProjectionRebuildGroup :: ValidatedProjectionCatalog -> ProjectionId -> Text -> Maybe RebuildGroupId
+ Keiro: asyncProjectionRegistrations :: ValidatedProjectionCatalog -> [AsyncProjectionRegistration]
+ Keiro: catalogAsyncIdempotencyKeys :: ValidatedProjectionCatalog -> RebuildGroupId -> [CatalogAsyncDedupSpec]
+ Keiro: catalogExternalReadContracts :: ValidatedProjectionCatalog -> [ExternalReadContract]
+ Keiro: catalogFingerprint :: ValidatedProjectionCatalog -> CatalogFingerprint
+ Keiro: catalogFingerprintText :: CatalogFingerprint -> Text
+ Keiro: catalogInventory :: ValidatedProjectionCatalog -> CatalogInventory
+ Keiro: catalogProjectionRevision :: ValidatedProjectionCatalog -> ProjectionRevisionId -> Maybe ProjectionRevision
+ Keiro: catalogProjectionRevisions :: ValidatedProjectionCatalog -> [ProjectionRevision]
+ Keiro: catalogRebuildVerifications :: ValidatedProjectionCatalog -> RebuildGroupId -> [RebuildVerification]
+ Keiro: catalogRegistrations :: ValidatedProjectionCatalog -> [CatalogRegistration]
+ Keiro: catalogReplayAdapterGroupId :: CatalogReplayAdapter -> RebuildGroupId
+ Keiro: catalogReplayAdapterOrder :: CatalogReplayAdapter -> Int
+ Keiro: catalogReplayAdapterProjectionId :: CatalogReplayAdapter -> ProjectionId
+ Keiro: catalogReplayAdapterSourceId :: CatalogReplayAdapter -> SourceId
+ Keiro: catalogReplayAdapters :: ValidatedProjectionCatalog -> RebuildGroupId -> [CatalogReplayAdapter]
+ Keiro: catalogRevisionLiveHandler :: ValidatedProjectionCatalog -> ProjectionRevisionId -> RevisionLiveDelivery -> Maybe RevisionLiveHandler
+ Keiro: catalogStreamScopedReplay :: ValidatedProjectionCatalog -> ProjectionRevisionId -> ProjectionId -> Maybe StreamScopedReplay
+ Keiro: claimSiteText :: ClaimSite -> Text
+ Keiro: compareCatalogBaseline :: CatalogInventory -> CatalogInventory -> [CatalogEvolution]
+ Keiro: data AsyncProjectionRegistration
+ Keiro: data CatalogAsyncDedupSpec
+ Keiro: data CatalogDiagnostic
+ Keiro: data CatalogDiagnosticCode
+ Keiro: data CatalogEvolution
+ Keiro: data CatalogFingerprint
+ Keiro: data CatalogIdentityError
+ Keiro: data CatalogInventory
+ Keiro: data CatalogRegistration
+ Keiro: data CatalogReplayAdapter
+ Keiro: data ClaimSite
+ Keiro: data DedupKeyDeclaration
+ Keiro: data DedupKeyId
+ Keiro: data ExternalReadContract
+ Keiro: data ExternalReadContractId
+ Keiro: data ExternalReadContractKind
+ Keiro: data GroupSliceFingerprint
+ Keiro: data InventoryDedupKey
+ Keiro: data InventoryExternalReadContract
+ Keiro: data InventoryGroup
+ Keiro: data InventoryHandler
+ Keiro: data InventoryProjection
+ Keiro: data InventoryProjectionRevision
+ Keiro: data InventoryQueryCursor
+ Keiro: data InventoryQueryFreshness
+ Keiro: data InventoryQueryModel
+ Keiro: data InventoryRevisionHandler
+ Keiro: data InventorySource
+ Keiro: data InventoryStreamScopedReplay
+ Keiro: data InventorySubscription
+ Keiro: data InventoryTarget
+ Keiro: data InventoryTargetProvisioner
+ Keiro: data PhysicalTargetMapError
+ Keiro: data PhysicalTargets
+ Keiro: data ProjectionCatalog
+ Keiro: data ProjectionDefinition event
+ Keiro: data ProjectionHandler event
+ Keiro: data ProjectionHandlerCapability
+ Keiro: data ProjectionId
+ Keiro: data ProjectionReplayPolicy event
+ Keiro: data ProjectionRevision
+ Keiro: data ProjectionRevisionId
+ Keiro: data ProjectionSet event
+ Keiro: data PromotionObjectKind
+ Keiro: data PromotionObjectName
+ Keiro: data QualifiedFunction
+ Keiro: data QualifiedSqlType
+ Keiro: data QualifiedTable
+ Keiro: data QueryModelBinding q r
+ Keiro: data QueryModelId
+ Keiro: data RebuildGroupDeclaration
+ Keiro: data RebuildGroupId
+ Keiro: data RebuildVerification
+ Keiro: data ReplayAdapter event
+ Keiro: data ReplayAdapterMetadata
+ Keiro: data ReplayDecodeResult event
+ Keiro: data ResolvedQuerySupply
+ Keiro: data RevisionLiveDelivery
+ Keiro: data RevisionLiveHandler
+ Keiro: data RevisionReplayAdapter
+ Keiro: data RevisionVerification
+ Keiro: data SomeProjectionSet
+ Keiro: data SomeQueryModelBinding
+ Keiro: data SourceDeclaration
+ Keiro: data SourceId
+ Keiro: data SourceScope
+ Keiro: data SqlFunctionArgument
+ Keiro: data StreamClearCount
+ Keiro: data StreamScopedReplay
+ Keiro: data SubscriptionDeclaration
+ Keiro: data SubscriptionId
+ Keiro: data TargetDeclaration
+ Keiro: data TargetId
+ Keiro: data TargetProvisioner
+ Keiro: data TargetProvisioningContext
+ Keiro: data TargetResetPolicy
+ Keiro: data TargetSchemaEvidence
+ Keiro: data TargetSchemaViolation
+ Keiro: data UnmanagedAsyncProjection
+ Keiro: data UnmanagedInlineProjections event
+ Keiro: data UnmanagedReadModel q r
+ Keiro: data ValidatedProjectionCatalog
+ Keiro: data Validation err value
+ Keiro: dedupKeyIdText :: DedupKeyId -> Text
+ Keiro: diagnosticCodeText :: CatalogDiagnosticCode -> Text
+ Keiro: emptyProjectionCatalog :: ProjectionCatalog
+ Keiro: externalReadContractIdText :: ExternalReadContractId -> Text
+ Keiro: externalReadContractVersionValue :: ExternalReadContractVersion -> Int
+ Keiro: externalReadFunctionName :: ExternalReadContract -> Text
+ Keiro: getUnmanagedAsyncProjection :: UnmanagedAsyncProjection -> AsyncProjection
+ Keiro: getUnmanagedInlineProjections :: UnmanagedInlineProjections event -> [InlineProjection event]
+ Keiro: getUnmanagedReadModel :: UnmanagedReadModel q r -> ReadModel q r
+ Keiro: groupSliceFingerprint :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe GroupSliceFingerprint
+ Keiro: groupSliceFingerprintText :: GroupSliceFingerprint -> Text
+ Keiro: missingCheckpointPolicyText :: MissingCheckpointPolicy -> Text
+ Keiro: mkClaimSite :: Text -> Either CatalogIdentityError ClaimSite
+ Keiro: mkDedupKeyId :: Text -> Either CatalogIdentityError DedupKeyId
+ Keiro: mkExternalReadContractId :: Text -> Either CatalogIdentityError ExternalReadContractId
+ Keiro: mkPhysicalTargets :: [TargetId] -> Map TargetId QualifiedTable -> Either (NonEmpty PhysicalTargetMapError) PhysicalTargets
+ Keiro: mkProjectionId :: Text -> Either CatalogIdentityError ProjectionId
+ Keiro: mkProjectionRevisionId :: Text -> Either CatalogIdentityError ProjectionRevisionId
+ Keiro: mkQueryModelId :: Text -> Either CatalogIdentityError QueryModelId
+ Keiro: mkRebuildGroupId :: Text -> Either CatalogIdentityError RebuildGroupId
+ Keiro: mkSourceId :: Text -> Either CatalogIdentityError SourceId
+ Keiro: mkSubscriptionId :: Text -> Either CatalogIdentityError SubscriptionId
+ Keiro: mkTargetId :: Text -> Either CatalogIdentityError TargetId
+ Keiro: newtype ExternalReadContractVersion
+ Keiro: newtype LiveOnlyReason
+ Keiro: newtype ReplayDecodeError
+ Keiro: newtype TargetGenerationId
+ Keiro: newtype TargetSchemaVersion
+ Keiro: physicalTargetMap :: PhysicalTargets -> Map TargetId QualifiedTable
+ Keiro: projectionIdText :: ProjectionId -> Text
+ Keiro: projectionRevisionIdText :: ProjectionRevisionId -> Text
+ Keiro: queryModelIdText :: QueryModelId -> Text
+ Keiro: rebuildGroupIdText :: RebuildGroupId -> Text
+ Keiro: renderCatalogInventory :: ValidatedProjectionCatalog -> Text
+ Keiro: replayAdapterFromCodec :: Codec event -> (event -> RecordedEvent -> Transaction ()) -> ReplayAdapter event
+ Keiro: replayAdapterMetadata :: ValidatedProjectionCatalog -> [ReplayAdapterMetadata]
+ Keiro: resolvePhysicalTarget :: TargetId -> PhysicalTargets -> Maybe QualifiedTable
+ Keiro: resolvedQuerySupplies :: ValidatedProjectionCatalog -> [ResolvedQuerySupply]
+ Keiro: runCatalogReplayAdapter :: CatalogReplayAdapter -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool)
+ Keiro: sourceIdText :: SourceId -> Text
+ Keiro: subscriptionIdText :: SubscriptionId -> Text
+ Keiro: targetIdText :: TargetId -> Text
+ Keiro: typedInlineProjections :: ValidatedProjectionCatalog -> ProjectionSet event -> [InlineProjection event]
+ Keiro: typedInlineProjectionsForGroup :: ValidatedProjectionCatalog -> ProjectionSet event -> RebuildGroupId -> [InlineProjection event]
+ Keiro: typedProjectionRebuildGroups :: ValidatedProjectionCatalog -> ProjectionSet event -> [RebuildGroupId]
+ Keiro: unmanagedAsyncProjection :: AsyncProjection -> UnmanagedAsyncProjection
+ Keiro: unmanagedInlineProjections :: [InlineProjection event] -> UnmanagedInlineProjections event
+ Keiro: unmanagedReadModel :: ReadModel q r -> UnmanagedReadModel q r
+ Keiro: useProjectionCatalog :: ProjectionCatalog -> (ValidatedProjectionCatalog -> result) -> Validation (NonEmpty CatalogDiagnostic) result
+ Keiro: useProjectionCatalogM :: Monad effect => ProjectionCatalog -> (ValidatedProjectionCatalog -> effect result) -> effect (Validation (NonEmpty CatalogDiagnostic) result)
+ Keiro: validateProjectionCatalog :: ProjectionCatalog -> Validation (NonEmpty CatalogDiagnostic) ValidatedProjectionCatalog
+ Keiro.Command: CommitSqlTransaction :: !a -> SqlTransactionDecision a
+ Keiro.Command: DomainAccepted :: !NonEmpty co -> DomainDecision co rejection noOp
+ Keiro.Command: DomainCommandHandler :: !ValidatedEventStream phi rs s ci co -> !SilentCommandContext rs s ci -> SilentDomainDecision rejection noOp -> DomainCommandHandler phi (rs :: [Slot]) s ci co rejection noOp
+ Keiro.Command: DomainCommandOutcome :: !DomainDecision co rejection noOp -> !CommandResult target -> DomainCommandOutcome (target :: k) co rejection noOp
+ Keiro.Command: DomainNoOp :: !noOp -> DomainDecision co rejection noOp
+ Keiro.Command: DomainRejected :: !rejection -> DomainDecision co rejection noOp
+ Keiro.Command: DomainSqlCommandCommitted :: !DomainCommandOutcome target co rejection noOp -> !a -> DomainSqlCommandOutcome (target :: k) co rejection noOp a
+ Keiro.Command: DomainSqlCommandRolledBack :: !a -> DomainSqlCommandOutcome (target :: k) co rejection noOp a
+ Keiro.Command: DomainSqlCommandSilent :: !DomainCommandOutcome target co rejection noOp -> DomainSqlCommandOutcome (target :: k) co rejection noOp a
+ Keiro.Command: RollbackSqlTransaction :: !a -> SqlTransactionDecision a
+ Keiro.Command: SilentCommandContext :: !s -> !RegFile rs -> !ci -> !EdgeRef s -> SilentCommandContext (rs :: [Slot]) s ci
+ Keiro.Command: SilentNoOp :: !noOp -> SilentDomainDecision rejection noOp
+ Keiro.Command: SilentRejected :: !rejection -> SilentDomainDecision rejection noOp
+ Keiro.Command: SqlCommandCommitted :: !CommandResult target -> !a -> SqlCommandOutcome (target :: k) a
+ Keiro.Command: SqlCommandNoOp :: !CommandResult target -> SqlCommandOutcome (target :: k) a
+ Keiro.Command: SqlCommandRolledBack :: !a -> SqlCommandOutcome (target :: k) a
+ Keiro.Command: [classifySilent] :: DomainCommandHandler phi (rs :: [Slot]) s ci co rejection noOp -> !SilentCommandContext rs s ci -> SilentDomainDecision rejection noOp
+ Keiro.Command: [command] :: SilentCommandContext (rs :: [Slot]) s ci -> !ci
+ Keiro.Command: [decision] :: DomainCommandOutcome (target :: k) co rejection noOp -> !DomainDecision co rejection noOp
+ Keiro.Command: [eventStream] :: DomainCommandHandler phi (rs :: [Slot]) s ci co rejection noOp -> !ValidatedEventStream phi rs s ci co
+ Keiro.Command: [result] :: DomainCommandOutcome (target :: k) co rejection noOp -> !CommandResult target
+ Keiro.Command: [selectedEdge] :: SilentCommandContext (rs :: [Slot]) s ci -> !EdgeRef s
+ Keiro.Command: data DomainCommandHandler phi (rs :: [Slot]) s ci co rejection noOp
+ Keiro.Command: data DomainCommandOutcome (target :: k) co rejection noOp
+ Keiro.Command: data DomainDecision co rejection noOp
+ Keiro.Command: data DomainSqlCommandOutcome (target :: k) co rejection noOp a
+ Keiro.Command: data SilentCommandContext (rs :: [Slot]) s ci
+ Keiro.Command: data SilentDomainDecision rejection noOp
+ Keiro.Command: data SqlCommandOutcome (target :: k) a
+ Keiro.Command: data SqlTransactionDecision a
+ Keiro.Command: forgetDomainDecision :: forall {k} (target :: k) co rejection noOp. DomainCommandOutcome target co rejection noOp -> CommandResult target
+ Keiro.Command: instance (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp) => GHC.Classes.Eq (Keiro.Command.DomainDecision co rejection noOp)
+ Keiro.Command: instance (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp) => GHC.Internal.Show.Show (Keiro.Command.DomainDecision co rejection noOp)
+ Keiro.Command: instance GHC.Classes.Eq a => GHC.Classes.Eq (Keiro.Command.SqlTransactionDecision a)
+ Keiro.Command: instance GHC.Internal.Generics.Generic (Keiro.Command.DomainCommandHandler phi rs s ci co rejection noOp)
+ Keiro.Command: instance GHC.Internal.Generics.Generic (Keiro.Command.DomainDecision co rejection noOp)
+ Keiro.Command: instance GHC.Internal.Generics.Generic (Keiro.Command.SqlTransactionDecision a)
+ Keiro.Command: instance GHC.Internal.Show.Show a => GHC.Internal.Show.Show (Keiro.Command.SqlTransactionDecision a)
+ Keiro.Command: instance forall k (target :: k) (rs :: [Keiki.Core.Slot]) s co rejection noOp. GHC.Internal.Generics.Generic (Keiro.Command.DomainCommandPlan target rs s co rejection noOp)
+ Keiro.Command: instance forall k (target :: k) a. GHC.Classes.Eq a => GHC.Classes.Eq (Keiro.Command.SqlCommandOutcome target a)
+ Keiro.Command: instance forall k (target :: k) a. GHC.Internal.Generics.Generic (Keiro.Command.SqlCommandOutcome target a)
+ Keiro.Command: instance forall k (target :: k) a. GHC.Internal.Show.Show a => GHC.Internal.Show.Show (Keiro.Command.SqlCommandOutcome target a)
+ Keiro.Command: instance forall k (target :: k) co rejection noOp a. (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp, GHC.Classes.Eq a) => GHC.Classes.Eq (Keiro.Command.DomainSqlCommandOutcome target co rejection noOp a)
+ Keiro.Command: instance forall k (target :: k) co rejection noOp a. (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp, GHC.Internal.Show.Show a) => GHC.Internal.Show.Show (Keiro.Command.DomainSqlCommandOutcome target co rejection noOp a)
+ Keiro.Command: instance forall k (target :: k) co rejection noOp a. GHC.Internal.Generics.Generic (Keiro.Command.DomainSqlCommandOutcome target co rejection noOp a)
+ Keiro.Command: instance forall k (target :: k) co rejection noOp. (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp) => GHC.Classes.Eq (Keiro.Command.DomainCommandOutcome target co rejection noOp)
+ Keiro.Command: instance forall k (target :: k) co rejection noOp. (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp) => GHC.Internal.Show.Show (Keiro.Command.DomainCommandOutcome target co rejection noOp)
+ Keiro.Command: instance forall k (target :: k) co rejection noOp. GHC.Internal.Generics.Generic (Keiro.Command.DomainCommandOutcome target co rejection noOp)
+ Keiro.Command: runCommandWithSqlEventsControlled :: forall phi (rs :: [Slot]) s ci co a (es :: [Effect]). (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 -> Transaction (SqlTransactionDecision a)) -> Eff es (Either CommandError (SqlCommandOutcome (EventStream phi rs s ci co) a))
+ Keiro.Command: runDomainCommand :: forall phi (rs :: [Slot]) s ci co rejection noOp (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> DomainCommandHandler phi rs s ci co rejection noOp -> Stream (EventStream phi rs s ci co) -> ci -> Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp))
+ Keiro.Command: runDomainCommandWithSql :: forall phi (rs :: [Slot]) s ci co rejection noOp a (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> DomainCommandHandler phi rs s ci co rejection noOp -> Stream (EventStream phi rs s ci co) -> ci -> (AppendResult -> Transaction a) -> Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp, Maybe a))
+ Keiro.Command: runDomainCommandWithSqlEvents :: forall phi (rs :: [Slot]) s ci co rejection noOp a (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> DomainCommandHandler phi rs s ci co rejection noOp -> Stream (EventStream phi rs s ci co) -> ci -> ([(co, RecordedEvent)] -> AppendResult -> Transaction a) -> Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp, Maybe a))
+ Keiro.Command: runDomainCommandWithSqlEventsControlled :: forall phi (rs :: [Slot]) s ci co rejection noOp a (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> DomainCommandHandler phi rs s ci co rejection noOp -> Stream (EventStream phi rs s ci co) -> ci -> ([(co, RecordedEvent)] -> AppendResult -> Transaction (SqlTransactionDecision a)) -> Eff es (Either CommandError (DomainSqlCommandOutcome (EventStream phi rs s ci co) co rejection noOp a))
+ Keiro.DeterministicId: deterministicIdProbes :: Text -> NonEmpty UUID
+ Keiro.DeterministicId: identitySeedBytes :: Text -> [Word8]
+ Keiro.DeterministicId: legacySeedBytes :: Text -> [Word8]
+ Keiro.DeterministicId: seedMovedAcrossEncodings :: Text -> Bool
+ Keiro.Inbox: listCompletedInboxGcCandidates :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [InboxRow]
+ Keiro.Inbox.Schema: listCompletedInboxGcCandidates :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [InboxRow]
+ Keiro.Outbox: listSentOutboxGcCandidates :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow]
+ Keiro.Outbox: listStuckOutbox :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow]
+ Keiro.Outbox.Schema: listSentOutboxGcCandidates :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow]
+ Keiro.Outbox.Schema: listStuckOutbox :: forall (es :: [Effect]). Store :> es => NominalDiffTime -> UTCTime -> Eff es [OutboxRow]
+ Keiro.ProcessManager: DomainDispatchSummary :: !Int64 -> ![DispatchFailure] -> DomainDispatchSummary
+ Keiro.ProcessManager: DomainPMCommandDuplicate :: !EventId -> DomainPMCommandResult (target :: k) co rejection noOp
+ Keiro.ProcessManager: DomainPMCommandFailed :: !StreamName -> !CommandError -> DomainPMCommandResult (target :: k) co rejection noOp
+ Keiro.ProcessManager: DomainPMCommandHandled :: !DomainCommandOutcome target co rejection noOp -> DomainPMCommandResult (target :: k) co rejection noOp
+ Keiro.ProcessManager: DomainProcessManager :: !Text -> !input -> Text -> !ValidatedEventStream phi rs s ci co -> !Text -> Stream (EventStream phi rs s ci co) -> !DomainCommandHandler targetPhi targetRs targetState targetCi targetCo rejection noOp -> !Stream targetCi -> [InlineProjection targetCo] -> !input -> ProcessManagerAction ci targetCi -> DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp
+ Keiro.ProcessManager: DomainProcessManagerResult :: !PMStateResult managerTarget -> ![DomainPMCommandResult commandTarget co rejection noOp] -> !Int -> DomainProcessManagerResult (managerTarget :: k) (commandTarget :: k1) co rejection noOp
+ Keiro.ProcessManager: [duplicates] :: DomainDispatchSummary -> !Int64
+ Keiro.ProcessManager: [failures] :: DomainDispatchSummary -> ![DispatchFailure]
+ Keiro.ProcessManager: [targetHandler] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !DomainCommandHandler targetPhi targetRs targetState targetCi targetCo rejection noOp
+ Keiro.ProcessManager: ackForDomainSummary :: forall (es :: [Effect]) msg. (IOE :> es, Store :> es) => WorkerOptions es msg -> DispatcherKind -> Text -> Text -> RecordedEvent -> Int -> Int64 -> DomainDispatchSummary -> Eff es AckDecision
+ Keiro.ProcessManager: data DomainDispatchSummary
+ Keiro.ProcessManager: data DomainPMCommandResult (target :: k) co rejection noOp
+ Keiro.ProcessManager: data DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp
+ Keiro.ProcessManager: data DomainProcessManagerResult (managerTarget :: k) (commandTarget :: k1) co rejection noOp
+ Keiro.ProcessManager: deterministicCommandIdProbes :: Text -> Text -> EventId -> Int -> NonEmpty EventId
+ Keiro.ProcessManager: dispatchDeduplicatedCommand :: forall (es :: [Effect]) result value. Store :> es => RunCommandOptions -> StreamName -> NonEmpty EventId -> (EventId -> result) -> (CommandError -> result) -> (value -> result) -> Eff es (Either CommandError value) -> Eff es result
+ Keiro.ProcessManager: firstExistingEventId :: forall (es :: [Effect]). Store :> es => RunCommandOptions -> StreamName -> NonEmpty EventId -> Eff es (Maybe EventId)
+ Keiro.ProcessManager: instance GHC.Classes.Eq Keiro.ProcessManager.DomainDispatchSummary
+ Keiro.ProcessManager: instance GHC.Internal.Generics.Generic (Keiro.ProcessManager.DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp)
+ Keiro.ProcessManager: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.DomainDispatchSummary
+ Keiro.ProcessManager: instance GHC.Internal.Show.Show Keiro.ProcessManager.DomainDispatchSummary
+ Keiro.ProcessManager: instance forall k (target :: k) co rejection noOp. (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp) => GHC.Classes.Eq (Keiro.ProcessManager.DomainPMCommandResult target co rejection noOp)
+ Keiro.ProcessManager: instance forall k (target :: k) co rejection noOp. (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp) => GHC.Internal.Show.Show (Keiro.ProcessManager.DomainPMCommandResult target co rejection noOp)
+ Keiro.ProcessManager: instance forall k (target :: k) co rejection noOp. GHC.Internal.Generics.Generic (Keiro.ProcessManager.DomainPMCommandResult target co rejection noOp)
+ Keiro.ProcessManager: instance forall k1 (managerTarget :: k1) k2 (commandTarget :: k2) co rejection noOp. (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp) => GHC.Classes.Eq (Keiro.ProcessManager.DomainProcessManagerResult managerTarget commandTarget co rejection noOp)
+ Keiro.ProcessManager: instance forall k1 (managerTarget :: k1) k2 (commandTarget :: k2) co rejection noOp. (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp) => GHC.Internal.Show.Show (Keiro.ProcessManager.DomainProcessManagerResult managerTarget commandTarget co rejection noOp)
+ Keiro.ProcessManager: instance forall k1 (managerTarget :: k1) k2 (commandTarget :: k2) co rejection noOp. GHC.Internal.Generics.Generic (Keiro.ProcessManager.DomainProcessManagerResult managerTarget commandTarget co rejection noOp)
+ Keiro.ProcessManager: legacyDeterministicCommandId :: Text -> Text -> EventId -> Int -> EventId
+ Keiro.ProcessManager: runDomainProcessManagerOnce :: forall input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]). (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 -> DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp -> RecordedEvent -> input -> Eff es (Either CommandError (DomainProcessManagerResult (EventStream phi rs s ci co) (EventStream targetPhi targetRs targetState targetCi targetCo) targetCo rejection noOp))
+ Keiro.ProcessManager: runDomainProcessManagerWorker :: forall msg input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]). (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 -> DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp -> Adapter es msg -> (msg -> Maybe (RecordedEvent, input)) -> Eff es ()
+ Keiro.ProcessManager: runDomainProcessManagerWorkerWith :: forall msg input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]). (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 -> DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp -> Adapter es msg -> (msg -> Maybe (RecordedEvent, input)) -> Eff es ()
+ Keiro.ProcessManager: summarizeDomainCommandResult :: forall {k} (target :: k) co rejection noOp. Int -> DomainPMCommandResult target co rejection noOp -> DomainDispatchSummary -> DomainDispatchSummary
+ Keiro.Projection: CatalogAsyncApplied :: CatalogAsyncApplyOutcome
+ Keiro.Projection: CatalogAsyncDuplicate :: CatalogAsyncApplyOutcome
+ Keiro.Projection: CatalogAsyncFenced :: !RebuildGroupId -> !RebuildRunId -> CatalogAsyncApplyOutcome
+ Keiro.Projection: CatalogAsyncGroupUnregistered :: !RebuildGroupId -> CatalogAsyncApplyOutcome
+ Keiro.Projection: CatalogAsyncProjectionUnknown :: !ProjectionId -> CatalogAsyncApplyOutcome
+ Keiro.Projection: CatalogAsyncServingBindingInvalid :: !RebuildGroupId -> !ProjectionRevisionId -> !Text -> CatalogAsyncApplyOutcome
+ Keiro.Projection: CatalogAsyncServingRevisionUnavailable :: !RebuildGroupId -> !ProjectionRevisionId -> CatalogAsyncApplyOutcome
+ Keiro.Projection: DomainProjectionCommandApplied :: !DomainCommandOutcome target co rejection noOp -> DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: DomainProjectionCommandCatalogMismatch :: !SourceId -> DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: DomainProjectionCommandFenced :: !RebuildGroupId -> !RebuildRunId -> DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: DomainProjectionCommandGroupUnregistered :: !RebuildGroupId -> DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: DomainProjectionCommandServingBindingInvalid :: !RebuildGroupId -> !ProjectionRevisionId -> !Text -> DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: DomainProjectionCommandServingRevisionUnavailable :: !RebuildGroupId -> !ProjectionRevisionId -> DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: ProjectionCommandApplied :: !CommandResult target -> ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: ProjectionCommandCatalogMismatch :: !SourceId -> ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: ProjectionCommandFenced :: !RebuildGroupId -> !RebuildRunId -> ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: ProjectionCommandGroupUnregistered :: !RebuildGroupId -> ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: ProjectionCommandServingBindingInvalid :: !RebuildGroupId -> !ProjectionRevisionId -> !Text -> ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: ProjectionCommandServingRevisionUnavailable :: !RebuildGroupId -> !ProjectionRevisionId -> ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: applyAsyncProjectionFromCatalog :: ValidatedProjectionCatalog -> ProjectionId -> AsyncProjection -> RecordedEvent -> Transaction CatalogAsyncApplyOutcome
+ Keiro.Projection: countAsyncProjectionDedupForBefore :: forall (es :: [Effect]). Store :> es => Text -> UTCTime -> Eff es Int64
+ Keiro.Projection: data CatalogAsyncApplyOutcome
+ Keiro.Projection: data DomainProjectionCommandOutcome (target :: k) co rejection noOp
+ Keiro.Projection: data ProjectionCommandOutcome (target :: k)
+ Keiro.Projection: instance GHC.Classes.Eq Keiro.Projection.CatalogAsyncApplyOutcome
+ Keiro.Projection: instance GHC.Internal.Generics.Generic Keiro.Projection.CatalogAsyncApplyOutcome
+ Keiro.Projection: instance GHC.Internal.Show.Show Keiro.Projection.CatalogAsyncApplyOutcome
+ Keiro.Projection: instance forall k (target :: k) co rejection noOp. (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp) => GHC.Classes.Eq (Keiro.Projection.DomainProjectionCommandOutcome target co rejection noOp)
+ Keiro.Projection: instance forall k (target :: k) co rejection noOp. (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp) => GHC.Internal.Show.Show (Keiro.Projection.DomainProjectionCommandOutcome target co rejection noOp)
+ Keiro.Projection: instance forall k (target :: k) co rejection noOp. GHC.Internal.Generics.Generic (Keiro.Projection.DomainProjectionCommandOutcome target co rejection noOp)
+ Keiro.Projection: instance forall k (target :: k). GHC.Classes.Eq (Keiro.Projection.ProjectionCommandOutcome target)
+ Keiro.Projection: instance forall k (target :: k). GHC.Internal.Generics.Generic (Keiro.Projection.ProjectionCommandOutcome target)
+ Keiro.Projection: instance forall k (target :: k). GHC.Internal.Show.Show (Keiro.Projection.ProjectionCommandOutcome target)
+ Keiro.Projection: pruneAsyncProjectionDedupForBefore :: forall (es :: [Effect]). Store :> es => Text -> UTCTime -> Eff es Int64
+ Keiro.Projection: recordProjectionGlobalPositionDistance :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Maybe KeiroMetrics -> AsyncProjection -> Eff es ()
+ Keiro.Projection: runCommandWithCatalogProjections :: forall phi (rs :: [Slot]) s ci co (es :: [Effect]). (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 -> ValidatedProjectionCatalog -> ProjectionSet co -> Eff es (Either CommandError (ProjectionCommandOutcome (EventStream phi rs s ci co)))
+ Keiro.Projection: runDomainCommandWithCatalogProjections :: forall phi (rs :: [Slot]) s ci co rejection noOp (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> DomainCommandHandler phi rs s ci co rejection noOp -> Stream (EventStream phi rs s ci co) -> ci -> ValidatedProjectionCatalog -> ProjectionSet co -> Eff es (Either CommandError (DomainProjectionCommandOutcome (EventStream phi rs s ci co) co rejection noOp))
+ Keiro.Projection: runDomainCommandWithProjections :: forall phi (rs :: [Slot]) s ci co rejection noOp (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> DomainCommandHandler phi rs s ci co rejection noOp -> Stream (EventStream phi rs s ci co) -> ci -> [InlineProjection co] -> Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp))
+ Keiro.Projection.Catalog: AllRowsExternalRead :: !ExternalReadContractId -> !ExternalReadContractVersion -> !QueryModelId -> !QualifiedSqlType -> !Text -> !NonEmpty ProjectionRevisionId -> !Int -> !ClaimSite -> ExternalReadContract
+ Keiro.Projection.Catalog: AllStreams :: SourceScope
+ Keiro.Projection.Catalog: AmbiguousSourceOrdering :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: AsyncHandler :: !AsyncProjection -> !SubscriptionId -> !DedupKeyId -> !ClaimSite -> ProjectionHandler event
+ Keiro.Projection.Catalog: AsyncHandlerDedupMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: AsyncHandlerSubscriptionMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: AsyncProjectionRegistration :: !ProjectionId -> !Text -> !SubscriptionId -> !Text -> !MissingCheckpointPolicy -> !DedupKeyId -> !Text -> AsyncProjectionRegistration
+ Keiro.Projection.Catalog: CatalogAsyncDedupSpec :: !DedupKeyId -> !Text -> !Text -> !SourceId -> !SourceScope -> !RecordedEvent -> EventId -> CatalogAsyncDedupSpec
+ Keiro.Projection.Catalog: CatalogDiagnostic :: !CatalogDiagnosticCode -> !Text -> ![ClaimSite] -> !Text -> CatalogDiagnostic
+ Keiro.Projection.Catalog: CatalogIdentityHasSurroundingWhitespace :: !Text -> CatalogIdentityError
+ Keiro.Projection.Catalog: CatalogInventory :: ![InventorySource] -> ![InventoryTarget] -> ![InventoryGroup] -> ![InventoryProjection] -> ![InventoryProjectionRevision] -> ![InventoryExternalReadContract] -> ![InventoryQueryModel] -> ![InventorySubscription] -> ![InventoryDedupKey] -> CatalogInventory
+ Keiro.Projection.Catalog: CatalogRegistration :: !QueryModelId -> !Text -> !Int -> !Text -> !RebuildGroupId -> CatalogRegistration
+ Keiro.Projection.Catalog: CategorySource :: !CategoryName -> SourceScope
+ Keiro.Projection.Catalog: ClearBeforeReplay :: TargetResetPolicy
+ Keiro.Projection.Catalog: ClearTargetRequiresReplayableOwner :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DedupKeyDeclaration :: !DedupKeyId -> !Text -> !ClaimSite -> DedupKeyDeclaration
+ Keiro.Projection.Catalog: DedupKeyRemoved :: !DedupKeyId -> CatalogEvolution
+ Keiro.Projection.Catalog: DuplicateDedupKeyId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateDedupName :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateExternalReadContractVersion :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateExternalReadFunctionName :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateGroupTarget :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateProjectionId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateProjectionRevisionId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateQualifiedTable :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateQueryModelId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateQueryModelRegistryName :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateRebuildGroupId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateRebuildVerificationId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateSourceId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateStreamScopedReplayProjection :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateSubscriptionId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateSubscriptionName :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: DuplicateTargetId :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: EmptyCatalogIdentity :: CatalogIdentityError
+ Keiro.Projection.Catalog: EmptyQueryObservedTargets :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: EmptyRebuildGroup :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ExternalReadContractRemoved :: !ExternalReadContractId -> !ExternalReadContractVersion -> CatalogEvolution
+ Keiro.Projection.Catalog: ExternalReadContractVersion :: Int -> ExternalReadContractVersion
+ Keiro.Projection.Catalog: ExternalReadImmutableSignatureDrift :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ExternalReadImplementationCollision :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ExternalReadRevisionOwnershipMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ExternalReadShapeMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ExternalReadSurfaceGenerationRegression :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: Failure :: !err -> Validation err value
+ Keiro.Projection.Catalog: InlineCapability :: !Text -> ProjectionHandlerCapability
+ Keiro.Projection.Catalog: InlineHandler :: !InlineProjection event -> !ClaimSite -> ProjectionHandler event
+ Keiro.Projection.Catalog: InvalidExternalReadContractIdentity :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: InvalidExternalReadSqlIdentifier :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: InvalidExternalReadSqlType :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: InvalidProjectionRevisionIdentity :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: InvalidRebuildVerificationIdentity :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: InvalidStreamScopedReplayIdentity :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: InventoryAllRowsExternalRead :: ExternalReadContractKind
+ Keiro.Projection.Catalog: InventoryAsyncHandler :: !Text -> !SubscriptionId -> !DedupKeyId -> InventoryHandler
+ Keiro.Projection.Catalog: InventoryDedupKey :: !DedupKeyId -> !Text -> InventoryDedupKey
+ Keiro.Projection.Catalog: InventoryExternalReadContract :: !ExternalReadContractId -> !ExternalReadContractVersion -> !QueryModelId -> !RebuildGroupId -> !Text -> !ExternalReadContractKind -> ![SqlFunctionArgument] -> !QualifiedSqlType -> !Maybe QualifiedFunction -> !Maybe Int -> !Text -> !NonEmpty ProjectionRevisionId -> !Int -> InventoryExternalReadContract
+ Keiro.Projection.Catalog: InventoryGroup :: !RebuildGroupId -> ![TargetId] -> ![(Text, Text)] -> InventoryGroup
+ Keiro.Projection.Catalog: InventoryImmediate :: InventoryQueryFreshness
+ Keiro.Projection.Catalog: InventoryInlineHandler :: !Text -> InventoryHandler
+ Keiro.Projection.Catalog: InventoryKeyedExternalRead :: ExternalReadContractKind
+ Keiro.Projection.Catalog: InventoryProjection :: !ProjectionId -> !SourceId -> !RebuildGroupId -> ![TargetId] -> !Text -> ![InventoryHandler] -> InventoryProjection
+ Keiro.Projection.Catalog: InventoryProjectionRevision :: !ProjectionRevisionId -> !RebuildGroupId -> ![InventoryTargetProvisioner] -> ![InventoryRevisionHandler] -> ![InventoryRevisionHandler] -> ![InventoryRevisionHandler] -> ![InventoryStreamScopedReplay] -> InventoryProjectionRevision
+ Keiro.Projection.Catalog: InventoryQueryCursor :: !SubscriptionId -> !Text -> InventoryQueryCursor
+ Keiro.Projection.Catalog: InventoryQueryModel :: !QueryModelId -> !Text -> !Int -> !Text -> !RebuildGroupId -> ![TargetId] -> !InventoryQueryFreshness -> !Maybe InventoryQueryCursor -> InventoryQueryModel
+ Keiro.Projection.Catalog: InventoryRevisionHandler :: !Text -> !Int -> !Maybe RevisionLiveDelivery -> ![TargetId] -> InventoryRevisionHandler
+ Keiro.Projection.Catalog: InventorySource :: !SourceId -> !SourceScope -> !Text -> InventorySource
+ Keiro.Projection.Catalog: InventoryStreamScopedReplay :: !ProjectionId -> ![TargetId] -> !Text -> !Int -> !Text -> !Int -> !Text -> !Int -> ![DedupKeyId] -> InventoryStreamScopedReplay
+ Keiro.Projection.Catalog: InventorySubscription :: !SubscriptionId -> !Text -> !SourceId -> !MissingCheckpointPolicy -> InventorySubscription
+ Keiro.Projection.Catalog: InventoryTarget :: !TargetId -> !QualifiedTable -> !TargetResetPolicy -> ![TargetId] -> !ProjectionId -> InventoryTarget
+ Keiro.Projection.Catalog: InventoryTargetProvisioner :: !TargetId -> !Text -> !Int -> !TargetSchemaVersion -> !Text -> !Text -> !Int -> ![PromotionObjectName] -> InventoryTargetProvisioner
+ Keiro.Projection.Catalog: InventoryWaitForHead :: !HeadScope -> InventoryQueryFreshness
+ Keiro.Projection.Catalog: InventoryWaitForPosition :: InventoryQueryFreshness
+ Keiro.Projection.Catalog: KeyedExternalRead :: !ExternalReadContractId -> !ExternalReadContractVersion -> !QueryModelId -> ![SqlFunctionArgument] -> !QualifiedSqlType -> !QualifiedFunction -> !Int -> !Text -> !NonEmpty ProjectionRevisionId -> !Int -> !ClaimSite -> ExternalReadContract
+ Keiro.Projection.Catalog: LiveOnly :: !LiveOnlyReason -> ProjectionReplayPolicy event
+ Keiro.Projection.Catalog: LiveOnlyReason :: Text -> LiveOnlyReason
+ Keiro.Projection.Catalog: MissingPhysicalTarget :: !TargetId -> PhysicalTargetMapError
+ Keiro.Projection.Catalog: MixedResetGroupRequiresReplayAdapter :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: PreserveAndReconcile :: TargetResetPolicy
+ Keiro.Projection.Catalog: ProjectionCatalog :: ![SourceDeclaration] -> ![TargetDeclaration] -> ![RebuildGroupDeclaration] -> ![ProjectionRevision] -> ![ExternalReadContract] -> ![SubscriptionDeclaration] -> ![DedupKeyDeclaration] -> ![SomeQueryModelBinding] -> ![SomeProjectionSet] -> ProjectionCatalog
+ Keiro.Projection.Catalog: ProjectionCrossesRebuildGroups :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionDefinition :: !ProjectionId -> !RebuildGroupId -> !NonEmpty TargetId -> !ProjectionReplayPolicy event -> !NonEmpty (ProjectionHandler event) -> !ClaimSite -> ProjectionDefinition event
+ Keiro.Projection.Catalog: ProjectionRemoved :: !ProjectionId -> CatalogEvolution
+ Keiro.Projection.Catalog: ProjectionRevision :: !ProjectionRevisionId -> !RebuildGroupId -> !Map TargetId TargetProvisioner -> ![RevisionLiveHandler] -> ![RevisionReplayAdapter] -> ![RevisionVerification] -> ![StreamScopedReplay] -> !ClaimSite -> ProjectionRevision
+ Keiro.Projection.Catalog: ProjectionRevisionLiveCapabilityMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionRevisionLiveTargetOwnershipMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionRevisionMissingSchemaValidation :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionRevisionPhysicalTargetsNotTotal :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionRevisionRemoved :: !ProjectionRevisionId -> CatalogEvolution
+ Keiro.Projection.Catalog: ProjectionRevisionTargetSetDrift :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionRevisionWithoutLiveHandler :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionRevisionWithoutReplayAdapter :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ProjectionSet :: !SourceId -> !NonEmpty (ProjectionDefinition event) -> !ClaimSite -> ProjectionSet event
+ Keiro.Projection.Catalog: PromotionConstraint :: PromotionObjectKind
+ Keiro.Projection.Catalog: PromotionIndex :: PromotionObjectKind
+ Keiro.Projection.Catalog: PromotionObjectName :: !PromotionObjectKind -> !Text -> !Text -> PromotionObjectName
+ Keiro.Projection.Catalog: PromotionOwnedSequence :: PromotionObjectKind
+ Keiro.Projection.Catalog: QualifiedFunction :: !Text -> !Text -> QualifiedFunction
+ Keiro.Projection.Catalog: QualifiedSqlType :: !Text -> !Text -> QualifiedSqlType
+ Keiro.Projection.Catalog: QualifiedTable :: !Text -> !Text -> QualifiedTable
+ Keiro.Projection.Catalog: QueryModelBinding :: !QueryModelId -> !ReadModel q r -> !RebuildGroupId -> ![TargetId] -> !ClaimSite -> QueryModelBinding q r
+ Keiro.Projection.Catalog: QueryModelOutsideRebuildGroup :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: QueryModelRemoved :: !QueryModelId -> CatalogEvolution
+ Keiro.Projection.Catalog: QueryModelWithMultipleSuppliers :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: QueryModelWithoutSupplier :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: QueryWaitWithAmbiguousCursor :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: QueryWaitWithoutCompatibleCursor :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: RebuildGroupDeclaration :: !RebuildGroupId -> ![TargetId] -> ![RebuildVerification] -> !ClaimSite -> RebuildGroupDeclaration
+ Keiro.Projection.Catalog: RebuildGroupRemoved :: !RebuildGroupId -> CatalogEvolution
+ Keiro.Projection.Catalog: RebuildVerification :: !Text -> !Text -> !Transaction (Either Text ()) -> RebuildVerification
+ Keiro.Projection.Catalog: ReplayAdapter :: !RecordedEvent -> ReplayDecodeResult event -> !event -> RecordedEvent -> Transaction () -> ReplayAdapter event
+ Keiro.Projection.Catalog: ReplayAdapterMetadata :: !ProjectionId -> !SourceId -> !RebuildGroupId -> !Bool -> ReplayAdapterMetadata
+ Keiro.Projection.Catalog: ReplayDecodeError :: Text -> ReplayDecodeError
+ Keiro.Projection.Catalog: ReplayDecodeFailure :: !ReplayDecodeError -> ReplayDecodeResult event
+ Keiro.Projection.Catalog: ReplayIrrelevant :: ReplayDecodeResult event
+ Keiro.Projection.Catalog: ReplayRelevant :: !event -> ReplayDecodeResult event
+ Keiro.Projection.Catalog: Replayable :: !ReplayAdapter event -> ProjectionReplayPolicy event
+ Keiro.Projection.Catalog: ReplayableClearTargetStartsAtCurrentHead :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: ResolvedQuerySupply :: !QueryModelId -> !ProjectionId -> !RebuildGroupId -> !NonEmpty TargetId -> !SourceId -> !NonEmpty ProjectionHandlerCapability -> !InventoryQueryFreshness -> !Maybe InventoryQueryCursor -> ResolvedQuerySupply
+ Keiro.Projection.Catalog: RevisionInlineDelivery :: !ProjectionId -> !Text -> RevisionLiveDelivery
+ Keiro.Projection.Catalog: RevisionLiveHandler :: !Text -> !Int -> !RevisionLiveDelivery -> ![TargetId] -> !PhysicalTargets -> RecordedEvent -> Transaction () -> RevisionLiveHandler
+ Keiro.Projection.Catalog: RevisionReplayAdapter :: !Text -> !Int -> ![TargetId] -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool) -> RevisionReplayAdapter
+ Keiro.Projection.Catalog: RevisionSubscriptionDelivery :: !ProjectionId -> !SubscriptionId -> !DedupKeyId -> RevisionLiveDelivery
+ Keiro.Projection.Catalog: RevisionVerification :: !Text -> !Int -> ![TargetId] -> !PhysicalTargets -> Transaction (Either Text ()) -> RevisionVerification
+ Keiro.Projection.Catalog: SomeProjectionSet :: ProjectionSet event -> SomeProjectionSet
+ Keiro.Projection.Catalog: SomeQueryModelBinding :: QueryModelBinding q r -> SomeQueryModelBinding
+ Keiro.Projection.Catalog: SourceDeclaration :: !SourceId -> !SourceScope -> !Text -> !ClaimSite -> SourceDeclaration
+ Keiro.Projection.Catalog: SourceRemoved :: !SourceId -> CatalogEvolution
+ Keiro.Projection.Catalog: SqlFunctionArgument :: !Text -> !QualifiedSqlType -> SqlFunctionArgument
+ Keiro.Projection.Catalog: StreamClearCount :: !TargetId -> !Int64 -> StreamClearCount
+ Keiro.Projection.Catalog: StreamScopedReplay :: !ProjectionId -> !NonEmpty TargetId -> !Text -> !Int -> !PhysicalTargets -> StreamName -> Transaction (Either Text [StreamClearCount]) -> !Text -> !Int -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool) -> !Text -> !Int -> !PhysicalTargets -> StreamName -> Transaction (Either Text ()) -> ![DedupKeyId] -> !ClaimSite -> StreamScopedReplay
+ Keiro.Projection.Catalog: StreamScopedReplayDedupMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: StreamScopedReplayGroupMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: StreamScopedReplayTargetSetMismatch :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: SubscriptionCapability :: !Text -> !SubscriptionId -> !Text -> !SourceId -> !MissingCheckpointPolicy -> !DedupKeyId -> !Text -> ProjectionHandlerCapability
+ Keiro.Projection.Catalog: SubscriptionDeclaration :: !SubscriptionId -> !Text -> !SourceId -> !MissingCheckpointPolicy -> !ClaimSite -> SubscriptionDeclaration
+ Keiro.Projection.Catalog: SubscriptionRemoved :: !SubscriptionId -> CatalogEvolution
+ Keiro.Projection.Catalog: Success :: !value -> Validation err value
+ Keiro.Projection.Catalog: TargetDeclaration :: !TargetId -> !QualifiedTable -> !TargetResetPolicy -> ![TargetId] -> !ClaimSite -> TargetDeclaration
+ Keiro.Projection.Catalog: TargetDependencyCycle :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: TargetGenerationId :: UUID -> TargetGenerationId
+ Keiro.Projection.Catalog: TargetGroupChanged :: !TargetId -> !RebuildGroupId -> !RebuildGroupId -> CatalogEvolution
+ Keiro.Projection.Catalog: TargetOrderViolatesDependency :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: TargetOwnerChanged :: !TargetId -> !ProjectionId -> !ProjectionId -> CatalogEvolution
+ Keiro.Projection.Catalog: TargetProvisioner :: !Text -> !Int -> !TargetSchemaVersion -> !Text -> !TargetProvisioningContext -> Transaction () -> !Text -> !Int -> !Maybe (TargetProvisioningContext -> Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence)) -> ![PromotionObjectName] -> TargetProvisioner
+ Keiro.Projection.Catalog: TargetProvisioningContext :: !TargetId -> !TargetGenerationId -> !QualifiedTable -> !QualifiedTable -> TargetProvisioningContext
+ Keiro.Projection.Catalog: TargetRemoved :: !TargetId -> CatalogEvolution
+ Keiro.Projection.Catalog: TargetSchemaEvidence :: !Int64 -> !Text -> ![PromotionObjectName] -> !Text -> TargetSchemaEvidence
+ Keiro.Projection.Catalog: TargetSchemaVersion :: Text -> TargetSchemaVersion
+ Keiro.Projection.Catalog: TargetSchemaViolation :: !Text -> !Text -> TargetSchemaViolation
+ Keiro.Projection.Catalog: TargetWithMultipleOwners :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: TargetWithoutOwner :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnexpectedPhysicalTarget :: !TargetId -> PhysicalTargetMapError
+ Keiro.Projection.Catalog: UnknownDedupKeyReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownExternalReadQueryModel :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownGroupReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownQueryModelReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownRevisionReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownSourceReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownStreamScopedReplayProjection :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownSubscriptionReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownTargetDependency :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownTargetProvisioner :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: UnknownTargetReference :: CatalogDiagnosticCode
+ Keiro.Projection.Catalog: [adapterId] :: RevisionReplayAdapter -> !Text
+ Keiro.Projection.Catalog: [adapterVersion] :: RevisionReplayAdapter -> !Int
+ Keiro.Projection.Catalog: [affectedAsyncDedup] :: InventoryStreamScopedReplay -> ![DedupKeyId]
+ Keiro.Projection.Catalog: [applyForReplay] :: ReplayAdapter event -> !event -> RecordedEvent -> Transaction ()
+ Keiro.Projection.Catalog: [argumentName] :: SqlFunctionArgument -> !Text
+ Keiro.Projection.Catalog: [argumentType] :: SqlFunctionArgument -> !QualifiedSqlType
+ Keiro.Projection.Catalog: [arguments] :: InventoryExternalReadContract -> ![SqlFunctionArgument]
+ Keiro.Projection.Catalog: [canonicalName] :: PromotionObjectName -> !Text
+ Keiro.Projection.Catalog: [capabilityCheckpointOnMissing] :: ProjectionHandlerCapability -> !MissingCheckpointPolicy
+ Keiro.Projection.Catalog: [capabilityDedupKeyId] :: ProjectionHandlerCapability -> !DedupKeyId
+ Keiro.Projection.Catalog: [capabilityDedupName] :: ProjectionHandlerCapability -> !Text
+ Keiro.Projection.Catalog: [capabilityHandlerName] :: ProjectionHandlerCapability -> !Text
+ Keiro.Projection.Catalog: [capabilitySourceId] :: ProjectionHandlerCapability -> !SourceId
+ Keiro.Projection.Catalog: [capabilitySubscriptionId] :: ProjectionHandlerCapability -> !SubscriptionId
+ Keiro.Projection.Catalog: [capabilitySubscriptionName] :: ProjectionHandlerCapability -> !Text
+ Keiro.Projection.Catalog: [catalogSnapshot] :: TargetSchemaEvidence -> !Text
+ Keiro.Projection.Catalog: [checkpointOnMissing] :: AsyncProjectionRegistration -> !MissingCheckpointPolicy
+ Keiro.Projection.Catalog: [claimSite] :: ProjectionSet event -> !ClaimSite
+ Keiro.Projection.Catalog: [clearStreamRows] :: StreamScopedReplay -> !PhysicalTargets -> StreamName -> Transaction (Either Text [StreamClearCount])
+ Keiro.Projection.Catalog: [clearedRows] :: StreamClearCount -> !Int64
+ Keiro.Projection.Catalog: [clearerId] :: InventoryStreamScopedReplay -> !Text
+ Keiro.Projection.Catalog: [clearerVersion] :: InventoryStreamScopedReplay -> !Int
+ Keiro.Projection.Catalog: [codecFingerprint] :: InventorySource -> !Text
+ Keiro.Projection.Catalog: [compatibleRevisions] :: InventoryExternalReadContract -> !NonEmpty ProjectionRevisionId
+ Keiro.Projection.Catalog: [contractKind] :: InventoryExternalReadContract -> !ExternalReadContractKind
+ Keiro.Projection.Catalog: [contractVersion] :: InventoryExternalReadContract -> !ExternalReadContractVersion
+ Keiro.Projection.Catalog: [cursor] :: InventoryQueryModel -> !Maybe InventoryQueryCursor
+ Keiro.Projection.Catalog: [decodeForReplay] :: ReplayAdapter event -> !RecordedEvent -> ReplayDecodeResult event
+ Keiro.Projection.Catalog: [dedupKeyId] :: AsyncProjectionRegistration -> !DedupKeyId
+ Keiro.Projection.Catalog: [dedupKeys] :: ProjectionCatalog -> ![DedupKeyDeclaration]
+ Keiro.Projection.Catalog: [dedupName] :: AsyncProjectionRegistration -> !Text
+ Keiro.Projection.Catalog: [delivery] :: InventoryRevisionHandler -> !Maybe RevisionLiveDelivery
+ Keiro.Projection.Catalog: [dependsOn] :: InventoryTarget -> ![TargetId]
+ Keiro.Projection.Catalog: [diagnosticCode] :: CatalogDiagnostic -> !CatalogDiagnosticCode
+ Keiro.Projection.Catalog: [diagnosticIdentity] :: CatalogDiagnostic -> !Text
+ Keiro.Projection.Catalog: [diagnosticMessage] :: CatalogDiagnostic -> !Text
+ Keiro.Projection.Catalog: [diagnosticSites] :: CatalogDiagnostic -> ![ClaimSite]
+ Keiro.Projection.Catalog: [expectedShapeId] :: InventoryTargetProvisioner -> !Text
+ Keiro.Projection.Catalog: [externalReadContracts] :: ProjectionCatalog -> ![ExternalReadContract]
+ Keiro.Projection.Catalog: [freshness] :: InventoryQueryModel -> !InventoryQueryFreshness
+ Keiro.Projection.Catalog: [functionName] :: InventoryExternalReadContract -> !Text
+ Keiro.Projection.Catalog: [functionSchema] :: QualifiedFunction -> !Text
+ Keiro.Projection.Catalog: [generationId] :: TargetProvisioningContext -> !TargetGenerationId
+ Keiro.Projection.Catalog: [generationName] :: PromotionObjectName -> !Text
+ Keiro.Projection.Catalog: [handlerId] :: InventoryRevisionHandler -> !Text
+ Keiro.Projection.Catalog: [handlerVersion] :: InventoryRevisionHandler -> !Int
+ Keiro.Projection.Catalog: [handlers] :: InventoryProjection -> ![InventoryHandler]
+ Keiro.Projection.Catalog: [inventoryDedupKeys] :: CatalogInventory -> ![InventoryDedupKey]
+ Keiro.Projection.Catalog: [inventoryExternalReadContracts] :: CatalogInventory -> ![InventoryExternalReadContract]
+ Keiro.Projection.Catalog: [inventoryGroups] :: CatalogInventory -> ![InventoryGroup]
+ Keiro.Projection.Catalog: [inventoryProjectionRevisions] :: CatalogInventory -> ![InventoryProjectionRevision]
+ Keiro.Projection.Catalog: [inventoryProjections] :: CatalogInventory -> ![InventoryProjection]
+ Keiro.Projection.Catalog: [inventoryQueryModels] :: CatalogInventory -> ![InventoryQueryModel]
+ Keiro.Projection.Catalog: [inventorySources] :: CatalogInventory -> ![InventorySource]
+ Keiro.Projection.Catalog: [inventorySubscriptions] :: CatalogInventory -> ![InventorySubscription]
+ Keiro.Projection.Catalog: [inventoryTargets] :: CatalogInventory -> ![InventoryTarget]
+ Keiro.Projection.Catalog: [liveHandlers] :: InventoryProjectionRevision -> ![InventoryRevisionHandler]
+ Keiro.Projection.Catalog: [objectKind] :: PromotionObjectName -> !PromotionObjectKind
+ Keiro.Projection.Catalog: [observedPromotionObjects] :: TargetSchemaEvidence -> ![PromotionObjectName]
+ Keiro.Projection.Catalog: [observedShapeFingerprint] :: TargetSchemaEvidence -> !Text
+ Keiro.Projection.Catalog: [observedTargets] :: InventoryQueryModel -> ![TargetId]
+ Keiro.Projection.Catalog: [orderedTargets] :: InventoryGroup -> ![TargetId]
+ Keiro.Projection.Catalog: [ownedTargets] :: InventoryStreamScopedReplay -> ![TargetId]
+ Keiro.Projection.Catalog: [owner] :: InventoryTarget -> !ProjectionId
+ Keiro.Projection.Catalog: [privateImplementationVersion] :: InventoryExternalReadContract -> !Maybe Int
+ Keiro.Projection.Catalog: [privateImplementation] :: InventoryExternalReadContract -> !Maybe QualifiedFunction
+ Keiro.Projection.Catalog: [projectionDefinitions] :: ProjectionSet event -> !NonEmpty (ProjectionDefinition event)
+ Keiro.Projection.Catalog: [projectionId] :: ReplayAdapterMetadata -> !ProjectionId
+ Keiro.Projection.Catalog: [projectionName] :: AsyncProjectionRegistration -> !Text
+ Keiro.Projection.Catalog: [projectionRevisions] :: ProjectionCatalog -> ![ProjectionRevision]
+ Keiro.Projection.Catalog: [projectionSets] :: ProjectionCatalog -> ![SomeProjectionSet]
+ Keiro.Projection.Catalog: [projectionSource] :: ProjectionSet event -> !SourceId
+ Keiro.Projection.Catalog: [promotionObjectNames] :: InventoryTargetProvisioner -> ![PromotionObjectName]
+ Keiro.Projection.Catalog: [provisionTarget] :: TargetProvisioner -> !TargetProvisioningContext -> Transaction ()
+ Keiro.Projection.Catalog: [provisionerId] :: InventoryTargetProvisioner -> !Text
+ Keiro.Projection.Catalog: [provisionerVersion] :: InventoryTargetProvisioner -> !Int
+ Keiro.Projection.Catalog: [qualifiedTable] :: InventoryTarget -> !QualifiedTable
+ Keiro.Projection.Catalog: [queryModelId] :: CatalogRegistration -> !QueryModelId
+ Keiro.Projection.Catalog: [queryModels] :: ProjectionCatalog -> ![SomeQueryModelBinding]
+ Keiro.Projection.Catalog: [readContractId] :: InventoryExternalReadContract -> !ExternalReadContractId
+ Keiro.Projection.Catalog: [readModel] :: QueryModelBinding q r -> !ReadModel q r
+ Keiro.Projection.Catalog: [rebuildGroupId] :: ReplayAdapterMetadata -> !RebuildGroupId
+ Keiro.Projection.Catalog: [rebuildGroup] :: ProjectionDefinition event -> !RebuildGroupId
+ Keiro.Projection.Catalog: [rebuildGroups] :: ProjectionCatalog -> ![RebuildGroupDeclaration]
+ Keiro.Projection.Catalog: [registryName] :: CatalogRegistration -> !Text
+ Keiro.Projection.Catalog: [relationOid] :: TargetSchemaEvidence -> !Int64
+ Keiro.Projection.Catalog: [replayAdapters] :: InventoryProjectionRevision -> ![InventoryRevisionHandler]
+ Keiro.Projection.Catalog: [replayDisposition] :: InventoryProjection -> !Text
+ Keiro.Projection.Catalog: [replayId] :: InventoryStreamScopedReplay -> !Text
+ Keiro.Projection.Catalog: [replayPolicy] :: ProjectionDefinition event -> !ProjectionReplayPolicy event
+ Keiro.Projection.Catalog: [replayStreamEvent] :: StreamScopedReplay -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool)
+ Keiro.Projection.Catalog: [replayVersion] :: InventoryStreamScopedReplay -> !Int
+ Keiro.Projection.Catalog: [replayable] :: ReplayAdapterMetadata -> !Bool
+ Keiro.Projection.Catalog: [requiredTargets] :: InventoryRevisionHandler -> ![TargetId]
+ Keiro.Projection.Catalog: [resetPolicy] :: InventoryTarget -> !TargetResetPolicy
+ Keiro.Projection.Catalog: [resolvedHandlerCapabilities] :: ResolvedQuerySupply -> !NonEmpty ProjectionHandlerCapability
+ Keiro.Projection.Catalog: [resolvedObservedTargets] :: ResolvedQuerySupply -> !NonEmpty TargetId
+ Keiro.Projection.Catalog: [resolvedProjectionId] :: ResolvedQuerySupply -> !ProjectionId
+ Keiro.Projection.Catalog: [resolvedQueryCursor] :: ResolvedQuerySupply -> !Maybe InventoryQueryCursor
+ Keiro.Projection.Catalog: [resolvedQueryFreshness] :: ResolvedQuerySupply -> !InventoryQueryFreshness
+ Keiro.Projection.Catalog: [resolvedQueryModelId] :: ResolvedQuerySupply -> !QueryModelId
+ Keiro.Projection.Catalog: [resolvedRebuildGroupId] :: ResolvedQuerySupply -> !RebuildGroupId
+ Keiro.Projection.Catalog: [resolvedSourceId] :: ResolvedQuerySupply -> !SourceId
+ Keiro.Projection.Catalog: [resultContractType] :: InventoryExternalReadContract -> !QualifiedSqlType
+ Keiro.Projection.Catalog: [resultShapeHash] :: InventoryExternalReadContract -> !Text
+ Keiro.Projection.Catalog: [revisionId] :: InventoryProjectionRevision -> !ProjectionRevisionId
+ Keiro.Projection.Catalog: [revisionVerificationId] :: RevisionVerification -> !Text
+ Keiro.Projection.Catalog: [revisionVerificationVersion] :: RevisionVerification -> !Int
+ Keiro.Projection.Catalog: [revisionVerifications] :: ProjectionRevision -> ![RevisionVerification]
+ Keiro.Projection.Catalog: [runRevisionLive] :: RevisionLiveHandler -> !PhysicalTargets -> RecordedEvent -> Transaction ()
+ Keiro.Projection.Catalog: [runRevisionReplay] :: RevisionReplayAdapter -> !PhysicalTargets -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool)
+ Keiro.Projection.Catalog: [runRevisionVerification] :: RevisionVerification -> !PhysicalTargets -> Transaction (Either Text ())
+ Keiro.Projection.Catalog: [schemaName] :: QualifiedTable -> !Text
+ Keiro.Projection.Catalog: [schemaVersion] :: InventoryTargetProvisioner -> !TargetSchemaVersion
+ Keiro.Projection.Catalog: [servingTable] :: TargetProvisioningContext -> !QualifiedTable
+ Keiro.Projection.Catalog: [shapeHash] :: CatalogRegistration -> !Text
+ Keiro.Projection.Catalog: [sourceId] :: ReplayAdapterMetadata -> !SourceId
+ Keiro.Projection.Catalog: [sourceScope] :: InventorySource -> !SourceScope
+ Keiro.Projection.Catalog: [sources] :: ProjectionCatalog -> ![SourceDeclaration]
+ Keiro.Projection.Catalog: [specDedupKeyId] :: CatalogAsyncDedupSpec -> !DedupKeyId
+ Keiro.Projection.Catalog: [specDedupName] :: CatalogAsyncDedupSpec -> !Text
+ Keiro.Projection.Catalog: [specIdempotencyKey] :: CatalogAsyncDedupSpec -> !RecordedEvent -> EventId
+ Keiro.Projection.Catalog: [specSourceId] :: CatalogAsyncDedupSpec -> !SourceId
+ Keiro.Projection.Catalog: [specSourceScope] :: CatalogAsyncDedupSpec -> !SourceScope
+ Keiro.Projection.Catalog: [specSubscriptionName] :: CatalogAsyncDedupSpec -> !Text
+ Keiro.Projection.Catalog: [stagingTable] :: TargetProvisioningContext -> !QualifiedTable
+ Keiro.Projection.Catalog: [streamOwnedTargets] :: StreamScopedReplay -> !NonEmpty TargetId
+ Keiro.Projection.Catalog: [streamProjectionId] :: StreamScopedReplay -> !ProjectionId
+ Keiro.Projection.Catalog: [streamReplayId] :: StreamScopedReplay -> !Text
+ Keiro.Projection.Catalog: [streamReplayVersion] :: StreamScopedReplay -> !Int
+ Keiro.Projection.Catalog: [streamScopedReplays] :: InventoryProjectionRevision -> ![InventoryStreamScopedReplay]
+ Keiro.Projection.Catalog: [streamVerificationId] :: StreamScopedReplay -> !Text
+ Keiro.Projection.Catalog: [streamVerificationVersion] :: StreamScopedReplay -> !Int
+ Keiro.Projection.Catalog: [subscriptionId] :: AsyncProjectionRegistration -> !SubscriptionId
+ Keiro.Projection.Catalog: [subscriptionName] :: AsyncProjectionRegistration -> !Text
+ Keiro.Projection.Catalog: [subscriptionSource] :: SubscriptionDeclaration -> !SourceId
+ Keiro.Projection.Catalog: [subscriptions] :: ProjectionCatalog -> ![SubscriptionDeclaration]
+ Keiro.Projection.Catalog: [surfaceGeneration] :: InventoryExternalReadContract -> !Int
+ Keiro.Projection.Catalog: [tableName] :: QualifiedTable -> !Text
+ Keiro.Projection.Catalog: [targetId] :: InventoryTargetProvisioner -> !TargetId
+ Keiro.Projection.Catalog: [targetProvisioners] :: InventoryProjectionRevision -> ![InventoryTargetProvisioner]
+ Keiro.Projection.Catalog: [targets] :: ProjectionCatalog -> ![TargetDeclaration]
+ Keiro.Projection.Catalog: [typeName] :: QualifiedSqlType -> !Text
+ Keiro.Projection.Catalog: [typeSchema] :: QualifiedSqlType -> !Text
+ Keiro.Projection.Catalog: [validateTarget] :: TargetProvisioner -> !Maybe (TargetProvisioningContext -> Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence))
+ Keiro.Projection.Catalog: [validatorId] :: InventoryTargetProvisioner -> !Text
+ Keiro.Projection.Catalog: [validatorVersion] :: InventoryTargetProvisioner -> !Int
+ Keiro.Projection.Catalog: [verificationHooks] :: RebuildGroupDeclaration -> ![RebuildVerification]
+ Keiro.Projection.Catalog: [verificationId] :: InventoryStreamScopedReplay -> !Text
+ Keiro.Projection.Catalog: [verificationVersion] :: InventoryStreamScopedReplay -> !Int
+ Keiro.Projection.Catalog: [verifications] :: InventoryProjectionRevision -> ![InventoryRevisionHandler]
+ Keiro.Projection.Catalog: [verifyRebuild] :: RebuildVerification -> !Transaction (Either Text ())
+ Keiro.Projection.Catalog: [verifyStreamRows] :: StreamScopedReplay -> !PhysicalTargets -> StreamName -> Transaction (Either Text ())
+ Keiro.Projection.Catalog: [version] :: CatalogRegistration -> !Int
+ Keiro.Projection.Catalog: [violationCode] :: TargetSchemaViolation -> !Text
+ Keiro.Projection.Catalog: [violationDetail] :: TargetSchemaViolation -> !Text
+ Keiro.Projection.Catalog: asyncProjectionRebuildGroup :: ValidatedProjectionCatalog -> ProjectionId -> Text -> Maybe RebuildGroupId
+ Keiro.Projection.Catalog: asyncProjectionRegistrations :: ValidatedProjectionCatalog -> [AsyncProjectionRegistration]
+ Keiro.Projection.Catalog: catalogAsyncIdempotencyKeys :: ValidatedProjectionCatalog -> RebuildGroupId -> [CatalogAsyncDedupSpec]
+ Keiro.Projection.Catalog: catalogExternalReadContracts :: ValidatedProjectionCatalog -> [ExternalReadContract]
+ Keiro.Projection.Catalog: catalogFingerprint :: ValidatedProjectionCatalog -> CatalogFingerprint
+ Keiro.Projection.Catalog: catalogFingerprintText :: CatalogFingerprint -> Text
+ Keiro.Projection.Catalog: catalogInventory :: ValidatedProjectionCatalog -> CatalogInventory
+ Keiro.Projection.Catalog: catalogProjectionRevision :: ValidatedProjectionCatalog -> ProjectionRevisionId -> Maybe ProjectionRevision
+ Keiro.Projection.Catalog: catalogProjectionRevisions :: ValidatedProjectionCatalog -> [ProjectionRevision]
+ Keiro.Projection.Catalog: catalogRebuildVerifications :: ValidatedProjectionCatalog -> RebuildGroupId -> [RebuildVerification]
+ Keiro.Projection.Catalog: catalogRegistrations :: ValidatedProjectionCatalog -> [CatalogRegistration]
+ Keiro.Projection.Catalog: catalogReplayAdapterGroupId :: CatalogReplayAdapter -> RebuildGroupId
+ Keiro.Projection.Catalog: catalogReplayAdapterOrder :: CatalogReplayAdapter -> Int
+ Keiro.Projection.Catalog: catalogReplayAdapterProjectionId :: CatalogReplayAdapter -> ProjectionId
+ Keiro.Projection.Catalog: catalogReplayAdapterSourceId :: CatalogReplayAdapter -> SourceId
+ Keiro.Projection.Catalog: catalogReplayAdapters :: ValidatedProjectionCatalog -> RebuildGroupId -> [CatalogReplayAdapter]
+ Keiro.Projection.Catalog: catalogRevisionLiveHandler :: ValidatedProjectionCatalog -> ProjectionRevisionId -> RevisionLiveDelivery -> Maybe RevisionLiveHandler
+ Keiro.Projection.Catalog: catalogStreamScopedReplay :: ValidatedProjectionCatalog -> ProjectionRevisionId -> ProjectionId -> Maybe StreamScopedReplay
+ Keiro.Projection.Catalog: claimSiteText :: ClaimSite -> Text
+ Keiro.Projection.Catalog: compareCatalogBaseline :: CatalogInventory -> CatalogInventory -> [CatalogEvolution]
+ Keiro.Projection.Catalog: data AsyncProjectionRegistration
+ Keiro.Projection.Catalog: data CatalogAsyncDedupSpec
+ Keiro.Projection.Catalog: data CatalogDiagnostic
+ Keiro.Projection.Catalog: data CatalogDiagnosticCode
+ Keiro.Projection.Catalog: data CatalogEvolution
+ Keiro.Projection.Catalog: data CatalogFingerprint
+ Keiro.Projection.Catalog: data CatalogIdentityError
+ Keiro.Projection.Catalog: data CatalogInventory
+ Keiro.Projection.Catalog: data CatalogRegistration
+ Keiro.Projection.Catalog: data CatalogReplayAdapter
+ Keiro.Projection.Catalog: data ClaimSite
+ Keiro.Projection.Catalog: data DedupKeyDeclaration
+ Keiro.Projection.Catalog: data DedupKeyId
+ Keiro.Projection.Catalog: data ExternalReadContract
+ Keiro.Projection.Catalog: data ExternalReadContractId
+ Keiro.Projection.Catalog: data ExternalReadContractKind
+ Keiro.Projection.Catalog: data GroupSliceFingerprint
+ Keiro.Projection.Catalog: data InventoryDedupKey
+ Keiro.Projection.Catalog: data InventoryExternalReadContract
+ Keiro.Projection.Catalog: data InventoryGroup
+ Keiro.Projection.Catalog: data InventoryHandler
+ Keiro.Projection.Catalog: data InventoryProjection
+ Keiro.Projection.Catalog: data InventoryProjectionRevision
+ Keiro.Projection.Catalog: data InventoryQueryCursor
+ Keiro.Projection.Catalog: data InventoryQueryFreshness
+ Keiro.Projection.Catalog: data InventoryQueryModel
+ Keiro.Projection.Catalog: data InventoryRevisionHandler
+ Keiro.Projection.Catalog: data InventorySource
+ Keiro.Projection.Catalog: data InventoryStreamScopedReplay
+ Keiro.Projection.Catalog: data InventorySubscription
+ Keiro.Projection.Catalog: data InventoryTarget
+ Keiro.Projection.Catalog: data InventoryTargetProvisioner
+ Keiro.Projection.Catalog: data PhysicalTargetMapError
+ Keiro.Projection.Catalog: data PhysicalTargets
+ Keiro.Projection.Catalog: data ProjectionCatalog
+ Keiro.Projection.Catalog: data ProjectionDefinition event
+ Keiro.Projection.Catalog: data ProjectionHandler event
+ Keiro.Projection.Catalog: data ProjectionHandlerCapability
+ Keiro.Projection.Catalog: data ProjectionId
+ Keiro.Projection.Catalog: data ProjectionReplayPolicy event
+ Keiro.Projection.Catalog: data ProjectionRevision
+ Keiro.Projection.Catalog: data ProjectionRevisionId
+ Keiro.Projection.Catalog: data ProjectionSet event
+ Keiro.Projection.Catalog: data PromotionObjectKind
+ Keiro.Projection.Catalog: data PromotionObjectName
+ Keiro.Projection.Catalog: data QualifiedFunction
+ Keiro.Projection.Catalog: data QualifiedSqlType
+ Keiro.Projection.Catalog: data QualifiedTable
+ Keiro.Projection.Catalog: data QueryModelBinding q r
+ Keiro.Projection.Catalog: data QueryModelId
+ Keiro.Projection.Catalog: data RebuildGroupDeclaration
+ Keiro.Projection.Catalog: data RebuildGroupId
+ Keiro.Projection.Catalog: data RebuildVerification
+ Keiro.Projection.Catalog: data ReplayAdapter event
+ Keiro.Projection.Catalog: data ReplayAdapterMetadata
+ Keiro.Projection.Catalog: data ReplayDecodeResult event
+ Keiro.Projection.Catalog: data ResolvedQuerySupply
+ Keiro.Projection.Catalog: data RevisionLiveDelivery
+ Keiro.Projection.Catalog: data RevisionLiveHandler
+ Keiro.Projection.Catalog: data RevisionReplayAdapter
+ Keiro.Projection.Catalog: data RevisionVerification
+ Keiro.Projection.Catalog: data SomeProjectionSet
+ Keiro.Projection.Catalog: data SomeQueryModelBinding
+ Keiro.Projection.Catalog: data SourceDeclaration
+ Keiro.Projection.Catalog: data SourceId
+ Keiro.Projection.Catalog: data SourceScope
+ Keiro.Projection.Catalog: data SqlFunctionArgument
+ Keiro.Projection.Catalog: data StreamClearCount
+ Keiro.Projection.Catalog: data StreamScopedReplay
+ Keiro.Projection.Catalog: data SubscriptionDeclaration
+ Keiro.Projection.Catalog: data SubscriptionId
+ Keiro.Projection.Catalog: data TargetDeclaration
+ Keiro.Projection.Catalog: data TargetId
+ Keiro.Projection.Catalog: data TargetProvisioner
+ Keiro.Projection.Catalog: data TargetProvisioningContext
+ Keiro.Projection.Catalog: data TargetResetPolicy
+ Keiro.Projection.Catalog: data TargetSchemaEvidence
+ Keiro.Projection.Catalog: data TargetSchemaViolation
+ Keiro.Projection.Catalog: data UnmanagedAsyncProjection
+ Keiro.Projection.Catalog: data UnmanagedInlineProjections event
+ Keiro.Projection.Catalog: data UnmanagedReadModel q r
+ Keiro.Projection.Catalog: data ValidatedProjectionCatalog
+ Keiro.Projection.Catalog: data Validation err value
+ Keiro.Projection.Catalog: dedupKeyIdText :: DedupKeyId -> Text
+ Keiro.Projection.Catalog: diagnosticCodeText :: CatalogDiagnosticCode -> Text
+ Keiro.Projection.Catalog: emptyProjectionCatalog :: ProjectionCatalog
+ Keiro.Projection.Catalog: externalReadContractIdText :: ExternalReadContractId -> Text
+ Keiro.Projection.Catalog: externalReadContractVersionValue :: ExternalReadContractVersion -> Int
+ Keiro.Projection.Catalog: externalReadFunctionName :: ExternalReadContract -> Text
+ Keiro.Projection.Catalog: getUnmanagedAsyncProjection :: UnmanagedAsyncProjection -> AsyncProjection
+ Keiro.Projection.Catalog: getUnmanagedInlineProjections :: UnmanagedInlineProjections event -> [InlineProjection event]
+ Keiro.Projection.Catalog: getUnmanagedReadModel :: UnmanagedReadModel q r -> ReadModel q r
+ Keiro.Projection.Catalog: groupSliceFingerprint :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe GroupSliceFingerprint
+ Keiro.Projection.Catalog: groupSliceFingerprintText :: GroupSliceFingerprint -> Text
+ Keiro.Projection.Catalog: instance (GHC.Classes.Eq err, GHC.Classes.Eq value) => GHC.Classes.Eq (Keiro.Projection.Catalog.Validation err value)
+ Keiro.Projection.Catalog: instance (GHC.Internal.Show.Show err, GHC.Internal.Show.Show value) => GHC.Internal.Show.Show (Keiro.Projection.Catalog.Validation err value)
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.AsyncProjectionRegistration
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogDiagnostic
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogDiagnosticCode
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogEvolution
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogFingerprint
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogIdentityError
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogInventory
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.CatalogRegistration
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ClaimSite
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.DedupKeyDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.DedupKeyId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ExternalReadContractId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ExternalReadContractKind
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ExternalReadContractVersion
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.GroupSliceFingerprint
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryDedupKey
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryGroup
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryHandler
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryProjection
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryProjectionRevision
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryQueryCursor
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryQueryFreshness
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryQueryModel
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryRevisionHandler
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventorySource
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryStreamScopedReplay
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventorySubscription
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryTarget
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.InventoryTargetProvisioner
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.LiveOnlyReason
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.PhysicalTargetMapError
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.PhysicalTargets
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ProjectionHandlerCapability
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ProjectionId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ProjectionRevisionId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.PromotionObjectKind
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.PromotionObjectName
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.QualifiedFunction
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.QualifiedSqlType
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.QualifiedTable
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.QueryCursorCandidate
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.QueryModelId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.RebuildGroupId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ReplayAdapterMetadata
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ReplayDecodeError
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.ResolvedQuerySupply
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.RevisionLiveDelivery
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.SourceDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.SourceId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.SourceScope
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.SqlFunctionArgument
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.StreamClearCount
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.SubscriptionDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.SubscriptionId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetGenerationId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetId
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetProvisioningContext
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetResetPolicy
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetSchemaEvidence
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetSchemaVersion
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq Keiro.Projection.Catalog.TargetSchemaViolation
+ Keiro.Projection.Catalog: instance GHC.Classes.Eq event => GHC.Classes.Eq (Keiro.Projection.Catalog.ReplayDecodeResult event)
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.AsyncProjectionRegistration
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogDiagnostic
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogDiagnosticCode
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogEvolution
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogFingerprint
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogIdentityError
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogInventory
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.CatalogRegistration
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ClaimSite
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.DedupKeyDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.DedupKeyId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ExternalReadContractId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ExternalReadContractKind
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ExternalReadContractVersion
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.GroupSliceFingerprint
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryDedupKey
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryGroup
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryHandler
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryProjection
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryProjectionRevision
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryQueryCursor
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryQueryFreshness
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryQueryModel
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryRevisionHandler
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventorySource
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryStreamScopedReplay
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventorySubscription
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryTarget
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.InventoryTargetProvisioner
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.LiveOnlyReason
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.PhysicalTargetMapError
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.PhysicalTargets
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ProjectionId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ProjectionRevisionId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.PromotionObjectKind
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.PromotionObjectName
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.QualifiedFunction
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.QualifiedSqlType
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.QualifiedTable
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.QueryCursorCandidate
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.QueryModelId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.RebuildGroupId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ReplayAdapterMetadata
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.ReplayDecodeError
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.RevisionLiveDelivery
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.SourceDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.SourceId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.SourceScope
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.SqlFunctionArgument
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.StreamClearCount
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.SubscriptionDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.SubscriptionId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetDeclaration
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetGenerationId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetId
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetProvisioningContext
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetResetPolicy
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetSchemaEvidence
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetSchemaVersion
+ Keiro.Projection.Catalog: instance GHC.Classes.Ord Keiro.Projection.Catalog.TargetSchemaViolation
+ Keiro.Projection.Catalog: instance GHC.Internal.Enum.Bounded Keiro.Projection.Catalog.CatalogDiagnosticCode
+ Keiro.Projection.Catalog: instance GHC.Internal.Enum.Enum Keiro.Projection.Catalog.CatalogDiagnosticCode
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.ProjectionDefinition event)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.ProjectionHandler event)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.ProjectionReplayPolicy event)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.ProjectionSet event)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.QueryModelBinding q r)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.ReplayAdapter event)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.ReplayDecodeResult event)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic (Keiro.Projection.Catalog.Validation err value)
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.AsyncProjectionRegistration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogAsyncDedupSpec
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogDiagnostic
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogDiagnosticCode
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogEvolution
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogFingerprint
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogIdentityError
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogInventory
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.CatalogRegistration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ClaimSite
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.DedupKeyDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.DedupKeyId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ExternalReadContractId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ExternalReadContractKind
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ExternalReadContractVersion
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.GroupSliceFingerprint
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryDedupKey
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryGroup
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryHandler
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryProjection
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryProjectionRevision
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryQueryCursor
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryQueryFreshness
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryQueryModel
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryRevisionHandler
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventorySource
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryStreamScopedReplay
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventorySubscription
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryTarget
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.InventoryTargetProvisioner
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.LiveOnlyReason
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.PhysicalTargetMapError
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.PhysicalTargets
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ProjectionCatalog
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ProjectionHandlerCapability
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ProjectionId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ProjectionRevision
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ProjectionRevisionId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.PromotionObjectKind
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.PromotionObjectName
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.QualifiedFunction
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.QualifiedSqlType
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.QualifiedTable
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.QueryModelId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RebuildGroupDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RebuildGroupId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RebuildVerification
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ReplayAdapterMetadata
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ReplayDecodeError
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ResolvedQuerySupply
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RevisionLiveDelivery
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RevisionLiveHandler
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RevisionReplayAdapter
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.RevisionVerification
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.SourceDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.SourceId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.SourceScope
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.SqlFunctionArgument
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.StreamClearCount
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.StreamScopedReplay
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.SubscriptionDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.SubscriptionId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetGenerationId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetId
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetProvisioner
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetProvisioningContext
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetResetPolicy
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetSchemaEvidence
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetSchemaVersion
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.TargetSchemaViolation
+ Keiro.Projection.Catalog: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.ValidatedProjectionCatalog
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.AsyncProjectionRegistration
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogDiagnostic
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogDiagnosticCode
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogEvolution
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogFingerprint
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogIdentityError
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogInventory
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.CatalogRegistration
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ClaimSite
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.DedupKeyDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.DedupKeyId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ExternalReadContractId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ExternalReadContractKind
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ExternalReadContractVersion
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.GroupSliceFingerprint
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryDedupKey
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryExternalReadContract
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryGroup
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryHandler
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryProjection
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryProjectionRevision
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryQueryCursor
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryQueryFreshness
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryQueryModel
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryRevisionHandler
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventorySource
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryStreamScopedReplay
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventorySubscription
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryTarget
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.InventoryTargetProvisioner
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.LiveOnlyReason
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.PhysicalTargetMapError
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.PhysicalTargets
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ProjectionHandlerCapability
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ProjectionId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ProjectionRevisionId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.PromotionObjectKind
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.PromotionObjectName
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.QualifiedFunction
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.QualifiedSqlType
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.QualifiedTable
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.QueryCursorCandidate
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.QueryModelId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.RebuildGroupId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ReplayAdapterMetadata
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ReplayDecodeError
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.ResolvedQuerySupply
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.RevisionLiveDelivery
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.SourceDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.SourceId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.SourceScope
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.SqlFunctionArgument
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.StreamClearCount
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.SubscriptionDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.SubscriptionId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetDeclaration
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetGenerationId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetId
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetProvisioningContext
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetResetPolicy
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetSchemaEvidence
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetSchemaVersion
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.TargetSchemaViolation
+ Keiro.Projection.Catalog: instance GHC.Internal.Show.Show event => GHC.Internal.Show.Show (Keiro.Projection.Catalog.ReplayDecodeResult event)
+ Keiro.Projection.Catalog: missingCheckpointPolicyText :: MissingCheckpointPolicy -> Text
+ Keiro.Projection.Catalog: mkClaimSite :: Text -> Either CatalogIdentityError ClaimSite
+ Keiro.Projection.Catalog: mkDedupKeyId :: Text -> Either CatalogIdentityError DedupKeyId
+ Keiro.Projection.Catalog: mkExternalReadContractId :: Text -> Either CatalogIdentityError ExternalReadContractId
+ Keiro.Projection.Catalog: mkPhysicalTargets :: [TargetId] -> Map TargetId QualifiedTable -> Either (NonEmpty PhysicalTargetMapError) PhysicalTargets
+ Keiro.Projection.Catalog: mkProjectionId :: Text -> Either CatalogIdentityError ProjectionId
+ Keiro.Projection.Catalog: mkProjectionRevisionId :: Text -> Either CatalogIdentityError ProjectionRevisionId
+ Keiro.Projection.Catalog: mkQueryModelId :: Text -> Either CatalogIdentityError QueryModelId
+ Keiro.Projection.Catalog: mkRebuildGroupId :: Text -> Either CatalogIdentityError RebuildGroupId
+ Keiro.Projection.Catalog: mkSourceId :: Text -> Either CatalogIdentityError SourceId
+ Keiro.Projection.Catalog: mkSubscriptionId :: Text -> Either CatalogIdentityError SubscriptionId
+ Keiro.Projection.Catalog: mkTargetId :: Text -> Either CatalogIdentityError TargetId
+ Keiro.Projection.Catalog: newtype ExternalReadContractVersion
+ Keiro.Projection.Catalog: newtype LiveOnlyReason
+ Keiro.Projection.Catalog: newtype ReplayDecodeError
+ Keiro.Projection.Catalog: newtype TargetGenerationId
+ Keiro.Projection.Catalog: newtype TargetSchemaVersion
+ Keiro.Projection.Catalog: physicalTargetMap :: PhysicalTargets -> Map TargetId QualifiedTable
+ Keiro.Projection.Catalog: projectionIdText :: ProjectionId -> Text
+ Keiro.Projection.Catalog: projectionRevisionIdText :: ProjectionRevisionId -> Text
+ Keiro.Projection.Catalog: queryModelIdText :: QueryModelId -> Text
+ Keiro.Projection.Catalog: rebuildGroupIdText :: RebuildGroupId -> Text
+ Keiro.Projection.Catalog: renderCatalogInventory :: ValidatedProjectionCatalog -> Text
+ Keiro.Projection.Catalog: replayAdapterFromCodec :: Codec event -> (event -> RecordedEvent -> Transaction ()) -> ReplayAdapter event
+ Keiro.Projection.Catalog: replayAdapterMetadata :: ValidatedProjectionCatalog -> [ReplayAdapterMetadata]
+ Keiro.Projection.Catalog: resolvePhysicalTarget :: TargetId -> PhysicalTargets -> Maybe QualifiedTable
+ Keiro.Projection.Catalog: resolvedQuerySupplies :: ValidatedProjectionCatalog -> [ResolvedQuerySupply]
+ Keiro.Projection.Catalog: runCatalogReplayAdapter :: CatalogReplayAdapter -> RecordedEvent -> Transaction (Either ReplayDecodeError Bool)
+ Keiro.Projection.Catalog: sourceIdText :: SourceId -> Text
+ Keiro.Projection.Catalog: subscriptionIdText :: SubscriptionId -> Text
+ Keiro.Projection.Catalog: targetIdText :: TargetId -> Text
+ Keiro.Projection.Catalog: typedInlineProjections :: ValidatedProjectionCatalog -> ProjectionSet event -> [InlineProjection event]
+ Keiro.Projection.Catalog: typedInlineProjectionsForGroup :: ValidatedProjectionCatalog -> ProjectionSet event -> RebuildGroupId -> [InlineProjection event]
+ Keiro.Projection.Catalog: typedProjectionRebuildGroups :: ValidatedProjectionCatalog -> ProjectionSet event -> [RebuildGroupId]
+ Keiro.Projection.Catalog: unmanagedAsyncProjection :: AsyncProjection -> UnmanagedAsyncProjection
+ Keiro.Projection.Catalog: unmanagedInlineProjections :: [InlineProjection event] -> UnmanagedInlineProjections event
+ Keiro.Projection.Catalog: unmanagedReadModel :: ReadModel q r -> UnmanagedReadModel q r
+ Keiro.Projection.Catalog: useProjectionCatalog :: ProjectionCatalog -> (ValidatedProjectionCatalog -> result) -> Validation (NonEmpty CatalogDiagnostic) result
+ Keiro.Projection.Catalog: useProjectionCatalogM :: Monad effect => ProjectionCatalog -> (ValidatedProjectionCatalog -> effect result) -> effect (Validation (NonEmpty CatalogDiagnostic) result)
+ Keiro.Projection.Catalog: validateProjectionCatalog :: ProjectionCatalog -> Validation (NonEmpty CatalogDiagnostic) ValidatedProjectionCatalog
+ Keiro.Projection.Catalog.Operations: CatalogAdoptionGroupPreview :: !RebuildGroupId -> !GroupAdoptionClass -> !Maybe Text -> !Text -> !Bool -> CatalogAdoptionGroupPreview
+ Keiro.Projection.Catalog.Operations: CatalogAdoptionOrphanPreview :: !Text -> !RebuildGroupId -> !Bool -> CatalogAdoptionOrphanPreview
+ Keiro.Projection.Catalog.Operations: CatalogAdoptionOutcome :: !Text -> ![GroupRebuildMetadata] -> ![RegistrationAdoption] -> ![OrphanedRegistration] -> CatalogAdoptionOutcome
+ Keiro.Projection.Catalog.Operations: CatalogAdoptionRegistrationPreview :: !Text -> !RebuildGroupId -> !RegistrationAdoptionAction -> !Bool -> CatalogAdoptionRegistrationPreview
+ Keiro.Projection.Catalog.Operations: CatalogAdoptionReport :: !Text -> !Text -> ![RebuildGroupId] -> ![CatalogAdoptionGroupPreview] -> ![CatalogAdoptionRegistrationPreview] -> ![CatalogAdoptionOrphanPreview] -> ![RebuildGroupId] -> ![RebuildGroupId] -> CatalogAdoptionReport
+ Keiro.Projection.Catalog.Operations: CatalogExternalReadRetirementReport :: !Text -> !ExternalReadContractId -> !ExternalReadContractVersion -> !Text -> !Text -> !Int -> ![Text] -> ![Text] -> CatalogExternalReadRetirementReport
+ Keiro.Projection.Catalog.Operations: CatalogInventoryReport :: !Text -> !Text -> ![(RebuildGroupId, Text)] -> !CatalogInventory -> CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: CatalogOpsAdoptionRefused :: !CatalogAdoptionError -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsExternalReadRetirementError :: !ExternalReadRetirementError -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsInvalidVersionedRequest :: !Text -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsRebuildError :: !CatalogRebuildError -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsRunSliceMismatch :: !RebuildRunId -> !Text -> !Text -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsStreamReprojectionError :: !StreamReprojectionError -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsUnknownGroup :: !RebuildGroupId -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogOpsVersionedError :: !VersionedRebuildError -> CatalogOpsError
+ Keiro.Projection.Catalog.Operations: CatalogRetiredDropOutcome :: !VersionedRetiredDropResult -> CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: CatalogRetiredDropPreview :: !VersionedRetiredGenerationPreview -> CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: CatalogRetiredGenerationsReport :: !Text -> ![VersionedTargetGeneration] -> CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: CatalogRunReport :: !Text -> !RebuildRunReport -> CatalogRunReport
+ Keiro.Projection.Catalog.Operations: CatalogStreamReprojectionPreview :: !Text -> !RebuildGroupId -> !ProjectionId -> !StreamName -> !ProjectionRevisionId -> ![InventoryTarget] -> ![InventoryDedupKey] -> !Maybe StreamVersion -> !Maybe Int64 -> !Maybe Integer -> !Int64 -> !Bool -> !Maybe StreamVersion -> !Bool -> !Maybe Text -> !Text -> CatalogStreamReprojectionPreview
+ Keiro.Projection.Catalog.Operations: CatalogStreamReprojectionReport :: !Text -> !StreamReprojectionReport -> CatalogStreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: CatalogVersionedRunReport :: !Text -> !VersionedRebuildReport -> CatalogVersionedRunReport
+ Keiro.Projection.Catalog.Operations: CatalogVersionedStartOptions :: !RebuildRunId -> !RebuildGroupId -> !ProjectionRevisionId -> !ProjectionRevisionId -> !VersionedTargetMode -> !Int32 -> !Int64 -> !Int64 -> !Int64 -> !DiffTime -> !Text -> !Text -> CatalogVersionedStartOptions
+ Keiro.Projection.Catalog.Operations: RebuildPreview :: !Text -> !RebuildGroupId -> !Text -> !Text -> ![InventoryTarget] -> ![InventorySource] -> ![InventoryProjection] -> ![InventoryQueryModel] -> ![InventorySubscription] -> ![InventoryDedupKey] -> ![(Text, Text)] -> ![RebuildGroupId] -> !Text -> !Bool -> RebuildPreview
+ Keiro.Projection.Catalog.Operations: RegisteredRebuildPreview :: !Text -> !RebuildPreview -> !Maybe GroupRebuildMetadata -> !Maybe Bool -> RegisteredRebuildPreview
+ Keiro.Projection.Catalog.Operations: [action] :: CatalogAdoptionRegistrationPreview -> !RegistrationAdoptionAction
+ Keiro.Projection.Catalog.Operations: [adoptedGroups] :: CatalogAdoptionOutcome -> ![GroupRebuildMetadata]
+ Keiro.Projection.Catalog.Operations: [affectedDedup] :: CatalogStreamReprojectionPreview -> ![InventoryDedupKey]
+ Keiro.Projection.Catalog.Operations: [boundGroupId] :: CatalogAdoptionOrphanPreview -> !RebuildGroupId
+ Keiro.Projection.Catalog.Operations: [candidateRevisionId] :: CatalogVersionedStartOptions -> !ProjectionRevisionId
+ Keiro.Projection.Catalog.Operations: [capturedHeadStrategy] :: RebuildPreview -> !Text
+ Keiro.Projection.Catalog.Operations: [catalogFingerprint] :: CatalogAdoptionReport -> !Text
+ Keiro.Projection.Catalog.Operations: [classification] :: CatalogAdoptionGroupPreview -> !GroupAdoptionClass
+ Keiro.Projection.Catalog.Operations: [contractId] :: CatalogExternalReadRetirementReport -> !ExternalReadContractId
+ Keiro.Projection.Catalog.Operations: [contractVersion] :: CatalogExternalReadRetirementReport -> !ExternalReadContractVersion
+ Keiro.Projection.Catalog.Operations: [currentSlice] :: CatalogAdoptionGroupPreview -> !Text
+ Keiro.Projection.Catalog.Operations: [currentState] :: CatalogExternalReadRetirementReport -> !Text
+ Keiro.Projection.Catalog.Operations: [cutoverLockTimeoutMs] :: CatalogVersionedStartOptions -> !Int64
+ Keiro.Projection.Catalog.Operations: [cutoverThreshold] :: CatalogVersionedStartOptions -> !Int64
+ Keiro.Projection.Catalog.Operations: [dedupResets] :: RebuildPreview -> ![InventoryDedupKey]
+ Keiro.Projection.Catalog.Operations: [dependentObjects] :: CatalogExternalReadRetirementReport -> ![Text]
+ Keiro.Projection.Catalog.Operations: [destructive] :: RebuildPreview -> !Bool
+ Keiro.Projection.Catalog.Operations: [eligible] :: CatalogStreamReprojectionPreview -> !Bool
+ Keiro.Projection.Catalog.Operations: [eventCount] :: CatalogStreamReprojectionPreview -> !Maybe Int64
+ Keiro.Projection.Catalog.Operations: [executeGrants] :: CatalogExternalReadRetirementReport -> ![Text]
+ Keiro.Projection.Catalog.Operations: [expectedDedupClaims] :: CatalogStreamReprojectionPreview -> !Maybe Integer
+ Keiro.Projection.Catalog.Operations: [forceOperation] :: CatalogStreamReprojectionPreview -> !Text
+ Keiro.Projection.Catalog.Operations: [generations] :: CatalogRetiredGenerationsReport -> ![VersionedTargetGeneration]
+ Keiro.Projection.Catalog.Operations: [groupSlices] :: CatalogInventoryReport -> ![(RebuildGroupId, Text)]
+ Keiro.Projection.Catalog.Operations: [groups] :: CatalogAdoptionReport -> ![CatalogAdoptionGroupPreview]
+ Keiro.Projection.Catalog.Operations: [inScope] :: CatalogAdoptionOrphanPreview -> !Bool
+ Keiro.Projection.Catalog.Operations: [inventory] :: CatalogInventoryReport -> !CatalogInventory
+ Keiro.Projection.Catalog.Operations: [lockScope] :: RebuildPreview -> ![RebuildGroupId]
+ Keiro.Projection.Catalog.Operations: [maxEvents] :: CatalogStreamReprojectionPreview -> !Int64
+ Keiro.Projection.Catalog.Operations: [orphanedRegistrations] :: CatalogAdoptionReport -> ![CatalogAdoptionOrphanPreview]
+ Keiro.Projection.Catalog.Operations: [outOfScopeChangedGroups] :: CatalogAdoptionReport -> ![RebuildGroupId]
+ Keiro.Projection.Catalog.Operations: [preview] :: RegisteredRebuildPreview -> !RebuildPreview
+ Keiro.Projection.Catalog.Operations: [projectionId] :: CatalogStreamReprojectionPreview -> !ProjectionId
+ Keiro.Projection.Catalog.Operations: [projections] :: RebuildPreview -> ![InventoryProjection]
+ Keiro.Projection.Catalog.Operations: [promotionDedupLimit] :: CatalogVersionedStartOptions -> !Int64
+ Keiro.Projection.Catalog.Operations: [publicFunction] :: CatalogExternalReadRetirementReport -> !Text
+ Keiro.Projection.Catalog.Operations: [queryModels] :: RebuildPreview -> ![InventoryQueryModel]
+ Keiro.Projection.Catalog.Operations: [rebuildGroupId] :: CatalogStreamReprojectionPreview -> !RebuildGroupId
+ Keiro.Projection.Catalog.Operations: [rebuildRunId] :: CatalogVersionedStartOptions -> !RebuildRunId
+ Keiro.Projection.Catalog.Operations: [refusal] :: CatalogStreamReprojectionPreview -> !Maybe Text
+ Keiro.Projection.Catalog.Operations: [registeredSliceMatches] :: RegisteredRebuildPreview -> !Maybe Bool
+ Keiro.Projection.Catalog.Operations: [registeredState] :: RegisteredRebuildPreview -> !Maybe GroupRebuildMetadata
+ Keiro.Projection.Catalog.Operations: [registrationOutcomes] :: CatalogAdoptionOutcome -> ![RegistrationAdoption]
+ Keiro.Projection.Catalog.Operations: [registrations] :: CatalogAdoptionReport -> ![CatalogAdoptionRegistrationPreview]
+ Keiro.Projection.Catalog.Operations: [registryName] :: CatalogAdoptionOrphanPreview -> !Text
+ Keiro.Projection.Catalog.Operations: [removedGroups] :: CatalogAdoptionReport -> ![RebuildGroupId]
+ Keiro.Projection.Catalog.Operations: [removedOrphans] :: CatalogAdoptionOutcome -> ![OrphanedRegistration]
+ Keiro.Projection.Catalog.Operations: [repair] :: CatalogStreamReprojectionReport -> !StreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: [replayPageSize] :: CatalogVersionedStartOptions -> !Int32
+ Keiro.Projection.Catalog.Operations: [reportSchema] :: CatalogStreamReprojectionReport -> !Text
+ Keiro.Projection.Catalog.Operations: [requestReason] :: CatalogVersionedStartOptions -> !Text
+ Keiro.Projection.Catalog.Operations: [requestedBy] :: CatalogVersionedStartOptions -> !Text
+ Keiro.Projection.Catalog.Operations: [requestedGroups] :: CatalogAdoptionReport -> ![RebuildGroupId]
+ Keiro.Projection.Catalog.Operations: [retentionDuration] :: CatalogVersionedStartOptions -> !DiffTime
+ Keiro.Projection.Catalog.Operations: [run] :: CatalogVersionedRunReport -> !VersionedRebuildReport
+ Keiro.Projection.Catalog.Operations: [servingRevisionId] :: CatalogStreamReprojectionPreview -> !ProjectionRevisionId
+ Keiro.Projection.Catalog.Operations: [sliceFingerprint] :: RebuildPreview -> !Text
+ Keiro.Projection.Catalog.Operations: [softDeleted] :: CatalogStreamReprojectionPreview -> !Bool
+ Keiro.Projection.Catalog.Operations: [sources] :: RebuildPreview -> ![InventorySource]
+ Keiro.Projection.Catalog.Operations: [storedSlice] :: CatalogAdoptionGroupPreview -> !Maybe Text
+ Keiro.Projection.Catalog.Operations: [streamName] :: CatalogStreamReprojectionPreview -> !StreamName
+ Keiro.Projection.Catalog.Operations: [streamVersion] :: CatalogStreamReprojectionPreview -> !Maybe StreamVersion
+ Keiro.Projection.Catalog.Operations: [subscriptionResets] :: RebuildPreview -> ![InventorySubscription]
+ Keiro.Projection.Catalog.Operations: [surfaceGeneration] :: CatalogExternalReadRetirementReport -> !Int
+ Keiro.Projection.Catalog.Operations: [targetMode] :: CatalogVersionedStartOptions -> !VersionedTargetMode
+ Keiro.Projection.Catalog.Operations: [targets] :: CatalogStreamReprojectionPreview -> ![InventoryTarget]
+ Keiro.Projection.Catalog.Operations: [truncateBefore] :: CatalogStreamReprojectionPreview -> !Maybe StreamVersion
+ Keiro.Projection.Catalog.Operations: [verifications] :: RebuildPreview -> ![(Text, Text)]
+ Keiro.Projection.Catalog.Operations: abandonGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> RebuildRunId -> RebuildFailure -> Eff es (Either CatalogOpsError CatalogRunReport)
+ Keiro.Projection.Catalog.Operations: abandonVersionedGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> RebuildRunId -> Eff es (Either CatalogOpsError CatalogVersionedRunReport)
+ Keiro.Projection.Catalog.Operations: adoptCatalogGroups :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> NonEmpty RebuildGroupId -> Eff es (Either CatalogOpsError CatalogAdoptionOutcome)
+ Keiro.Projection.Catalog.Operations: catalogInventoryReport :: ProjectionCatalogOperations -> CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: data CatalogAdoptionGroupPreview
+ Keiro.Projection.Catalog.Operations: data CatalogAdoptionOrphanPreview
+ Keiro.Projection.Catalog.Operations: data CatalogAdoptionOutcome
+ Keiro.Projection.Catalog.Operations: data CatalogAdoptionRegistrationPreview
+ Keiro.Projection.Catalog.Operations: data CatalogAdoptionReport
+ Keiro.Projection.Catalog.Operations: data CatalogExternalReadRetirementReport
+ Keiro.Projection.Catalog.Operations: data CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: data CatalogOpsError
+ Keiro.Projection.Catalog.Operations: data CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: data CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: data CatalogRunReport
+ Keiro.Projection.Catalog.Operations: data CatalogStreamReprojectionPreview
+ Keiro.Projection.Catalog.Operations: data CatalogStreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: data CatalogVersionedRunReport
+ Keiro.Projection.Catalog.Operations: data CatalogVersionedStartOptions
+ Keiro.Projection.Catalog.Operations: data ProjectionCatalogOperations
+ Keiro.Projection.Catalog.Operations: data RebuildPreview
+ Keiro.Projection.Catalog.Operations: data RegisteredRebuildPreview
+ Keiro.Projection.Catalog.Operations: dropRetiredGeneration :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> TargetGenerationId -> Eff es (Either CatalogOpsError CatalogRetiredDropReport)
+ Keiro.Projection.Catalog.Operations: inspectExternalReadContract :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> ExternalReadContractId -> ExternalReadContractVersion -> Eff es (Either CatalogOpsError CatalogExternalReadRetirementReport)
+ Keiro.Projection.Catalog.Operations: inspectGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> RebuildRunId -> Eff es (Either CatalogOpsError CatalogRunReport)
+ Keiro.Projection.Catalog.Operations: inspectVersionedGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> RebuildRunId -> Eff es (Either CatalogOpsError CatalogVersionedRunReport)
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogAdoptionOutcome
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogAdoptionReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogExternalReadRetirementReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogRunReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionPreview
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.CatalogVersionedRunReport
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.RebuildPreview
+ Keiro.Projection.Catalog.Operations: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Projection.Catalog.Operations.RegisteredRebuildPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogAdoptionGroupPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogAdoptionOrphanPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogAdoptionOutcome
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogAdoptionRegistrationPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogAdoptionReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogExternalReadRetirementReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogOpsError
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogRunReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogVersionedRunReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.CatalogVersionedStartOptions
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.RebuildPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Classes.Eq Keiro.Projection.Catalog.Operations.RegisteredRebuildPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogAdoptionGroupPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogAdoptionOrphanPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogAdoptionOutcome
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogAdoptionRegistrationPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogAdoptionReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogExternalReadRetirementReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogOpsError
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogRunReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogVersionedRunReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.CatalogVersionedStartOptions
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.ProjectionCatalogOperations
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.RebuildPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Generics.Generic Keiro.Projection.Catalog.Operations.RegisteredRebuildPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogAdoptionGroupPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogAdoptionOrphanPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogAdoptionOutcome
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogAdoptionRegistrationPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogAdoptionReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogExternalReadRetirementReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogInventoryReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogOpsError
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogRetiredDropReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogRunReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogStreamReprojectionReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogVersionedRunReport
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.CatalogVersionedStartOptions
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.RebuildPreview
+ Keiro.Projection.Catalog.Operations: instance GHC.Internal.Show.Show Keiro.Projection.Catalog.Operations.RegisteredRebuildPreview
+ Keiro.Projection.Catalog.Operations: listRetiredGenerations :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> Eff es CatalogRetiredGenerationsReport
+ Keiro.Projection.Catalog.Operations: previewCatalogAdoption :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> NonEmpty RebuildGroupId -> Eff es (Either CatalogOpsError CatalogAdoptionReport)
+ Keiro.Projection.Catalog.Operations: previewGroupRebuild :: ProjectionCatalogOperations -> RebuildGroupId -> Either CatalogOpsError RebuildPreview
+ Keiro.Projection.Catalog.Operations: previewRegisteredGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> RebuildGroupId -> Eff es (Either CatalogOpsError RegisteredRebuildPreview)
+ Keiro.Projection.Catalog.Operations: previewRetiredGenerationDrop :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> TargetGenerationId -> Eff es (Either CatalogOpsError CatalogRetiredDropReport)
+ Keiro.Projection.Catalog.Operations: previewStreamReprojection :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> StreamReprojectionRequest -> Eff es (Either CatalogOpsError CatalogStreamReprojectionPreview)
+ Keiro.Projection.Catalog.Operations: projectionCatalogOperations :: ValidatedProjectionCatalog -> ProjectionCatalogOperations
+ Keiro.Projection.Catalog.Operations: reprojectCatalogStream :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> StreamReprojectionRequest -> Eff es (Either CatalogOpsError CatalogStreamReprojectionReport)
+ Keiro.Projection.Catalog.Operations: resumeGroupRebuild :: forall (es :: [Effect]). (IOE :> es, Store :> es) => ProjectionCatalogOperations -> RebuildRunId -> RebuildOptions -> Eff es (Either CatalogOpsError CatalogRunReport)
+ Keiro.Projection.Catalog.Operations: resumeVersionedGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> RebuildRunId -> Eff es (Either CatalogOpsError CatalogVersionedRunReport)
+ Keiro.Projection.Catalog.Operations: retireExternalReadContract :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> ExternalReadContractId -> ExternalReadContractVersion -> Eff es (Either CatalogOpsError CatalogExternalReadRetirementReport)
+ Keiro.Projection.Catalog.Operations: startGroupRebuild :: forall (es :: [Effect]). (IOE :> es, Store :> es) => ProjectionCatalogOperations -> RebuildGroupId -> RebuildOptions -> Eff es (Either CatalogOpsError CatalogRunReport)
+ Keiro.Projection.Catalog.Operations: startVersionedGroupRebuild :: forall (es :: [Effect]). Store :> es => ProjectionCatalogOperations -> CatalogVersionedStartOptions -> Eff es (Either CatalogOpsError CatalogVersionedRunReport)
+ Keiro.ReadModel: CategoryVisibleHead :: !Text -> HeadScope
+ Keiro.ReadModel: DurableQueryCursor :: !Text -> QueryCursorAuthority
+ Keiro.ReadModel: EntireVisibleLog :: HeadScope
+ Keiro.ReadModel: Immediate :: QueryFreshness
+ Keiro.ReadModel: NoQueryCursor :: QueryCursorAuthority
+ Keiro.ReadModel: ReadModelBlueprint :: !Text -> !Text -> !Text -> !Int -> !Text -> !QueryCursorAuthority -> !q -> Transaction r -> ReadModelBlueprint q r
+ Keiro.ReadModel: ReadModelDefinitionMissingCursor :: !Text -> !QueryFreshness -> ReadModelDefinitionError
+ Keiro.ReadModel: ReadModelDefinitionMissingPosition :: !Text -> ReadModelDefinitionError
+ Keiro.ReadModel: ReadModelMissingCursor :: !Text -> !QueryFreshness -> ReadModelError
+ Keiro.ReadModel: ReadModelMissingPosition :: !Text -> ReadModelError
+ Keiro.ReadModel: WaitForHead :: !HeadScope -> QueryFreshness
+ Keiro.ReadModel: WaitForPosition :: !PositionWaitOptions -> QueryFreshness
+ Keiro.ReadModel: [cursorAuthority] :: ReadModelBlueprint q r -> !QueryCursorAuthority
+ Keiro.ReadModel: data HeadScope
+ Keiro.ReadModel: data QueryCursorAuthority
+ Keiro.ReadModel: data QueryFreshness
+ Keiro.ReadModel: data ReadModelBlueprint q r
+ Keiro.ReadModel: data ReadModelDefinitionError
+ Keiro.ReadModel: defaultHeadWaitOptions :: PositionWaitOptions
+ Keiro.ReadModel: headWaitingReadModel :: HeadScope -> ReadModelBlueprint q r -> Either ReadModelDefinitionError (ReadModel q r)
+ Keiro.ReadModel: immediateReadModel :: ReadModelBlueprint q r -> ReadModel q r
+ Keiro.ReadModel: instance GHC.Classes.Eq Keiro.ReadModel.HeadScope
+ Keiro.ReadModel: instance GHC.Classes.Eq Keiro.ReadModel.QueryCursorAuthority
+ Keiro.ReadModel: instance GHC.Classes.Eq Keiro.ReadModel.QueryFreshness
+ Keiro.ReadModel: instance GHC.Classes.Eq Keiro.ReadModel.ReadModelDefinitionError
+ Keiro.ReadModel: instance GHC.Classes.Ord Keiro.ReadModel.HeadScope
+ Keiro.ReadModel: instance GHC.Internal.Generics.Generic (Keiro.ReadModel.ReadModelBlueprint q r)
+ Keiro.ReadModel: instance GHC.Internal.Generics.Generic Keiro.ReadModel.HeadScope
+ Keiro.ReadModel: instance GHC.Internal.Generics.Generic Keiro.ReadModel.QueryCursorAuthority
+ Keiro.ReadModel: instance GHC.Internal.Generics.Generic Keiro.ReadModel.QueryFreshness
+ Keiro.ReadModel: instance GHC.Internal.Generics.Generic Keiro.ReadModel.ReadModelDefinitionError
+ Keiro.ReadModel: instance GHC.Internal.Show.Show Keiro.ReadModel.HeadScope
+ Keiro.ReadModel: instance GHC.Internal.Show.Show Keiro.ReadModel.QueryCursorAuthority
+ Keiro.ReadModel: instance GHC.Internal.Show.Show Keiro.ReadModel.QueryFreshness
+ Keiro.ReadModel: instance GHC.Internal.Show.Show Keiro.ReadModel.ReadModelDefinitionError
+ Keiro.ReadModel: positionWaitingReadModel :: PositionWaitOptions -> ReadModelBlueprint q r -> Either ReadModelDefinitionError (ReadModel q r)
+ Keiro.ReadModel: readModelCursorAuthority :: ReadModel q r -> QueryCursorAuthority
+ Keiro.ReadModel: readModelDefaultFreshness :: ReadModel q r -> QueryFreshness
+ Keiro.ReadModel: runQueryWithFreshness :: forall (es :: [Effect]) q r. (IOE :> es, Store :> es) => Maybe KeiroMetrics -> QueryFreshness -> ReadModel q r -> q -> Eff es (Either ReadModelError r)
+ Keiro.ReadModel: subscriptionPositionFromInventory :: SubscriptionName -> SubscriptionCheckpointInventory -> Maybe GlobalPosition
+ Keiro.ReadModel.External: ExternalReadAllRowsRequiresSingleTarget :: !ExternalReadContractId -> !ExternalReadContractVersion -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadDefinitionGenerationConflict :: !ExternalReadContractId -> !ExternalReadContractVersion -> !Int -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadImmutableSignatureConflict :: !ExternalReadContractId -> !ExternalReadContractVersion -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadManagedObjectOwnershipConflict :: !Text -> !Text -> !Text -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadPrivateImplementationMissing :: !QualifiedFunction -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadPrivateImplementationResultMismatch :: !QualifiedFunction -> !QualifiedSqlType -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadResultTypeMissing :: !QualifiedSqlType -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadResultTypeNotComposite :: !QualifiedSqlType -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadRetiredContractCannotReactivate :: !ExternalReadContractId -> !ExternalReadContractVersion -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadRetirementAlreadyRetired :: !ExternalReadContractId -> !ExternalReadContractVersion -> ExternalReadRetirementError
+ Keiro.ReadModel.External: ExternalReadRetirementPreview :: !ExternalReadContractId -> !ExternalReadContractVersion -> !Text -> !Text -> !Int -> ![Text] -> ![Text] -> ExternalReadRetirementPreview
+ Keiro.ReadModel.External: ExternalReadRetirementUnknown :: !ExternalReadContractId -> !ExternalReadContractVersion -> ExternalReadRetirementError
+ Keiro.ReadModel.External: ExternalReadSurfaceDowngrade :: !ExternalReadContractId -> !ExternalReadContractVersion -> !Int -> !Int -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: ExternalReadUnmanagedObjectCollision :: !Text -> !Text -> !Text -> ExternalReadReconciliationError
+ Keiro.ReadModel.External: [contractId] :: ExternalReadRetirementPreview -> !ExternalReadContractId
+ Keiro.ReadModel.External: [contractVersion] :: ExternalReadRetirementPreview -> !ExternalReadContractVersion
+ Keiro.ReadModel.External: [currentState] :: ExternalReadRetirementPreview -> !Text
+ Keiro.ReadModel.External: [dependentObjects] :: ExternalReadRetirementPreview -> ![Text]
+ Keiro.ReadModel.External: [executeGrants] :: ExternalReadRetirementPreview -> ![Text]
+ Keiro.ReadModel.External: [publicFunction] :: ExternalReadRetirementPreview -> !Text
+ Keiro.ReadModel.External: [surfaceGeneration] :: ExternalReadRetirementPreview -> !Int
+ Keiro.ReadModel.External: data ExternalReadReconciliationError
+ Keiro.ReadModel.External: data ExternalReadRetirementError
+ Keiro.ReadModel.External: data ExternalReadRetirementPreview
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.ExternalReadReconciliationError
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.ExternalReadRetirementError
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.ExternalReadRetirementPreview
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.ExternalReadSpec
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.ManagedObject
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.PersistedContract
+ Keiro.ReadModel.External: instance GHC.Classes.Eq Keiro.ReadModel.External.PersistedContractKey
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.ContractState
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.ExternalReadReconciliationError
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.ExternalReadRetirementError
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.ExternalReadRetirementPreview
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.ExternalReadSpec
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.ManagedObject
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.PersistedContract
+ Keiro.ReadModel.External: instance GHC.Internal.Generics.Generic Keiro.ReadModel.External.PersistedContractKey
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.ExternalReadReconciliationError
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.ExternalReadRetirementError
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.ExternalReadRetirementPreview
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.ExternalReadSpec
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.ManagedObject
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.PersistedContract
+ Keiro.ReadModel.External: instance GHC.Internal.Show.Show Keiro.ReadModel.External.PersistedContractKey
+ Keiro.ReadModel.External: previewExternalReadContractRetirement :: forall (es :: [Effect]). Store :> es => ExternalReadContractId -> ExternalReadContractVersion -> Eff es (Either ExternalReadRetirementError ExternalReadRetirementPreview)
+ Keiro.ReadModel.External: reconcileExternalReadContracts :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> Eff es (Either ExternalReadReconciliationError ())
+ Keiro.ReadModel.External: reconcileExternalReadContractsForGroupsTx :: ValidatedProjectionCatalog -> Maybe (Set RebuildGroupId) -> Transaction (Either ExternalReadReconciliationError ())
+ Keiro.ReadModel.External: reconcileExternalReadContractsTx :: ValidatedProjectionCatalog -> Transaction (Either ExternalReadReconciliationError ())
+ Keiro.ReadModel.External: retireExternalReadContract :: forall (es :: [Effect]). Store :> es => ExternalReadContractId -> ExternalReadContractVersion -> Eff es (Either ExternalReadRetirementError ExternalReadRetirementPreview)
+ Keiro.ReadModel.Rebuild: AdoptExternalReadContract :: !ExternalReadReconciliationError -> CatalogAdoptionError
+ Keiro.ReadModel.Rebuild: AdoptGroupNotInCatalog :: !RebuildGroupId -> CatalogAdoptionError
+ Keiro.ReadModel.Rebuild: AdoptGroupNotLive :: !RebuildGroupId -> !GroupLifecycleStatus -> !Maybe RebuildRunId -> CatalogAdoptionError
+ Keiro.ReadModel.Rebuild: AdoptGroupUnregistered :: !RebuildGroupId -> CatalogAdoptionError
+ Keiro.ReadModel.Rebuild: AdoptionNew :: GroupAdoptionClass
+ Keiro.ReadModel.Rebuild: AdoptionSliceChanged :: !Text -> !Text -> GroupAdoptionClass
+ Keiro.ReadModel.Rebuild: AdoptionStaleFormat :: !Text -> GroupAdoptionClass
+ Keiro.ReadModel.Rebuild: AdoptionUnchanged :: GroupAdoptionClass
+ Keiro.ReadModel.Rebuild: ApplicationProvisioned :: VersionedTargetMode
+ Keiro.ReadModel.Rebuild: AsyncDedupBackfill :: ![(Text, UUID)] -> ![(Text, GlobalPosition)] -> AsyncDedupBackfill
+ Keiro.ReadModel.Rebuild: CatalogAdoptionPlan :: ![(RebuildGroupId, GroupAdoptionClass)] -> ![RebuildGroupId] -> ![RegistrationAdoption] -> ![OrphanedRegistration] -> CatalogAdoptionPlan
+ Keiro.ReadModel.Rebuild: CatalogAdoptionResult :: ![GroupRebuildMetadata] -> ![RegistrationAdoption] -> ![OrphanedRegistration] -> CatalogAdoptionResult
+ Keiro.ReadModel.Rebuild: CatalogRebuildAbandonFailed :: !GroupTransitionError -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildContractMismatch :: !RebuildRunId -> !Text -> !Text -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildDecodeFailed :: !RebuildRunId -> !SourceId -> !Text -> !GlobalPosition -> !ReplayDecodeError -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildGroupMissing :: !RebuildGroupId -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildInvalidPageSize :: !Int32 -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildInvariantFailed :: !RebuildRunId -> !Text -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildPromotionCheckpointsMissing :: !RebuildRunId -> ![SubscriptionName] -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildPromotionFailed :: !GroupTransitionError -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildRunAlreadyExists :: !RebuildRunId -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildRunNotActive :: !RebuildRunId -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildRunNotFound :: !RebuildRunId -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildRunPreCanonical :: !RebuildRunId -> !RebuildGroupId -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildSliceMismatch :: !RebuildRunId -> !Text -> !Text -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildStartAfterCapturedHead :: !GlobalPosition -> !GlobalPosition -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildStartFailed :: !RebuildStartError -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: CatalogRebuildVerificationFailed :: !RebuildRunId -> !Text -> !Text -> CatalogRebuildError
+ Keiro.ReadModel.Rebuild: GenerationDropped :: VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: GenerationRetired :: VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: GenerationServing :: VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: GenerationStaging :: VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: GroupFailed :: GroupLifecycleStatus
+ Keiro.ReadModel.Rebuild: GroupLive :: GroupLifecycleStatus
+ Keiro.ReadModel.Rebuild: GroupPreparation :: ![QualifiedTable] -> ![QualifiedTable] -> ![Text] -> ![Text] -> GroupPreparation
+ Keiro.ReadModel.Rebuild: GroupRebuildMetadata :: !RebuildGroupId -> !Text -> !GroupLifecycleStatus -> !Maybe RebuildRunId -> !Maybe Text -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> GroupRebuildMetadata
+ Keiro.ReadModel.Rebuild: GroupRebuilding :: GroupLifecycleStatus
+ Keiro.ReadModel.Rebuild: OrphanedRegistration :: !Text -> !RebuildGroupId -> OrphanedRegistration
+ Keiro.ReadModel.Rebuild: ProjectionGroupStatusV1 :: !RebuildGroupId -> !Text -> !Bool -> !Bool -> !Maybe ProjectionRevisionId -> !Int64 -> !ServingPositionBasis -> !Maybe GlobalPosition -> !Maybe RebuildRunId -> !Maybe ProjectionRevisionId -> !Maybe GlobalPosition -> !Maybe GlobalPosition -> ![Text] -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> ProjectionGroupStatusV1
+ Keiro.ReadModel.Rebuild: ProjectionServingBindingInvalid :: !RebuildGroupId -> !ProjectionRevisionId -> !Text -> ProjectionWriteFence
+ Keiro.ReadModel.Rebuild: ProjectionServingRevisionUnavailable :: !RebuildGroupId -> !ProjectionRevisionId -> ProjectionWriteFence
+ Keiro.ReadModel.Rebuild: ProjectionWriteFenced :: !RebuildGroupId -> !RebuildRunId -> ProjectionWriteFence
+ Keiro.ReadModel.Rebuild: ProjectionWriteGroupUnregistered :: !RebuildGroupId -> ProjectionWriteFence
+ Keiro.ReadModel.Rebuild: ProjectionWritesAllowed :: ![ProjectionWriteBinding] -> ProjectionWriteFence
+ Keiro.ReadModel.Rebuild: RebuildAdapterProgress :: !SourceId -> !Text -> !Int -> !Int64 -> !Int64 -> !Maybe GlobalPosition -> RebuildAdapterProgress
+ Keiro.ReadModel.Rebuild: RebuildCompletionTokenMismatch :: !RebuildGroupId -> !RebuildRunId -> GroupTransitionError
+ Keiro.ReadModel.Rebuild: RebuildFailure :: !Text -> !Text -> RebuildFailure
+ Keiro.ReadModel.Rebuild: RebuildFailureEvidence :: !Text -> !Text -> !Maybe SourceId -> !Maybe Text -> !Maybe GlobalPosition -> RebuildFailureEvidence
+ Keiro.ReadModel.Rebuild: RebuildGroupNotInCatalog :: !RebuildGroupId -> RebuildStartError
+ Keiro.ReadModel.Rebuild: RebuildGroupNotLive :: !RebuildGroupId -> !GroupLifecycleStatus -> !Maybe RebuildRunId -> RebuildStartError
+ Keiro.ReadModel.Rebuild: RebuildGroupSliceDrift :: !RebuildGroupId -> !Text -> !Text -> RebuildStartError
+ Keiro.ReadModel.Rebuild: RebuildGroupUnregistered :: !RebuildGroupId -> RebuildStartError
+ Keiro.ReadModel.Rebuild: RebuildHandleNoLongerActive :: !RebuildGroupId -> !RebuildRunId -> GroupTransitionError
+ Keiro.ReadModel.Rebuild: RebuildOptions :: !RebuildRequest -> !Int32 -> !Maybe KeiroMetrics -> RebuildOptions
+ Keiro.ReadModel.Rebuild: RebuildRequest :: !RebuildRunId -> !Text -> !Text -> !GlobalPosition -> RebuildRequest
+ Keiro.ReadModel.Rebuild: RebuildRunFailed :: RebuildRunStatus
+ Keiro.ReadModel.Rebuild: RebuildRunPromoted :: RebuildRunStatus
+ Keiro.ReadModel.Rebuild: RebuildRunReport :: !RebuildRunId -> !RebuildGroupId -> !Text -> !Text -> !Text -> !Text -> !GlobalPosition -> !Int32 -> !RebuildRunStatus -> !Maybe RebuildFailureEvidence -> ![RebuildSourceProgress] -> ![RebuildAdapterProgress] -> ![RebuildVerificationProgress] -> RebuildRunReport
+ Keiro.ReadModel.Rebuild: RebuildRunRunning :: RebuildRunStatus
+ Keiro.ReadModel.Rebuild: RebuildRunVerified :: RebuildRunStatus
+ Keiro.ReadModel.Rebuild: RebuildSourceProgress :: !SourceId -> !SourceScope -> !GlobalPosition -> !GlobalPosition -> !Maybe GlobalPosition -> !Int64 -> RebuildSourceProgress
+ Keiro.ReadModel.Rebuild: RebuildSubscriptionCheckpointsMissing :: !RebuildGroupId -> ![SubscriptionName] -> RebuildStartError
+ Keiro.ReadModel.Rebuild: RebuildVerificationProgress :: !Text -> !Text -> !Text -> !Maybe Text -> RebuildVerificationProgress
+ Keiro.ReadModel.Rebuild: RegisteredExternalReadContract :: !ExternalReadReconciliationError -> CatalogRegistrationError
+ Keiro.ReadModel.Rebuild: RegisteredGroupSliceDrift :: !RebuildGroupId -> !Text -> !Text -> CatalogRegistrationError
+ Keiro.ReadModel.Rebuild: RegisteredGroupStaleFingerprint :: !RebuildGroupId -> !Text -> CatalogRegistrationError
+ Keiro.ReadModel.Rebuild: RegisteredQueryModelDrift :: !Text -> !Text -> CatalogRegistrationError
+ Keiro.ReadModel.Rebuild: RegisteredQueryModelNotLive :: !Text -> CatalogRegistrationError
+ Keiro.ReadModel.Rebuild: RegistrationAdoption :: !Text -> !RebuildGroupId -> !RegistrationAdoptionAction -> RegistrationAdoption
+ Keiro.ReadModel.Rebuild: RegistrationInsert :: RegistrationAdoptionAction
+ Keiro.ReadModel.Rebuild: RegistrationUpdate :: RegistrationAdoptionAction
+ Keiro.ReadModel.Rebuild: RestrictedClone :: VersionedTargetMode
+ Keiro.ReadModel.Rebuild: ServingPositionAppend :: ServingPositionBasis
+ Keiro.ReadModel.Rebuild: ServingPositionCheckpoint :: ServingPositionBasis
+ Keiro.ReadModel.Rebuild: ServingPositionUnmanaged :: ServingPositionBasis
+ Keiro.ReadModel.Rebuild: StreamReprojectionActiveRebuild :: !RebuildGroupId -> !RebuildRunId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionClearEvidenceInvalid :: ![TargetId] -> ![TargetId] -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionClearFailed :: !Text -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionDecodeFailed :: !StreamVersion -> !ReplayDecodeError -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionDedupIdentityUnavailable :: !DedupKeyId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionEventLimitExceeded :: !StreamName -> !Int64 -> !Int64 -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionForeignEvent :: !StreamName -> !StreamVersion -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionGroupUnavailable :: !RebuildGroupId -> !Text -> !Bool -> !Bool -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionGroupUnregistered :: !RebuildGroupId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionHistoryIncomplete :: !StreamVersion -> !StreamVersion -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionHistoryUnavailable :: !StreamHistoryUnavailable -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionInvalidMaxEvents :: !Int64 -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionInvalidPageSize :: !Int32 -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionPolicyUnavailable :: !ProjectionRevisionId -> !ProjectionId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionProjectionGroupMismatch :: !ProjectionId -> !RebuildGroupId -> !RebuildGroupId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionReport :: !RebuildGroupId -> !ProjectionId -> !StreamName -> !ProjectionRevisionId -> !StreamVersion -> !Int64 -> ![StreamClearCount] -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Bool -> StreamReprojectionReport
+ Keiro.ReadModel.Rebuild: StreamReprojectionRequest :: !RebuildGroupId -> !ProjectionId -> !StreamName -> !Int32 -> !Int64 -> StreamReprojectionRequest
+ Keiro.ReadModel.Rebuild: StreamReprojectionServingBindingInvalid :: !RebuildGroupId -> !ProjectionRevisionId -> !Text -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionServingRevisionUnavailable :: !RebuildGroupId -> !ProjectionRevisionId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionSliceDrift :: !RebuildGroupId -> !Text -> !Text -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionSoftDeleted :: !StreamName -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionSourceMismatch :: !SourceId -> !StreamName -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionTruncated :: !StreamName -> !StreamVersion -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionUnknownProjection :: !ProjectionId -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: StreamReprojectionVerificationFailed :: !Text -> StreamReprojectionError
+ Keiro.ReadModel.Rebuild: UnknownGenerationLifecycle :: !Text -> VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: UnknownGroupStatus :: !Text -> GroupLifecycleStatus
+ Keiro.ReadModel.Rebuild: UnknownRebuildRunStatus :: !Text -> RebuildRunStatus
+ Keiro.ReadModel.Rebuild: UnknownVersionedRebuildPhase :: !Text -> !Text -> VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedAbandonResult :: !RebuildRunId -> !RebuildGroupId -> !Bool -> ![VersionedTargetGeneration] -> VersionedAbandonResult
+ Keiro.ReadModel.Rebuild: VersionedAbandoned :: VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedCandidateVerificationFailed :: !RebuildRunId -> !Text -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedCloneContractMismatch :: !TargetId -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedClonePromotionObjectMissing :: !TargetId -> !PromotionObjectName -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedCloneRefused :: !TargetId -> !QualifiedTable -> ![Text] -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedCutoverDeadlineExceeded :: !RebuildRunId -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedCutoverPendingHead :: VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedCutoverReplaying :: VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedExternalReadReconciliationFailed :: !RebuildRunId -> !ExternalReadReconciliationError -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedFailed :: VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedGenerationNotFound :: !TargetGenerationId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedGenerationNotRetired :: !TargetGenerationId -> !VersionedGenerationLifecycle -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedGroupNotInCatalog :: !RebuildGroupId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedGroupNotReady :: !RebuildGroupId -> !Text -> !Maybe Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedGroupSliceDrift :: !RebuildGroupId -> !Text -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedGroupUnregistered :: !RebuildGroupId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedInvalidCutoverLockTimeout :: !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedInvalidCutoverThreshold :: !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedInvalidPromotionDedupLimit :: !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedInvalidReplayPageSize :: !Int32 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedLeaseEvidence :: !UUID -> !Text -> !GlobalPosition -> !UTCTime -> !UTCTime -> !Maybe UTCTime -> VersionedLeaseEvidence
+ Keiro.ReadModel.Rebuild: VersionedObservedShapeMismatch :: !TargetId -> !Text -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedPersistedLifecycleInvalid :: !RebuildRunId -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedPhysicalRelationMissing :: !TargetId -> !QualifiedTable -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedPromoted :: VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedPromotionCheckpointsMissing :: !RebuildRunId -> ![SubscriptionName] -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedPromotionDedupLimitExceeded :: !RebuildRunId -> !Int64 -> !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedPromotionEvidenceMismatch :: !TargetId -> ![PromotionObjectName] -> ![PromotionObjectName] -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRebuildHandle :: !RebuildRunId -> !RebuildGroupId -> !ProjectionRevisionId -> !ProjectionRevisionId -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !VersionedLeaseEvidence -> ![VersionedTargetGeneration] -> VersionedRebuildHandle
+ Keiro.ReadModel.Rebuild: VersionedRebuildReport :: !RebuildRunId -> !RebuildGroupId -> !VersionedRebuildPhase -> !ProjectionRevisionId -> !ProjectionRevisionId -> !Int64 -> !GlobalPosition -> !Int32 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Maybe GlobalPosition -> !Bool -> !VersionedLeaseEvidence -> ![VersionedSourceProgress] -> ![VersionedTargetGeneration] -> ![VersionedTargetGeneration] -> VersionedRebuildReport
+ Keiro.ReadModel.Rebuild: VersionedRebuildRequest :: !RebuildRunId -> !RebuildGroupId -> !ProjectionRevisionId -> !ProjectionRevisionId -> !PhysicalTargets -> !VersionedTargetMode -> !Int32 -> !Int64 -> !Int64 -> !Int64 -> !HistoryRetentionLeaseRequest -> !Text -> !Text -> VersionedRebuildRequest
+ Keiro.ReadModel.Rebuild: VersionedRelationIdentityMismatch :: !TargetId -> !Int64 -> !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedReplayContractMismatch :: !RebuildRunId -> !Text -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedReplayDecodeFailed :: !RebuildRunId -> !Text -> !ReplayDecodeError -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedReplayInvariantFailed :: !RebuildRunId -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedReplayRunning :: VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: VersionedRetentionOwnerInvalid :: !RebuildRunId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRetentionReleaseFailed :: !RebuildRunId -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRetentionRenewalFailed :: !RebuildRunId -> !HistoryRetentionRenewalError -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRetiredDropBlocked :: !TargetGenerationId -> ![Text] -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRetiredDropResult :: !VersionedTargetGeneration -> !Bool -> VersionedRetiredDropResult
+ Keiro.ReadModel.Rebuild: VersionedRetiredGenerationPreview :: !VersionedTargetGeneration -> !Maybe RebuildRunId -> ![Text] -> ![Text] -> !Bool -> VersionedRetiredGenerationPreview
+ Keiro.ReadModel.Rebuild: VersionedRetiredNameCollision :: !TargetId -> !QualifiedTable -> !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRevisionGroupMismatch :: !ProjectionRevisionId -> !RebuildGroupId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRevisionNotInCatalog :: !ProjectionRevisionId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedRunIdentityConflict :: !RebuildRunId -> !Text -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedSchemaValidationFailed :: !TargetId -> ![TargetSchemaViolation] -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedSchemaValidatorMissing :: !TargetId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedServingRevisionMismatch :: !RebuildGroupId -> !ProjectionRevisionId -> !ProjectionRevisionId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedServingTargetBindingMismatch :: !TargetId -> !QualifiedTable -> !QualifiedTable -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedServingTargetSetMismatch :: !ProjectionRevisionId -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedSourceProgress :: !SourceId -> !SourceScope -> !GlobalPosition -> !GlobalPosition -> !Maybe GlobalPosition -> !Int64 -> VersionedSourceProgress
+ Keiro.ReadModel.Rebuild: VersionedStagingNameCollision :: !TargetId -> !QualifiedTable -> !Int64 -> VersionedRebuildError
+ Keiro.ReadModel.Rebuild: VersionedTargetGeneration :: !TargetGenerationId -> !RebuildGroupId -> !TargetId -> !ProjectionRevisionId -> !QualifiedTable -> !Int64 -> !TargetSchemaVersion -> !Text -> !Text -> !VersionedGenerationLifecycle -> VersionedTargetGeneration
+ Keiro.ReadModel.Rebuild: [action] :: RegistrationAdoption -> !RegistrationAdoptionAction
+ Keiro.ReadModel.Rebuild: [activeRunId] :: VersionedRetiredGenerationPreview -> !Maybe RebuildRunId
+ Keiro.ReadModel.Rebuild: [adapterOrder] :: RebuildAdapterProgress -> !Int
+ Keiro.ReadModel.Rebuild: [adapters] :: RebuildRunReport -> ![RebuildAdapterProgress]
+ Keiro.ReadModel.Rebuild: [adoptedGroups] :: CatalogAdoptionResult -> ![GroupRebuildMetadata]
+ Keiro.ReadModel.Rebuild: [alreadyAbandoned] :: VersionedAbandonResult -> !Bool
+ Keiro.ReadModel.Rebuild: [alreadyDropped] :: VersionedRetiredDropResult -> !Bool
+ Keiro.ReadModel.Rebuild: [appliedEvents] :: StreamReprojectionReport -> !Int64
+ Keiro.ReadModel.Rebuild: [applyCount] :: RebuildAdapterProgress -> !Int64
+ Keiro.ReadModel.Rebuild: [backfillFloors] :: AsyncDedupBackfill -> ![(Text, GlobalPosition)]
+ Keiro.ReadModel.Rebuild: [backfillPairs] :: AsyncDedupBackfill -> ![(Text, UUID)]
+ Keiro.ReadModel.Rebuild: [boundGroupId] :: OrphanedRegistration -> !RebuildGroupId
+ Keiro.ReadModel.Rebuild: [candidateGenerations] :: VersionedRebuildReport -> ![VersionedTargetGeneration]
+ Keiro.ReadModel.Rebuild: [candidateRebuildHead] :: ProjectionGroupStatusV1 -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [candidateRebuildPosition] :: ProjectionGroupStatusV1 -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [candidateRevisionId] :: VersionedRebuildReport -> !ProjectionRevisionId
+ Keiro.ReadModel.Rebuild: [capturedHead] :: RebuildRunReport -> !GlobalPosition
+ Keiro.ReadModel.Rebuild: [catalogFingerprint] :: RebuildRunReport -> !Text
+ Keiro.ReadModel.Rebuild: [clearTargets] :: GroupPreparation -> ![QualifiedTable]
+ Keiro.ReadModel.Rebuild: [clearedRows] :: StreamReprojectionReport -> ![StreamClearCount]
+ Keiro.ReadModel.Rebuild: [completedAt] :: GroupRebuildMetadata -> !Maybe UTCTime
+ Keiro.ReadModel.Rebuild: [completedThrough] :: RebuildAdapterProgress -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [configuredPageSize] :: RebuildRunReport -> !Int32
+ Keiro.ReadModel.Rebuild: [contractFingerprint] :: RebuildRunReport -> !Text
+ Keiro.ReadModel.Rebuild: [cursorPosition] :: RebuildSourceProgress -> !GlobalPosition
+ Keiro.ReadModel.Rebuild: [cutoverLockTimeoutMs] :: VersionedRebuildReport -> !Int64
+ Keiro.ReadModel.Rebuild: [cutoverThreshold] :: VersionedRebuildReport -> !Int64
+ Keiro.ReadModel.Rebuild: [dedupExisting] :: StreamReprojectionReport -> !Int64
+ Keiro.ReadModel.Rebuild: [dedupInserted] :: StreamReprojectionReport -> !Int64
+ Keiro.ReadModel.Rebuild: [dedupProvisionalHead] :: VersionedRebuildReport -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [droppable] :: VersionedRetiredGenerationPreview -> !Bool
+ Keiro.ReadModel.Rebuild: [droppedGenerations] :: VersionedAbandonResult -> ![VersionedTargetGeneration]
+ Keiro.ReadModel.Rebuild: [evaluationCount] :: RebuildAdapterProgress -> !Int64
+ Keiro.ReadModel.Rebuild: [eventCount] :: RebuildSourceProgress -> !Int64
+ Keiro.ReadModel.Rebuild: [exhaustedThrough] :: RebuildSourceProgress -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [expectedShapeId] :: VersionedTargetGeneration -> !Text
+ Keiro.ReadModel.Rebuild: [expiresAt] :: VersionedLeaseEvidence -> !UTCTime
+ Keiro.ReadModel.Rebuild: [externalDependencies] :: VersionedRetiredGenerationPreview -> ![Text]
+ Keiro.ReadModel.Rebuild: [failedAt] :: ProjectionGroupStatusV1 -> !Maybe UTCTime
+ Keiro.ReadModel.Rebuild: [failureCode] :: RebuildFailureEvidence -> !Text
+ Keiro.ReadModel.Rebuild: [failureDetail] :: RebuildFailureEvidence -> !Text
+ Keiro.ReadModel.Rebuild: [failureEvidence] :: RebuildRunReport -> !Maybe RebuildFailureEvidence
+ Keiro.ReadModel.Rebuild: [failurePosition] :: RebuildFailureEvidence -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [failureProjectionId] :: RebuildFailureEvidence -> !Maybe Text
+ Keiro.ReadModel.Rebuild: [failureSourceId] :: RebuildFailureEvidence -> !Maybe SourceId
+ Keiro.ReadModel.Rebuild: [generationId] :: VersionedTargetGeneration -> !TargetGenerationId
+ Keiro.ReadModel.Rebuild: [generation] :: VersionedRetiredDropResult -> !VersionedTargetGeneration
+ Keiro.ReadModel.Rebuild: [groupId] :: ProjectionGroupStatusV1 -> !RebuildGroupId
+ Keiro.ReadModel.Rebuild: [groupSliceFingerprint] :: RebuildRunReport -> !Text
+ Keiro.ReadModel.Rebuild: [groupStates] :: CatalogAdoptionPlan -> ![(RebuildGroupId, GroupAdoptionClass)]
+ Keiro.ReadModel.Rebuild: [lastPromotedAt] :: ProjectionGroupStatusV1 -> !Maybe UTCTime
+ Keiro.ReadModel.Rebuild: [leaseId] :: VersionedLeaseEvidence -> !UUID
+ Keiro.ReadModel.Rebuild: [lease] :: VersionedRebuildReport -> !VersionedLeaseEvidence
+ Keiro.ReadModel.Rebuild: [lifecyclePhase] :: ProjectionGroupStatusV1 -> !Text
+ Keiro.ReadModel.Rebuild: [lifecycle] :: VersionedTargetGeneration -> !VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: [maxEvents] :: StreamReprojectionReport -> !Int64
+ Keiro.ReadModel.Rebuild: [observedShapeFingerprint] :: VersionedTargetGeneration -> !Text
+ Keiro.ReadModel.Rebuild: [orphanedRegistrations] :: CatalogAdoptionPlan -> ![OrphanedRegistration]
+ Keiro.ReadModel.Rebuild: [owner] :: VersionedLeaseEvidence -> !Text
+ Keiro.ReadModel.Rebuild: [pageSize] :: StreamReprojectionRequest -> !Int32
+ Keiro.ReadModel.Rebuild: [phase] :: VersionedRebuildReport -> !VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: [physicalTable] :: VersionedTargetGeneration -> !QualifiedTable
+ Keiro.ReadModel.Rebuild: [preservedTargets] :: GroupPreparation -> ![QualifiedTable]
+ Keiro.ReadModel.Rebuild: [projectionId] :: RebuildAdapterProgress -> !Text
+ Keiro.ReadModel.Rebuild: [promotionDedupLimit] :: VersionedRebuildReport -> !Int64
+ Keiro.ReadModel.Rebuild: [promotionPrepared] :: VersionedRebuildReport -> !Bool
+ Keiro.ReadModel.Rebuild: [protectedThrough] :: VersionedLeaseEvidence -> !GlobalPosition
+ Keiro.ReadModel.Rebuild: [queryModels] :: ProjectionGroupStatusV1 -> ![Text]
+ Keiro.ReadModel.Rebuild: [readsAllowed] :: ProjectionGroupStatusV1 -> !Bool
+ Keiro.ReadModel.Rebuild: [rebuildGroupId] :: RebuildRunReport -> !RebuildGroupId
+ Keiro.ReadModel.Rebuild: [rebuildMetrics] :: RebuildOptions -> !Maybe KeiroMetrics
+ Keiro.ReadModel.Rebuild: [rebuildRequest] :: RebuildOptions -> !RebuildRequest
+ Keiro.ReadModel.Rebuild: [rebuildRunId] :: RebuildRunReport -> !RebuildRunId
+ Keiro.ReadModel.Rebuild: [rebuildStartedAt] :: ProjectionGroupStatusV1 -> !Maybe UTCTime
+ Keiro.ReadModel.Rebuild: [registrationOutcomes] :: CatalogAdoptionResult -> ![RegistrationAdoption]
+ Keiro.ReadModel.Rebuild: [registrations] :: CatalogAdoptionPlan -> ![RegistrationAdoption]
+ Keiro.ReadModel.Rebuild: [registryName] :: OrphanedRegistration -> !Text
+ Keiro.ReadModel.Rebuild: [relationOid] :: VersionedTargetGeneration -> !Int64
+ Keiro.ReadModel.Rebuild: [releasedAt] :: VersionedLeaseEvidence -> !Maybe UTCTime
+ Keiro.ReadModel.Rebuild: [removedGroups] :: CatalogAdoptionPlan -> ![RebuildGroupId]
+ Keiro.ReadModel.Rebuild: [removedOrphans] :: CatalogAdoptionResult -> ![OrphanedRegistration]
+ Keiro.ReadModel.Rebuild: [renewedAt] :: VersionedLeaseEvidence -> !UTCTime
+ Keiro.ReadModel.Rebuild: [replayFrom] :: RebuildRequest -> !GlobalPosition
+ Keiro.ReadModel.Rebuild: [replayPageSize] :: RebuildOptions -> !Int32
+ Keiro.ReadModel.Rebuild: [replayedEvents] :: StreamReprojectionReport -> !Int64
+ Keiro.ReadModel.Rebuild: [requestReason] :: VersionedRebuildRequest -> !Text
+ Keiro.ReadModel.Rebuild: [requestedBy] :: VersionedRebuildRequest -> !Text
+ Keiro.ReadModel.Rebuild: [resetDedupNames] :: GroupPreparation -> ![Text]
+ Keiro.ReadModel.Rebuild: [resetSubscriptionNames] :: GroupPreparation -> ![Text]
+ Keiro.ReadModel.Rebuild: [retentionLeaseRequest] :: VersionedRebuildRequest -> !HistoryRetentionLeaseRequest
+ Keiro.ReadModel.Rebuild: [revisionId] :: VersionedTargetGeneration -> !ProjectionRevisionId
+ Keiro.ReadModel.Rebuild: [runStatus] :: RebuildRunReport -> !RebuildRunStatus
+ Keiro.ReadModel.Rebuild: [runnerFormatVersion] :: RebuildRunReport -> !Text
+ Keiro.ReadModel.Rebuild: [schemaVersion] :: VersionedTargetGeneration -> !TargetSchemaVersion
+ Keiro.ReadModel.Rebuild: [servingAppliedPosition] :: ProjectionGroupStatusV1 -> !Maybe GlobalPosition
+ Keiro.ReadModel.Rebuild: [servingEpoch] :: VersionedRebuildReport -> !Int64
+ Keiro.ReadModel.Rebuild: [servingGenerations] :: VersionedRebuildReport -> ![VersionedTargetGeneration]
+ Keiro.ReadModel.Rebuild: [servingPositionBasis] :: ProjectionGroupStatusV1 -> !ServingPositionBasis
+ Keiro.ReadModel.Rebuild: [servingRevisionId] :: StreamReprojectionReport -> !ProjectionRevisionId
+ Keiro.ReadModel.Rebuild: [servingTargets] :: VersionedRebuildRequest -> !PhysicalTargets
+ Keiro.ReadModel.Rebuild: [sliceFingerprint] :: GroupRebuildMetadata -> !Text
+ Keiro.ReadModel.Rebuild: [sourceId] :: RebuildAdapterProgress -> !SourceId
+ Keiro.ReadModel.Rebuild: [sourceScope] :: RebuildSourceProgress -> !SourceScope
+ Keiro.ReadModel.Rebuild: [sources] :: RebuildRunReport -> ![RebuildSourceProgress]
+ Keiro.ReadModel.Rebuild: [stagedDedupCount] :: VersionedRebuildReport -> !Int64
+ Keiro.ReadModel.Rebuild: [startedAt] :: GroupRebuildMetadata -> !Maybe UTCTime
+ Keiro.ReadModel.Rebuild: [status] :: GroupRebuildMetadata -> !GroupLifecycleStatus
+ Keiro.ReadModel.Rebuild: [streamName] :: StreamReprojectionReport -> !StreamName
+ Keiro.ReadModel.Rebuild: [streamVersion] :: StreamReprojectionReport -> !StreamVersion
+ Keiro.ReadModel.Rebuild: [supportedReadContracts] :: VersionedRetiredGenerationPreview -> ![Text]
+ Keiro.ReadModel.Rebuild: [targetId] :: VersionedTargetGeneration -> !TargetId
+ Keiro.ReadModel.Rebuild: [targetMode] :: VersionedRebuildRequest -> !VersionedTargetMode
+ Keiro.ReadModel.Rebuild: [targetPosition] :: RebuildSourceProgress -> !GlobalPosition
+ Keiro.ReadModel.Rebuild: [verificationDetail] :: RebuildVerificationProgress -> !Maybe Text
+ Keiro.ReadModel.Rebuild: [verificationId] :: RebuildVerificationProgress -> !Text
+ Keiro.ReadModel.Rebuild: [verificationStatus] :: RebuildVerificationProgress -> !Text
+ Keiro.ReadModel.Rebuild: [verificationVersion] :: RebuildVerificationProgress -> !Text
+ Keiro.ReadModel.Rebuild: [verifications] :: RebuildRunReport -> ![RebuildVerificationProgress]
+ Keiro.ReadModel.Rebuild: [verified] :: StreamReprojectionReport -> !Bool
+ Keiro.ReadModel.Rebuild: [writesAllowed] :: ProjectionGroupStatusV1 -> !Bool
+ Keiro.ReadModel.Rebuild: abandonCatalogRebuild :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> RebuildRunId -> RebuildFailure -> Eff es (Either CatalogRebuildError RebuildRunReport)
+ Keiro.ReadModel.Rebuild: abandonGroupRebuild :: forall (es :: [Effect]). Store :> es => GroupRebuildHandle -> RebuildFailure -> Eff es (Either GroupTransitionError GroupRebuildMetadata)
+ Keiro.ReadModel.Rebuild: abandonVersionedRebuild :: forall (es :: [Effect]). Store :> es => RebuildRunId -> Eff es (Either VersionedRebuildError VersionedAbandonResult)
+ Keiro.ReadModel.Rebuild: adoptCatalogGroups :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> NonEmpty RebuildGroupId -> Eff es (Either CatalogAdoptionError CatalogAdoptionResult)
+ Keiro.ReadModel.Rebuild: applyVersionedReplayEvent :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> RebuildRunId -> RecordedEvent -> Eff es (Either VersionedRebuildError Int)
+ Keiro.ReadModel.Rebuild: beginGroupRebuild :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> RebuildGroupId -> RebuildRequest -> Eff es (Either RebuildStartError GroupRebuildHandle)
+ Keiro.ReadModel.Rebuild: beginVersionedRebuild :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> VersionedRebuildRequest -> Eff es (Either VersionedRebuildError VersionedRebuildHandle)
+ Keiro.ReadModel.Rebuild: collectAsyncDedupBackfill :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> RebuildGroupId -> Int32 -> GlobalPosition -> Eff es (Either [SubscriptionName] AsyncDedupBackfill)
+ Keiro.ReadModel.Rebuild: data AsyncDedupBackfill
+ Keiro.ReadModel.Rebuild: data CatalogAdoptionError
+ Keiro.ReadModel.Rebuild: data CatalogAdoptionPlan
+ Keiro.ReadModel.Rebuild: data CatalogAdoptionResult
+ Keiro.ReadModel.Rebuild: data CatalogRebuildError
+ Keiro.ReadModel.Rebuild: data CatalogRegistrationError
+ Keiro.ReadModel.Rebuild: data GroupAdoptionClass
+ Keiro.ReadModel.Rebuild: data GroupCompletionToken
+ Keiro.ReadModel.Rebuild: data GroupLifecycleStatus
+ Keiro.ReadModel.Rebuild: data GroupPreparation
+ Keiro.ReadModel.Rebuild: data GroupRebuildHandle
+ Keiro.ReadModel.Rebuild: data GroupRebuildMetadata
+ Keiro.ReadModel.Rebuild: data GroupTransitionError
+ Keiro.ReadModel.Rebuild: data OrphanedRegistration
+ Keiro.ReadModel.Rebuild: data ProjectionGroupStatusV1
+ Keiro.ReadModel.Rebuild: data ProjectionWriteFence
+ Keiro.ReadModel.Rebuild: data RebuildAdapterProgress
+ Keiro.ReadModel.Rebuild: data RebuildFailure
+ Keiro.ReadModel.Rebuild: data RebuildFailureEvidence
+ Keiro.ReadModel.Rebuild: data RebuildOptions
+ Keiro.ReadModel.Rebuild: data RebuildRequest
+ Keiro.ReadModel.Rebuild: data RebuildRunId
+ Keiro.ReadModel.Rebuild: data RebuildRunReport
+ Keiro.ReadModel.Rebuild: data RebuildRunStatus
+ Keiro.ReadModel.Rebuild: data RebuildSourceProgress
+ Keiro.ReadModel.Rebuild: data RebuildStartError
+ Keiro.ReadModel.Rebuild: data RebuildVerificationProgress
+ Keiro.ReadModel.Rebuild: data RegistrationAdoption
+ Keiro.ReadModel.Rebuild: data RegistrationAdoptionAction
+ Keiro.ReadModel.Rebuild: data ServingPositionBasis
+ Keiro.ReadModel.Rebuild: data StreamReprojectionError
+ Keiro.ReadModel.Rebuild: data StreamReprojectionReport
+ Keiro.ReadModel.Rebuild: data StreamReprojectionRequest
+ Keiro.ReadModel.Rebuild: data VersionedAbandonResult
+ Keiro.ReadModel.Rebuild: data VersionedGenerationLifecycle
+ Keiro.ReadModel.Rebuild: data VersionedLeaseEvidence
+ Keiro.ReadModel.Rebuild: data VersionedRebuildError
+ Keiro.ReadModel.Rebuild: data VersionedRebuildHandle
+ Keiro.ReadModel.Rebuild: data VersionedRebuildPhase
+ Keiro.ReadModel.Rebuild: data VersionedRebuildReport
+ Keiro.ReadModel.Rebuild: data VersionedRebuildRequest
+ Keiro.ReadModel.Rebuild: data VersionedRetiredDropResult
+ Keiro.ReadModel.Rebuild: data VersionedRetiredGenerationPreview
+ Keiro.ReadModel.Rebuild: data VersionedSourceProgress
+ Keiro.ReadModel.Rebuild: data VersionedTargetGeneration
+ Keiro.ReadModel.Rebuild: data VersionedTargetMode
+ Keiro.ReadModel.Rebuild: defaultRebuildOptions :: RebuildRequest -> RebuildOptions
+ Keiro.ReadModel.Rebuild: dropVersionedRetiredGeneration :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> TargetGenerationId -> Eff es (Either VersionedRebuildError VersionedRetiredDropResult)
+ Keiro.ReadModel.Rebuild: finishGroupRebuild :: forall (es :: [Effect]). Store :> es => GroupRebuildHandle -> GroupCompletionToken -> Eff es (Either GroupTransitionError GroupRebuildMetadata)
+ Keiro.ReadModel.Rebuild: groupRebuildHandleGroup :: GroupRebuildHandle -> RebuildGroupId
+ Keiro.ReadModel.Rebuild: groupRebuildHandlePreparation :: GroupRebuildHandle -> GroupPreparation
+ Keiro.ReadModel.Rebuild: groupRebuildHandleResetCheckpointKeys :: GroupRebuildHandle -> [SubscriptionCheckpointKey]
+ Keiro.ReadModel.Rebuild: groupRebuildHandleRun :: GroupRebuildHandle -> RebuildRunId
+ Keiro.ReadModel.Rebuild: groupRebuildHandleSliceFingerprint :: GroupRebuildHandle -> GroupSliceFingerprint
+ Keiro.ReadModel.Rebuild: insertProjectionDedupBatchStmt :: Statement ([Text], [UUID]) Int64
+ Keiro.ReadModel.Rebuild: inspectCatalogRebuild :: forall (es :: [Effect]). Store :> es => RebuildRunId -> Eff es (Either CatalogRebuildError RebuildRunReport)
+ Keiro.ReadModel.Rebuild: inspectVersionedRebuild :: forall (es :: [Effect]). Store :> es => RebuildRunId -> Eff es (Either VersionedRebuildError VersionedRebuildReport)
+ Keiro.ReadModel.Rebuild: listProjectionGroupStatuses :: forall (es :: [Effect]). Store :> es => Eff es [ProjectionGroupStatusV1]
+ Keiro.ReadModel.Rebuild: listVersionedRetiredGenerations :: forall (es :: [Effect]). Store :> es => Eff es [VersionedTargetGeneration]
+ Keiro.ReadModel.Rebuild: lookupProjectionGroupStatus :: forall (es :: [Effect]). Store :> es => RebuildGroupId -> Eff es (Maybe ProjectionGroupStatusV1)
+ Keiro.ReadModel.Rebuild: lookupProjectionRebuildGroup :: forall (es :: [Effect]). Store :> es => RebuildGroupId -> Eff es (Maybe GroupRebuildMetadata)
+ Keiro.ReadModel.Rebuild: mkRebuildRunId :: Text -> Either Text RebuildRunId
+ Keiro.ReadModel.Rebuild: preCanonicalRunSliceSentinel :: Text
+ Keiro.ReadModel.Rebuild: previewCatalogAdoption :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> Eff es CatalogAdoptionPlan
+ Keiro.ReadModel.Rebuild: previewVersionedRetiredDrop :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> TargetGenerationId -> Eff es (Either VersionedRebuildError VersionedRetiredGenerationPreview)
+ Keiro.ReadModel.Rebuild: rebuildRunIdText :: RebuildRunId -> Text
+ Keiro.ReadModel.Rebuild: registerProjectionCatalog :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> Eff es (Either CatalogRegistrationError [GroupRebuildMetadata])
+ Keiro.ReadModel.Rebuild: reprojectStream :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> StreamReprojectionRequest -> Eff es (Either StreamReprojectionError StreamReprojectionReport)
+ Keiro.ReadModel.Rebuild: resetDeclaredSubscriptions :: GroupPreparation -> GlobalPosition -> Transaction SubscriptionCheckpointResetReport
+ Keiro.ReadModel.Rebuild: resumeCatalogRebuild :: forall (es :: [Effect]). (IOE :> es, Store :> es) => ValidatedProjectionCatalog -> RebuildRunId -> RebuildOptions -> Eff es (Either CatalogRebuildError RebuildRunReport)
+ Keiro.ReadModel.Rebuild: resumeVersionedRebuild :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> RebuildRunId -> Eff es (Either VersionedRebuildError VersionedRebuildReport)
+ Keiro.ReadModel.Rebuild: startCatalogRebuild :: forall (es :: [Effect]). (IOE :> es, Store :> es) => ValidatedProjectionCatalog -> RebuildGroupId -> RebuildOptions -> Eff es (Either CatalogRebuildError RebuildRunReport)
+ Keiro.ReadModel.Rebuild: verifyVersionedCandidate :: forall (es :: [Effect]). Store :> es => ValidatedProjectionCatalog -> RebuildRunId -> Eff es (Either VersionedRebuildError ())
+ Keiro.ReadModel.Schema: [rebuildGroupId] :: ReadModelMetadata -> !Text
+ Keiro.Router: DeclarativeRouter :: !Text -> !input -> Text -> !RouterSelectionContract -> !input -> Eff es (Either RouterSelectionFailure [PMCommand targetCi]) -> !ValidatedEventStream targetPhi targetRs targetState targetCi targetCo -> !Stream targetCi -> [InlineProjection targetCo] -> DeclarativeRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect])
+ Keiro.Router: DeclarativeSelectionDispatched :: !RouterResult target -> DeclarativeRouterResult (target :: k)
+ Keiro.Router: DeclarativeSelectionEmpty :: DeclarativeRouterResult (target :: k)
+ Keiro.Router: DeclarativeSelectionFailed :: !RouterSelectionFailure -> DeclarativeRouterResult (target :: k)
+ Keiro.Router: DomainRouter :: !Text -> !input -> Text -> !input -> Eff es [PMCommand targetCi] -> !DomainCommandHandler targetPhi targetRs targetState targetCi targetCo rejection noOp -> !Stream targetCi -> [InlineProjection targetCo] -> DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect])
+ Keiro.Router: DomainRouterResult :: [DomainPMCommandResult target co rejection noOp] -> DomainRouterResult (target :: k) co rejection noOp
+ Keiro.Router: [select] :: DeclarativeRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !input -> Eff es (Either RouterSelectionFailure [PMCommand targetCi])
+ Keiro.Router: [selectionContract] :: DeclarativeRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !RouterSelectionContract
+ Keiro.Router: [targetHandler] :: DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]) -> !DomainCommandHandler targetPhi targetRs targetState targetCi targetCo rejection noOp
+ Keiro.Router: data DeclarativeRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect])
+ Keiro.Router: data DeclarativeRouterResult (target :: k)
+ Keiro.Router: data DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect])
+ Keiro.Router: instance GHC.Internal.Generics.Generic (Keiro.Router.DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es)
+ Keiro.Router: instance GHC.Internal.Generics.Generic (Keiro.Router.DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es)
+ Keiro.Router: instance forall k (target :: k) co rejection noOp. (GHC.Classes.Eq co, GHC.Classes.Eq rejection, GHC.Classes.Eq noOp) => GHC.Classes.Eq (Keiro.Router.DomainRouterResult target co rejection noOp)
+ Keiro.Router: instance forall k (target :: k) co rejection noOp. (GHC.Internal.Show.Show co, GHC.Internal.Show.Show rejection, GHC.Internal.Show.Show noOp) => GHC.Internal.Show.Show (Keiro.Router.DomainRouterResult target co rejection noOp)
+ Keiro.Router: instance forall k (target :: k) co rejection noOp. GHC.Internal.Generics.Generic (Keiro.Router.DomainRouterResult target co rejection noOp)
+ Keiro.Router: instance forall k (target :: k). GHC.Classes.Eq (Keiro.Router.DeclarativeRouterResult target)
+ Keiro.Router: instance forall k (target :: k). GHC.Internal.Generics.Generic (Keiro.Router.DeclarativeRouterResult target)
+ Keiro.Router: instance forall k (target :: k). GHC.Internal.Show.Show (Keiro.Router.DeclarativeRouterResult target)
+ Keiro.Router: newtype DomainRouterResult (target :: k) co rejection noOp
+ Keiro.Router: runDeclarativeRouterOnce :: forall input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg targetPhi (RegFile targetRs, targetCi), Eq targetCi, Eq targetCo) => RunCommandOptions -> DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es -> RecordedEvent -> input -> Eff es (DeclarativeRouterResult (EventStream targetPhi targetRs targetState targetCi targetCo))
+ Keiro.Router: runDeclarativeRouterWorker :: forall msg input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg targetPhi (RegFile targetRs, targetCi), Eq targetCi, Eq targetCo) => RunCommandOptions -> DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es -> Adapter es msg -> (msg -> Maybe (RecordedEvent, input)) -> Eff es ()
+ Keiro.Router: runDeclarativeRouterWorkerWith :: forall msg input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg targetPhi (RegFile targetRs, targetCi), Eq targetCi, Eq targetCo) => WorkerOptions es msg -> RunCommandOptions -> DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es -> Adapter es msg -> (msg -> Maybe (RecordedEvent, input)) -> Eff es ()
+ Keiro.Router: runDomainRouterOnce :: forall input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg targetPhi (RegFile targetRs, targetCi), Eq targetCo) => RunCommandOptions -> DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es -> RecordedEvent -> input -> Eff es (DomainRouterResult (EventStream targetPhi targetRs targetState targetCi targetCo) targetCo rejection noOp)
+ Keiro.Router: runDomainRouterWorker :: forall msg input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg targetPhi (RegFile targetRs, targetCi), Eq targetCo) => RunCommandOptions -> DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es -> Adapter es msg -> (msg -> Maybe (RecordedEvent, input)) -> Eff es ()
+ Keiro.Router: runDomainRouterWorkerWith :: forall msg input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg targetPhi (RegFile targetRs, targetCi), Eq targetCo) => WorkerOptions es msg -> RunCommandOptions -> DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es -> Adapter es msg -> (msg -> Maybe (RecordedEvent, input)) -> Eff es ()
+ Keiro.Router.Selection: DedupeByTargetStream :: SelectionDedupe
+ Keiro.Router.Selection: EmptyAck :: EmptySelectionPolicy
+ Keiro.Router.Selection: EmptyDeadLetter :: EmptySelectionPolicy
+ Keiro.Router.Selection: EmptyHalt :: EmptySelectionPolicy
+ Keiro.Router.Selection: EmptyRetry :: EmptySelectionPolicy
+ Keiro.Router.Selection: FailureDeadLetter :: SelectionFailurePolicy
+ Keiro.Router.Selection: FailureHalt :: SelectionFailurePolicy
+ Keiro.Router.Selection: FailureRetry :: SelectionFailurePolicy
+ Keiro.Router.Selection: OrderByTargetStream :: SelectionOrder
+ Keiro.Router.Selection: RetainSuccesses :: PartialDispatchPolicy
+ Keiro.Router.Selection: RouterSelectionContract :: !SelectionIdentity -> !SelectionVersion -> !SelectionFingerprint -> !RecipientLimit -> !SelectionOrder -> !SelectionDedupe -> !EmptySelectionPolicy -> !SelectionFailurePolicy -> !RedeliveryPolicy -> !PartialDispatchPolicy -> RouterSelectionContract
+ Keiro.Router.Selection: SelectionConflictingCommands :: !StreamName -> RouterSelectionFailure
+ Keiro.Router.Selection: SelectionEvaluationFailed :: !Text -> RouterSelectionFailure
+ Keiro.Router.Selection: SelectionFingerprint :: Text -> SelectionFingerprint
+ Keiro.Router.Selection: SelectionIdentity :: Text -> SelectionIdentity
+ Keiro.Router.Selection: SelectionQueryFailed :: !Text -> RouterSelectionFailure
+ Keiro.Router.Selection: SelectionRecipientOverflow :: !RecipientLimit -> !Natural -> RouterSelectionFailure
+ Keiro.Router.Selection: StableUnion :: RedeliveryPolicy
+ Keiro.Router.Selection: [dedupe] :: RouterSelectionContract -> !SelectionDedupe
+ Keiro.Router.Selection: [emptyPolicy] :: RouterSelectionContract -> !EmptySelectionPolicy
+ Keiro.Router.Selection: [failurePolicy] :: RouterSelectionContract -> !SelectionFailurePolicy
+ Keiro.Router.Selection: [fingerprint] :: RouterSelectionContract -> !SelectionFingerprint
+ Keiro.Router.Selection: [identity] :: RouterSelectionContract -> !SelectionIdentity
+ Keiro.Router.Selection: [limit] :: RouterSelectionContract -> !RecipientLimit
+ Keiro.Router.Selection: [order] :: RouterSelectionContract -> !SelectionOrder
+ Keiro.Router.Selection: [partialPolicy] :: RouterSelectionContract -> !PartialDispatchPolicy
+ Keiro.Router.Selection: [redeliveryPolicy] :: RouterSelectionContract -> !RedeliveryPolicy
+ Keiro.Router.Selection: [version] :: RouterSelectionContract -> !SelectionVersion
+ Keiro.Router.Selection: data EmptySelectionPolicy
+ Keiro.Router.Selection: data PartialDispatchPolicy
+ Keiro.Router.Selection: data RecipientLimit
+ Keiro.Router.Selection: data RedeliveryPolicy
+ Keiro.Router.Selection: data RouterSelectionContract
+ Keiro.Router.Selection: data RouterSelectionFailure
+ Keiro.Router.Selection: data SelectionDedupe
+ Keiro.Router.Selection: data SelectionFailurePolicy
+ Keiro.Router.Selection: data SelectionOrder
+ Keiro.Router.Selection: data SelectionVersion
+ Keiro.Router.Selection: emptySelectionDeadLetterReason :: RouterSelectionContract -> DeadLetterReason
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.EmptySelectionPolicy
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.PartialDispatchPolicy
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.RecipientLimit
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.RedeliveryPolicy
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.RouterSelectionContract
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.RouterSelectionFailure
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.SelectionDedupe
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.SelectionFailurePolicy
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.SelectionFingerprint
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.SelectionIdentity
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.SelectionOrder
+ Keiro.Router.Selection: instance GHC.Classes.Eq Keiro.Router.Selection.SelectionVersion
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.EmptySelectionPolicy
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.PartialDispatchPolicy
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.RecipientLimit
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.RedeliveryPolicy
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.SelectionDedupe
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.SelectionFailurePolicy
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.SelectionFingerprint
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.SelectionIdentity
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.SelectionOrder
+ Keiro.Router.Selection: instance GHC.Classes.Ord Keiro.Router.Selection.SelectionVersion
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.EmptySelectionPolicy
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.PartialDispatchPolicy
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.RecipientLimit
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.RedeliveryPolicy
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.RouterSelectionContract
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.RouterSelectionFailure
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.SelectionDedupe
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.SelectionFailurePolicy
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.SelectionFingerprint
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.SelectionIdentity
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.SelectionOrder
+ Keiro.Router.Selection: instance GHC.Internal.Generics.Generic Keiro.Router.Selection.SelectionVersion
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.EmptySelectionPolicy
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.PartialDispatchPolicy
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.RecipientLimit
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.RedeliveryPolicy
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.RouterSelectionContract
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.RouterSelectionFailure
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.SelectionDedupe
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.SelectionFailurePolicy
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.SelectionFingerprint
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.SelectionIdentity
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.SelectionOrder
+ Keiro.Router.Selection: instance GHC.Internal.Show.Show Keiro.Router.Selection.SelectionVersion
+ Keiro.Router.Selection: mkRecipientLimit :: Natural -> Either RouterSelectionFailure RecipientLimit
+ Keiro.Router.Selection: mkSelectionVersion :: Natural -> Either RouterSelectionFailure SelectionVersion
+ Keiro.Router.Selection: newtype SelectionFingerprint
+ Keiro.Router.Selection: newtype SelectionIdentity
+ Keiro.Router.Selection: normalizeRecipients :: Eq targetCi => RecipientLimit -> [PMCommand targetCi] -> Either RouterSelectionFailure [PMCommand targetCi]
+ Keiro.Router.Selection: recipientLimitValue :: RecipientLimit -> Natural
+ Keiro.Router.Selection: selectionFailureDeadLetterReason :: RouterSelectionContract -> RouterSelectionFailure -> DeadLetterReason
+ Keiro.Router.Selection: selectionVersionValue :: SelectionVersion -> Natural
+ Keiro.Snapshot.Schema: deleteSnapshotRow :: forall (es :: [Effect]). Store :> es => StreamId -> Eff es Bool
+ Keiro.Snapshot.Schema: lookupSnapshotRow :: forall (es :: [Effect]). Store :> es => StreamId -> Eff es (Maybe SnapshotRow)
+ Keiro.Subscription.Shard: ownershipSnapshotFor :: forall (es :: [Effect]). Store :> es => SubscriptionName -> Eff es [(Int, Maybe WorkerId, Maybe UTCTime)]
+ Keiro.Subscription.Shard: shardCountSnapshot :: forall (es :: [Effect]). Store :> es => SubscriptionName -> Eff es [(Int, Int)]
+ Keiro.Telemetry: DecisionAccepted :: CommandDecisionClass
+ Keiro.Telemetry: DecisionNoOp :: CommandDecisionClass
+ Keiro.Telemetry: DecisionRejected :: CommandDecisionClass
+ Keiro.Telemetry: [commandDecisions] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [projectionGlobalPositionDistance] :: KeiroMetrics -> Gauge Int64
+ Keiro.Telemetry: [projectionRebuildEvents] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [projectionRebuildFailures] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [projectionRebuildPageDuration] :: KeiroMetrics -> Histogram
+ Keiro.Telemetry: [projectionRebuildPages] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [projectionRebuildPromotions] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [projectionRebuildResumes] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [projectionRebuildStarts] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: commandDecisionClassText :: CommandDecisionClass -> Text
+ Keiro.Telemetry: data CommandDecisionClass
+ Keiro.Telemetry: instance GHC.Classes.Eq Keiro.Telemetry.CommandDecisionClass
+ Keiro.Telemetry: instance GHC.Internal.Generics.Generic Keiro.Telemetry.CommandDecisionClass
+ Keiro.Telemetry: instance GHC.Internal.Show.Show Keiro.Telemetry.CommandDecisionClass
+ Keiro.Telemetry: keiroCommandDecisionsName :: Text
+ Keiro.Telemetry: keiroProjectionGlobalPositionDistanceName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildEventsName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildFailuresName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildPageDurationName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildPagesName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildPromotionsName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildResumesName :: Text
+ Keiro.Telemetry: keiroProjectionRebuildStartsName :: Text
+ Keiro.Telemetry: keiro_command_decision :: AttributeKey Text
+ Keiro.Telemetry: recordCommandDecision :: MonadIO m => Maybe KeiroMetrics -> CommandDecisionClass -> m ()
+ Keiro.Telemetry: recordProjectionGlobalPositionDistance :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordProjectionRebuildEvents :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordProjectionRebuildFailures :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordProjectionRebuildPageDuration :: MonadIO m => Maybe KeiroMetrics -> Double -> m ()
+ Keiro.Telemetry: recordProjectionRebuildPages :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordProjectionRebuildPromotions :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordProjectionRebuildResumes :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordProjectionRebuildStarts :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Timer: drainDueTimers :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Maybe KeiroMetrics -> UTCTime -> Int -> (TimerRow -> Eff es (Maybe EventId)) -> Eff es Int
+ Keiro.Timer: drainDueTimersWith :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Maybe KeiroMetrics -> TimerWorkerOptions -> UTCTime -> Int -> (TimerRow -> Eff es (Maybe EventId)) -> Eff es Int
+ Keiro.Timer: lookupTimer :: forall (es :: [Effect]). Store :> es => TimerId -> Eff es (Maybe TimerRow)
+ Keiro.Timer.Schema: lookupTimer :: forall (es :: [Effect]). Store :> es => TimerId -> Eff es (Maybe TimerRow)
+ Keiro.Workflow: JournalRefusedTerminal :: !Text -> JournalAppendOutcome
+ Keiro.Workflow: WfCancelled :: WorkflowStatus
+ Keiro.Workflow: WfCompleted :: WorkflowStatus
+ Keiro.Workflow: WfFailed :: WorkflowStatus
+ Keiro.Workflow: WfRunning :: WorkflowStatus
+ Keiro.Workflow: WfSuspended :: WorkflowStatus
+ Keiro.Workflow: WorkflowAlreadyTerminal :: !WorkflowStatus -> CancelWorkflowOutcome
+ Keiro.Workflow: WorkflowCancelRecorded :: CancelWorkflowOutcome
+ Keiro.Workflow: WorkflowCancelUnknown :: CancelWorkflowOutcome
+ Keiro.Workflow: WorkflowInstanceFilter :: !Maybe (NonEmpty WorkflowStatus) -> !Maybe Text -> !Maybe (Text, Text) -> !Int -> WorkflowInstanceFilter
+ Keiro.Workflow: WorkflowInstanceRow :: !Text -> !Text -> !Int32 -> !WorkflowStatus -> !Int32 -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> !UTCTime -> !Maybe UTCTime -> WorkflowInstanceRow
+ Keiro.Workflow: [afterKey] :: WorkflowInstanceFilter -> !Maybe (Text, Text)
+ Keiro.Workflow: [attempts] :: WorkflowInstanceRow -> !Int32
+ Keiro.Workflow: [completedAt] :: WorkflowInstanceRow -> !Maybe UTCTime
+ Keiro.Workflow: [createdAt] :: WorkflowInstanceRow -> !UTCTime
+ Keiro.Workflow: [lastError] :: WorkflowInstanceRow -> !Maybe Text
+ Keiro.Workflow: [leaseExpiresAt] :: WorkflowInstanceRow -> !Maybe UTCTime
+ Keiro.Workflow: [leasedBy] :: WorkflowInstanceRow -> !Maybe Text
+ Keiro.Workflow: [nextAttemptAt] :: WorkflowInstanceRow -> !Maybe UTCTime
+ Keiro.Workflow: [onJournalAppend] :: WorkflowRunOptions -> !Maybe (IO ())
+ Keiro.Workflow: [status] :: WorkflowInstanceRow -> !WorkflowStatus
+ Keiro.Workflow: [statuses] :: WorkflowInstanceFilter -> !Maybe (NonEmpty WorkflowStatus)
+ Keiro.Workflow: [updatedAt] :: WorkflowInstanceRow -> !UTCTime
+ Keiro.Workflow: [wakeAfter] :: WorkflowInstanceRow -> !Maybe UTCTime
+ Keiro.Workflow: cancelWorkflow :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Eff es CancelWorkflowOutcome
+ Keiro.Workflow: data CancelWorkflowOutcome
+ Keiro.Workflow: data WorkflowInstanceFilter
+ Keiro.Workflow: data WorkflowInstanceRow
+ Keiro.Workflow: data WorkflowStatus
+ Keiro.Workflow: defaultWorkflowInstanceFilter :: WorkflowInstanceFilter
+ Keiro.Workflow: forceReleaseInstanceLease :: forall (es :: [Effect]). Store :> es => WorkflowName -> WorkflowId -> Eff es Bool
+ Keiro.Workflow: instance GHC.Internal.Exception.Type.Exception Keiro.Workflow.WorkflowFailPending
+ Keiro.Workflow: instance GHC.Internal.Show.Show Keiro.Workflow.WorkflowFailPending
+ Keiro.Workflow: listWorkflowInstances :: forall (es :: [Effect]). Store :> es => WorkflowInstanceFilter -> Eff es [WorkflowInstanceRow]
+ Keiro.Workflow.Awakeable.Compatibility: generation0AwakeableId :: WorkflowName -> WorkflowId -> Text -> AwakeableId
+ Keiro.Workflow.Awakeable.Compatibility: preUtf8Generation0AwakeableId :: WorkflowName -> WorkflowId -> Text -> AwakeableId
+ Keiro.Workflow.Gc: WorkflowGcCandidate :: !Text -> !Text -> WorkflowGcCandidate
+ Keiro.Workflow.Gc: [workflowId] :: WorkflowGcCandidate -> !Text
+ Keiro.Workflow.Gc: [workflowName] :: WorkflowGcCandidate -> !Text
+ Keiro.Workflow.Gc: data WorkflowGcCandidate
+ Keiro.Workflow.Gc: instance GHC.Classes.Eq Keiro.Workflow.Gc.WorkflowGcCandidate
+ Keiro.Workflow.Gc: instance GHC.Internal.Generics.Generic Keiro.Workflow.Gc.WorkflowGcCandidate
+ Keiro.Workflow.Gc: instance GHC.Internal.Show.Show Keiro.Workflow.Gc.WorkflowGcCandidate
+ Keiro.Workflow.Gc: listWorkflowGcCandidates :: forall (es :: [Effect]). Store :> es => UTCTime -> WorkflowGcPolicy -> Eff es [WorkflowGcCandidate]
+ Keiro.Workflow.Gc: runWorkflowGcWorkerWith :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => WorkflowGcPolicy -> Int -> (Text -> IO ()) -> Eff es ()
+ Keiro.Workflow.Instance: ClaimAcquired :: ClaimOutcome
+ Keiro.Workflow.Instance: ClaimLeaseHeld :: ClaimOutcome
+ Keiro.Workflow.Instance: ClaimPaced :: ClaimOutcome
+ Keiro.Workflow.Instance: ClaimUnavailable :: ClaimOutcome
+ Keiro.Workflow.Instance: WorkflowAlreadyTerminal :: !WorkflowStatus -> CancelWorkflowOutcome
+ Keiro.Workflow.Instance: WorkflowCancelRecorded :: CancelWorkflowOutcome
+ Keiro.Workflow.Instance: WorkflowCancelUnknown :: CancelWorkflowOutcome
+ Keiro.Workflow.Instance: WorkflowInstanceFilter :: !Maybe (NonEmpty WorkflowStatus) -> !Maybe Text -> !Maybe (Text, Text) -> !Int -> WorkflowInstanceFilter
+ Keiro.Workflow.Instance: [afterKey] :: WorkflowInstanceFilter -> !Maybe (Text, Text)
+ Keiro.Workflow.Instance: [pageSize] :: WorkflowInstanceFilter -> !Int
+ Keiro.Workflow.Instance: [statuses] :: WorkflowInstanceFilter -> !Maybe (NonEmpty WorkflowStatus)
+ Keiro.Workflow.Instance: [wakeAfter] :: WorkflowInstanceRow -> !Maybe UTCTime
+ Keiro.Workflow.Instance: cancelWorkflow :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Eff es CancelWorkflowOutcome
+ Keiro.Workflow.Instance: data CancelWorkflowOutcome
+ Keiro.Workflow.Instance: data ClaimOutcome
+ Keiro.Workflow.Instance: data WorkflowInstanceFilter
+ Keiro.Workflow.Instance: defaultWorkflowInstanceFilter :: WorkflowInstanceFilter
+ Keiro.Workflow.Instance: forceReleaseInstanceLease :: forall (es :: [Effect]). Store :> es => WorkflowName -> WorkflowId -> Eff es Bool
+ Keiro.Workflow.Instance: instance GHC.Classes.Eq Keiro.Workflow.Instance.CancelWorkflowOutcome
+ Keiro.Workflow.Instance: instance GHC.Classes.Eq Keiro.Workflow.Instance.ClaimOutcome
+ Keiro.Workflow.Instance: instance GHC.Classes.Eq Keiro.Workflow.Instance.WorkflowInstanceFilter
+ Keiro.Workflow.Instance: instance GHC.Internal.Generics.Generic Keiro.Workflow.Instance.CancelWorkflowOutcome
+ Keiro.Workflow.Instance: instance GHC.Internal.Generics.Generic Keiro.Workflow.Instance.ClaimOutcome
+ Keiro.Workflow.Instance: instance GHC.Internal.Generics.Generic Keiro.Workflow.Instance.WorkflowInstanceFilter
+ Keiro.Workflow.Instance: instance GHC.Internal.Show.Show Keiro.Workflow.Instance.CancelWorkflowOutcome
+ Keiro.Workflow.Instance: instance GHC.Internal.Show.Show Keiro.Workflow.Instance.ClaimOutcome
+ Keiro.Workflow.Instance: instance GHC.Internal.Show.Show Keiro.Workflow.Instance.WorkflowInstanceFilter
+ Keiro.Workflow.Instance: listWorkflowInstances :: forall (es :: [Effect]). Store :> es => WorkflowInstanceFilter -> Eff es [WorkflowInstanceRow]
+ Keiro.Workflow.Instance: markInstanceSuspendedAwaiting :: forall (es :: [Effect]). Store :> es => WorkflowName -> WorkflowId -> Int -> Text -> Eff es ()
+ Keiro.Workflow.Resume: ResumeCrashRecordSkipped :: !Text -> !Text -> ResumeLogEvent
+ Keiro.Workflow.Resume: [advanced] :: ResumeSummary -> !Int
+ Keiro.Workflow.Resume: [maxConcurrentAdvances] :: WorkflowResumeOptions -> !Int
+ Keiro.Workflow.Resume: [paced] :: ResumeSummary -> !Int
+ Keiro.Workflow.Resume: [sleepDue] :: ResumeSummary -> !Int
+ Keiro.Workflow.Resume: [unregisteredNames] :: ResumeSummary -> !Set Text
+ Keiro.Workflow.Resume: instance GHC.Internal.Base.Monoid Keiro.Workflow.Resume.ResumeSummary
+ Keiro.Workflow.Resume: instance GHC.Internal.Base.Semigroup Keiro.Workflow.Resume.ResumeSummary
+ Keiro.Workflow.Resume: resumeWorkflowsOnceUpTo :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => Int -> WorkflowResumeOptions -> WorkflowRegistry es -> Eff es ResumeSummary
+ Keiro.Workflow.Schema: terminalMarkers :: forall (es :: [Effect]). Store :> es => WorkflowName -> WorkflowId -> Int -> Eff es [Text]
+ Keiro.Workflow.Schema: terminalMarkersTx :: Text -> Text -> Int -> Transaction [Text]
+ Keiro.Workflow.Schema: workflowLifecycleMarkersTx :: Text -> Text -> Int -> Transaction [Text]
+ Keiro.Workflow.Schema: workflowStepLockKey :: Text -> Text -> Int -> Text -> Text
+ Keiro.Workflow.Sleep: drainWorkflowSleepTimers :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Maybe KeiroMetrics -> UTCTime -> Int -> (TimerRow -> Eff es (Maybe EventId)) -> Eff es Int
+ Keiro.Workflow.Types: WorkflowJournalAppendError :: !Text -> WorkflowError
+ Keiro.Workflow.Types: WorkflowJournalDecodeError :: !Text -> WorkflowError
+ Keiro.Workflow.Types: WorkflowJournalEncodeError :: !Text -> WorkflowError
+ Keiro.Workflow.Types: WorkflowStepDecodeError :: !Text -> !Text -> WorkflowError
+ Keiro.Workflow.Types: data WorkflowError
+ Keiro.Workflow.Types: instance GHC.Classes.Eq Keiro.Workflow.Types.WorkflowError
+ Keiro.Workflow.Types: instance GHC.Internal.Exception.Type.Exception Keiro.Workflow.Types.WorkflowError
+ Keiro.Workflow.Types: instance GHC.Internal.Show.Show Keiro.Workflow.Types.WorkflowError
- Keiro: [schemaVersion] :: Codec e -> !Int
+ Keiro: [schemaVersion] :: TargetProvisioner -> !TargetSchemaVersion
- Keiro.ProcessManager: [commandResults] :: ProcessManagerResult (managerTarget :: k) (commandTarget :: k1) -> ![PMCommandResult commandTarget]
+ Keiro.ProcessManager: [commandResults] :: DomainProcessManagerResult (managerTarget :: k) (commandTarget :: k1) co rejection noOp -> ![DomainPMCommandResult commandTarget co rejection noOp]
- Keiro.ProcessManager: [correlate] :: ProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo -> !input -> Text
+ Keiro.ProcessManager: [correlate] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !input -> Text
- Keiro.ProcessManager: [eventStream] :: ProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo -> !ValidatedEventStream phi rs s ci co
+ Keiro.ProcessManager: [eventStream] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !ValidatedEventStream phi rs s ci co
- Keiro.ProcessManager: [handle] :: ProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo -> !input -> ProcessManagerAction ci targetCi
+ Keiro.ProcessManager: [handle] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !input -> ProcessManagerAction ci targetCi
- Keiro.ProcessManager: [managerResult] :: ProcessManagerResult (managerTarget :: k) (commandTarget :: k1) -> !PMStateResult managerTarget
+ Keiro.ProcessManager: [managerResult] :: DomainProcessManagerResult (managerTarget :: k) (commandTarget :: k1) co rejection noOp -> !PMStateResult managerTarget
- Keiro.ProcessManager: [name] :: ProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo -> !Text
+ Keiro.ProcessManager: [name] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !Text
- Keiro.ProcessManager: [streamFor] :: ProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo -> !Text -> Stream (EventStream phi rs s ci co)
+ Keiro.ProcessManager: [streamFor] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !Text -> Stream (EventStream phi rs s ci co)
- Keiro.ProcessManager: [targetProjections] :: ProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo -> !Stream targetCi -> [InlineProjection targetCo]
+ Keiro.ProcessManager: [targetProjections] :: DomainProcessManager input phi (rs :: [Slot]) s ci co targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp -> !Stream targetCi -> [InlineProjection targetCo]
- Keiro.ProcessManager: [timersScheduled] :: ProcessManagerResult (managerTarget :: k) (commandTarget :: k1) -> !Int
+ Keiro.ProcessManager: [timersScheduled] :: DomainProcessManagerResult (managerTarget :: k) (commandTarget :: k1) co rejection noOp -> !Int
- Keiro.Projection: InlineProjection :: !Text -> !co -> RecordedEvent -> Transaction () -> InlineProjection co
+ Keiro.Projection: InlineProjection :: !Text -> !event -> RecordedEvent -> Transaction () -> InlineProjection event
- Keiro.Projection: [apply] :: InlineProjection co -> !co -> RecordedEvent -> Transaction ()
+ Keiro.Projection: [apply] :: InlineProjection event -> !event -> RecordedEvent -> Transaction ()
- Keiro.Projection: data InlineProjection co
+ Keiro.Projection: data InlineProjection event
- Keiro.ReadModel: [name] :: ReadModel q r -> !Text
+ Keiro.ReadModel: [name] :: ReadModelBlueprint q r -> !Text
- Keiro.ReadModel: [query] :: ReadModel q r -> !q -> Transaction r
+ Keiro.ReadModel: [query] :: ReadModelBlueprint q r -> !q -> Transaction r
- Keiro.ReadModel: [schema] :: ReadModel q r -> !Text
+ Keiro.ReadModel: [schema] :: ReadModelBlueprint q r -> !Text
- Keiro.ReadModel: [shapeHash] :: ReadModel q r -> !Text
+ Keiro.ReadModel: [shapeHash] :: ReadModelBlueprint q r -> !Text
- Keiro.ReadModel: [tableName] :: ReadModel q r -> !Text
+ Keiro.ReadModel: [tableName] :: ReadModelBlueprint q r -> !Text
- Keiro.ReadModel: [version] :: ReadModel q r -> !Int
+ Keiro.ReadModel: [version] :: ReadModelBlueprint q r -> !Int
- Keiro.ReadModel.Schema: ReadModelMetadata :: !Text -> !Int -> !Text -> !Maybe UTCTime -> !ReadModelStatus -> ReadModelMetadata
+ Keiro.ReadModel.Schema: ReadModelMetadata :: !Text -> !Int -> !Text -> !Text -> !Maybe UTCTime -> !ReadModelStatus -> ReadModelMetadata
- Keiro.Router: [commandResults] :: RouterResult (target :: k) -> [PMCommandResult target]
+ Keiro.Router: [commandResults] :: DomainRouterResult (target :: k) co rejection noOp -> [DomainPMCommandResult target co rejection noOp]
- Keiro.Router: [key] :: Router input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !input -> Text
+ Keiro.Router: [key] :: DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]) -> !input -> Text
- Keiro.Router: [name] :: Router input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !Text
+ Keiro.Router: [name] :: DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]) -> !Text
- Keiro.Router: [resolve] :: Router input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !input -> Eff es [PMCommand targetCi]
+ Keiro.Router: [resolve] :: DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]) -> !input -> Eff es [PMCommand targetCi]
- Keiro.Router: [targetEventStream] :: Router input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !ValidatedEventStream targetPhi targetRs targetState targetCi targetCo
+ Keiro.Router: [targetEventStream] :: DeclarativeRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !ValidatedEventStream targetPhi targetRs targetState targetCi targetCo
- Keiro.Router: [targetProjections] :: Router input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo (es :: [Effect]) -> !Stream targetCi -> [InlineProjection targetCo]
+ Keiro.Router: [targetProjections] :: DomainRouter input targetPhi (targetRs :: [Slot]) targetState targetCi targetCo rejection noOp (es :: [Effect]) -> !Stream targetCi -> [InlineProjection targetCo]
- Keiro.Telemetry: KeiroMetrics :: Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Histogram -> Histogram -> Gauge Int64 -> Counter Int64 -> Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Histogram -> Gauge Int64 -> KeiroMetrics
+ Keiro.Telemetry: KeiroMetrics :: Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Histogram -> Histogram -> Gauge Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Histogram -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Histogram -> Gauge Int64 -> KeiroMetrics
- Keiro.Workflow: WorkflowRunOptions :: !SnapshotPolicy WorkflowState -> !Int32 -> !Maybe KeiroMetrics -> !Maybe Tracer -> !Set PatchId -> !Maybe LeaseHeartbeat -> WorkflowRunOptions
+ Keiro.Workflow: WorkflowRunOptions :: !SnapshotPolicy WorkflowState -> !Int32 -> !Maybe KeiroMetrics -> !Maybe Tracer -> !Set PatchId -> !Maybe LeaseHeartbeat -> !Maybe (IO ()) -> WorkflowRunOptions
- Keiro.Workflow: [generation] :: WorkflowStepRow -> !Int
+ Keiro.Workflow: [generation] :: WorkflowInstanceRow -> !Int32
- Keiro.Workflow: [pageSize] :: WorkflowRunOptions -> !Int32
+ Keiro.Workflow: [pageSize] :: WorkflowInstanceFilter -> !Int
- Keiro.Workflow: [workflowId] :: WorkflowStepRow -> !Text
+ Keiro.Workflow: [workflowId] :: WorkflowInstanceRow -> !Text
- Keiro.Workflow: [workflowName] :: WorkflowStepRow -> !Text
+ Keiro.Workflow: [workflowName] :: WorkflowInstanceFilter -> !Maybe Text
- Keiro.Workflow.Awakeable.Schema: cancelAwakeableTx :: UUID -> Transaction Bool
+ Keiro.Workflow.Awakeable.Schema: cancelAwakeableTx :: UUID -> Transaction (Maybe (Text, Text))
- Keiro.Workflow.Gc: gcWorkflowsOnce :: forall (es :: [Effect]). Store :> es => UTCTime -> WorkflowGcPolicy -> Eff es WorkflowGcSummary
+ Keiro.Workflow.Gc: gcWorkflowsOnce :: forall (es :: [Effect]). (Store :> es, Error StoreError :> es) => UTCTime -> WorkflowGcPolicy -> Eff es WorkflowGcSummary
- Keiro.Workflow.Gc: runWorkflowGcWorker :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowGcPolicy -> Int -> Eff es ()
+ Keiro.Workflow.Gc: runWorkflowGcWorker :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => WorkflowGcPolicy -> Int -> Eff es ()
- Keiro.Workflow.Instance: WorkflowInstanceRow :: !Text -> !Text -> !Int32 -> !WorkflowStatus -> !Int32 -> !Maybe Text -> !Maybe UTCTime -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> !UTCTime -> !Maybe UTCTime -> WorkflowInstanceRow
+ Keiro.Workflow.Instance: WorkflowInstanceRow :: !Text -> !Text -> !Int32 -> !WorkflowStatus -> !Int32 -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> !UTCTime -> !Maybe UTCTime -> WorkflowInstanceRow
- Keiro.Workflow.Instance: [workflowName] :: WorkflowInstanceRow -> !Text
+ Keiro.Workflow.Instance: [workflowName] :: WorkflowInstanceFilter -> !Maybe Text
- Keiro.Workflow.Instance: claimInstance :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Text -> NominalDiffTime -> WorkflowName -> WorkflowId -> Eff es Bool
+ Keiro.Workflow.Instance: claimInstance :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Text -> NominalDiffTime -> WorkflowName -> WorkflowId -> Eff es ClaimOutcome
- Keiro.Workflow.Instance: recordCrashTx :: Text -> Text -> Text -> Transaction Int32
+ Keiro.Workflow.Instance: recordCrashTx :: Text -> Text -> Text -> Transaction (Maybe Int32)
- Keiro.Workflow.Resume: ResumeSummary :: !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> ResumeSummary
+ Keiro.Workflow.Resume: ResumeSummary :: !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Set Text -> ResumeSummary
- Keiro.Workflow.Resume: WorkflowResumeOptions :: !WorkflowRunOptions -> !Int -> !Int -> !NominalDiffTime -> !ResumeLogEvent -> IO () -> WorkflowResumeOptions
+ Keiro.Workflow.Resume: WorkflowResumeOptions :: !WorkflowRunOptions -> !Int -> !Int -> !NominalDiffTime -> !Int -> !ResumeLogEvent -> IO () -> WorkflowResumeOptions

Files

CHANGELOG.md view
@@ -4,6 +4,456 @@ [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow the [Haskell Package Versioning Policy](https://pvp.haskell.org/). +## Unreleased++## 0.12.0.0 — 2026-08-14++### Breaking Changes++- `Keiro.Workflow.Awakeable` no longer exports+  `deterministicAwakeableId` or `legacyDeterministicAwakeableId`. Compatibility+  tooling that must inspect generation-0 rows should import+  `generation0AwakeableId` or `preUtf8Generation0AwakeableId` from+  `Keiro.Workflow.Awakeable.Compatibility`; ordinary workflow code must retain and+  pass the `AwakeableId` returned by allocation.+- The schema-versioned rebuild resume contract advances to+  `versioned-contract-v3` and its persisted runner to+  `keiro/versioned-rebuild/v3`. The contract adds run-scoped dedup staging and a+  persisted promotion admission limit. Complete active v2 versioned runs with the old+  runtime or abandon them before upgrading; they cannot resume under v3.+- `adoptCatalogGroups` now returns `CatalogAdoptionResult`, including adopted group+  metadata, per-registration update/insert outcomes, and removed old-name rows.+  `CatalogAdoptionPlan` likewise adds planned registration actions and orphan rows; code+  constructing or exhaustively matching either result must handle the registry-complete+  contract.+- `Keiro.Workflow.Instance.claimInstance` now returns `ClaimOutcome` instead of+  `Bool`, distinguishing an acquired lease from a live foreign lease, crash+  pacing, and an instance that became unavailable. `ResumeSummary` adds+  `advanced`, `paced`, `sleepDue`, and `unregisteredNames`; `advanced` counts+  only a fresh journal append by the re-invocation, a terminal failure recorded+  at the crash ceiling, or an external wake observed mid-pass. Replay-only+  re-suspensions and terminal short-circuit races report no advance, so bounded+  drains terminate on every reachable pool. `WorkflowRunOptions` adds+  `onJournalAppend`; direct record constructions must initialize it, while+  `defaultWorkflowRunOptions` users are unaffected.++- The catalog rebuild resume contract advances to `contract-v4:` and the persisted runner+  format to `keiro/projection-replay/v4`. The contract now pins replay-adapter application+  order. `CatalogRebuildError` gains `CatalogRebuildSliceMismatch`, and+  `abandonCatalogRebuild` compares group slices rather than resume contracts.++- Canonical identity advances to `catalog-v3:`, `slice-v2:`, `contract-v3:`, and+  `keiro/projection-replay/v3`; catalog inventory and rebuild preview JSON advance to v2.+  Stored `slice-v1:` groups require preview and explicit live-group adoption. An active v2+  replay cannot resume under the v3 runner: complete it with the old runtime or abandon it+  before upgrading and adopting metadata.+- Catalog fingerprints, group metadata, rebuild contracts, and grouped errors+  use the new canonical slice identity. `GroupRebuildMetadata.catalogFingerprint`+  is now `sliceFingerprint`; fingerprint-drift errors are slice-specific; and+  `RebuildRunReport` adds `groupSliceFingerprint`. Persisted replay format is+  `keiro/projection-replay/v3`. Complete or abandon active catalog rebuilds before+  migration `0024` or the v3 runner cutover, then explicitly adopt stale group rows.++- Requires `kiroku-store >=0.7 && <0.8`. Direct constructors of+  `SubscriptionDeclaration` and exhaustive matches on grouped+  `RebuildStartError` must adopt the explicit checkpoint lifecycle surface.+  `KeiroMetrics` gains the+  `projectionGlobalPositionDistance` gauge field; code constructing that record+  directly must initialize it. Exhaustive custom Kiroku interpreters must also+  implement the 0.5 checkpoint lifecycle, 0.6 visible-head effect surface, and+  0.7 renewable history-retention lease surface.+- Requires `keiro-core ^>=0.12.0.0` and `shibuya-core ^>=0.9.0.0`, replacing+  the 0.11 package-family and Shibuya 0.8 bounds.+- `Keiro.Workflow.JournalAppendOutcome` gains a `JournalRefusedTerminal !Text`+  constructor. The journal-append transaction now declines an ordinary+  `StepRecorded` append into a workflow generation that already carries a+  `__workflow_cancelled__` or `__workflow_failed__` marker, and reports which+  marker refused it. A refusal is not an error: the wake source should settle+  its own durable row and deliver nothing. `appendJournalEntry` /+  `appendJournalEntryReturningId` absorb it and return normally, so only code+  that pattern-matches on `JournalAppendOutcome` needs to change.++- A workflow that is terminally *failed* now stops at the next step boundary,+  as a cancelled one already did. Previously the failure marker was checked only+  at run entry, so a direct `runWorkflow` call that overlapped the resume+  worker's `WorkflowFailed` marker kept executing fresh side effects. Runs that+  relied on that behaviour will now return `Failed` at the boundary.++- `Keiro.Workflow.Instance.markInstanceSuspended` is replaced by+  `markInstanceSuspendedAwaiting :: WorkflowName -> WorkflowId -> Int -> Text ->+  Eff es ()`, which takes the run's generation and the awaited step name and+  arbitrates the suspended-status write against a concurrent wake delivery.++- `Keiro.Workflow.Instance.recordCrashTx` returns `Maybe Int32` instead of+  `Int32`. `Nothing` means the workflow reached a terminal status between+  crashing and having that crash recorded, so no attempt was counted — an+  ordinary race, not an error.++- `Keiro.Workflow.Gc.gcWorkflowsOnce` and `runWorkflowGcWorker` gain an+  `Error StoreError :> es` constraint, which they need to isolate a failing+  deletion. Callers running them under `runStoreIO` are unaffected.++- `Keiro.Workflow.Resume.ResumeLogEvent` gains a `ResumeCrashRecordSkipped+  !Text !Text` constructor (workflow name, workflow id). Only code that+  pattern-matches exhaustively on `ResumeLogEvent` needs to change.++- `Keiro.Workflow.Resume.WorkflowResumeOptions` gains a+  `maxConcurrentAdvances :: !Int` field. Only code that builds the record+  without `defaultWorkflowResumeOptions` needs to change; the default is 1,+  which is the behaviour every previous release had.++### New Features++- Projection revisions now bind every live closure to its exact inline owner or+  subscription/dedup delivery capability. Catalog validation requires one handler per+  declared capability and restricts it to the supplying projection's targets; command+  and subscription paths dispatch only their matching closure. The canonical identity+  advances to `catalog-v7:` and `slice-v6:`.+- Projection revisions may declare explicit `StreamScopedReplay` policies. The targeted+  runner repairs one complete retained stream against the persisted serving revision in+  one group-fenced transaction, verifies exact target evidence, backfills ordinary async+  dedup keys, leaves subscription checkpoints unchanged, and refuses truncated/deleted+  history, active rebuilds, slice drift, incomplete serving bindings, or a locked event+  count above the request's positive `maxEvents` admission limit. Oversized work refuses+  before the group-wide writer fence.+- `ProjectionCatalogOperations` adds database-backed targeted-reprojection preview and+  outcome reports. Preview v2 exposes event count, expected dedup claims, and the+  reviewed maximum; outcome v2 records the admitted maximum. Jitsurei demonstrates a V2+  serving-row repair that leaves unrelated streams unchanged.++- Projection catalogs now declare versioned all-row and keyed external read contracts,+  including validated query/shape/revision compatibility, immutable SQL signatures,+  application-owned keyed implementation identity, and monotonic surface generations.+  Canonical identity advances to `catalog-v5:` and `slice-v4:`.+- `Keiro.ReadModel.External` reconciles those declarations into execute-only,+  security-definer functions in `keiro_read`, backed by private managed bindings and a+  lifecycle guard with stable `KR001`/`KR002`/`KR003` SQLSTATEs. The adversarial release+  pass makes crossed-promotion snapshots fail retryably with `KR001`, caps all-row+  results at 100 with `KR004`, verifies exact keyed set-result types, and reports grants+  for the selected overload only. Registration,+  reviewed adoption, versioned-rebuild start, and promotion perform reconciliation in+  their own transaction; retirement is explicit and dependency/grant previewed.+  All-row bindings project only the stable composite type's attributes, candidate+  versions remain metadata-only until promotion, and an explicit implementation-backed+  compatibility wrapper may retain an old zero-argument signature after a breaking+  cutover.+- `ProjectionCatalogOperations` exposes external-read inspection and retirement reports+  with stable JSON envelopes over the same dependency/grant preview used by the runtime.+- `ProjectionGroupStatusV1`, `ServingPositionBasis`,+  `listProjectionGroupStatuses`, and `lookupProjectionGroupStatus` decode the frozen+  public SQL status contract. Catalog registration and reviewed adoption now reconcile+  per-group append/checkpoint cursor authority transactionally.+- Projection catalogs now declare executable `ProjectionRevision` bridges, total+  `PhysicalTargets` mappings, per-target schema/provisioner/validator contracts, and+  ordered promotion names. Candidate Language 5 accepts `projection-revision` blocks and scaffolds+  transaction-local provision, validation, live, replay, and verification holes.+- `Keiro.ReadModel.Rebuild` now implements durable schema-versioned target generations,+  converging candidate replay beside a live serving revision, renewable Kiroku history+  retention, resumable writer-fence/final-head phases, bounded atomic multi-target+  promotion, async dedup/checkpoint reconciliation, restricted exact-shape cloning, and+  dependency-aware retired-generation preview/drop. `ProjectionCatalogOperations`+  exposes dedicated versioned run and retirement reports without widening the legacy+  offline rebuild protocol.+- `Keiro.DeterministicId.deterministicIdProbes` centralizes the unchanged+  current-first, legacy-only-for-moved-seeds compatibility order shared by+  process-manager preflights and generation-0 awakeable adoption.+- `QueryFreshness`, `HeadScope`, `QueryCursorAuthority`, `ReadModelBlueprint`, truthful+  read-model builders, and `runQueryWithFreshness`. Immediate reads need no cursor;+  captured-head and caller-position waits require one durable cursor and fail with typed+  missing-cursor or missing-position errors before polling. Candidate Language 5 generated+  code now constructs only through `immediateReadModel` or `headWaitingReadModel`, deriving+  cursor authority from its validated projection owner.+- Catalog query inventory now records normalized freshness and the optional cursor derived+  from the validated projection owner. Validation rejects waiting queries with zero or+  several compatible durable cursors, and canonical identity normalizes set-valued owned+  targets.+- `ProjectionHandlerCapability`, `ResolvedQuerySupply`, and+  `resolvedQuerySupplies` expose the closure-free relationship from each validated+  query model to the single projection owner of its complete observed-target set.+  Validation rejects empty, missing, and split suppliers with stable diagnostic codes;+  several queries may resolve to one owner without duplicating its handler.++- `DomainDecision`, `DomainCommandOutcome`, `SilentCommandContext`,+  `SilentDomainDecision`, and `DomainCommandHandler`, plus `runDomainCommand`+  and `forgetDomainDecision`. Accepted decisions carry the exact non-empty+  event batch; explicitly selected state-preserving silent edges carry typed+  rejection/no-op payloads; unmatched commands and infrastructure failures+  remain `CommandError`.+- Domain-aware SQL and projection runners, including controlled transaction and+  catalog-fenced outcomes. Rejection/no-op opens no append transaction and runs+  no callback or inline projection; an optimistic conflict returns only the+  final rehydrated decision.+- Additive `DomainRouter` and `DomainProcessManager` configurations, detailed+  one-shot result families, and configurable/default workers. Accepted,+  rejection, and no-op are handled; accepted duplicates remain distinct; only+  genuine `CommandError` enters existing failure policy. Workers use strict+  payload-free summaries rather than retaining detailed fan-out results.+- The `keiro.command.decision` span attribute and+  `keiro.command.decisions` counter, with the closed value set `accepted`,+  `rejected`, and `no_op`. Application payloads never become telemetry labels+  or error descriptions.+- `subscriptionPositionFromInventory` derives a subscription's durable floor+  across all matching consumer-group members, and `readSubscriptionPosition`+  consumes Kiroku's public one-statement checkpoint inventory instead of+  querying the owned table. `storeHeadPosition` delegates to Kiroku 0.6's+  public payload-free visible-global-head effect; transactional rebuild+  completion uses its matching public statement.+- `recordProjectionGlobalPositionDistance` records the preferred+  `keiro.projection.global_position_distance` gauge and the deprecated+  compatibility `keiro.projection.lag` gauge from the visible head and durable+  member floor. Both use `{position}` and neither claims to count relevant+  events.+- Projection catalog subscriptions now carry Kiroku's+  `MissingCheckpointPolicy` through registration, inventory, stable rendering,+  ordering, fingerprints, and operator JSON. Validation rejects+  `FromCurrentHead` for replayable clear-before-replay ownership.+- Group rebuild uses `resetSubscriptionCheckpointsTx`, returns the exact reset+  member keys, and reports `RebuildSubscriptionCheckpointsMissing` while+  condemning the entire preparation transaction if a declared subscription has+  no persisted member. The legacy unmanaged rebuild also uses the public API.+- Projection catalog fingerprints now use an injective, length-prefixed+  canonical preimage and explicit `catalog-v3:` / `slice-v2:` prefixes. Group+  registration and rebuild lifecycle fences use only the affected group slice,+  while rebuild runs retain the whole catalog fingerprint as provenance.+- `previewCatalogAdoption` and `adoptCatalogGroups` provide a read-only plan and+  an all-or-nothing path for adopting reviewed slice changes and reconciling+  query-model registration metadata. Adoption normally requires a live group;+  a failed stale-format group may adopt while remaining fenced for recovery.+- `CatalogRebuildRunPreCanonical` and `preCanonicalRunSliceSentinel` make the+  migration-0024 recovery boundary explicit. Sentinel runs are never resumable+  but are inspectable and idempotently abandonable while active; failed+  stale-format groups can be adopted without lifting their fence, and a fresh+  rebuild can then start from `failed`.+- `CatalogAsyncDedupSpec`, `catalogAsyncIdempotencyKeys`,+  `AsyncDedupBackfill`, `collectAsyncDedupBackfill`,+  `resetDeclaredSubscriptions`, `insertProjectionDedupBatchStmt`, and+  `CatalogRebuildPromotionCheckpointsMissing` expose the catalog-derived+  redelivery-safety inputs and transactional primitives used by rebuild+  promotion.+- `Keiro.Workflow.Resume.resumeWorkflowsOnceUpTo` runs a resume pass over at+  most the requested number of candidates. `resumeWorkflowsOnce` retains its+  unbounded compatibility behavior and delegates to the bounded function.++- `Keiro.Projection.Catalog.Operations` derives versioned JSON inventory,+  pure/registered rebuild previews, and start/inspect/resume/abandon actions+  from one `ValidatedProjectionCatalog`. Callers cannot provide replacement+  targets, sources, handlers, subscriptions, or dedup lists.++- `Keiro.ReadModel.Rebuild.abandonCatalogRebuild` records explicit failure+  evidence against both the durable run and its catalog group while retaining+  the writer fence.++- `Keiro.Workflow.Schema.terminalMarkers` / `terminalMarkersTx` report which+  stopping terminal markers a workflow generation carries, in one query.++- `Keiro.Workflow.Schema.workflowStepLockKey` exposes the per-step advisory-lock+  key derivation shared by the append path and the suspend write.++- `Keiro.Workflow.Gc.runWorkflowGcWorkerWith` takes a `Text -> IO ()` logging+  hook, mirroring the resume worker's `logEvent`. It reports both a failed pass+  and a partial one (fewer workflows collected than scanned).++- A resume pass can advance several workflows at once. Set+  `WorkflowResumeOptions.maxConcurrentAdvances` above 1 and `resumeWorkflowsOnce`+  advances that many candidates concurrently, so one slow step body no longer+  delays every other workflow in the pass. It is safe by construction —+  discovery returns one row per instance, each advance holds its own lease, and+  the append path's per-step advisory lock already serializes same-step writers+  across processes — but it multiplies in-flight database traffic, so size it+  against the store's connection-pool headroom. The default of 1 preserves the+  previous sequential behaviour exactly. `logEvent` may now be called from+  several threads and must be thread-safe when concurrency is enabled.++- `Keiro.Workflow.Resume.ResumeSummary` has `Semigroup` and `Monoid` instances+  that add fields, so per-candidate deltas combine into a pass summary that does+  not depend on the order candidates finish in.++- `Keiro.Timer.drainDueTimersWith` / `drainDueTimers` claim and fire up to a+  caller-supplied number of due timers in one pass, returning how many were+  processed. Per-timer semantics are `runTimerWorkerWith`'s, unchanged; the+  requeue-and-gauge preamble now runs once per batch instead of once per timer,+  so a backlog of due timers no longer drains at one row per poll tick.+  `Keiro.Workflow.Sleep.drainWorkflowSleepTimers` is the batched sibling of+  `runWorkflowTimerWorker`, routing sleeps and process-manager timers the same+  way. `runTimerWorker` / `runTimerWorkerWith` are unchanged.++### Bug Fixes++- `Keiro.version` now renders Cabal's generated package metadata instead of a stale+  hand-maintained literal, so diagnostics and telemetry automatically follow each release.+- The durable-workflow reference and worked guide now document at-least-once+  step actions, opaque awakeable-id publication, fresh publication after+  `continueAsNew`, and the real `IOE` constraints. Compile-owned forwarding+  signatures keep the displayed workflow API aligned with the public modules.+- Fresh workflow awakeables are now opaque allocation results. Generation-0+  coordinate probes are isolated behind `Keiro.Workflow.Awakeable.Compatibility`,+  while runtime adoption and the compatibility surface share one internal identity+  implementation; ordinary workflow and generated-code APIs no longer expose a+  coordinate-derived candidate for a fresh awakeable.+- Schema-versioned rebuilds now enforce an absolute database-clock deadline for+  writer-fence and promotion lock attempts, acquire every target relation in one+  cumulative lock statement, and return typed phase-specific deadline failures.+  Async redelivery evidence is staged incrementally in PostgreSQL and admitted against+  a persisted operator limit; candidate verification, set-based dedup installation,+  checkpoint reconciliation, and lease release complete in a resumable preparation+  phase before target relations are locked.+- Cursorless read models built through `immediateReadModel` with `NoQueryCursor`+  now fail fast with `ReadModelMissingCursor` on every public wait path. The+  exported `waitFor` and deprecated `runQueryWith` waiting overrides no longer+  poll the private cursor sentinel for the full timeout or record a spurious+  `keiro.projection.wait.timeouts` increment. Models with durable cursors,+  including all directly constructed 0.11 records, retain their behavior.+- `Keiro.ReadModel.Rebuild.startRebuild` now recognizes a cursorless model through+  `readModelCursorAuthority` and skips the subscription-checkpoint reset because+  there is no cursor to reset. It no longer passes the private NUL-prefixed+  compatibility sentinel into Kiroku, while preserving the documented fence,+  truncate, and dedup clear used by generated inline Language-5 rebuild helpers.+- A workflow suspended on a due sleep while no timer worker ran no longer makes+  the documented `ResumeSummary.advanced > 0` drain loop spin forever. It now+  reports `advanced = 0` and `sleepDue = 1`, preserving discovery while naming+  the blocked remedy.+- Offline catalog rebuild promotion now backfills replayable async projection+  dedup rows for each subscription's replayed redelivery window and advances+  every declared checkpoint member to the captured head in the promotion+  transaction. Previously the reset checkpoints and deleted dedup rows caused+  every replayed event to be redelivered and re-applied after promotion,+  corrupting non-idempotent async read models.+- Catalog adoption no longer silently succeeds after a zero-row query-registration+  update. It updates or inserts every selected catalog registration and deletes a+  previewed renamed-model row only when no registration in the complete catalog claims+  the old name, all in the slice-adoption transaction.+- A database upgraded by migration 0024 while a catalog rebuild was+  `rebuilding` or `failed` can now recover entirely through supported APIs:+  abandon the pre-canonical run, adopt the fenced stale-format group, and start+  a fresh canonical rebuild.+- Resuming an interrupted catalog rebuild after a deploy reorders the group's replayable+  projection declarations now refuses with `CatalogRebuildContractMismatch` instead of+  silently applying the remaining history in a different adapter order.+- Multi-source catalog rebuilds again apply events in strictly ascending global position+  across sources. Buffered chunks are clamped to the smallest safe source horizon, and+  the runner records `replay.global-position-regression` or+  `replay.buffer-horizon-stalled` invariant evidence instead of promoting if ordering+  would regress or the merge cannot advance. Per-event source-read counts are unchanged.+- Upgrades with non-ASCII process-manager correlations, router keys, or+  awakeable labels now deduplicate against IDs written before deterministic seed+  encoding switched to UTF-8. The runtime probes the frozen historical identity+  alongside the current one, with no extra database probe for ASCII seeds, and+  adopts in-flight generation-0 awakeables rather than orphaning them. See ADR+  0024 for the operator-attested removal criteria.++- Awakeable cancellation now serializes with a concurrent suspend write under+  the awaited step's advisory lock. Suspend arbitration also consults a valid+  `awk:` awakeable row after an absent step-index result, keeping the instance+  `running` when the awakeable is already completed or cancelled. A cancellation+  that committed just before the stale suspend write can therefore no longer+  strand the workflow as undiscoverable `suspended` work.++- A workflow that goes terminal while the resume worker is recording its crash+  no longer aborts the rest of the pass. The crash-recording `UPDATE` matches no+  row once the workflow is terminal, which used to fail a single-row decoder;+  because the crash record sits outside the per-advance error handling, that+  store error escaped the whole pass and every remaining candidate was skipped+  until the next tick. The race is now logged as `ResumeCrashRecordSkipped`,+  counted under `transientErrors`, and the pass continues.++- Workflow garbage collection survives errors. A failing deletion is isolated to+  its own workflow instead of aborting the batch, and `WorkflowGcSummary`'s+  `deleted` now counts workflows actually collected rather than restating+  `scanned`. `runWorkflowGcWorker`'s loop catches store errors and synchronous+  exceptions per pass and continues on the next tick — previously a bare+  `forever` loop that the first transient error ended until process restart.++- Deterministic ids are derived from the UTF-8 bytes of their seed text rather+  than each character's codepoint truncated to eight bits. The old encoding gave+  two different seeds the same id whenever their characters agreed modulo 256+  (`"ā"` and `"\SOH"`, and many CJK pairs), which wedged the workflow journal —+  the colliding step's append was refused as a duplicate event id on every+  retry, until the resume worker marked the workflow failed — and silently+  suppressed a legitimate process-manager command. Affects+  `Keiro.Workflow.deterministicJournalId`, `Keiro.Workflow.Sleep.sleepTimerId`,+  `Keiro.Workflow.Awakeable.deterministicAwakeableId`, and+  `Keiro.ProcessManager.deterministicCommandId`.++  **For ASCII seeds the two encodings are byte-identical, so every id in a+  deployment that uses ASCII workflow names, ids, step names, sleep names,+  awakeable labels, patch ids, and correlation ids is unchanged.** There is no+  migration. Only non-ASCII seeds derive new ids; in-flight consequences are+  bounded (a journal still replays, a sleep may arm one duplicate timer whose+  fire collapses idempotently, a legacy generation-0 awakeable id is no longer+  adopted, and a retried process-manager emission may emit one duplicate+  command). The derivation is now frozen and pinned by fixtures: see+  `docs/adr/0024-deterministic-ids-hash-utf-8-seed-bytes-and-are-frozen-replay-identity.md`.++### Other Changes++- `ConsistencyMode`, `StrongScope`, `Strong`, `Eventual`, `PositionWait`,+  `defaultStrongWaitOptions`, `runQueryWith`, and the legacy `ReadModel` waiting/cursor+  record fields remain source-compatible in 0.12 and are scheduled for removal in 0.13.+  Use the truthful freshness/cursor façade; legacy `PositionWait` with no target retains+  its historical immediate behavior during the migration window.++- The source distribution now includes the BSD-3-Clause license file.++- Bounded workflow drains repeat while `ResumeSummary.advanced > 0`, not while+  `discovered > 0`. `discovered` remains the admitted pool size; `advanced`+  counts only fresh journal appends, crash-ceiling terminal failures, and+  externally delivered wakes observed mid-pass. Replay-only re-suspensions,+  paced retries, unregistered workflow names, foreign leases, transient errors,+  and due sleeps remain blocked in place; due sleeps are reported separately as+  `sleepDue`.++- Workflow discovery is now exact: `findUnfinishedWorkflowIds` returns an+  instance only when its status is `running`, or `suspended` with a due+  `wake_after`. A workflow parked on an awakeable, a child, or a future-dated+  sleep is no longer re-claimed, replayed, and re-suspended on every resume+  pass, so idle cost no longer scales with the number of parked workflows.+  Every path that resolves or abandons a wake writes the instance row in the+  same transaction; a third-party wake source that transitions its own durable+  row without appending to the journal must now flip the owning instance row+  itself. See `docs/adr/0023-workflow-discovery-is-exact-and-the-instance-row-is-the-complete-wake-ledger.md`.++- `Keiro.Workflow.Awakeable.cancelAwakeable` flips the owning workflow's+  instance row to `running` in the same transaction as the cancellation, so the+  workflow is discovered and can observe the cancellation. Its signature is+  unchanged; `Keiro.Workflow.Awakeable.Schema.cancelAwakeableTx` now returns+  `Maybe (Text, Text)` (the owner's name and id) instead of `Bool`.++- `Keiro.Workflow.Sleep.workflowSleepFireAction` clears `wake_after` only when+  its journal append is fresh, so a stale re-fire can no longer erase the wake+  hint written by a later sleep's first arm.++- The resume worker no longer unions `findRunningChildIds` into discovery; a+  freshly spawned child is already discovered through the instance row+  `spawnChild` writes. The query remains exported for operator inspection.++- Fewer database round-trips per workflow step: the run-entry terminal probe is+  one query instead of two, the terminal boundary check rides the append+  transaction instead of costing a separate query before and after each action,+  and `claimInstance` no longer resolves `MAX(generation)` on every claim.++- `Keiro.Workflow`'s overview gains a "Writing a custom wake source" section+  stating the four obligations a third-party wake source owes — a durable row+  keyed by the logical workflow, delivery under the awaited step name, an arm+  that re-checks the row and re-delivers, and an instance-row write on every+  lifecycle transition — with the rotation race spelled out on+  `appendJournalEntryReturningId`. The same contract is written for adopters in+  `docs/guides/durable-workflows.md` and `docs/user/durable-workflows.md`.++- `continueAsNew` documents that rotating abandons any awakeable id already+  handed out: the next generation re-runs the allocation step and hands out a+  fresh one, so the holder must be re-notified from that step. Stated on+  `continueAsNew`, on `Keiro.Workflow.Awakeable.awakeableNamed`, and in the+  guide.++- A "what suspension costs" section in both durable-workflow documents, the+  roadmap, and the production-status page: parked workflows are free, and the+  costs that remain are due sleeps awaiting a timer worker, crash retries, and+  journal replay under the default `snapshotPolicy = Never`.++- Corrected drift: `recordStepTx`'s haddock named a two-column conflict key that+  has been four columns since migration 0008, and `Keiro.Workflow.Gc` now states+  that collecting a terminal parent deliberately detaches its still-running+  children. ## [0.11.0.0] - 2026-08-05  ### Breaking Changes
+ LICENSE view
@@ -0,0 +1,28 @@+BSD 3-Clause License++Copyright (c) 2026, Nadeem Bitar++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software without+   specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES+INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS+OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT INCLUDING+NEGLIGENCE OR OTHERWISE ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN+IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
bench/Main.hs view
@@ -7,13 +7,35 @@ where  import Control.Concurrent (threadDelay)+import Data.Aeson qualified as Aeson import Data.ByteString qualified as BS+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.Int (Int32) import Data.Text qualified as Text import Data.Time (UTCTime (..), secondsToDiffTime) import Data.Time.Calendar (Day (ModifiedJulianDay)) import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)+import Keiki.Core+  ( Edge (..),+    HsPred,+    InCtor,+    RegFile (..),+    SymTransducer (..),+    Update (..),+    WireCtor,+    inpCtor,+    lit,+    matchInCtor,+    oNil,+    pack,+    unavailableInCtor,+    unavailableWireCtor,+    (*:),+  )+import Keiki.Core qualified as Keiki+import Keiro import Keiro.Inbox   ( InboxDedupePolicy (..),     InboxPersistence (..),@@ -33,15 +55,57 @@     publishClaimedOutbox,   ) import Keiro.Prelude+import Keiro.ProcessManager+  ( DomainProcessManager (..),+    PMCommand (..),+    ProcessManagerAction (..),+    runDomainProcessManagerWorker,+  )+import Keiro.Projection (InlineProjection (..))+import Keiro.ReadModel.Rebuild+  ( RebuildOptions (..),+    RebuildRequest (..),+    RebuildRunId,+    RebuildRunStatus (..),+    defaultRebuildOptions,+    mkRebuildRunId,+    registerProjectionCatalog,+    startCatalogRebuild,+  ) import Keiro.Telemetry qualified as Telemetry-import Keiro.Test.Postgres (withFreshStore, withMigratedSuite)+import Keiro.Test.Postgres (StoreRunner (..), withFreshResourceStore, withMigratedSuite) import Kiroku.Store qualified as Store import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource)+import Kiroku.Store.Lifecycle qualified as Lifecycle+import Kiroku.Store.Types+  ( CategoryName (..),+    EventData (..),+    EventId (..),+    ExpectedVersion (..),+    GlobalPosition (..),+    RecordedEvent (..),+    StreamId (..),+    StreamName (..),+    StreamVersion (..),+  ) import OpenTelemetry.MeterProvider (createMeterProvider, defaultSdkMeterProviderOptions) import OpenTelemetry.Metric.Core (getMeter) import OpenTelemetry.Resource (emptyMaterializedResources)-import Test.Tasty.Bench (Benchmark, bench, bgroup, defaultMain, nfIO)+import ReadModelBench+  ( readModelBenchmarks,+    runReadModelExplainEvidenceIfRequested,+    runReadModelLatencyEvidenceIfRequested,+    setupReadModelBench,+  )+import Shibuya.Adapter (Adapter (..))+import Shibuya.Core.AckHandle (AckHandle (..))+import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Types (Envelope (..))+import Streamly.Data.Stream qualified as Streamly+import Test.Tasty.Bench (Benchmark, bcompareWithin, bench, bgroup, defaultMain, nfIO) import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude  workloadSize :: Int workloadSize = 2000@@ -81,17 +145,23 @@ 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)+    withFreshResourceStore fixture \(store, runner) -> do+      withFreshResourceStore fixture \(readModelStore, readModelRunner) -> do+        (provider, _env) <-+          createMeterProvider+            emptyMaterializedResources+            defaultSdkMeterProviderOptions+        meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+        metrics <- Telemetry.newKeiroMetrics meter+        rebuildRunCounter <- newIORef 0+        runStoreChecked store (Store.runTransaction (Tx.sql rebuildBenchSql))+        readModelFixture <- setupReadModelBench readModelStore readModelRunner+        runReadModelExplainEvidenceIfRequested readModelFixture+        runReadModelLatencyEvidenceIfRequested readModelFixture+        defaultMain (benchmarks store runner metrics rebuildRunCounter <> readModelBenchmarks readModelFixture) -benchmarks :: Store.KirokuStore -> Telemetry.KeiroMetrics -> [Benchmark]-benchmarks store metrics =+benchmarks :: Store.KirokuStore -> StoreRunner -> Telemetry.KeiroMetrics -> IORef Int -> [Benchmark]+benchmarks store runner metrics rebuildRunCounter =   [ bgroup       "outbox"       [ scenarioBench store hotKey,@@ -104,7 +174,55 @@         inboxScenarioBench store singleNoMetrics,         inboxScenarioBench store batch100,         inboxScenarioBench store singleSlim-      ]+      ],+    bgroup+      "command"+      [ bgroup+          "legacy"+          [commandScenarioBench store "accepted-1" legacyAcceptedOneStream legacyAcceptedOneTarget EmitOne],+        bgroup+          "control"+          [commandScenarioBench store "accepted-1" legacyAcceptedOneStream legacyAcceptedOneTarget EmitOne],+        bgroup+          "domain-warmup"+          [domainCommandScenarioBench store "accepted-1" domainAcceptedOneHandler domainAcceptedOneTarget EmitOne],+        bgroup+          "domain"+          [ bcompareWithin 0 1.25 acceptedOneControlPattern $+              domainCommandScenarioBench store "accepted-1" domainAcceptedOneHandler domainAcceptedOneTarget EmitOne+          ],+        bgroup+          "legacy"+          [commandScenarioBench store "accepted-large" legacyAcceptedLargeStream legacyAcceptedLargeTarget EmitLarge],+        bgroup+          "domain"+          [ bcompareWithin 0 1.25 legacyAcceptedLargePattern $+              domainCommandScenarioBench store "accepted-large" domainAcceptedLargeHandler domainAcceptedLargeTarget EmitLarge+          ],+        bgroup+          "legacy"+          [commandScenarioBench store "no-op" legacyNoOpStream legacyNoOpTarget SelectNoOp],+        bgroup+          "domain"+          [ bcompareWithin 0 1.25 legacyNoOpPattern $+              domainCommandScenarioBench store "rejected" domainRejectedHandler domainRejectedTarget SelectNoOp,+            bcompareWithin 0 1.25 legacyNoOpPattern $+              domainCommandScenarioBench store "no-op" domainNoOpHandler domainNoOpTarget SelectNoOp,+            bgroup+              "router-fanout"+              [ domainRouterFanoutBench runner fanout+              | fanout <- coordinatorFanouts+              ],+            bgroup+              "process-manager-fanout"+              [ domainProcessManagerFanoutBench runner fanout+              | fanout <- coordinatorFanouts+              ]+          ]+      ],+    bgroup+      "rebuild"+      [rebuildScenarioBench store rebuildRunCounter]   ]   where     hotKey =@@ -166,7 +284,7 @@ runScenario :: Store.KirokuStore -> OutboxScenario -> IO () runScenario store scenario = do   runStoreChecked store do-    Store.runTransaction (Tx.sql "TRUNCATE keiro_outbox")+    Store.runTransaction (Tx.sql "TRUNCATE keiro.keiro_outbox")   seedOutbox store scenario.messages   runStoreChecked store (drainOutbox scenario.brokerModel 0) @@ -178,7 +296,7 @@ runInboxScenario :: Store.KirokuStore -> InboxScenario -> IO () runInboxScenario store scenario = do   runStoreChecked store do-    Store.runTransaction (Tx.sql "TRUNCATE keiro_inbox")+    Store.runTransaction (Tx.sql "TRUNCATE keiro.keiro_inbox")   runStoreChecked store $     case scenario.inboxBatchSize of       Nothing ->@@ -296,6 +414,640 @@       case splitAt n xs of         ([], _) -> []         (chunk, rest) -> chunk : chunksOf n rest++-- * Command runner benchmark fixture ----------------------------------------++data BenchCommand+  = EmitOne+  | EmitLarge+  | SelectNoOp+  deriving stock (Eq, Show)++data BenchEvent+  = BenchOneEmitted !Text+  | BenchLargeEmitted !Text+  deriving stock (Eq, Show)++data BenchState = BenchReady+  deriving stock (Bounded, Enum, Eq, Ord, Show)++type BenchEventStream = EventStream (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent++type ValidatedBenchEventStream = ValidatedEventStream (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent++largeCommandBatchSize :: Int+largeCommandBatchSize = 100++fixedCommandPayload :: Text+fixedCommandPayload = Text.replicate payloadSize "a"++legacyAcceptedOneTarget :: Stream BenchEventStream+legacyAcceptedOneTarget = stream "bench-command-legacy-accepted-1"++legacyAcceptedLargeTarget :: Stream BenchEventStream+legacyAcceptedLargeTarget = stream "bench-command-legacy-accepted-large"++legacyNoOpTarget :: Stream BenchEventStream+legacyNoOpTarget = stream "bench-command-legacy-no-op"++domainAcceptedOneTarget :: Stream BenchEventStream+domainAcceptedOneTarget = legacyAcceptedOneTarget++domainAcceptedLargeTarget :: Stream BenchEventStream+domainAcceptedLargeTarget = legacyAcceptedLargeTarget++domainRejectedTarget :: Stream BenchEventStream+domainRejectedTarget = legacyNoOpTarget++domainNoOpTarget :: Stream BenchEventStream+domainNoOpTarget = legacyNoOpTarget++legacyAcceptedOneStream :: ValidatedBenchEventStream+legacyAcceptedOneStream = mkEventStreamOrThrow "bench-command-legacy-accepted-1" (benchEventStream oneTransducer)++legacyAcceptedLargeStream :: ValidatedBenchEventStream+legacyAcceptedLargeStream = mkEventStreamOrThrow "bench-command-legacy-accepted-large" (benchEventStream largeTransducer)++legacyNoOpStream :: ValidatedBenchEventStream+legacyNoOpStream = mkEventStreamOrThrow "bench-command-legacy-no-op" (benchEventStream noOpTransducer)++domainAcceptedOneHandler :: DomainCommandHandler (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent Text Text+domainAcceptedOneHandler =+  DomainCommandHandler+    { eventStream = legacyAcceptedOneStream,+      classifySilent = \_ -> error "domainAcceptedOneHandler: eventful edge classified as silent"+    }++domainAcceptedLargeHandler :: DomainCommandHandler (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent Text Text+domainAcceptedLargeHandler =+  DomainCommandHandler+    { eventStream = legacyAcceptedLargeStream,+      classifySilent = \_ -> error "domainAcceptedLargeHandler: eventful edge classified as silent"+    }++domainRejectedHandler :: DomainCommandHandler (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent Text Text+domainRejectedHandler =+  DomainCommandHandler+    { eventStream = legacyNoOpStream,+      classifySilent = \_ -> SilentRejected "benchmark rejection"+    }++domainNoOpHandler :: DomainCommandHandler (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent Text Text+domainNoOpHandler =+  DomainCommandHandler+    { eventStream = legacyNoOpStream,+      classifySilent = \_ -> SilentNoOp "benchmark no-op"+    }++acceptedOneControlPattern :: String+acceptedOneControlPattern = "$NF == \"accepted-1\" && $(NF-1) == \"control\" && $(NF-2) == \"command\""++legacyAcceptedLargePattern :: String+legacyAcceptedLargePattern = "$NF == \"accepted-large\" && $(NF-1) == \"legacy\" && $(NF-2) == \"command\""++legacyNoOpPattern :: String+legacyNoOpPattern = "$NF == \"no-op\" && $(NF-1) == \"legacy\" && $(NF-2) == \"command\""++benchEventStream :: SymTransducer (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent -> BenchEventStream+benchEventStream transducer =+  EventStream+    { transducer,+      initialState = BenchReady,+      initialRegisters = RNil,+      eventCodec = benchEventCodec,+      resolveStreamName = streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++oneTransducer :: SymTransducer (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent+oneTransducer = singleEdgeTransducer emitOneCtor [pack emitOneCtor oneEventCtor (lit fixedCommandPayload *: oNil)]++largeTransducer :: SymTransducer (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent+largeTransducer =+  singleEdgeTransducer+    emitLargeCtor+    (Prelude.replicate largeCommandBatchSize (pack emitLargeCtor largeEventCtor (lit fixedCommandPayload *: oNil)))++noOpTransducer :: SymTransducer (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent+noOpTransducer = singleEdgeTransducer selectNoOpCtor []++singleEdgeTransducer :: InCtor BenchCommand '[] -> [Keiki.OutTerm '[] BenchCommand BenchEvent] -> SymTransducer (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent+singleEdgeTransducer commandCtor emitted =+  SymTransducer+    { edgesOut = \BenchReady ->+        [ Edge+            { guard = matchInCtor commandCtor,+              update = UKeep,+              output = emitted,+              target = BenchReady,+              mode = Keiki.Live+            }+        ],+      initial = BenchReady,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++emitOneCtor :: InCtor BenchCommand '[]+emitOneCtor =+  unavailableInCtor+    "EmitOne"+    (\case EmitOne -> Just RNil; _ -> Nothing)+    (\RNil -> EmitOne)++emitLargeCtor :: InCtor BenchCommand '[]+emitLargeCtor =+  unavailableInCtor+    "EmitLarge"+    (\case EmitLarge -> Just RNil; _ -> Nothing)+    (\RNil -> EmitLarge)++selectNoOpCtor :: InCtor BenchCommand '[]+selectNoOpCtor =+  unavailableInCtor+    "SelectNoOp"+    (\case SelectNoOp -> Just RNil; _ -> Nothing)+    (\RNil -> SelectNoOp)++oneEventCtor :: WireCtor BenchEvent (Text, ())+oneEventCtor =+  unavailableWireCtor+    "BenchOneEmitted"+    (\case BenchOneEmitted value -> Just (value, ()); _ -> Nothing)+    (\(value, ()) -> BenchOneEmitted value)++largeEventCtor :: WireCtor BenchEvent (Text, ())+largeEventCtor =+  unavailableWireCtor+    "BenchLargeEmitted"+    (\case BenchLargeEmitted value -> Just (value, ()); _ -> Nothing)+    (\(value, ()) -> BenchLargeEmitted value)++benchEventCodec :: Codec BenchEvent+benchEventCodec =+  Codec+    { eventTypes = EventType "BenchOneEmitted" :| [EventType "BenchLargeEmitted"],+      eventType = \case+        BenchOneEmitted {} -> EventType "BenchOneEmitted"+        BenchLargeEmitted {} -> EventType "BenchLargeEmitted",+      schemaVersion = 1,+      encode = \case+        BenchOneEmitted value -> toJSON value+        BenchLargeEmitted value -> toJSON value,+      decode = \(EventType eventTypeName) _ ->+        case eventTypeName of+          "BenchOneEmitted" -> Right (BenchOneEmitted fixedCommandPayload)+          "BenchLargeEmitted" -> Right (BenchLargeEmitted fixedCommandPayload)+          other -> Left ("unknown command benchmark event type: " <> other),+      upcasters = []+    }++commandScenarioBench :: Store.KirokuStore -> String -> ValidatedBenchEventStream -> Stream BenchEventStream -> BenchCommand -> Benchmark+commandScenarioBench store benchmarkName validatedStream target command =+  bench benchmarkName $ nfIO $ runStoreChecked store do+    void (Lifecycle.hardDeleteStream (streamName target))+    result <- runCommand defaultRunCommandOptions validatedStream target command+    case result of+      Right _ -> pure ()+      Left err -> liftIO (fail ("unexpected command benchmark result: " <> show err))++domainCommandScenarioBench :: Store.KirokuStore -> String -> DomainCommandHandler (HsPred '[] BenchCommand) '[] BenchState BenchCommand BenchEvent Text Text -> Stream BenchEventStream -> BenchCommand -> Benchmark+domainCommandScenarioBench store benchmarkName handler target command =+  bench benchmarkName $ nfIO $ runStoreChecked store do+    void (Lifecycle.hardDeleteStream (streamName target))+    result <- runDomainCommand defaultRunCommandOptions handler target command+    case result of+      Right DomainCommandOutcome {} -> pure ()+      Left err -> liftIO (fail ("unexpected typed command benchmark result: " <> show err))++-- * Projection rebuild benchmark fixtures ---------------------------------++rebuildEventsPerCategory :: Int+rebuildEventsPerCategory = 200++rebuildPageSize :: Int32+rebuildPageSize = 16++rebuildScenarioBench :: Store.KirokuStore -> IORef Int -> Benchmark+rebuildScenarioBench store runCounter =+  bench "three-categories-200" $ nfIO $ runStoreChecked store do+    traverse_ (void . Lifecycle.hardDeleteStream) rebuildStreams+    traverse_ seedRebuildStream rebuildStreams+    registered <- registerProjectionCatalog rebuildCatalog+    case registered of+      Left err -> liftIO (fail ("unexpected rebuild catalog registration result: " <> show err))+      Right _ -> pure ()+    runNumber <- liftIO $ atomicModifyIORef' runCounter (\current -> let next = current + 1 in (next, next))+    let request =+          RebuildRequest+            { rebuildRunId = benchmarkRebuildRunId runNumber,+              requestedBy = "keiro-bench",+              requestReason = "measure buffered projection replay",+              replayFrom = GlobalPosition 0+            }+        options =+          (defaultRebuildOptions request)+            { replayPageSize = rebuildPageSize+            }+    startCatalogRebuild rebuildCatalog benchmarkRebuildGroupId options >>= \case+      Left err -> liftIO (fail ("unexpected rebuild benchmark result: " <> show err))+      Right report+        | report ^. #runStatus == RebuildRunPromoted -> pure ()+        | otherwise -> liftIO (fail ("rebuild benchmark did not promote: " <> show (report ^. #runStatus)))++seedRebuildStream :: (Store :> es) => StreamName -> Eff es ()+seedRebuildStream streamName' =+  void $+    Store.appendToStream+      streamName'+      NoStream+      [ EventData+          { eventId = Nothing,+            eventType = EventType "BenchRebuildEvent",+            payload = Aeson.toJSON eventNumber,+            metadata = Nothing,+            causationId = Nothing,+            correlationId = Nothing+          }+      | eventNumber <- [1 .. rebuildEventsPerCategory]+      ]++rebuildStreams :: [StreamName]+rebuildStreams =+  [ StreamName "orders-bench-rebuild",+    StreamName "customers-bench-rebuild",+    StreamName "billing-bench-rebuild"+  ]++rebuildCatalog :: ValidatedProjectionCatalog+rebuildCatalog =+  case validateProjectionCatalog rebuildCatalogDeclaration of+    Success catalog -> catalog+    Failure diagnostics -> error ("invalid rebuild benchmark catalog: " <> show diagnostics)++rebuildCatalogDeclaration :: ProjectionCatalog+rebuildCatalogDeclaration =+  ProjectionCatalog+    { sources =+        [ source "orders" ordersRebuildSourceId,+          source "customers" customersRebuildSourceId,+          source "billing" billingRebuildSourceId+        ],+      targets =+        [ target ordersRebuildTargetId "bench_rebuild_orders",+          target customersRebuildTargetId "bench_rebuild_customers",+          target billingRebuildTargetId "bench_rebuild_billing"+        ],+      rebuildGroups =+        [ RebuildGroupDeclaration+            { rebuildGroupId = benchmarkRebuildGroupId,+              orderedTargets = [ordersRebuildTargetId, customersRebuildTargetId, billingRebuildTargetId],+              verificationHooks = [],+              claimSite = rebuildSite "bench:rebuild-group"+            }+        ],+      projectionRevisions = [],+      externalReadContracts = [],+      subscriptions = [],+      dedupKeys = [],+      queryModels = [],+      projectionSets =+        [ projectionSet "orders" ordersRebuildSourceId ordersRebuildProjectionId ordersRebuildTargetId,+          projectionSet "customers" customersRebuildSourceId customersRebuildProjectionId customersRebuildTargetId,+          projectionSet "billing" billingRebuildSourceId billingRebuildProjectionId billingRebuildTargetId+        ]+    }+  where+    source sourceCategory sourceId =+      SourceDeclaration+        { sourceId,+          sourceScope = CategorySource (CategoryName sourceCategory),+          codecFingerprint = "bench-rebuild-v1",+          claimSite = rebuildSite ("bench:rebuild-source:" <> sourceCategory)+        }+    target targetId tableName =+      TargetDeclaration+        { targetId,+          qualifiedTable = QualifiedTable "app" tableName,+          resetPolicy = ClearBeforeReplay,+          dependsOn = [],+          claimSite = rebuildSite ("bench:rebuild-target:" <> tableName)+        }+    projectionSet label sourceId projectionId targetId =+      SomeProjectionSet+        ProjectionSet+          { projectionSource = sourceId,+            projectionDefinitions =+              ProjectionDefinition+                { projectionId,+                  rebuildGroup = benchmarkRebuildGroupId,+                  ownedTargets = targetId :| [],+                  replayPolicy =+                    Replayable+                      ReplayAdapter+                        { decodeForReplay = const ReplayIrrelevant,+                          applyForReplay = \() _ -> pure ()+                        },+                  handlers =+                    InlineHandler+                      InlineProjection+                        { name = "bench-rebuild-live-" <> label,+                          apply = \_ _ -> pure ()+                        }+                      (rebuildSite ("bench:rebuild-handler:" <> label))+                      :| [],+                  claimSite = rebuildSite ("bench:rebuild-projection:" <> label)+                }+                :| [],+            claimSite = rebuildSite ("bench:rebuild-set:" <> label)+          }++benchmarkRebuildRunId :: Int -> RebuildRunId+benchmarkRebuildRunId runNumber =+  either (error . Text.unpack) id (mkRebuildRunId ("bench-rebuild-" <> Text.pack (show runNumber)))++rebuildSite :: Text -> ClaimSite+rebuildSite raw = either (error . show) id (mkClaimSite raw)++rebuildIdentity :: (Text -> Either CatalogIdentityError value) -> Text -> value+rebuildIdentity constructor raw = either (error . show) id (constructor raw)++ordersRebuildSourceId, customersRebuildSourceId, billingRebuildSourceId :: SourceId+ordersRebuildSourceId = rebuildIdentity mkSourceId "bench-orders-source"+customersRebuildSourceId = rebuildIdentity mkSourceId "bench-customers-source"+billingRebuildSourceId = rebuildIdentity mkSourceId "bench-billing-source"++ordersRebuildTargetId, customersRebuildTargetId, billingRebuildTargetId :: TargetId+ordersRebuildTargetId = rebuildIdentity mkTargetId "bench-orders-target"+customersRebuildTargetId = rebuildIdentity mkTargetId "bench-customers-target"+billingRebuildTargetId = rebuildIdentity mkTargetId "bench-billing-target"++ordersRebuildProjectionId, customersRebuildProjectionId, billingRebuildProjectionId :: ProjectionId+ordersRebuildProjectionId = rebuildIdentity mkProjectionId "bench-orders-projection"+customersRebuildProjectionId = rebuildIdentity mkProjectionId "bench-customers-projection"+billingRebuildProjectionId = rebuildIdentity mkProjectionId "bench-billing-projection"++benchmarkRebuildGroupId :: RebuildGroupId+benchmarkRebuildGroupId = rebuildIdentity mkRebuildGroupId "bench-rebuild-group"++rebuildBenchSql :: BS.ByteString+rebuildBenchSql =+  "CREATE SCHEMA IF NOT EXISTS app; \+  \CREATE TABLE IF NOT EXISTS app.bench_rebuild_orders (marker bigint); \+  \CREATE TABLE IF NOT EXISTS app.bench_rebuild_customers (marker bigint); \+  \CREATE TABLE IF NOT EXISTS app.bench_rebuild_billing (marker bigint)"++-- * Domain coordinator worker benchmark fixtures ---------------------------++data FanoutInput = FanoutInput !Text !Int++data FanoutCommand = EmitFanout !Int+  deriving stock (Eq, Show)++data FanoutEvent = FanoutEmitted !Int !Text+  deriving stock (Eq, Show)++data FanoutState = FanoutReady+  deriving stock (Bounded, Enum, Eq, Ord, Show)++type ValidatedFanoutEventStream = ValidatedEventStream (HsPred '[] FanoutCommand) '[] FanoutState FanoutCommand FanoutEvent++type FanoutCommandFields = '[ '("targetIndex", Int)]++fanoutCommandCtor :: InCtor FanoutCommand FanoutCommandFields+fanoutCommandCtor =+  unavailableInCtor+    "EmitFanout"+    (\case EmitFanout targetIndex -> Just (RCons Proxy targetIndex RNil))+    (\(RCons _ targetIndex RNil) -> EmitFanout targetIndex)++fanoutEventCtor :: WireCtor FanoutEvent (Int, ())+fanoutEventCtor =+  unavailableWireCtor+    "FanoutEmitted"+    ( \case+        FanoutEmitted targetIndex payload+          | payload == fanoutPayload targetIndex -> Just (targetIndex, ())+        _ -> Nothing+    )+    (\(targetIndex, ()) -> FanoutEmitted targetIndex (fanoutPayload targetIndex))++fanoutTransducer :: SymTransducer (HsPred '[] FanoutCommand) '[] FanoutState FanoutCommand FanoutEvent+fanoutTransducer =+  SymTransducer+    { edgesOut = \FanoutReady ->+        [ Edge+            { guard = matchInCtor fanoutCommandCtor,+              update = UKeep,+              output = [pack fanoutCommandCtor fanoutEventCtor (inpCtor fanoutCommandCtor #targetIndex *: oNil)],+              target = FanoutReady,+              mode = Keiki.Live+            }+        ],+      initial = FanoutReady,+      initialRegs = RNil,+      isFinal = const False+    }++fanoutEventCodec :: Codec FanoutEvent+fanoutEventCodec =+  Codec+    { eventTypes = EventType "FanoutEmitted" :| [],+      eventType = const (EventType "FanoutEmitted"),+      schemaVersion = 1,+      encode = \(FanoutEmitted targetIndex payload) -> toJSON (targetIndex, payload),+      decode = \(EventType eventTypeName) payload ->+        case eventTypeName of+          "FanoutEmitted" ->+            case Aeson.fromJSON payload of+              Aeson.Success (targetIndex, value) -> Right (FanoutEmitted targetIndex value)+              Aeson.Error err -> Left (Text.pack err)+          other -> Left ("unknown fan-out benchmark event type: " <> other),+      upcasters = []+    }++fanoutEventStream :: ValidatedFanoutEventStream+fanoutEventStream =+  mkEventStreamOrThrow+    "bench-command-domain-fanout"+    EventStream+      { transducer = fanoutTransducer,+        initialState = FanoutReady,+        initialRegisters = RNil,+        eventCodec = fanoutEventCodec,+        resolveStreamName = streamName,+        snapshotPolicy = Never,+        stateCodec = Nothing+      }++fanoutDomainHandler :: DomainCommandHandler (HsPred '[] FanoutCommand) '[] FanoutState FanoutCommand FanoutEvent Text Text+fanoutDomainHandler =+  DomainCommandHandler+    { eventStream = fanoutEventStream,+      classifySilent = \_ -> error "fanoutDomainHandler: eventful edge classified as silent"+    }++fanoutPayload :: Int -> Text+fanoutPayload targetIndex =+  Text.take payloadSize (Text.replicate repetitions seed)+  where+    seed = Text.pack (show targetIndex) <> ":"+    repetitions = payloadSize `div` Text.length seed + 1++coordinatorFanouts :: [Int]+coordinatorFanouts = [10, 100, 1000]++domainRouterFanoutBench :: StoreRunner -> Int -> Benchmark+domainRouterFanoutBench runner fanout =+  bench (show fanout) $ nfIO $ do+    let correlationId = "router-" <> Text.pack (show fanout)+        input = FanoutInput correlationId fanout+    runResourceStoreChecked runner do+      resetFanoutTargets "router" correlationId fanout+      runDomainRouterWorker+        defaultRunCommandOptions+        fanoutDomainRouter+        (fanoutAdapter input)+        (\message -> Just (fanoutSourceEvent, message))++domainProcessManagerFanoutBench :: StoreRunner -> Int -> Benchmark+domainProcessManagerFanoutBench runner fanout =+  bench (show fanout) $ nfIO $ do+    let correlationId = "process-manager-" <> Text.pack (show fanout)+        input = FanoutInput correlationId fanout+    runResourceStoreChecked runner do+      resetFanoutTargets "process-manager" correlationId fanout+      void (Lifecycle.hardDeleteStream (StreamName ("bench-command-domain-process-manager:" <> correlationId)))+      runDomainProcessManagerWorker+        defaultRunCommandOptions+        fanoutDomainProcessManager+        (fanoutAdapter input)+        (\message -> Just (fanoutSourceEvent, message))++fanoutDomainRouter ::+  DomainRouter+    FanoutInput+    (HsPred '[] FanoutCommand)+    '[]+    FanoutState+    FanoutCommand+    FanoutEvent+    Text+    Text+    es+fanoutDomainRouter =+  DomainRouter+    { name = "bench-domain-router",+      key = \(FanoutInput correlationId _) -> correlationId,+      resolve = \(FanoutInput correlationId fanout) -> pure (fanoutCommands "router" correlationId fanout),+      targetHandler = fanoutDomainHandler,+      targetProjections = const []+    }++fanoutDomainProcessManager ::+  DomainProcessManager+    FanoutInput+    (HsPred '[] BenchCommand)+    '[]+    BenchState+    BenchCommand+    BenchEvent+    (HsPred '[] FanoutCommand)+    '[]+    FanoutState+    FanoutCommand+    FanoutEvent+    Text+    Text+fanoutDomainProcessManager =+  DomainProcessManager+    { name = "bench-domain-process-manager",+      correlate = \(FanoutInput correlationId _) -> correlationId,+      eventStream = legacyNoOpStream,+      streamFor = \correlationId -> stream ("bench-command-domain-process-manager:" <> correlationId),+      targetHandler = fanoutDomainHandler,+      targetProjections = const [],+      handle = \(FanoutInput correlationId fanout) ->+        ProcessManagerAction+          { command = SelectNoOp,+            commands = fanoutCommands "process-manager" correlationId fanout,+            timers = []+          }+    }++fanoutCommands :: Text -> Text -> Int -> [PMCommand FanoutCommand]+fanoutCommands coordinator correlationId fanout =+  [ PMCommand+      { target = stream (fanoutTargetName coordinator correlationId targetIndex),+        command = EmitFanout targetIndex+      }+  | targetIndex <- [0 .. fanout - 1]+  ]++fanoutTargetName :: Text -> Text -> Int -> Text+fanoutTargetName coordinator correlationId targetIndex =+  "bench-command-domain-"+    <> coordinator+    <> ":"+    <> correlationId+    <> ":"+    <> Text.pack (show targetIndex)++resetFanoutTargets :: (Store :> es) => Text -> Text -> Int -> Eff es ()+resetFanoutTargets coordinator correlationId fanout =+  traverse_+    (\targetIndex -> void (Lifecycle.hardDeleteStream (StreamName (fanoutTargetName coordinator correlationId targetIndex))))+    [0 .. fanout - 1]++fanoutSourceEvent :: RecordedEvent+fanoutSourceEvent =+  RecordedEvent+    { eventId = EventId (UUID.fromWords64 0x018f0f1800007000 0x8000000000000abc),+      eventType = EventType "BenchFanoutSource",+      streamVersion = StreamVersion 1,+      globalPosition = GlobalPosition 1,+      originalStreamId = StreamId 1,+      originalVersion = StreamVersion 1,+      payload = toJSON ("fanout" :: Text),+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing,+      createdAt = fixedOccurredAt+    }++fanoutAdapter :: FanoutInput -> Adapter es FanoutInput+fanoutAdapter input =+  Adapter+    { adapterName = "bench-domain-coordinator",+      source =+        Streamly.fromList+          [ Ingested+              { envelope =+                  Envelope+                    { messageId = "bench-domain-fanout",+                      cursor = Nothing,+                      partition = Nothing,+                      enqueuedAt = Nothing,+                      traceContext = Nothing,+                      headers = Nothing,+                      attempt = Nothing,+                      attributes = mempty,+                      payload = input+                    },+                ack = AckHandle (\_ -> pure ()),+                lease = Nothing+              }+          ],+      shutdown = pure ()+    }++runResourceStoreChecked :: StoreRunner -> Eff '[Store, Error Store.StoreError, KirokuStoreResource, IOE] a -> IO a+runResourceStoreChecked (StoreRunner runner) action = do+  result <- runner action+  case result of+    Left err -> fail (show err)+    Right value -> pure value  runStoreChecked :: Store.KirokuStore -> Eff [Store, Error Store.StoreError, IOE] a -> IO a runStoreChecked store action = do
+ bench/ReadModelBench.hs view
@@ -0,0 +1,1442 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedRecordDot #-}++module ReadModelBench+  ( ReadModelBenchFixture,+    readModelBenchmarks,+    runReadModelExplainEvidenceIfRequested,+    runReadModelLatencyEvidenceIfRequested,+    setupReadModelBench,+  )+where++import Control.Monad (forM, replicateM_)+import Data.Aeson qualified as Aeson+import Data.ByteString qualified as ByteString+import Data.Foldable (toList)+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.List qualified as List+import Data.Map.Strict qualified as Map+import Data.Maybe (listToMaybe)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.Time (UTCTime (..), secondsToDiffTime)+import Data.Time.Calendar (Day (ModifiedJulianDay))+import Data.UUID qualified as UUID+import Data.UUID.V5 qualified as UUID.V5+import Data.Word (Word64)+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiki.Core+  ( Edge (..),+    HsPred,+    InCtor,+    RegFile (..),+    SymTransducer (..),+    Update (..),+    WireCtor,+    matchInCtor,+    oNil,+    pack,+    unavailableInCtor,+    unavailableWireCtor,+  )+import Keiki.Core qualified as Keiki+import Keiro+import Keiro.Connection (qualifyTable)+import Keiro.Prelude+import Keiro.Projection+  ( AsyncProjection (..),+    CatalogAsyncApplyOutcome (..),+    InlineProjection (..),+    ProjectionCommandOutcome (..),+    applyAsyncProjectionFromCatalog,+    runCommandWithCatalogProjections,+  )+import Keiro.ReadModel+  ( QueryCursorAuthority (NoQueryCursor),+    ReadModelBlueprint (..),+    immediateReadModel,+  )+import Keiro.ReadModel.External (reconcileExternalReadContracts)+import Keiro.ReadModel.Rebuild+  ( RebuildRunId,+    StreamReprojectionRequest (..),+    VersionedRebuildPhase (..),+    VersionedRebuildRequest (..),+    VersionedTargetMode (..),+    beginVersionedRebuild,+    listProjectionGroupStatuses,+    lookupProjectionGroupStatus,+    mkRebuildRunId,+    rebuildRunIdText,+    registerProjectionCatalog,+    reprojectStream,+    resumeVersionedRebuild,+  )+import Keiro.Stream qualified as Keiro.Stream+import Keiro.Test.Postgres (StoreRunner (..))+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource)+import Kiroku.Store.HistoryRetention+  ( HistoryRetentionLeaseRequest (..),+    mkHistoryRetentionLeaseDuration,+    mkHistoryRetentionLeaseOwner,+    mkHistoryRetentionLeaseReason,+  )+import Kiroku.Store.Subscription.Types (MissingCheckpointPolicy (FromBeginning))+import Kiroku.Store.Types+  ( CategoryName (..),+    EventData (..),+    EventId (..),+    ExpectedVersion (..),+    GlobalPosition (..),+    RecordedEvent (..),+    StreamId (..),+    StreamName (..),+    StreamVersion (..),+  )+import System.Environment (lookupEnv)+import Test.Tasty.Bench (Benchmark, bcompareWithin, bench, bgroup, nfIO)+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude++data ProjectionBenchCommand = EmitProjection+  deriving stock (Eq, Show)++data ProjectionBenchEvent = ProjectionBenchEvent+  deriving stock (Eq, Show)++data ProjectionBenchState = ProjectionBenchReady+  deriving stock (Bounded, Enum, Eq, Ord, Show)++type ProjectionBenchEventStream = EventStream (HsPred '[] ProjectionBenchCommand) '[] ProjectionBenchState ProjectionBenchCommand ProjectionBenchEvent++type ValidatedProjectionBenchEventStream = ValidatedEventStream (HsPred '[] ProjectionBenchCommand) '[] ProjectionBenchState ProjectionBenchCommand ProjectionBenchEvent++data ScenarioGroup = ScenarioGroup+  { groupId :: !RebuildGroupId,+    targets :: ![(TargetId, QualifiedTable)],+    revisions :: ![ProjectionRevision]+  }++data ProjectionScenario = ProjectionScenario+  { key :: !Text,+    sourceDeclaration :: !SourceDeclaration,+    targetDeclarations :: ![TargetDeclaration],+    groupDeclarations :: ![RebuildGroupDeclaration],+    projectionSet :: !(ProjectionSet ProjectionBenchEvent),+    subscriptions :: ![SubscriptionDeclaration],+    dedupKeys :: ![DedupKeyDeclaration],+    scenarioGroups :: ![ScenarioGroup],+    asyncDelivery :: !(Maybe (ProjectionId, AsyncProjection)),+    versionManaged :: !Bool+  }++data ReadModelBenchFixture = ReadModelBenchFixture+  { store :: !Store.KirokuStore,+    runner :: !StoreRunner,+    catalog :: !ValidatedProjectionCatalog,+    scenarios :: !(Map.Map Text ProjectionScenario),+    commandCounter :: !(IORef Int64),+    asyncCounter :: !(IORef Int64),+    runCounter :: !(IORef Int64),+    promotionRevision :: !(IORef Bool)+  }++scenarioDefinitions :: [ProjectionScenario]+scenarioDefinitions =+  [ mkInlineScenario "bench-legacy-one" False [1] 0,+    mkInlineScenario "bench-versioned-one" True [1] 0,+    mkInlineScenario "bench-legacy-three" False [3] 0,+    mkInlineScenario "bench-versioned-three" True [3] 0,+    mkInlineScenario "bench-legacy-groups" False [1, 1, 1] 0,+    mkInlineScenario "bench-versioned-groups" True [1, 1, 1] 0,+    mkInlineScenario "bench-legacy-many-revisions" False [1] 0,+    mkInlineScenario "bench-versioned-many-revisions" True [1] 30,+    mkMixedScenario "bench-legacy-mixed" False,+    mkMixedScenario "bench-versioned-mixed" True,+    mkInlineScenario "bench-versioned-allrows" True [1] 0,+    mkInlineScenario "bench-versioned-keyed" True [1] 0,+    mkRepairScenario+  ]++setupReadModelBench :: Store.KirokuStore -> StoreRunner -> IO ReadModelBenchFixture+setupReadModelBench store runner = do+  runStoreChecked store (Store.runTransaction setupTables)+  catalog <-+    case validateProjectionCatalog projectionBenchCatalog of+      Failure diagnostics -> fail ("invalid read-model benchmark catalog: " <> show diagnostics)+      Success value -> pure value+  registration <- runStoreChecked store (registerProjectionCatalog catalog)+  case registration of+    Left err -> fail ("read-model benchmark catalog registration failed: " <> show err)+    Right _ -> pure ()+  runStoreChecked store (Store.runTransaction (bootstrapVersionedGroups scenarioDefinitions))+  reconciliation <- runStoreChecked store (reconcileExternalReadContracts catalog)+  case reconciliation of+    Left err -> fail ("read-model benchmark external contract reconciliation failed: " <> show err)+    Right () -> pure ()+  runStoreChecked store (Store.runTransaction seedStatusScale)+  seedStabilizingHistory store+  seedRepairStreams store+  commandCounter <- newIORef 0+  asyncCounter <- newIORef 0+  runCounter <- newIORef 0+  promotionRevision <- newIORef False+  pure+    ReadModelBenchFixture+      { store,+        runner,+        catalog,+        scenarios = Map.fromList [(scenario.key, scenario) | scenario <- scenarioDefinitions],+        commandCounter,+        asyncCounter,+        runCounter,+        promotionRevision+      }++readModelBenchmarks :: ReadModelBenchFixture -> [Benchmark]+readModelBenchmarks fixture =+  [ bgroup+      "projection"+      [ bgroup+          "inline"+          [ bgroup+              "legacy"+              [ inlineScenarioBench fixture "one-target" "bench-legacy-one",+                inlineScenarioBench fixture "three-target" "bench-legacy-three",+                inlineScenarioBench fixture "three-groups" "bench-legacy-groups",+                inlineScenarioBench fixture "many-revisions" "bench-legacy-many-revisions",+                inlineScenarioBench fixture "mixed-delivery" "bench-legacy-mixed"+              ],+            bgroup+              "versioned"+              [ comparedInline "one-target" "bench-versioned-one",+                comparedInline "three-target" "bench-versioned-three",+                comparedInline "three-groups" "bench-versioned-groups",+                comparedInline "many-revisions" "bench-versioned-many-revisions",+                comparedInline "mixed-delivery" "bench-versioned-mixed"+              ]+          ],+        bgroup+          "async"+          [ bgroup "legacy" [asyncScenarioBench fixture "mixed-delivery" "bench-legacy-mixed"],+            bgroup+              "versioned"+              [ bcompareWithin 0 projectionDiagnosticTimeBudget asyncLegacyPattern $+                  asyncScenarioBench fixture "mixed-delivery" "bench-versioned-mixed"+              ]+          ]+      ],+    bgroup+      "read-model-lifecycle"+      [ bgroup+          "serving-binding"+          [ bench "legacy-three" (nfIO (runServingBindingLookup fixture "bench-legacy-three")),+            bench "versioned-three" (nfIO (runServingBindingLookup fixture "bench-versioned-three"))+          ],+        bgroup+          "promotion"+          [bench "three-target-end-to-end" (nfIO (runPromotionScenario fixture))],+        bgroup+          "repair"+          [ repairScenarioBench fixture eventCount+          | eventCount <- repairEventCounts+          ]+      ],+    bgroup+      "read-model-reads"+      [ bgroup+          "status"+          [ bench "list-1000-synthetic-groups" (nfIO (runStatusList fixture)),+            bench "lookup-among-1000-synthetic-groups" (nfIO (runStatusLookup fixture))+          ],+        bgroup+          "guarded"+          [ bench "all-rows-100" (nfIO (runGuardedAllRows fixture)),+            bench "keyed-10000" (nfIO (runGuardedKeyed fixture))+          ]+      ]+  ]+  where+    comparedInline benchmarkName scenarioKey =+      bcompareWithin 0 projectionDiagnosticTimeBudget (inlineLegacyPattern benchmarkName) $+        inlineScenarioBench fixture benchmarkName scenarioKey++runServingBindingLookup :: ReadModelBenchFixture -> Text -> IO Int+runServingBindingLookup fixture scenarioKey = do+  scenario <- requireScenario fixture scenarioKey+  let groupId = (firstOrError "binding benchmark scenario group" scenario.scenarioGroups).groupId+  row <-+    runStoreChecked fixture.store $+      Store.runTransaction $+        Tx.statement (rebuildGroupIdText groupId) servingBindingStatement+  pure (maybe 0 (\(_, _, _, _, bindings) -> length bindings) row)++runStatusList :: ReadModelBenchFixture -> IO Int+runStatusList fixture =+  length <$> runStoreChecked fixture.store listProjectionGroupStatuses++runStatusLookup :: ReadModelBenchFixture -> IO Bool+runStatusLookup fixture =+  isJust+    <$> runStoreChecked+      fixture.store+      (lookupProjectionGroupStatus (identity mkRebuildGroupId "bench-status-1000"))++runGuardedAllRows :: ReadModelBenchFixture -> IO Int+runGuardedAllRows fixture = do+  rows <-+    runStoreChecked+      fixture.store+      (Store.runTransaction (Tx.statement () guardedAllRowsStatement))+  unless (length rows == 100) $+    fail ("guarded all-row benchmark fixture drifted: " <> show (length rows))+  pure (length rows)++runGuardedKeyed :: ReadModelBenchFixture -> IO Int+runGuardedKeyed fixture = do+  rows <-+    runStoreChecked+      fixture.store+      (Store.runTransaction (Tx.statement 5_000 guardedKeyedStatement))+  unless (rows == [(5_000, 50_000)]) $+    fail ("guarded keyed benchmark fixture drifted: " <> show rows)+  pure (length rows)++-- Sequential tasty-bench cases share and grow one event store, so this is a+-- broad diagnostic tripwire. The warmed, alternating sampler below owns the+-- release requirement: >=90% throughput and <=1.25x p95 for three targets.+projectionDiagnosticTimeBudget :: Double+projectionDiagnosticTimeBudget = 2++inlineLegacyPattern :: String -> String+inlineLegacyPattern benchmarkName =+  "$NF == \""+    <> benchmarkName+    <> "\" && $(NF-1) == \"legacy\" && $(NF-2) == \"inline\" && $(NF-3) == \"projection\""++asyncLegacyPattern :: String+asyncLegacyPattern =+  "$NF == \"mixed-delivery\" && $(NF-1) == \"legacy\" && $(NF-2) == \"async\" && $(NF-3) == \"projection\""++inlineScenarioBench :: ReadModelBenchFixture -> String -> Text -> Benchmark+inlineScenarioBench fixture benchmarkName scenarioKey =+  bench benchmarkName $ nfIO (runInlineScenario fixture scenarioKey)++runInlineScenario :: ReadModelBenchFixture -> Text -> IO ()+runInlineScenario fixture scenarioKey = do+  scenario <- requireScenario fixture scenarioKey+  invocation <- nextCounter fixture.commandCounter+  let target = stream ("bench-projection-command-" <> scenarioKey <> "-" <> Text.pack (show invocation))+  result <-+    runResourceStoreChecked fixture.runner $+      runCommandWithCatalogProjections+        defaultRunCommandOptions+        projectionBenchEventStream+        target+        EmitProjection+        fixture.catalog+        scenario.projectionSet+  case result of+    Right ProjectionCommandApplied {} -> pure ()+    other -> fail ("unexpected inline projection benchmark result: " <> show other)++asyncScenarioBench :: ReadModelBenchFixture -> String -> Text -> Benchmark+asyncScenarioBench fixture benchmarkName scenarioKey =+  bench benchmarkName $ nfIO (runAsyncScenario fixture scenarioKey)++runAsyncScenario :: ReadModelBenchFixture -> Text -> IO ()+runAsyncScenario fixture scenarioKey = do+  scenario <- requireScenario fixture scenarioKey+  (projectionId, projection) <-+    maybe (fail ("scenario has no async delivery: " <> Text.unpack scenarioKey)) pure scenario.asyncDelivery+  invocation <- nextCounter fixture.asyncCounter+  outcome <-+    runStoreChecked fixture.store $+      Store.runTransaction+        ( applyAsyncProjectionFromCatalog+            fixture.catalog+            projectionId+            projection+            (syntheticRecordedEvent invocation)+        )+  case outcome of+    CatalogAsyncApplied -> pure ()+    other -> fail ("unexpected async projection benchmark result: " <> show other)++repairEventCounts :: [Int]+repairEventCounts = [10, 100, 1000]++repairScenarioBench :: ReadModelBenchFixture -> Int -> Benchmark+repairScenarioBench fixture eventCount =+  bench (show eventCount) $ nfIO $ do+    scenario <- requireScenario fixture "benchrepair"+    let projectionId = identity mkProjectionId "benchrepair-g1-inline"+        request =+          StreamReprojectionRequest+            { rebuildGroupId = (firstOrError "repair scenario group" scenario.scenarioGroups).groupId,+              projectionId,+              streamName = repairStreamName eventCount,+              pageSize = 64,+              maxEvents = fromIntegral eventCount+            }+    runStoreChecked fixture.store (reprojectStream fixture.catalog request) >>= \case+      Right report+        | report ^. #replayedEvents == fromIntegral eventCount -> pure ()+      other -> fail ("unexpected targeted repair benchmark result: " <> show other)++runPromotionScenario :: ReadModelBenchFixture -> IO ()+runPromotionScenario fixture = do+  scenario <- requireScenario fixture "bench-versioned-three"+  runNumber <- nextCounter fixture.runCounter+  promoteToV1 <- atomicModifyIORef' fixture.promotionRevision (\current -> (not current, current))+  let group = firstOrError "promotion scenario group" scenario.scenarioGroups+      revisionIds = map (^. #revisionId) group.revisions+      (servingRevisionId, candidateRevisionId) =+        if promoteToV1+          then (revisionIds !! 1, revisionIds !! 0)+          else (revisionIds !! 0, revisionIds !! 1)+      runId = identity mkRebuildRunId ("bench-promotion-" <> Text.pack (show runNumber))+      request =+        VersionedRebuildRequest+          { rebuildRunId = runId,+            rebuildGroupId = group.groupId,+            servingRevisionId,+            candidateRevisionId,+            servingTargets = physicalTargetsFor group.targets,+            targetMode = ApplicationProvisioned,+            replayPageSize = 128,+            cutoverThreshold = 0,+            cutoverLockTimeoutMs = 2_000,+            promotionDedupLimit = 10_000,+            retentionLeaseRequest = retentionRequest runId,+            requestedBy = "keiro-bench",+            requestReason = "measure schema-versioned promotion"+          }+  runStoreChecked fixture.store (beginVersionedRebuild fixture.catalog request) >>= \case+    Left err -> fail ("promotion benchmark begin failed: " <> show err)+    Right _ -> drivePromotion 20 runId+  where+    drivePromotion :: Int -> RebuildRunId -> IO ()+    drivePromotion 0 runId = fail ("promotion benchmark exceeded resume cap: " <> show runId)+    drivePromotion remaining runId = do+      runStoreChecked fixture.store (resumeVersionedRebuild fixture.catalog runId) >>= \case+        Left err -> fail ("promotion benchmark resume failed: " <> show err)+        Right report+          | report ^. #phase == VersionedPromoted -> pure ()+          | otherwise -> drivePromotion (remaining - 1) runId++runReadModelLatencyEvidenceIfRequested :: ReadModelBenchFixture -> IO ()+runReadModelLatencyEvidenceIfRequested fixture =+  lookupEnv "KEIRO_READ_MODEL_P95" >>= \case+    Nothing -> pure ()+    Just _ -> do+      replicateM_ latencyWarmupCount $ do+        runInlineScenario fixture "bench-legacy-three"+        runInlineScenario fixture "bench-versioned-three"+      runs <- forM [1 .. latencyRunCount] $ \runNumber -> do+        (legacy, versioned) <-+          samplePairedLatency+            latencySampleCount+            (runInlineScenario fixture "bench-legacy-three")+            (runInlineScenario fixture "bench-versioned-three")+        let legacyP95 = percentile95 legacy+            versionedP95 = percentile95 versioned+            p95Ratio = fromIntegral versionedP95 / fromIntegral legacyP95 :: Double+            timeRatio = fromIntegral (sum versioned) / fromIntegral (sum legacy) :: Double+            throughputRatio = 1 / timeRatio+        putStrLn+          ( "read-model-latency-run="+              <> show runNumber+              <> " samples="+              <> show latencySampleCount+              <> " legacy_p95_ns="+              <> show legacyP95+              <> " versioned_p95_ns="+              <> show versionedP95+              <> " p95_ratio="+              <> show p95Ratio+              <> " throughput_ratio="+              <> show throughputRatio+          )+        pure (p95Ratio, throughputRatio)+      let medianP95Ratio = median (map fst runs)+          medianThroughputRatio = median (map snd runs)+      putStrLn+        ( "read-model-latency-median runs="+            <> show latencyRunCount+            <> " samples_per_run="+            <> show latencySampleCount+            <> " p95_ratio="+            <> show medianP95Ratio+            <> " p95_budget=1.25 throughput_ratio="+            <> show medianThroughputRatio+            <> " throughput_budget=0.90"+        )+      when (medianP95Ratio > 1.25) $+        fail ("versioned three-target median p95 exceeded 1.25x legacy: " <> show medianP95Ratio)+      when (medianThroughputRatio < 0.90) $+        fail ("versioned three-target median throughput fell below 90% of legacy: " <> show medianThroughputRatio)++runReadModelExplainEvidenceIfRequested :: ReadModelBenchFixture -> IO ()+runReadModelExplainEvidenceIfRequested fixture =+  lookupEnv "KEIRO_READ_MODEL_EXPLAIN" >>= \case+    Nothing -> pure ()+    Just _ -> do+      for_ ["bench-legacy-three", "bench-versioned-three"] $ \scenarioKey -> do+        scenario <- requireScenario fixture scenarioKey+        let groupId = (firstOrError "explain scenario group" scenario.scenarioGroups).groupId+        plan <-+          runStoreChecked fixture.store $+            Store.runTransaction $+              Tx.statement (rebuildGroupIdText groupId) explainServingBindingStatement+        putStrLn ("read-model-serving-binding-plan-json scenario=" <> Text.unpack scenarioKey <> " groups=1")+        print plan+      statusListPlan <- runStoreChecked fixture.store (Store.runTransaction (Tx.statement () explainStatusListStatement))+      putStrLn "read-model-status-list-plan-json synthetic_groups=1000"+      print statusListPlan+      statusLookupPlan <- runStoreChecked fixture.store (Store.runTransaction (Tx.statement () explainStatusLookupStatement))+      putStrLn "read-model-status-lookup-plan-json synthetic_groups=1000"+      print statusLookupPlan+      allRowsPlan <- runStoreChecked fixture.store (Store.runTransaction (Tx.statement () explainGuardedAllRowsStatement))+      putStrLn "read-model-guarded-all-rows-plan-json rows=100"+      print allRowsPlan+      keyedPlan <- runStoreChecked fixture.store (Store.runTransaction (Tx.statement () explainGuardedKeyedStatement))+      putStrLn "read-model-guarded-keyed-wrapper-plan-json rows=10000 requested_id=5000"+      print keyedPlan+      keyedIndexPlan <- runStoreChecked fixture.store (Store.runTransaction (Tx.statement () explainKeyedIndexStatement))+      putStrLn "read-model-guarded-keyed-index-plan-json rows=10000 requested_id=5000"+      print keyedIndexPlan++latencySampleCount :: Int+latencySampleCount = 500++latencyRunCount :: Int+latencyRunCount = 5++latencyWarmupCount :: Int+latencyWarmupCount = 25++sampleOneLatency :: IO () -> IO Word64+sampleOneLatency action = do+  started <- getMonotonicTimeNSec+  action+  finished <- getMonotonicTimeNSec+  pure (finished - started)++samplePairedLatency :: Int -> IO () -> IO () -> IO ([Word64], [Word64])+samplePairedLatency count legacyAction versionedAction = do+  pairs <- forM [0 .. count - 1] $ \sampleIndex ->+    if even sampleIndex+      then do+        legacy <- sampleOneLatency legacyAction+        versioned <- sampleOneLatency versionedAction+        pure (legacy, versioned)+      else do+        versioned <- sampleOneLatency versionedAction+        legacy <- sampleOneLatency legacyAction+        pure (legacy, versioned)+  pure (map fst pairs, map snd pairs)++percentile95 :: [Word64] -> Word64+percentile95 samples =+  sorted !! max 0 (ceiling (0.95 * fromIntegral (length sorted) :: Double) - 1)+  where+    sorted = List.sort samples++median :: (Ord value) => [value] -> value+median values =+  sorted !! (length sorted `div` 2)+  where+    sorted = List.sort values++projectionBenchCatalog :: ProjectionCatalog+projectionBenchCatalog =+  ProjectionCatalog+    { sources = map (\scenario -> scenario.sourceDeclaration) scenarioDefinitions,+      targets = concatMap (\scenario -> scenario.targetDeclarations) scenarioDefinitions,+      rebuildGroups = concatMap (\scenario -> scenario.groupDeclarations) scenarioDefinitions,+      projectionRevisions = concatMap (concatMap (\group -> group.revisions) . (\scenario -> scenario.scenarioGroups)) scenarioDefinitions,+      externalReadContracts = benchmarkExternalReadContracts,+      subscriptions = concatMap (\scenario -> scenario.subscriptions) scenarioDefinitions,+      dedupKeys = concatMap (\scenario -> scenario.dedupKeys) scenarioDefinitions,+      queryModels = concatMap scenarioQueryModels scenarioDefinitions,+      projectionSets = SomeProjectionSet . (\scenario -> scenario.projectionSet) <$> scenarioDefinitions+    }++scenarioQueryModels :: ProjectionScenario -> [SomeQueryModelBinding]+scenarioQueryModels scenario =+  asyncModels <> externalModels+  where+    asyncModels = case scenario.asyncDelivery of+      Nothing -> []+      Just (_, projection) ->+        let group = firstOrError "async query-model group" scenario.scenarioGroups+            (targetId, table) = lastOrError "async query-model target" group.targets+         in [queryModelBinding scenario group targetId table (scenario.key <> "-async-query") (projection ^. #readModelName) (scenario.key <> "-async-query-v1")]+    externalModels+      | scenario.key == "bench-versioned-allrows" =+          [externalBinding "bench-all-rows-query" "bench-all-rows-model" "bench-all-rows-v1"]+      | scenario.key == "bench-versioned-keyed" =+          [externalBinding "bench-keyed-query" "bench-keyed-model" "bench-keyed-v1"]+      | otherwise = []+    externalBinding queryId registryName shapeHash =+      let group = firstOrError "external query-model group" scenario.scenarioGroups+          (targetId, table) = firstOrError "external query-model target" group.targets+       in queryModelBinding scenario group targetId table queryId registryName shapeHash++queryModelBinding :: ProjectionScenario -> ScenarioGroup -> TargetId -> QualifiedTable -> Text -> Text -> Text -> SomeQueryModelBinding+queryModelBinding scenario group targetId table queryId registryName shapeHash =+  SomeQueryModelBinding+    QueryModelBinding+      { queryModelId = identity mkQueryModelId queryId,+        readModel = model,+        rebuildGroup = group.groupId,+        observedTargets = [targetId],+        claimSite = claim (scenario.key <> ":query:" <> queryId)+      }+  where+    model =+      immediateReadModel+        ReadModelBlueprint+          { name = registryName,+            tableName = table ^. #tableName,+            schema = table ^. #schemaName,+            version = 1,+            shapeHash,+            cursorAuthority = NoQueryCursor,+            query = \() -> pure ()+          }++benchmarkExternalReadContracts :: [ExternalReadContract]+benchmarkExternalReadContracts =+  [ AllRowsExternalRead+      { readContractId = identity mkExternalReadContractId "bench_all_rows",+        contractVersion = ExternalReadContractVersion 1,+        queryModelId = identity mkQueryModelId "bench-all-rows-query",+        resultContractType = QualifiedSqlType "app_contract" "bench_all_row_v1",+        resultShapeHash = "bench-all-rows-v1",+        compatibleRevisions =+          identity mkProjectionRevisionId "bench-versioned-allrows-g1-r0"+            :| [identity mkProjectionRevisionId "bench-versioned-allrows-g1-r1"],+        surfaceGeneration = 1,+        claimSite = claim "bench:external:all-rows"+      },+    KeyedExternalRead+      { readContractId = identity mkExternalReadContractId "bench_keyed",+        contractVersion = ExternalReadContractVersion 1,+        queryModelId = identity mkQueryModelId "bench-keyed-query",+        arguments = [SqlFunctionArgument "requested_id" (QualifiedSqlType "pg_catalog" "int8")],+        resultContractType = QualifiedSqlType "app_contract" "bench_keyed_row_v1",+        privateImplementation = QualifiedFunction "app_private" "bench_keyed_lookup",+        privateImplementationVersion = 1,+        resultShapeHash = "bench-keyed-v1",+        compatibleRevisions =+          identity mkProjectionRevisionId "bench-versioned-keyed-g1-r0"+            :| [identity mkProjectionRevisionId "bench-versioned-keyed-g1-r1"],+        surfaceGeneration = 1,+        claimSite = claim "bench:external:keyed"+      }+  ]++mkInlineScenario :: Text -> Bool -> [Int] -> Int -> ProjectionScenario+mkInlineScenario key versionManaged targetCounts extraRevisions =+  scenarioFrom key versionManaged extraRevisions False targetCounts Nothing++mkMixedScenario :: Text -> Bool -> ProjectionScenario+mkMixedScenario key versionManaged =+  scenarioFrom key versionManaged 0 True [3] Nothing++mkRepairScenario :: ProjectionScenario+mkRepairScenario =+  scenarioFrom "benchrepair" True 0 False [1] (Just repairPolicy)++scenarioFrom :: Text -> Bool -> Int -> Bool -> [Int] -> Maybe (TargetId -> StreamScopedReplay) -> ProjectionScenario+scenarioFrom key versionManaged extraRevisions mixed targetCounts streamPolicy =+  ProjectionScenario+    { key,+      sourceDeclaration =+        SourceDeclaration+          { sourceId,+            sourceScope = CategorySource (CategoryName key),+            codecFingerprint = key <> "-codec-v1",+            claimSite = claim (key <> ":source")+          },+      targetDeclarations = targetDeclarations,+      groupDeclarations = groupDeclarations,+      projectionSet =+        ProjectionSet+          { projectionSource = sourceId,+            projectionDefinitions = nonEmptyOrError "scenario definitions" definitions,+            claimSite = claim (key <> ":set")+          },+      subscriptions = asyncSubscriptions,+      dedupKeys = asyncDedupKeys,+      scenarioGroups = groups,+      asyncDelivery = listToMaybe asyncDeliveries,+      versionManaged+    }+  where+    sourceId = identity mkSourceId (key <> "-source")+    groupFacts =+      [ let groupId = identity mkRebuildGroupId (key <> "-g" <> indexText groupIndex)+            targets =+              [ ( identity mkTargetId (key <> "-g" <> indexText groupIndex <> "-t" <> indexText targetIndex),+                  QualifiedTable "app" (sqlName key <> "_g" <> indexText groupIndex <> "_t" <> indexText targetIndex)+                )+              | targetIndex <- [1 .. targetCount]+              ]+         in (groupIndex, groupId, targets)+      | (groupIndex, targetCount) <- zip [1 ..] targetCounts+      ]+    targetDeclarations =+      [ TargetDeclaration+          { targetId,+            qualifiedTable = table,+            resetPolicy = PreserveAndReconcile,+            dependsOn = [],+            claimSite = claim (key <> ":target:" <> targetIdText targetId)+          }+      | (_, _, targets) <- groupFacts,+        (targetId, table) <- targets+      ]+    groupDeclarations =+      [ RebuildGroupDeclaration+          { rebuildGroupId = groupId,+            orderedTargets = map fst targets,+            verificationHooks = [],+            claimSite = claim (key <> ":group:" <> rebuildGroupIdText groupId)+          }+      | (_, groupId, targets) <- groupFacts+      ]+    groupDefinitions = concatMap definitionsForGroup groupFacts+    definitions = [definition | (definition, _, _, _) <- groupDefinitions]+    asyncSubscriptions = [subscription | (_, _, Just subscription, _) <- groupDefinitions]+    asyncDedupKeys = [dedupKey | (_, _, _, Just dedupKey) <- groupDefinitions]+    asyncDeliveries =+      [ (definition ^. #projectionId, projection)+      | (definition, Just projection, _, _) <- groupDefinitions+      ]+    groups =+      [ ScenarioGroup+          { groupId,+            targets,+            revisions =+              if versionManaged+                then+                  [ scenarioRevision key groupIndex groupId targets groupDefinitions revisionIndex streamPolicy+                  | revisionIndex <- [0 .. 1 + extraRevisions]+                  ]+                else []+          }+      | (groupIndex, groupId, targets) <- groupFacts+      ]++    definitionsForGroup (groupIndex, groupId, targets)+      | mixed =+          let (inlineTargets, asyncTargets) = splitAt (length targets - 1) targets+              inline = inlineDefinition key groupIndex groupId inlineTargets+              async = asyncDefinition key groupIndex groupId asyncTargets+           in [inline, async]+      | otherwise = [inlineDefinition key groupIndex groupId targets]++inlineDefinition :: Text -> Int -> RebuildGroupId -> [(TargetId, QualifiedTable)] -> (ProjectionDefinition ProjectionBenchEvent, Maybe AsyncProjection, Maybe SubscriptionDeclaration, Maybe DedupKeyDeclaration)+inlineDefinition key groupIndex groupId targets =+  ( ProjectionDefinition+      { projectionId,+        rebuildGroup = groupId,+        ownedTargets = nonEmptyOrError "inline targets" (map fst targets),+        replayPolicy = benchReplayPolicy,+        handlers = InlineHandler projection (claim (key <> ":inline-handler:" <> indexText groupIndex)) :| [],+        claimSite = claim (key <> ":inline:" <> indexText groupIndex)+      },+    Nothing,+    Nothing,+    Nothing+  )+  where+    projectionId = identity mkProjectionId (key <> "-g" <> indexText groupIndex <> "-inline")+    projection = logicalInlineProjection (key <> "-inline") targets++asyncDefinition :: Text -> Int -> RebuildGroupId -> [(TargetId, QualifiedTable)] -> (ProjectionDefinition ProjectionBenchEvent, Maybe AsyncProjection, Maybe SubscriptionDeclaration, Maybe DedupKeyDeclaration)+asyncDefinition key groupIndex groupId targets =+  ( ProjectionDefinition+      { projectionId,+        rebuildGroup = groupId,+        ownedTargets = nonEmptyOrError "async targets" (map fst targets),+        replayPolicy = benchReplayPolicy,+        handlers = AsyncHandler projection subscriptionId dedupKeyId (claim (key <> ":async-handler")) :| [],+        claimSite = claim (key <> ":async")+      },+    Just projection,+    Just subscriptionDeclaration,+    Just dedupDeclaration+  )+  where+    projectionId = identity mkProjectionId (key <> "-g" <> indexText groupIndex <> "-async")+    subscriptionId = identity mkSubscriptionId (key <> "-subscription")+    dedupKeyId = identity mkDedupKeyId (key <> "-dedup")+    subscriptionName = key <> "-subscription"+    projectionName = key <> "-async"+    projection =+      AsyncProjection+        { name = projectionName,+          readModelName = key <> "-async-model",+          subscriptionName,+          applyRecorded = \_ -> updateLogicalTargets targets,+          idempotencyKey = (^. #eventId)+        }+    subscriptionDeclaration =+      SubscriptionDeclaration+        { subscriptionId,+          subscriptionName,+          subscriptionSource = identity mkSourceId (key <> "-source"),+          checkpointOnMissing = FromBeginning,+          claimSite = claim (key <> ":subscription")+        }+    dedupDeclaration =+      DedupKeyDeclaration+        { dedupKeyId,+          dedupName = projectionName,+          claimSite = claim (key <> ":dedup")+        }++scenarioRevision :: Text -> Int -> RebuildGroupId -> [(TargetId, QualifiedTable)] -> [(ProjectionDefinition ProjectionBenchEvent, Maybe AsyncProjection, Maybe SubscriptionDeclaration, Maybe DedupKeyDeclaration)] -> Int -> Maybe (TargetId -> StreamScopedReplay) -> ProjectionRevision+scenarioRevision key groupIndex groupId targets allDefinitions revisionIndex streamPolicy =+  ProjectionRevision+    { revisionId = identity mkProjectionRevisionId revisionName,+      rebuildGroup = groupId,+      targetProvisioners = Map.fromList [(targetId, targetProvisioner key targetId revisionName) | (targetId, _) <- targets],+      liveHandlers = concatMap revisionHandlers relevantDefinitions,+      replayAdapters =+        [ RevisionReplayAdapter+            (revisionName <> "-replay")+            1+            (map fst targets)+            (\_ _ -> pure (Right False))+        ],+      revisionVerifications = [],+      streamScopedReplays = maybe [] (\makePolicy -> [makePolicy (fst (firstOrError "revision target" targets))]) streamPolicy,+      claimSite = claim (key <> ":revision:" <> revisionName)+    }+  where+    revisionName = key <> "-g" <> indexText groupIndex <> "-r" <> indexText revisionIndex+    relevantDefinitions =+      [ definition+      | entry@(definition, _, _, _) <- allDefinitions,+        definition ^. #rebuildGroup == groupId,+        let _ = entry+      ]+    revisionHandlers definition =+      [ case handler of+          InlineHandler projection _ ->+            RevisionLiveHandler+              (revisionName <> "-" <> projection ^. #name)+              1+              (RevisionInlineDelivery (definition ^. #projectionId) (projection ^. #name))+              (toList (definition ^. #ownedTargets))+              (\physicalTargets _ -> updatePhysicalTargets physicalTargets (toList (definition ^. #ownedTargets)))+          AsyncHandler projection subscriptionId dedupKeyId _ ->+            RevisionLiveHandler+              (revisionName <> "-" <> projection ^. #name)+              1+              (RevisionSubscriptionDelivery (definition ^. #projectionId) subscriptionId dedupKeyId)+              (toList (definition ^. #ownedTargets))+              (\physicalTargets _ -> updatePhysicalTargets physicalTargets (toList (definition ^. #ownedTargets)))+      | handler <- toList (definition ^. #handlers)+      ]++logicalInlineProjection :: Text -> [(TargetId, QualifiedTable)] -> InlineProjection ProjectionBenchEvent+logicalInlineProjection name targets =+  InlineProjection+    { name,+      apply = \_ _ -> updateLogicalTargets targets+    }++benchReplayPolicy :: ProjectionReplayPolicy ProjectionBenchEvent+benchReplayPolicy =+  Replayable+    ReplayAdapter+      { decodeForReplay = const ReplayIrrelevant,+        applyForReplay = \_ _ -> pure ()+      }++targetProvisioner :: Text -> TargetId -> Text -> TargetProvisioner+targetProvisioner key targetId revisionName =+  TargetProvisioner+    { provisionerId = revisionName <> "-" <> targetIdText targetId <> "-provisioner",+      provisionerVersion = 1,+      schemaVersion = TargetSchemaVersion "bench-v1",+      expectedShapeId = "bench-shape-v1",+      provisionTarget = \context -> do+        let table = context ^. #stagingTable+        createProjectionTable table+        seedProvisionedBenchmarkTable key targetId table,+      validatorId = revisionName <> "-" <> targetIdText targetId <> "-validator",+      validatorVersion = 1,+      validateTarget = Just validateProjectionTable,+      promotionObjectNames = []+    }+  where+    validateProjectionTable context = do+      relationOid <- Tx.statement () (relationOidStatement (context ^. #stagingTable))+      pure+        ( Right+            TargetSchemaEvidence+              { relationOid,+                observedShapeFingerprint = "bench-shape-v1",+                observedPromotionObjects = [],+                catalogSnapshot = "bench-catalog-v1"+              }+        )++seedProvisionedBenchmarkTable :: Text -> TargetId -> QualifiedTable -> Tx.Transaction ()+seedProvisionedBenchmarkTable key targetId table+  | key == "bench-versioned-allrows",+    targetIdText targetId == "bench-versioned-allrows-g1-t1" =+      insertGeneratedRows table 100+  | key == "bench-versioned-keyed",+    targetIdText targetId == "bench-versioned-keyed-g1-t1" =+      insertGeneratedRows table 10_000+  | otherwise = pure ()++insertGeneratedRows :: QualifiedTable -> Int -> Tx.Transaction ()+insertGeneratedRows table upperBound =+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "INSERT INTO "+            <> qualified table+            <> " (id, value) SELECT id, id * 10 FROM generate_series(2, "+            <> Text.pack (show upperBound)+            <> ") AS ids(id)"+        )+    )++repairPolicy :: TargetId -> StreamScopedReplay+repairPolicy targetId =+  StreamScopedReplay+    { streamProjectionId = identity mkProjectionId "benchrepair-g1-inline",+      streamOwnedTargets = targetId :| [],+      clearerId = "benchrepair-clear",+      clearerVersion = 1,+      clearStreamRows = \physicalTargets _ -> do+        let table = requirePhysicalTarget physicalTargets targetId+        cleared <- Tx.statement () (deleteProjectionRowsStatement table)+        pure (Right [StreamClearCount targetId cleared]),+      streamReplayId = "benchrepair-replay",+      streamReplayVersion = 1,+      replayStreamEvent = \physicalTargets _ -> do+        updateProjectionTable (requirePhysicalTarget physicalTargets targetId)+        pure (Right True),+      streamVerificationId = "benchrepair-verify",+      streamVerificationVersion = 1,+      verifyStreamRows = \physicalTargets _ -> do+        rows <- Tx.statement () (countProjectionRowsStatement (requirePhysicalTarget physicalTargets targetId))+        pure (if rows == 1 then Right () else Left "repair target must contain one row"),+      affectedAsyncDedup = [],+      claimSite = claim "benchrepair:policy"+    }++setupTables :: Tx.Transaction ()+setupTables = do+  Tx.sql "CREATE SCHEMA IF NOT EXISTS app"+  Tx.sql "CREATE SCHEMA app_contract"+  Tx.sql "CREATE SCHEMA app_private"+  traverse_ (createProjectionTable . (^. #qualifiedTable)) allTargets+  Tx.sql "CREATE TYPE app_contract.bench_all_row_v1 AS (id bigint, value bigint)"+  Tx.sql "CREATE TYPE app_contract.bench_keyed_row_v1 AS (id bigint, value bigint)"+  Tx.sql+    """+    INSERT INTO app.bench_versioned_allrows_g1_t1 (id, value)+    SELECT id, id * 10 FROM generate_series(2, 100) AS ids(id)+    """+  Tx.sql+    """+    INSERT INTO app.bench_versioned_keyed_g1_t1 (id, value)+    SELECT id, id * 10 FROM generate_series(2, 10000) AS ids(id)+    """+  Tx.sql+    """+    CREATE FUNCTION app_private.bench_keyed_lookup(requested_id bigint)+    RETURNS SETOF app_contract.bench_keyed_row_v1+    LANGUAGE sql+    STABLE+    AS $lookup$+      SELECT ROW(model.id, model.value)::app_contract.bench_keyed_row_v1+      FROM app.bench_versioned_keyed_g1_t1 AS model+      WHERE model.id = requested_id+    $lookup$+    """+  where+    allTargets = concatMap (\scenario -> scenario.targetDeclarations) scenarioDefinitions++statusScaleGroupCount :: Int+statusScaleGroupCount = 1_000++seedStatusScale :: Tx.Transaction ()+seedStatusScale = do+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "INSERT INTO keiro.keiro_projection_rebuild_groups (group_id, slice_fingerprint, status) "+            <> "SELECT 'bench-status-' || ordinal::text, 'slice-v6:bench-status', 'live' "+            <> "FROM generate_series(1, "+            <> Text.pack (show statusScaleGroupCount)+            <> ") AS ordinals(ordinal)"+        )+    )+  Tx.sql+    """+    INSERT INTO keiro.keiro_projection_group_cursors+      (group_id, position_basis, subscription_names)+    SELECT group_id, 'append', ARRAY[]::text[]+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id LIKE 'bench-status-%'+    """++createProjectionTable :: QualifiedTable -> Tx.Transaction ()+createProjectionTable table =+  do+    Tx.sql+      ( Text.Encoding.encodeUtf8+          ( "CREATE TABLE "+              <> qualified table+              <> " (id bigint PRIMARY KEY, value bigint NOT NULL)"+          )+      )+    Tx.sql+      ( Text.Encoding.encodeUtf8+          ( "INSERT INTO "+              <> qualified table+              <> " (id, value) VALUES (1, 0)"+          )+      )++bootstrapVersionedGroups :: [ProjectionScenario] -> Tx.Transaction ()+bootstrapVersionedGroups scenarios =+  traverse_ bootstrapScenario [scenario | scenario <- scenarios, scenario.versionManaged]+  where+    bootstrapScenario scenario = traverse_ bootstrapGroup scenario.scenarioGroups+    bootstrapGroup group = do+      let servingRevision = group.revisions ^?! ix 0 . #revisionId+      traverse_ (insertGeneration group.groupId servingRevision) (zip [1 :: Int64 ..] group.targets)+      Tx.sql+        ( Text.Encoding.encodeUtf8+            ( "UPDATE keiro.keiro_projection_rebuild_groups SET status = 'serving-versioned', "+                <> "serving_revision_id = "+                <> literal (projectionRevisionIdText servingRevision)+                <> ", serving_epoch = 1, reads_allowed = TRUE, writes_allowed = TRUE "+                <> "WHERE group_id = "+                <> literal (rebuildGroupIdText group.groupId)+            )+        )+    insertGeneration groupId revisionId (ordinal, (targetId, table)) =+      let generationId = generationUuid groupId targetId ordinal+       in Tx.sql+            ( Text.Encoding.encodeUtf8+                ( "INSERT INTO keiro.keiro_projection_target_generations "+                    <> "(generation_id, group_id, target_id, revision_id, schema_name, relation_name, relation_oid, "+                    <> "schema_version, expected_shape_id, observed_shape_fingerprint, observed_catalog_snapshot, lifecycle, served_at) VALUES ("+                    <> literal (UUID.toText generationId)+                    <> "::uuid, "+                    <> literal (rebuildGroupIdText groupId)+                    <> ", "+                    <> literal (targetIdText targetId)+                    <> ", "+                    <> literal (projectionRevisionIdText revisionId)+                    <> ", "+                    <> literal (table ^. #schemaName)+                    <> ", "+                    <> literal (table ^. #tableName)+                    <> ", "+                    <> literal (table ^. #schemaName <> "." <> table ^. #tableName)+                    <> "::regclass::oid, 'bench-v1', 'bench-shape-v1', 'bench-shape-v1', 'bench-catalog-v1', 'serving', now())"+                )+            )++generationUuid :: RebuildGroupId -> TargetId -> Int64 -> UUID.UUID+generationUuid groupId targetId ordinal =+  UUID.V5.generateNamed+    UUID.V5.namespaceURL+    ( ByteString.unpack+        ( Text.Encoding.encodeUtf8+            (rebuildGroupIdText groupId <> ":" <> targetIdText targetId <> ":" <> Text.pack (show ordinal))+        )+    )++updateLogicalTargets :: [(TargetId, QualifiedTable)] -> Tx.Transaction ()+updateLogicalTargets = traverse_ (updateProjectionTable . snd)++updatePhysicalTargets :: PhysicalTargets -> [TargetId] -> Tx.Transaction ()+updatePhysicalTargets physicalTargets =+  traverse_ (updateProjectionTable . requirePhysicalTarget physicalTargets)++updateProjectionTable :: QualifiedTable -> Tx.Transaction ()+updateProjectionTable table =+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "INSERT INTO "+            <> qualified table+            <> " (id, value) VALUES (1, 1) "+            <> "ON CONFLICT (id) DO UPDATE SET value = "+            <> quoteIdentifier (table ^. #tableName)+            <> ".value + 1"+        )+    )++requirePhysicalTarget :: PhysicalTargets -> TargetId -> QualifiedTable+requirePhysicalTarget targets targetId =+  fromMaybe+    (error ("missing benchmark physical target: " <> Text.unpack (targetIdText targetId)))+    (resolvePhysicalTarget targetId targets)++physicalTargetsFor :: [(TargetId, QualifiedTable)] -> PhysicalTargets+physicalTargetsFor targets =+  case mkPhysicalTargets (map fst targets) (Map.fromList targets) of+    Left errors -> error (show errors)+    Right value -> value++seedRepairStreams :: Store.KirokuStore -> IO ()+seedRepairStreams store =+  traverse_ seed repairEventCounts+  where+    seed count =+      runStoreChecked store $+        void $+          Store.appendToStream+            (repairStreamName count)+            NoStream+            [ EventData+                { eventId = Nothing,+                  eventType = EventType "BenchRepairEvent",+                  payload = Aeson.Null,+                  metadata = Nothing,+                  causationId = Nothing,+                  correlationId = Nothing+                }+            | _ <- [1 .. count]+            ]++stabilizingHistoryEventCount :: Int+stabilizingHistoryEventCount = 25_000++seedStabilizingHistory :: Store.KirokuStore -> IO ()+seedStabilizingHistory store =+  runStoreChecked store $+    void $+      Store.appendToStream+        (StreamName "benchseed-history")+        NoStream+        [ EventData+            { eventId = Nothing,+              eventType = EventType "BenchSeedEvent",+              payload = Aeson.Null,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | _ <- [1 .. stabilizingHistoryEventCount]+        ]++repairStreamName :: Int -> StreamName+repairStreamName count = StreamName ("benchrepair-" <> Text.pack (show count))++retentionRequest :: RebuildRunId -> HistoryRetentionLeaseRequest+retentionRequest runId =+  HistoryRetentionLeaseRequest+    { owner = requireRight (mkHistoryRetentionLeaseOwner ("keiro-rebuild/" <> rebuildRunIdText runId)),+      reason = requireRight (mkHistoryRetentionLeaseReason "benchmark schema-versioned promotion"),+      duration = requireRight (mkHistoryRetentionLeaseDuration (secondsToDiffTime 600))+    }++syntheticRecordedEvent :: Int64 -> RecordedEvent+syntheticRecordedEvent value =+  RecordedEvent+    { eventId = EventId (UUID.fromWords64 0x018f0f1800007000 (0x8000000000000000 + fromIntegral value)),+      eventType = EventType "ProjectionBenchEvent",+      streamVersion = StreamVersion value,+      globalPosition = GlobalPosition value,+      originalStreamId = StreamId value,+      originalVersion = StreamVersion value,+      payload = Aeson.Null,+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing,+      createdAt = fixedOccurredAt+    }++projectionBenchEventStream :: ValidatedProjectionBenchEventStream+projectionBenchEventStream =+  case mkEventStream "read-model-benchmark" projectionBenchRawEventStream of+    Left errors -> error (show errors)+    Right value -> value++projectionBenchRawEventStream :: ProjectionBenchEventStream+projectionBenchRawEventStream =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \ProjectionBenchReady ->+              [ Edge+                  { guard = matchInCtor projectionCommandCtor,+                    update = UKeep,+                    output = [pack projectionCommandCtor projectionEventCtor oNil],+                    target = ProjectionBenchReady,+                    mode = Keiki.Live+                  }+              ],+            initial = ProjectionBenchReady,+            initialRegs = RNil,+            isFinal = const False+          },+      initialState = ProjectionBenchReady,+      initialRegisters = RNil,+      eventCodec =+        Codec+          { eventTypes = EventType "ProjectionBenchEvent" :| [],+            eventType = const (EventType "ProjectionBenchEvent"),+            schemaVersion = 1,+            encode = const Aeson.Null,+            decode = \(EventType eventTypeName) _ ->+              if eventTypeName == "ProjectionBenchEvent"+                then Right ProjectionBenchEvent+                else Left ("unknown projection benchmark event: " <> eventTypeName),+            upcasters = []+          },+      resolveStreamName = Keiro.Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++projectionCommandCtor :: InCtor ProjectionBenchCommand '[]+projectionCommandCtor =+  unavailableInCtor+    "EmitProjection"+    (\case EmitProjection -> Just RNil)+    (\RNil -> EmitProjection)++projectionEventCtor :: WireCtor ProjectionBenchEvent ()+projectionEventCtor =+  unavailableWireCtor+    "ProjectionBenchEvent"+    (const (Just ()))+    (const ProjectionBenchEvent)++deleteProjectionRowsStatement :: QualifiedTable -> Statement () Int64+deleteProjectionRowsStatement table =+  preparable+    ("DELETE FROM " <> qualified table)+    E.noParams+    D.rowsAffected++countProjectionRowsStatement :: QualifiedTable -> Statement () Int64+countProjectionRowsStatement table =+  preparable+    ("SELECT count(*) FROM " <> qualified table)+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++relationOidStatement :: QualifiedTable -> Statement () Int64+relationOidStatement table =+  preparable+    ("SELECT " <> literal (table ^. #schemaName <> "." <> table ^. #tableName) <> "::regclass::oid::bigint")+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++guardedAllRowsStatement :: Statement () [(Int64, Int64)]+guardedAllRowsStatement =+  preparable+    "SELECT id, value FROM keiro_read.bench_all_rows_v1() ORDER BY id"+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++guardedKeyedStatement :: Statement Int64 [(Int64, Int64)]+guardedKeyedStatement =+  preparable+    "SELECT id, value FROM keiro_read.bench_keyed_v1($1)"+    (E.param (E.nonNullable E.int8))+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++explainServingBindingStatement :: Statement Text Aeson.Value+explainServingBindingStatement =+  preparable+    """+    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)+    SELECT locked_group.status,+           locked_group.active_run_id,+           locked_group.serving_revision_id,+           locked_group.writes_allowed,+           COALESCE(array_agg(generations.target_id ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.revision_id ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.schema_name ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.relation_name ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[])+    FROM (+      SELECT status, active_run_id, serving_revision_id, writes_allowed+      FROM keiro.keiro_projection_rebuild_groups+      WHERE group_id = $1+      FOR SHARE+    ) AS locked_group+    LEFT JOIN LATERAL (+      SELECT target_id, revision_id, schema_name, relation_name+      FROM keiro.keiro_projection_target_generations+      WHERE group_id = $1 AND lifecycle = 'serving'+    ) AS generations ON TRUE+    GROUP BY locked_group.status,+             locked_group.active_run_id,+             locked_group.serving_revision_id,+             locked_group.writes_allowed+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.json)))++explainStatusListStatement :: Statement () Aeson.Value+explainStatusListStatement =+  explainJsonNoParams+    "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM keiro_read.projection_group_status_v1 ORDER BY group_id"++explainStatusLookupStatement :: Statement () Aeson.Value+explainStatusLookupStatement =+  explainJsonNoParams+    "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM keiro_read.projection_group_status_v1 WHERE group_id = 'bench-status-1000'"++explainGuardedAllRowsStatement :: Statement () Aeson.Value+explainGuardedAllRowsStatement =+  explainJsonNoParams+    "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT id, value FROM keiro_read.bench_all_rows_v1() ORDER BY id"++explainGuardedKeyedStatement :: Statement () Aeson.Value+explainGuardedKeyedStatement =+  explainJsonNoParams+    "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT id, value FROM keiro_read.bench_keyed_v1(5000)"++explainKeyedIndexStatement :: Statement () Aeson.Value+explainKeyedIndexStatement =+  explainJsonNoParams+    """+    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)+    SELECT ROW(model.id, model.value)::app_contract.bench_keyed_row_v1+    FROM app.bench_versioned_keyed_g1_t1 AS model+    WHERE model.id = 5000+    """++explainJsonNoParams :: Text -> Statement () Aeson.Value+explainJsonNoParams sql =+  preparable+    sql+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.json)))++servingBindingStatement :: Statement Text (Maybe (Text, Maybe Text, Maybe Text, Bool, [(Text, Text, Text, Text)]))+servingBindingStatement =+  preparable+    """+    SELECT locked_group.status,+           locked_group.active_run_id,+           locked_group.serving_revision_id,+           locked_group.writes_allowed,+           COALESCE(array_agg(generations.target_id ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.revision_id ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.schema_name ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.relation_name ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[])+    FROM (+      SELECT status, active_run_id, serving_revision_id, writes_allowed+      FROM keiro.keiro_projection_rebuild_groups+      WHERE group_id = $1+      FOR SHARE+    ) AS locked_group+    LEFT JOIN LATERAL (+      SELECT target_id, revision_id, schema_name, relation_name+      FROM keiro.keiro_projection_target_generations+      WHERE group_id = $1 AND lifecycle = 'serving'+    ) AS generations ON TRUE+    GROUP BY locked_group.status,+             locked_group.active_run_id,+             locked_group.serving_revision_id,+             locked_group.writes_allowed+    """+    (E.param (E.nonNullable E.text))+    ( D.rowMaybe+        ( (,,,,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nonNullable D.bool)+            <*> ( List.zip4+                    <$> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                )+        )+    )++qualified :: QualifiedTable -> Text+qualified table = qualifyTable (table ^. #schemaName) (table ^. #tableName)++quoteIdentifier :: Text -> Text+quoteIdentifier value = "\"" <> Text.replace "\"" "\"\"" value <> "\""++literal :: Text -> Text+literal value = "'" <> Text.replace "'" "''" value <> "'"++sqlName :: Text -> Text+sqlName = Text.map (\character -> if character == '-' then '_' else character)++indexText :: (Show value) => value -> Text+indexText = Text.pack . show++claim :: Text -> ClaimSite+claim = identity mkClaimSite++identity :: (Show error) => (Text -> Either error value) -> Text -> value+identity constructor value = requireRight (constructor value)++requireRight :: (Show error) => Either error value -> value+requireRight = either (error . show) id++nonEmptyOrError :: Text -> [value] -> NonEmpty value+nonEmptyOrError label = \case+  [] -> error (Text.unpack label <> " must not be empty")+  first : rest -> first :| rest++firstOrError :: Text -> [value] -> value+firstOrError label = \case+  [] -> error (Text.unpack label <> " must not be empty")+  first : _ -> first++lastOrError :: Text -> [value] -> value+lastOrError label = \case+  [] -> error (Text.unpack label <> " must not be empty")+  values -> List.last values++requireScenario :: ReadModelBenchFixture -> Text -> IO ProjectionScenario+requireScenario fixture scenarioKey =+  maybe (fail ("unknown read-model benchmark scenario: " <> Text.unpack scenarioKey)) pure (Map.lookup scenarioKey fixture.scenarios)++nextCounter :: IORef Int64 -> IO Int64+nextCounter counter = atomicModifyIORef' counter (\current -> let next = current + 1 in (next, next))++fixedOccurredAt :: UTCTime+fixedOccurredAt = UTCTime (ModifiedJulianDay 61000) (secondsToDiffTime 0)++runResourceStoreChecked :: StoreRunner -> Eff '[Store, Error Store.StoreError, KirokuStoreResource, IOE] value -> IO value+runResourceStoreChecked (StoreRunner runner) action =+  runner action >>= either (fail . show) pure++runStoreChecked :: Store.KirokuStore -> Eff '[Store, Error Store.StoreError, IOE] value -> IO value+runStoreChecked store action =+  Store.runStoreIO store action >>= either (fail . show) pure
keiro.cabal view
@@ -1,12 +1,13 @@ cabal-version:   3.0 name:            keiro-version:         0.11.0.0+version:         0.12.0.0 synopsis:        Event sourcing framework and workflow engine description:   A library that composes kiroku, keiki, and shibuya into an   event-sourcing and workflow-orchestration framework.  license:         BSD-3-Clause+license-file:    LICENSE author:          Nadeem Bitar maintainer:      nadeem@gmail.com copyright:       2026 Nadeem Bitar@@ -41,6 +42,7 @@  library   import:             warnings, shared+  autogen-modules:    Paths_keiro   exposed-modules:     Keiro     Keiro.Command@@ -48,6 +50,7 @@     Keiro.DeadLetter     Keiro.DeadLetter.Replay     Keiro.DeadLetter.Schema+    Keiro.DeterministicId     Keiro.Inbox     Keiro.Inbox.Kafka     Keiro.Inbox.Schema@@ -58,11 +61,16 @@     Keiro.Outbox.Types     Keiro.ProcessManager     Keiro.Projection+    Keiro.Projection.Catalog+    Keiro.Projection.Catalog.Operations+    Keiro.Projection.Catalog.Preimage     Keiro.ReadModel+    Keiro.ReadModel.External     Keiro.ReadModel.Rebuild     Keiro.ReadModel.Schema     Keiro.ReplayAudit     Keiro.Router+    Keiro.Router.Selection     Keiro.Snapshot     Keiro.Snapshot.Codec     Keiro.Snapshot.Schema@@ -76,6 +84,7 @@     Keiro.Wake     Keiro.Workflow     Keiro.Workflow.Awakeable+    Keiro.Workflow.Awakeable.Compatibility     Keiro.Workflow.Awakeable.Schema     Keiro.Workflow.Child     Keiro.Workflow.Child.Schema@@ -87,7 +96,21 @@     Keiro.Workflow.Snapshot     Keiro.Workflow.Types -  other-modules:      Keiro.ReplayDigest+  other-modules:+    Keiro.Command.Domain+    Keiro.Projection.Types+    Keiro.ReadModel.Rebuild.Group+    Keiro.ReadModel.Rebuild.Runner+    Keiro.ReadModel.Rebuild.Status+    Keiro.ReadModel.Rebuild.Stream+    Keiro.ReadModel.Rebuild.Versioned+    Keiro.ReplayDigest+    Keiro.Workflow.Awakeable.Internal.Identity+    Keiro.Workflow.Child.Cancel+    Keiro.Workflow.Instance.Schema+    Keiro.Workflow.Journal+    Paths_keiro+   reexported-modules:     keiro-core:Keiro.Codec,     keiro-core:Keiro.Codec.IdDomain,@@ -123,13 +146,13 @@     , hs-opentelemetry-semantic-conventions  >=1.40      && <2     , keiki                                  >=0.9       && <0.10     , keiki-codec-json                       >=0.9       && <0.10-    , keiro-core                             ^>=0.11.0.0-    , kiroku-store                           >=0.3       && <0.4+    , keiro-core                             ^>=0.12.0.0+    , kiroku-store                           >=0.7       && <0.8     , lens                                   >=5.2       && <5.4     , mmzk-typeid                            >=0.7       && <0.8     , random                                 >=1.2.1     && <1.4     , scientific                             >=0.3       && <0.4-    , shibuya-core                           >=0.8.0.1   && <0.9+    , shibuya-core                           ^>=0.9.0.0     , stm                                    >=2.5       && <2.6     , streamly                               >=0.11      && <0.12     , streamly-core                          >=0.3       && <0.4@@ -140,60 +163,83 @@     , vector                                 >=0.13      && <0.14  test-suite keiro-test-  import:         warnings, shared-  type:           exitcode-stdio-1.0-  hs-source-dirs: test-  main-is:        Main.hs-  ghc-options:    -threaded -rtsopts -with-rtsopts=-N+  import:          warnings, shared+  type:            exitcode-stdio-1.0+  hs-source-dirs:  test+  main-is:         Main.hs+  autogen-modules: Paths_keiro+  other-modules:+    CatalogEvolutionSpec+    CatalogOperationsSpec+    CatalogSpec+    Compatibility.ReadModel011+    ExternalReadSpec+    GroupRebuildSpec+    Paths_keiro+    PreCanonicalRecoverySpec+    PreimageSpec+    ProjectionReplaySpec+    ReadModelSpec+    VersionedRebuildSpec+    VersionedTargetPostgresSpec++  ghc-options:     -threaded -rtsopts -with-rtsopts=-N   build-depends:-    , aeson                                  >=2.2     && <2.3-    , base                                   >=4.21    && <5-    , bytestring                             >=0.11    && <0.13-    , containers                             >=0.6     && <0.8-    , contravariant-extras                   >=0.3     && <0.4-    , effectful                              >=2.6     && <2.7-    , effectful-core                         >=2.6     && <2.7-    , hasql                                  >=1.10    && <1.11-    , hasql-pool                             >=1.2     && <1.5-    , hasql-transaction                      >=1.1     && <1.3-    , hs-opentelemetry-api                   >=1.0     && <1.1-    , hs-opentelemetry-exporter-in-memory    >=1.0     && <1.1-    , hs-opentelemetry-propagator-w3c        >=1.0     && <1.1-    , hs-opentelemetry-sdk                   >=1.0     && <1.1-    , hs-opentelemetry-semantic-conventions  >=1.40    && <2+    , aeson                                  >=2.2      && <2.3+    , base                                   >=4.21     && <5+    , bytestring                             >=0.11     && <0.13+    , containers                             >=0.6      && <0.8+    , contravariant-extras                   >=0.3      && <0.4+    , effectful                              >=2.6      && <2.7+    , effectful-core                         >=2.6      && <2.7+    , hasql                                  >=1.10     && <1.11+    , hasql-pool                             >=1.2      && <1.5+    , hasql-transaction                      >=1.1      && <1.3+    , hs-opentelemetry-api                   >=1.0      && <1.1+    , hs-opentelemetry-exporter-in-memory    >=1.0      && <1.1+    , hs-opentelemetry-propagator-w3c        >=1.0      && <1.1+    , hs-opentelemetry-sdk                   >=1.0      && <1.1+    , hs-opentelemetry-semantic-conventions  >=1.40     && <2     , hspec                                  >=2.11     , keiki     , keiki-codec-json     , keiro     , keiro-test-support-    , kiroku-store                           >=0.3     && <0.4-    , process                                >=1.6     && <1.7-    , shibuya-core                           >=0.8.0.1 && <0.9-    , stm                                    >=2.5     && <2.6-    , streamly-core                          >=0.3     && <0.4-    , text                                   >=2.1     && <2.2-    , time                                   >=1.12    && <1.15-    , unliftio-core                          >=0.2     && <0.3-    , uuid                                   >=1.3     && <1.4-    , vector                                 >=0.13    && <0.14+    , kiroku-store                           >=0.7      && <0.8+    , process                                >=1.6      && <1.7+    , shibuya-core                           ^>=0.9.0.0+    , stm                                    >=2.5      && <2.6+    , streamly-core                          >=0.3      && <0.4+    , text                                   >=2.1      && <2.2+    , time                                   >=1.12     && <1.15+    , unliftio-core                          >=0.2      && <0.3+    , uuid                                   >=1.3      && <1.4+    , vector                                 >=0.13     && <0.14  benchmark keiro-bench   import:         warnings, shared   type:           exitcode-stdio-1.0   hs-source-dirs: bench   main-is:        Main.hs+  other-modules:  ReadModelBench   ghc-options:    -threaded -rtsopts -with-rtsopts=-N   build-depends:+    , aeson                 >=2.2.2     && <2.3     , base                  >=4.21      && <5     , bytestring            >=0.11      && <0.13+    , containers            >=0.6       && <0.8     , effectful             >=2.6       && <2.7+    , hasql                 >=1.10      && <1.11     , hasql-transaction     >=1.1       && <1.3     , hs-opentelemetry-api  >=1.0       && <1.1     , hs-opentelemetry-sdk  >=1.0       && <1.1+    , keiki                 >=0.9       && <0.10     , keiro-    , keiro-core            ^>=0.11.0.0+    , keiro-core            ^>=0.12.0.0     , keiro-test-support-    , kiroku-store          >=0.3       && <0.4+    , kiroku-store          >=0.7       && <0.8+    , shibuya-core          ^>=0.9.0.0+    , streamly-core         >=0.3       && <0.4     , tasty-bench           >=0.4     , text                  >=2.1       && <2.2     , time                  >=1.12      && <1.15
src/Keiro.hs view
@@ -4,11 +4,12 @@ -- 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").+-- snapshot helpers ("Keiro.Snapshot"), typed 'Stream' handles+-- ("Keiro.Stream"), and the validated projection-catalog facade+-- ("Keiro.Projection.Catalog"). -- -- The more specialized subsystems are not re-exported here and are imported--- directly when needed: read models ("Keiro.ReadModel"), projections+-- directly when needed: read models ("Keiro.ReadModel"), low-level projections -- ("Keiro.Projection"), process managers ("Keiro.ProcessManager"), the -- integration in/outbox ("Keiro.Inbox", "Keiro.Outbox"), timers -- ("Keiro.Timer"), and telemetry ("Keiro.Telemetry").@@ -39,20 +40,25 @@     -- * Routing and snapshots     module Keiro.Router,     module Keiro.Snapshot,++    -- * Projection catalogs+    module Keiro.Projection.Catalog,   ) where +import Data.Text qualified as Text+import Data.Version (showVersion) import Keiro.Codec import Keiro.Command import Keiro.EventStream import Keiro.EventStream.Validate import Keiro.Prelude+import Keiro.Projection.Catalog hiding (AllStreams, version) import Keiro.Router import Keiro.Snapshot import Keiro.Stream+import Paths_keiro qualified as Package --- | 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 Cabal package version, rendered as 'Text' for display and telemetry. version :: Text-version = "0.4.0.0"+version = Text.pack (showVersion Package.version)
src/Keiro/Command.hs view
@@ -51,6 +51,11 @@ module Keiro.Command   ( -- * Results and errors     CommandResult (..),+    DomainDecision (..),+    DomainCommandOutcome (..),+    SilentCommandContext (..),+    SilentDomainDecision (..),+    DomainCommandHandler (..),     CommandError (..),     HydrationReplayReason (..),     commandErrorClass,@@ -61,8 +66,17 @@      -- * Running commands     runCommand,+    runDomainCommand,+    forgetDomainDecision,     runCommandWithSql,     runCommandWithSqlEvents,+    SqlTransactionDecision (..),+    SqlCommandOutcome (..),+    runCommandWithSqlEventsControlled,+    runDomainCommandWithSql,+    runDomainCommandWithSqlEvents,+    DomainSqlCommandOutcome (..),+    runDomainCommandWithSqlEventsControlled,      -- * Hydration primitives (replay audit)     Hydrated (..),@@ -78,7 +92,9 @@ import Data.ByteString.Lazy.Char8 qualified as LazyByteString import Data.Functor (($>)) import Data.Int (Int32)+import Data.List.NonEmpty qualified as NonEmpty import Data.Text qualified as Text+import Data.Void (Void) import Effectful (Eff, IOE, (:>)) import Effectful.Concurrent (runConcurrent) import Effectful.Concurrent.Async qualified as Async@@ -89,6 +105,7 @@ import Keiki.Core (BoolAlg, RegFile) import Keiki.Core qualified as Keiki import Keiro.Codec (Codec, CodecError, decodeRecorded, encodeForAppendWithMetadata)+import Keiro.Command.Domain (SilentCommandContext (..), SilentDomainDecision (..)) import Keiro.EventStream (EventStream, StateCodec, Terminality (..)) import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated) import Keiro.Prelude@@ -104,11 +121,15 @@ import Keiro.Snapshot.Policy (shouldSnapshotSpan) import Keiro.Stream (Stream) import Keiro.Telemetry-  ( KeiroMetrics,+  ( CommandDecisionClass (..),+    KeiroMetrics,+    commandDecisionClassText,+    keiro_command_decision,     keiro_events_appended,     keiro_replay_divergence,     keiro_retry_attempt,     recordCommandConflicts,+    recordCommandDecision,     recordCommandDuplicates,     recordCommandRetries,     recordSnapshotApplyDivergence,@@ -169,6 +190,52 @@   }   deriving stock (Generic, Eq, Show) +-- | The application-level decision made by one selected live edge.+-- Infrastructure failures and commands for which no edge was selected remain+-- 'CommandError's outside this value.+data DomainDecision co rejection noOp+  = -- | The exact non-empty event batch that was encoded and appended.+    DomainAccepted !(NonEmpty co)+  | -- | An explicitly selected silent edge classified as a rejection.+    DomainRejected !rejection+  | -- | An explicitly selected silent edge classified as a successful no-op.+    DomainNoOp !noOp+  deriving stock (Generic, Eq, Show)++-- | A typed domain decision paired with the ordinary persistence metadata.+data DomainCommandOutcome target co rejection noOp = DomainCommandOutcome+  { decision :: !(DomainDecision co rejection noOp),+    result :: !(CommandResult target)+  }+  deriving stock (Generic, Eq, Show)++-- | A validated stream plus pure application policy for selected silent edges.+-- The classifier does not select an edge and is invoked only after Keiki has+-- selected exactly one live edge whose output word is empty.+data DomainCommandHandler phi rs s ci co rejection noOp = DomainCommandHandler+  { eventStream :: !(ValidatedEventStream phi rs s ci co),+    classifySilent :: !(SilentCommandContext rs s ci -> SilentDomainDecision rejection noOp)+  }+  deriving stock (Generic)++-- Internal adapter for the historical command API. Every selected silent edge+-- is a successful no-op; the rejection type is uninhabited because this+-- classifier never constructs 'SilentRejected'.+silentNoOpHandler ::+  ValidatedEventStream phi rs s ci co ->+  DomainCommandHandler phi rs s ci co Void ()+silentNoOpHandler eventStream =+  DomainCommandHandler+    { eventStream,+      classifySilent = \_ -> SilentNoOp ()+    }++-- | Erase the typed domain decision while retaining historical command+-- persistence metadata. This is a collapse of successful matched decisions;+-- unmatched commands remain an outer 'Left' and never reach this adapter.+forgetDomainDecision :: DomainCommandOutcome target co rejection noOp -> CommandResult target+forgetDomainDecision DomainCommandOutcome {result} = result+ -- | Why a command did not complete. data CommandError   = -- | A stored event could not be decoded while rehydrating the aggregate.@@ -207,6 +274,40 @@     ConflictFixpoint !StreamVersion !StoreError   deriving stock (Generic, Eq, Show) +-- | Whether an in-transaction command callback accepts the append or asks the+-- runner to roll the whole transaction back while retaining a typed outcome.+data SqlTransactionDecision a+  = CommitSqlTransaction !a+  | RollbackSqlTransaction !a+  deriving stock (Generic, Eq, Show)++-- | Result of a controlled transactional command. A rolled-back outcome has no+-- 'CommandResult' because neither its event append nor its SQL effects exist.+data SqlCommandOutcome target a+  = SqlCommandNoOp !(CommandResult target)+  | SqlCommandCommitted !(CommandResult target) !a+  | SqlCommandRolledBack !a+  deriving stock (Generic, Eq, Show)++-- | Result of a controlled transactional domain command. Selected silent+-- decisions perform no transaction callback. A rolled-back accepted append has+-- no 'DomainCommandOutcome' because neither its append nor its SQL effects+-- exist.+data DomainSqlCommandOutcome target co rejection noOp a+  = DomainSqlCommandSilent !(DomainCommandOutcome target co rejection noOp)+  | DomainSqlCommandCommitted !(DomainCommandOutcome target co rejection noOp) !a+  | DomainSqlCommandRolledBack !a+  deriving stock (Generic, Eq, Show)++forgetDomainSqlOutcome ::+  DomainSqlCommandOutcome target co rejection noOp a ->+  SqlCommandOutcome target a+forgetDomainSqlOutcome = \case+  DomainSqlCommandSilent outcome -> SqlCommandNoOp (forgetDomainDecision outcome)+  DomainSqlCommandCommitted outcome userValue ->+    SqlCommandCommitted (forgetDomainDecision outcome) userValue+  DomainSqlCommandRolledBack userValue -> SqlCommandRolledBack userValue+ -- | Why replay of stored events stalled, projected from keiki's structured -- failure types onto a monomorphic vocabulary suitable for 'CommandError'. data HydrationReplayReason@@ -228,8 +329,10 @@ -- * '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.+-- * 'beforeAppend' — an observation/test hook run immediately before each+--   append attempt, primarily for injecting concurrent writes. It is not an+--   application transaction callback: it may run for an accepted decision that+--   later conflicts and is discarded. -- * '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.@@ -294,9 +397,9 @@   }   deriving stock (Generic) -data CommandPlan target rs s co-  = CommandNoOp !(CommandResult target)-  | CommandAppend !(Hydrated rs s) ![co] ![EventData]+data DomainCommandPlan target rs s co rejection noOp+  = DomainCommandSilent !(SilentDomainDecision rejection noOp) !(CommandResult target)+  | DomainCommandAppend !(Hydrated rs s) !(NonEmpty co) ![EventData]   deriving stock (Generic)  hydrate ::@@ -652,40 +755,125 @@   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+    (outcome, attemptNo) <-+      domainCommandAttempts+        options+        (silentNoOpHandler validatedEventStream)+        targetStream+        command+        mSpan+    let result = fmap forgetDomainDecision outcome     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+-- | Hydrate, select and evaluate one live edge, then return the exact typed+-- domain decision from the successful final optimistic-concurrency attempt.+-- Eventful decisions append the same non-empty batch carried by+-- 'DomainAccepted'. Selected silent edges are classified purely and perform no+-- append. No matching edge and every infrastructure failure remain+-- 'CommandError's.+runDomainCommand ::+  forall phi rs s ci co rejection noOp es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp))+runDomainCommand options handler@DomainCommandHandler {eventStream = validatedEventStream} targetStream command =+  withCommandSpan (options ^. #tracer) (resolvedStreamName (unvalidated validatedEventStream) targetStream) Nothing $ \mSpan -> do+    (outcome, attemptNo) <- domainCommandAttempts options handler targetStream command mSpan+    recordDomainCommandOutcome options mSpan attemptNo outcome+    pure outcome -    runPlan mSpan attemptNo lastConflict current =+-- | The optimistic-concurrency attempt loop shared by the plain and+-- transactional domain command runners: hydrate, detect a conflict fixpoint,+-- prepare the plan, classify a silent decision, or hand an accepted batch to+-- the caller's append action. The append action receives the retry+-- continuation so 'retryOrFail' can re-enter the loop.+domainCommandAttemptLoop ::+  forall phi rs s ci co rejection noOp outcome es.+  (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp -> outcome) ->+  ( (Int -> Maybe (StoreError, StreamVersion) -> Eff es (Either CommandError outcome, Int)) ->+    Int ->+    Hydrated rs s ->+    NonEmpty co ->+    [EventData] ->+    Eff es (Either CommandError outcome, Int)+  ) ->+  Eff es (Either CommandError outcome, Int)+domainCommandAttemptLoop options handler@DomainCommandHandler {eventStream = validatedEventStream} targetStream command wrapSilent appendAction =+  attempt 1 Nothing+  where+    eventStream' = unvalidated validatedEventStream++    attempt attemptNo lastConflict = do+      hydrated <- hydrate options eventStream' targetStream+      either (\err -> pure (Left err, attemptNo)) (runPlan attemptNo lastConflict) hydrated++    runPlan attemptNo lastConflict current =       case conflictFixpoint lastConflict (current ^. #streamVersion) of         Just err -> pure (Left err, attemptNo)         Nothing ->-          case prepareCommandPlan options eventStream targetStream current command of+          case prepareDomainCommandPlan options handler 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+            Right (DomainCommandSilent silentDecision result) ->+              pure+                ( Right+                    ( wrapSilent+                        DomainCommandOutcome+                          { decision = domainDecisionFromSilent silentDecision,+                            result+                          }+                    ),+                  attemptNo+                )+            Right (DomainCommandAppend current' events encoded) ->+              appendAction attempt attemptNo current' events encoded+{-# INLINE domainCommandAttemptLoop #-} -    appendOnce mSpan attemptNo current events encoded = do+domainCommandAttempts ::+  forall phi rs s ci co rejection noOp es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  Maybe Span ->+  Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp), Int)+domainCommandAttempts options handler@DomainCommandHandler {eventStream = validatedEventStream} targetStream command mSpan =+  domainCommandAttemptLoop options handler targetStream command Prelude.id appendOnce+  where+    eventStream' = unvalidated validatedEventStream++    appendOnce retry attemptNo current events encoded = do       liftIO (options ^. #beforeAppend)       appended <-         tryError @StoreError           $ appendToStream-            ((eventStream ^. #resolveStreamName) targetStream)+            ((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)+          verifyAndSnapshot options mSpan eventStream' current (NonEmpty.toList events) appendResult+          pure+            ( Right+                DomainCommandOutcome+                  { decision = DomainAccepted events,+                    result = appendedResult targetStream appendResult (Prelude.length encoded)+                  },+              attemptNo+            )         Left (_, storeError) ->-          retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError+          retryOrFail options retry 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.@@ -731,34 +919,137 @@   ([(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 =+  fmap (fmap collapse)+    $ runCommandWithSqlEventsControlled+      options+      validatedEventStream+      targetStream+      command+      (\pairs appendResult -> CommitSqlTransaction <$> afterAppend pairs appendResult)+  where+    collapse = \case+      SqlCommandNoOp result -> (result, Nothing)+      SqlCommandCommitted result userValue -> (result, Just userValue)+      SqlCommandRolledBack _ ->+        error "runCommandWithSqlEvents: an always-commit callback rolled back"++-- | Variant of 'runCommandWithSqlEvents' whose callback may condemn the whole+-- append transaction and still return a typed result. Rolled-back attempts do+-- not run replay verification or snapshot writes. Catalog-derived projection+-- fencing uses this boundary so discovering a rebuilding group after the append+-- SQL has taken its locks cannot leave the event or a partial projection write.+runCommandWithSqlEventsControlled ::+  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 (SqlTransactionDecision a)) ->+  Eff es (Either CommandError (SqlCommandOutcome (EventStream phi rs s ci co) a))+runCommandWithSqlEventsControlled 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+    (outcome, attemptNo) <-+      domainSqlCommandAttempts+        options+        (silentNoOpHandler validatedEventStream)+        targetStream+        command+        afterAppend+        mSpan+    let result = fmap forgetDomainSqlOutcome outcome+    recordCommandOutcome mSpan eventCount 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+    eventCount = \case+      SqlCommandNoOp result -> result ^. #eventsAppended+      SqlCommandCommitted result _ -> result ^. #eventsAppended+      SqlCommandRolledBack _ -> 0 -    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+-- | Domain-aware counterpart to 'runCommandWithSql'. The callback runs only+-- for an accepted non-empty event batch and commits atomically with it. A typed+-- rejection or no-op returns 'Nothing' and opens no SQL transaction.+runDomainCommandWithSql ::+  forall phi rs s ci co rejection noOp a es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp, Maybe a))+runDomainCommandWithSql options handler targetStream command afterAppend =+  runDomainCommandWithSqlEvents options handler targetStream command (\_ appendResult -> afterAppend appendResult) -    appendWithSqlOnce mSpan attemptNo current events encoded = do+-- | Domain-aware counterpart to 'runCommandWithSqlEvents'. Accepted commands+-- pass the exact typed events paired with their reconstructed persisted events+-- to the callback in append order. Selected silent decisions never invoke it.+runDomainCommandWithSqlEvents ::+  forall phi rs s ci co rejection noOp a es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  ([(co, RecordedEvent)] -> AppendResult -> Tx.Transaction a) ->+  Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp, Maybe a))+runDomainCommandWithSqlEvents options handler targetStream command afterAppend =+  fmap (fmap collapse)+    $ runDomainCommandWithSqlEventsControlled+      options+      handler+      targetStream+      command+      (\pairs appendResult -> CommitSqlTransaction <$> afterAppend pairs appendResult)+  where+    collapse = \case+      DomainSqlCommandSilent outcome -> (outcome, Nothing)+      DomainSqlCommandCommitted outcome userValue -> (outcome, Just userValue)+      DomainSqlCommandRolledBack _ ->+        error "runDomainCommandWithSqlEvents: an always-commit callback rolled back"++-- | Controlled domain transaction variant used by catalog projection fences.+-- A rollback discards the accepted batch and callback effects and therefore+-- cannot fabricate a successful 'DomainCommandOutcome'.+runDomainCommandWithSqlEventsControlled ::+  forall phi rs s ci co rejection noOp a es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  ([(co, RecordedEvent)] -> AppendResult -> Tx.Transaction (SqlTransactionDecision a)) ->+  Eff es (Either CommandError (DomainSqlCommandOutcome (EventStream phi rs s ci co) co rejection noOp a))+runDomainCommandWithSqlEventsControlled options handler@DomainCommandHandler {eventStream = validatedEventStream} targetStream command afterAppend =+  withCommandSpan (options ^. #tracer) (resolvedStreamName (unvalidated validatedEventStream) targetStream) Nothing $ \mSpan -> do+    (outcome, attemptNo) <- domainSqlCommandAttempts options handler targetStream command afterAppend mSpan+    recordDomainSqlCommandOutcome options mSpan attemptNo outcome+    pure outcome++domainSqlCommandAttempts ::+  forall phi rs s ci co rejection noOp a es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  ([(co, RecordedEvent)] -> AppendResult -> Tx.Transaction (SqlTransactionDecision a)) ->+  Maybe Span ->+  Eff es (Either CommandError (DomainSqlCommandOutcome (EventStream phi rs s ci co) co rejection noOp a), Int)+domainSqlCommandAttempts options handler@DomainCommandHandler {eventStream = validatedEventStream} targetStream command afterAppend mSpan =+  domainCommandAttemptLoop options handler targetStream command DomainSqlCommandSilent appendWithSqlOnce+  where+    eventStream' = unvalidated validatedEventStream++    appendWithSqlOnce retry 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+      let streamName = (eventStream' ^. #resolveStreamName) targetStream           expected = expectedVersion (current ^. #streamVersion)           body = do             appended <- appendToStreamTx streamName expected prepared now@@ -766,39 +1057,85 @@               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))+                let typedEvents = NonEmpty.toList events+                    recordeds = reconstructRecorded appendResult now prepared+                sqlDecision <- afterAppend (Prelude.zip typedEvents recordeds) appendResult+                case sqlDecision of+                  CommitSqlTransaction userValue ->+                    pure (Right (appendResult, Right userValue))+                  RollbackSqlTransaction userValue -> do+                    Tx.condemn+                    pure (Right (appendResult, Left 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 (Right (appendResult, Right userValue)) -> do+          verifyAndSnapshot options mSpan eventStream' current (NonEmpty.toList events) appendResult+          pure+            ( Right+                ( DomainSqlCommandCommitted+                    DomainCommandOutcome+                      { decision = DomainAccepted events,+                        result = appendedResult targetStream appendResult (Prelude.length encoded)+                      }+                    userValue+                ),+              attemptNo+            )+        Right (Right (_, Left userValue)) ->+          pure (Right (DomainSqlCommandRolledBack userValue), attemptNo)         Right (Left storeError) ->-          retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError+          retryOrFail options retry attemptNo (current ^. #streamVersion) storeError         Left (_, storeError) ->-          retryOrFail options (attempt mSpan) attemptNo (current ^. #streamVersion) storeError+          retryOrFail options retry attemptNo (current ^. #streamVersion) storeError -prepareCommandPlan ::+prepareDomainCommandPlan ::   (BoolAlg phi (RegFile rs, ci)) =>   RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->   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+  Either CommandError (DomainCommandPlan (EventStream phi rs s ci co) rs s co rejection noOp)+prepareDomainCommandPlan options DomainCommandHandler {classifySilent} eventStream targetStream current command =+  case Keiki.stepDetailedEither (eventStream ^. #transducer) (current ^. #state, current ^. #registers) command of+    Left failure -> Left (commandStepFailure failure)+    Right success ->+      case Keiki.stepSuccessOutputs success of+        [] ->+          Right+            ( DomainCommandSilent+                ( classifySilent+                    SilentCommandContext+                      { state = current ^. #state,+                        registers = current ^. #registers,+                        command,+                        selectedEdge = Keiki.stepSuccessEdge success+                      }+                )+                (noOpResult targetStream current)+            )+        event : events ->+          let batch = event :| events+           in DomainCommandAppend current batch+                . assignEventIds (options ^. #eventIds)+                <$> encodeEvents (eventStream ^. #eventCodec) (options ^. #metadata) (NonEmpty.toList batch) +domainDecisionFromSilent :: SilentDomainDecision rejection noOp -> DomainDecision co rejection noOp+domainDecisionFromSilent = \case+  SilentRejected reason -> DomainRejected reason+  SilentNoOp explanation -> DomainNoOp explanation++commandStepFailure :: Keiki.StepFailure s -> CommandError+commandStepFailure = \case+  Keiki.NoOutgoingEdges {} -> CommandRejected+  Keiki.NoMatchingEdge {} -> CommandRejected+  Keiki.AmbiguousEdges _ matches ->+    CommandAmbiguous+      [ Keiki.edgeIndex (Keiki.matchedEdge matched)+      | matched <- matches+      ]+ -- | Render the stream that the command targets as plain 'Text', for use -- as a span name. resolvedStreamName ::@@ -834,6 +1171,60 @@       addAttribute sp (unkey error_type) (commandErrorClass err)       setStatus sp (Error (Text.take 256 (Text.pack (show err)))) +recordDomainCommandOutcome ::+  (IOE :> es) =>+  RunCommandOptions ->+  Maybe Span ->+  Int ->+  Either CommandError (DomainCommandOutcome target co rejection noOp) ->+  Eff es ()+recordDomainCommandOutcome options mSpan attemptNo outcome = do+  recordCommandOutcome mSpan ((^. #eventsAppended) . forgetDomainDecision) attemptNo outcome+  case outcome of+    Left _ -> pure ()+    Right DomainCommandOutcome {decision} ->+      recordDomainDecision options mSpan decision++recordDomainSqlCommandOutcome ::+  (IOE :> es) =>+  RunCommandOptions ->+  Maybe Span ->+  Int ->+  Either CommandError (DomainSqlCommandOutcome target co rejection noOp a) ->+  Eff es ()+recordDomainSqlCommandOutcome options mSpan attemptNo outcome = do+  recordCommandOutcome mSpan eventsAppended attemptNo outcome+  case outcome of+    Right (DomainSqlCommandSilent DomainCommandOutcome {decision}) ->+      recordDomainDecision options mSpan decision+    Right (DomainSqlCommandCommitted DomainCommandOutcome {decision} _) ->+      recordDomainDecision options mSpan decision+    Right (DomainSqlCommandRolledBack _) -> pure ()+    Left _ -> pure ()+  where+    eventsAppended = \case+      DomainSqlCommandSilent DomainCommandOutcome {result} -> result ^. #eventsAppended+      DomainSqlCommandCommitted DomainCommandOutcome {result} _ -> result ^. #eventsAppended+      DomainSqlCommandRolledBack _ -> 0++recordDomainDecision ::+  (IOE :> es) =>+  RunCommandOptions ->+  Maybe Span ->+  DomainDecision co rejection noOp ->+  Eff es ()+recordDomainDecision options mSpan domainDecision = do+  let decisionClass = domainDecisionClass domainDecision+  for_ mSpan $ \sp ->+    addAttribute sp (unkey keiro_command_decision) (commandDecisionClassText decisionClass)+  recordCommandDecision (options ^. #metrics) decisionClass++domainDecisionClass :: DomainDecision co rejection noOp -> CommandDecisionClass+domainDecisionClass = \case+  DomainAccepted _ -> DecisionAccepted+  DomainRejected _ -> DecisionRejected+  DomainNoOp _ -> DecisionNoOp+ -- | Low-cardinality classifier for a 'CommandError'. Used as the -- @error.type@ attribute value on the command span. commandErrorClass :: CommandError -> Text@@ -947,25 +1338,6 @@ 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 =
+ src/Keiro/Command/Domain.hs view
@@ -0,0 +1,29 @@+{-# LANGUAGE NoFieldSelectors #-}++-- | Pure domain-decision values that need ordinary record labels without+-- adding selector functions which collide with the established command API.+module Keiro.Command.Domain+  ( SilentCommandContext (..),+    SilentDomainDecision (..),+  )+where++import Keiki.Core (EdgeRef, RegFile)+import Keiro.Prelude++-- | Pre-command values supplied to the pure classifier for an already-selected+-- output-free live edge. The edge reference is local to this exact transducer+-- construction and must not be persisted as an application identifier.+data SilentCommandContext rs s ci = SilentCommandContext+  { state :: !s,+    registers :: !(RegFile rs),+    command :: !ci,+    selectedEdge :: !(EdgeRef s)+  }+  deriving stock (Generic)++-- | Total classification of one explicitly selected output-free edge.+data SilentDomainDecision rejection noOp+  = SilentRejected !rejection+  | SilentNoOp !noOp+  deriving stock (Generic, Eq, Show)
+ src/Keiro/DeterministicId.hs view
@@ -0,0 +1,69 @@+-- | The seed encoding shared by Keiro's deterministic (version-5 UUID)+-- identifiers.+--+-- A deterministic id is a pure function of a seed text, so that an+-- at-least-once writer collapses to exactly one row: the workflow journal's+-- event ids, sleep timer ids, legacy awakeable ids, and process-manager+-- command ids are all derived this way. That makes the seed encoding /replay+-- identity/ — it must yield the same bytes for the same text on every deploy,+-- forever. The rule is recorded in+-- @docs\/adr\/0024-deterministic-ids-hash-utf-8-seed-bytes-and-are-frozen-replay-identity.md@.+module Keiro.DeterministicId+  ( identitySeedBytes,+    legacySeedBytes,+    seedMovedAcrossEncodings,+    deterministicIdProbes,+  )+where++import Data.ByteString qualified as ByteString+import Data.Char (isAscii)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.UUID (UUID)+import Data.UUID.V5 qualified as UUID.V5+import Data.Word (Word8)+import Keiro.Prelude++-- | The UTF-8 bytes of a deterministic-id seed, in the @[Word8]@ shape+-- @Data.UUID.V5.generateNamed@ expects.+--+-- UTF-8 rather than the codepoints themselves: the original derivations fed+-- @fromIntegral . fromEnum@ over @Text.unpack@ into the hash, which truncated+-- every character to its codepoint modulo 256, so @"\x0101"@ and @"\SOH"@ (and+-- countless CJK pairs) hashed identically and could be assigned one id. For+-- pure-ASCII seeds UTF-8 bytes /are/ the codepoint values, so every id derived+-- from an ASCII seed is byte-identical to what the truncating encoding+-- produced; only non-ASCII seeds move, and they move off a collision.+--+-- This function is frozen. Changing it renames every deterministic id in every+-- deployment, which no amount of retrying recovers from, so a future change+-- needs an explicit versioned derivation and a migration story rather than an+-- edit here.+identitySeedBytes :: Text -> [Word8]+identitySeedBytes = ByteString.unpack . Text.Encoding.encodeUtf8++-- | The pre-0.12 deterministic-id seed encoding.+--+-- This deliberately reproduces the historical @Char -> Word8@ truncation so+-- deployed identifiers can be probed during the compatibility window. It is a+-- frozen reader for old identity, not an encoding for new writes.+legacySeedBytes :: Text -> [Word8]+legacySeedBytes = fmap (fromIntegral . fromEnum) . Text.unpack++-- | Whether UTF-8 and the historical seed encoding can produce different+-- bytes for this seed. ASCII is byte-identical under both encodings.+seedMovedAcrossEncodings :: Text -> Bool+seedMovedAcrossEncodings = not . Text.all isAscii++-- | Ordered candidate ids for one deterministic-id seed under the ADR 24+-- compatibility bridge: the current UTF-8-derived id first, the frozen+-- pre-UTF-8 id second only when the seed's bytes differ across encodings.+-- This is the single source of truth for the dual-probe policy; call sites+-- must not restate the ordering or the moved-seed condition.+deterministicIdProbes :: Text -> NonEmpty UUID+deterministicIdProbes seed =+  UUID.V5.generateNamed UUID.V5.namespaceURL (identitySeedBytes seed)+    :| [ UUID.V5.generateNamed UUID.V5.namespaceURL (legacySeedBytes seed)+       | seedMovedAcrossEncodings seed+       ]
src/Keiro/Inbox.hs view
@@ -27,6 +27,7 @@     -- * Storage primitives     lookupInbox,     listInbox,+    listCompletedInboxGcCandidates,     garbageCollectCompleted,     countInboxBacklog,     markFailedTx,
src/Keiro/Inbox/Schema.hs view
@@ -12,6 +12,7 @@     recordFailedAttemptTx,     lookupInbox,     listInbox,+    listCompletedInboxGcCandidates,     garbageCollectCompleted,     countInboxBacklog,   )@@ -115,6 +116,17 @@   runTransaction $     Tx.statement src listBySourceStmt +-- | List completed rows a retention pass would delete, oldest first.+-- This is a read-only operator preview for 'garbageCollectCompleted'.+listCompletedInboxGcCandidates ::+  (Store :> es) =>+  NominalDiffTime ->+  UTCTime ->+  Eff es [InboxRow]+listCompletedInboxGcCandidates keepFor now =+  runTransaction $+    Tx.statement (addUTCTime (negate keepFor) now) listCompletedGcCandidatesStmt+ -- | Count inbox rows in a non-terminal state (backlog gauge source). -- -- Backlog = rows still @processing@ (in flight) or @failed@ (awaiting a@@ -431,6 +443,13 @@   preparable     (selectAllSql <> " WHERE source = $1 ORDER BY received_at, dedupe_key")     (E.param (E.nonNullable E.text))+    (D.rowList inboxRowDecoder)++listCompletedGcCandidatesStmt :: Statement UTCTime [InboxRow]+listCompletedGcCandidatesStmt =+  preparable+    (selectAllSql <> " WHERE status = 'completed' AND completed_at < $1 ORDER BY completed_at, source, dedupe_key")+    (E.param (E.nonNullable E.timestamptz))     (D.rowList inboxRowDecoder)  countInboxBacklogStmt :: Statement () Int
src/Keiro/Outbox.hs view
@@ -40,6 +40,8 @@     markOutboxSent,     lookupOutbox,     listOutbox,+    listStuckOutbox,+    listSentOutboxGcCandidates,     countOutboxBacklog,     garbageCollectSent, 
src/Keiro/Outbox/Schema.hs view
@@ -15,6 +15,8 @@     markOutboxSkippedTx,     lookupOutbox,     listOutbox,+    listStuckOutbox,+    listSentOutboxGcCandidates,     countOutboxBacklog,     garbageCollectSent,   )@@ -72,6 +74,28 @@   runTransaction $     Tx.statement source listOutboxStmt +-- | List rows a stale-publisher recovery pass would consider, oldest first.+-- This is a read-only operator preview for 'requeueStuckOutbox'.+listStuckOutbox ::+  (Store :> es) =>+  NominalDiffTime ->+  UTCTime ->+  Eff es [OutboxRow]+listStuckOutbox olderThan now =+  runTransaction $+    Tx.statement (addUTCTime (negate olderThan) now) listStuckOutboxStmt++-- | List sent rows a retention pass would delete, oldest first.+-- This is a read-only operator preview for 'garbageCollectSent'.+listSentOutboxGcCandidates ::+  (Store :> es) =>+  NominalDiffTime ->+  UTCTime ->+  Eff es [OutboxRow]+listSentOutboxGcCandidates keepFor now =+  runTransaction $+    Tx.statement (addUTCTime (negate keepFor) now) listSentOutboxGcCandidatesStmt+ -- | Count outbox rows awaiting publish (backlog gauge source). -- -- Backlog = rows in a claimable, non-terminal state. Mirrors the claim@@ -661,6 +685,20 @@   preparable     (selectAllSql <> " WHERE source = $1 ORDER BY created_at, outbox_id")     (E.param (E.nonNullable E.text))+    (D.rowList outboxRowDecoder)++listStuckOutboxStmt :: Statement UTCTime [OutboxRow]+listStuckOutboxStmt =+  preparable+    (selectAllSql <> " WHERE status = 'publishing' AND updated_at < $1 ORDER BY updated_at, outbox_id")+    (E.param (E.nonNullable E.timestamptz))+    (D.rowList outboxRowDecoder)++listSentOutboxGcCandidatesStmt :: Statement UTCTime [OutboxRow]+listSentOutboxGcCandidatesStmt =+  preparable+    (selectAllSql <> " WHERE status = 'sent' AND published_at < $1 ORDER BY published_at, outbox_id")+    (E.param (E.nonNullable E.timestamptz))     (D.rowList outboxRowDecoder)  selectAllSql :: Text
src/Keiro/ProcessManager.hs view
@@ -99,13 +99,17 @@ module Keiro.ProcessManager   ( -- * Definition     ProcessManager (..),+    DomainProcessManager (..),     ProcessManagerAction (..),     PMCommand (..),      -- * Results     ProcessManagerResult (..),     PMCommandResult (..),+    DomainPMCommandResult (..),     PMStateResult (..),+    DomainProcessManagerResult (..),+    DomainDispatchSummary (..),      -- * Running     PoisonPolicy (..),@@ -118,18 +122,28 @@     isRejectionClass,     decideForFailures,     ackForCommandError,+    ackForDomainSummary,+    summarizeDomainCommandResult,     runProcessManagerOnce,     runProcessManagerWorkerWith,     runProcessManagerWorker,+    runDomainProcessManagerOnce,+    runDomainProcessManagerWorkerWith,+    runDomainProcessManagerWorker,      -- * Idempotency primitives     deterministicCommandId,+    legacyDeterministicCommandId,+    deterministicCommandIdProbes,+    dispatchDeduplicatedCommand,+    firstExistingEventId,     eventAlreadyIn,     confirmBenignDuplicate,   ) where  import Data.Coerce (coerce)+import Data.List.NonEmpty qualified as NonEmpty import Data.Text qualified as Text import Data.UUID qualified as UUID import Data.UUID.V5 qualified as UUID.V5@@ -137,12 +151,21 @@ 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.Command+  ( CommandError (..),+    CommandResult,+    DomainCommandHandler,+    DomainCommandOutcome,+    RunCommandOptions,+    commandErrorClass,+    runCommandWithSql,+  ) import Keiro.DeadLetter (DispatchDeadLetter (..), DispatcherKind (..), recordDispatchDeadLetter)+import Keiro.DeterministicId (deterministicIdProbes, legacySeedBytes) import Keiro.EventStream (EventStream) import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated) import Keiro.Prelude-import Keiro.Projection (InlineProjection, runCommandWithProjections)+import Keiro.Projection (InlineProjection, runCommandWithProjections, runDomainCommandWithProjections) import Keiro.Stream (Stream) import Keiro.Telemetry (KeiroMetrics, recordDispatchDeadLettered, recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison) import Keiro.Timer (TimerRequest, scheduleTimerTx)@@ -160,7 +183,7 @@ 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, (&&), (+))+import Prelude (any, filter, fromIntegral, length, not, reverse, seq, uncurry, zip, (&&), (+))  -- | A process manager wiring together a manager state machine and the target -- aggregate it drives.@@ -191,6 +214,20 @@   }   deriving stock (Generic) +-- | Process-manager configuration whose target aggregate returns typed domain+-- decisions. The manager's own state stream retains the legacy command result;+-- only dispatched target commands use 'DomainCommandHandler'.+data DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp = DomainProcessManager+  { name :: !Text,+    correlate :: !(input -> Text),+    eventStream :: !(ValidatedEventStream phi rs s ci co),+    streamFor :: !(Text -> Stream (EventStream phi rs s ci co)),+    targetHandler :: !(DomainCommandHandler targetPhi targetRs targetState targetCi targetCo rejection noOp),+    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@@ -222,6 +259,16 @@     PMCommandFailed !StoreTypes.StreamName !CommandError   deriving stock (Generic, Eq, Show) +-- | Outcome of one domain-aware target dispatch. A duplicate proves only that+-- the deterministic accepted event id already exists; it cannot reconstruct+-- the original in-memory event batch. Selected rejection/no-op decisions are+-- 'DomainPMCommandHandled' values and are never failures.+data DomainPMCommandResult target co rejection noOp+  = DomainPMCommandHandled !(DomainCommandOutcome target co rejection noOp)+  | DomainPMCommandDuplicate !EventId+  | DomainPMCommandFailed !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'.@@ -240,6 +287,24 @@   }   deriving stock (Generic, Eq, Show) +-- | Detailed domain-aware process-manager result. Its command result list owns+-- every returned accepted event batch; callers interested only in worker+-- acknowledgement should use the worker APIs, which retain only a strict+-- payload-free summary.+data DomainProcessManagerResult managerTarget commandTarget co rejection noOp = DomainProcessManagerResult+  { managerResult :: !(PMStateResult managerTarget),+    commandResults :: ![DomainPMCommandResult commandTarget co rejection noOp],+    timersScheduled :: !Int+  }+  deriving stock (Generic, Eq, Show)++-- | Strict payload-free accumulator used by domain coordinator workers.+data DomainDispatchSummary = DomainDispatchSummary+  { duplicates :: !Int64,+    failures :: ![DispatchFailure]+  }+  deriving stock (Generic, Eq, Show)+ -- | What a worker does with a message its decoder cannot parse. data PoisonPolicy es msg   = PoisonHalt@@ -291,6 +356,7 @@   StreamAlreadyExists {} -> True   EmptyAppendBatch {} -> False   StreamNotFound {} -> False+  HistoryRetentionActive {} -> True   ReservedStreamName {} -> False   StreamNameTooLong {} -> False   DuplicateEvent {} -> False@@ -402,22 +468,54 @@ -- 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.+--+-- The seed is hashed as UTF-8 bytes+-- ('Keiro.DeterministicId.identitySeedBytes'), which is+-- byte-identical to the original codepoint encoding for ASCII seeds and+-- collision-free for the rest; see+-- @docs\/adr\/0024-deterministic-ids-hash-utf-8-seed-bytes-and-are-frozen-replay-identity.md@.+-- During ADR 0024's operator-attested compatibility window, dispatch preflights+-- also consult 'deterministicCommandIdProbes'; this function remains the only+-- append identity.+-- Unlike 'Keiro.Router.deterministicRouterCommandId' the fields are joined with+-- a delimiter rather than length-prefixed, so a manager name or correlation id+-- containing @\":\"@ can still alias; new derivations should follow the router's+-- length-prefixed encoding. deterministicCommandId :: Text -> Text -> EventId -> Int -> EventId deterministicCommandId managerName correlationId sourceEventId emitIndex =+  NonEmpty.head (deterministicCommandIdProbes managerName correlationId sourceEventId emitIndex)++-- | Reproduce the deterministic command id written before Keiro switched its+-- seed encoding to UTF-8. This exists only for compatibility probes; all new+-- writes continue to use 'deterministicCommandId'. It is removed together with+-- the positional-router probe only under ADR 0024's operator-attested criteria.+legacyDeterministicCommandId :: Text -> Text -> EventId -> Int -> EventId+legacyDeterministicCommandId 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)-      ]+    $ legacySeedBytes+    $ commandIdSeed managerName correlationId sourceEventId emitIndex +-- | Candidate ids for a process-manager write, ordered with the current UTF-8+-- append id first and the frozen legacy id second only when the seed contains+-- non-ASCII text. 'deterministicIdProbes' is the single source of truth for+-- the compatibility probe described by ADR 0024.+deterministicCommandIdProbes :: Text -> Text -> EventId -> Int -> NonEmpty EventId+deterministicCommandIdProbes managerName correlationId sourceEventId emitIndex =+  fmap EventId (deterministicIdProbes (commandIdSeed managerName correlationId sourceEventId emitIndex))++commandIdSeed :: Text -> Text -> EventId -> Int -> Text+commandIdSeed managerName correlationId sourceEventId emitIndex =+  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.@@ -450,13 +548,14 @@   let correlationId = (manager ^. #correlate) input       action = (manager ^. #handle) input       managerStream = (manager ^. #streamFor) correlationId-      managerEventId = deterministicCommandId (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)+      managerProbes = deterministicCommandIdProbes (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)+      managerEventId = NonEmpty.head managerProbes       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+  existingManagerId <- firstExistingEventId options managerStreamName managerProbes+  case existingManagerId of+    Just matchedId -> finish correlationId (PMStateDuplicate matchedId) action+    Nothing -> do       managerOutcome <-         runCommandWithSql           managerOptions@@ -494,30 +593,336 @@         (zip [0 ..] commands)      dispatchCommand correlationId sourceEventId emitIndex command = do-      let commandId = deterministicCommandId (manager ^. #name) correlationId sourceEventId emitIndex+      let commandProbes = deterministicCommandIdProbes (manager ^. #name) correlationId sourceEventId emitIndex+          commandId = NonEmpty.head commandProbes           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+      dispatchDeduplicatedCommand+        options+        targetStreamName+        commandProbes+        PMCommandDuplicate+        (PMCommandFailed targetStreamName)+        PMCommandAppended+        ( runCommandWithProjections+            targetOptions+            (manager ^. #targetEventStream)+            targetStream+            (command ^. #command)+            ((manager ^. #targetProjections) (command ^. #target))+        )      retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)     retarget = coerce +-- | Detailed domain-aware process-manager runner. Accepted target commands+-- retain their exact event batches; selected rejection/no-op commands are+-- handled results; deterministic accepted redelivery is a separate duplicate+-- result because the original typed batch is not reconstructible from its id.+runDomainProcessManagerOnce ::+  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp 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 ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  RecordedEvent ->+  input ->+  Eff+    es+    ( Either+        CommandError+        ( DomainProcessManagerResult+            (EventStream phi rs s ci co)+            (EventStream targetPhi targetRs targetState targetCi targetCo)+            targetCo+            rejection+            noOp+        )+    )+runDomainProcessManagerOnce options manager sourceEvent input = do+  advanced <- advanceDomainProcessManager options manager sourceEvent input+  case advanced of+    Left err -> pure (Left err)+    Right (correlationId, managerResult, action) -> do+      commandResults <-+        dispatchDomainProcessManagerCommands+          options+          manager+          correlationId+          (sourceEvent ^. #eventId)+          (action ^. #commands)+      pure+        ( Right+            DomainProcessManagerResult+              { managerResult,+                commandResults,+                timersScheduled = length (action ^. #timers)+              }+        )++advanceDomainProcessManager ::+  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg phi (RegFile rs, ci),+    Eq co+  ) =>+  RunCommandOptions ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  RecordedEvent ->+  input ->+  Eff es (Either CommandError (Text, PMStateResult (EventStream phi rs s ci co), ProcessManagerAction ci targetCi))+advanceDomainProcessManager options manager sourceEvent input = do+  let correlationId = (manager ^. #correlate) input+      action = (manager ^. #handle) input+      managerStream = (manager ^. #streamFor) correlationId+      managerProbes = deterministicCommandIdProbes (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)+      managerEventId = NonEmpty.head managerProbes+      managerOptions = options & #eventIds .~ [managerEventId]+      managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream+      finish managerResult = pure (Right (correlationId, managerResult, action))+  existingManagerId <- firstExistingEventId options managerStreamName managerProbes+  case existingManagerId of+    Just matchedId -> finish (PMStateDuplicate matchedId)+    Nothing -> 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 (PMStateDuplicate managerEventId)+            else pure (Left err)+        Right (managerResult, scheduledInAppend) -> do+          case scheduledInAppend of+            Nothing -> runTransaction (traverse_ scheduleTimerTx (action ^. #timers))+            Just () -> pure ()+          finish (PMStateAppended managerResult)++dispatchDomainProcessManagerCommands ::+  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  Text ->+  EventId ->+  [PMCommand targetCi] ->+  Eff es [DomainPMCommandResult (EventStream targetPhi targetRs targetState targetCi targetCo) targetCo rejection noOp]+dispatchDomainProcessManagerCommands options manager correlationId sourceEventId commands =+  traverse+    (uncurry (dispatchDomainProcessManagerCommand options manager correlationId sourceEventId))+    (zip [0 ..] commands)++dispatchDomainProcessManagerSummary ::+  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  Text ->+  EventId ->+  [PMCommand targetCi] ->+  Eff es DomainDispatchSummary+dispatchDomainProcessManagerSummary options manager correlationId sourceEventId =+  go (DomainDispatchSummary 0 []) . zip [0 ..]+  where+    go summary = \case+      [] -> pure summary {failures = reverse (summary ^. #failures)}+      (emitIndex, command) : rest -> do+        result <- dispatchDomainProcessManagerCommand options manager correlationId sourceEventId emitIndex command+        let next = summarizeDomainCommandResult emitIndex result summary+        next `seq` go next rest++dispatchDomainProcessManagerCommand ::+  forall input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  Text ->+  EventId ->+  Int ->+  PMCommand targetCi ->+  Eff es (DomainPMCommandResult (EventStream targetPhi targetRs targetState targetCi targetCo) targetCo rejection noOp)+dispatchDomainProcessManagerCommand options manager correlationId sourceEventId emitIndex command = do+  let commandProbes = deterministicCommandIdProbes (manager ^. #name) correlationId sourceEventId emitIndex+      commandId = NonEmpty.head commandProbes+      targetOptions = options & #eventIds .~ [commandId]+      handler = manager ^. #targetHandler+      targetEventStream = handler ^. #eventStream+      targetStream = retarget (command ^. #target)+      targetStreamName = ((unvalidated targetEventStream) ^. #resolveStreamName) targetStream+  dispatchDeduplicatedCommand+    options+    targetStreamName+    commandProbes+    DomainPMCommandDuplicate+    (DomainPMCommandFailed targetStreamName)+    DomainPMCommandHandled+    ( runDomainCommandWithProjections+        targetOptions+        handler+        targetStream+        (command ^. #command)+        ((manager ^. #targetProjections) (command ^. #target))+    )+  where+    retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)+    retarget = coerce++summarizeDomainCommandResult ::+  Int ->+  DomainPMCommandResult target co rejection noOp ->+  DomainDispatchSummary ->+  DomainDispatchSummary+summarizeDomainCommandResult emitIndex result summary =+  case result of+    DomainPMCommandHandled _ -> summary+    DomainPMCommandDuplicate _ ->+      summary {duplicates = summary ^. #duplicates + 1}+    DomainPMCommandFailed targetStreamName commandError ->+      summary+        { failures =+            DispatchFailure+              { emitIndex,+                targetStreamName,+                commandError+              }+              : summary ^. #failures+        }++-- | Domain-aware process-manager worker with default policy. It dispatches+-- through a strict summary fold and does not retain handled domain payloads.+runDomainProcessManagerWorker ::+  forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp 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 ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runDomainProcessManagerWorker =+  runDomainProcessManagerWorkerWith defaultWorkerOptions++-- | Configurable domain-aware process-manager worker. Typed rejection/no-op is+-- handled and acknowledges normally; only 'CommandError' reaches failure+-- policy. Accepted payloads are released after each target is summarized.+runDomainProcessManagerWorkerWith ::+  forall msg input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp 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 ->+  DomainProcessManager input phi rs s ci co targetPhi targetRs targetState targetCi targetCo rejection noOp ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runDomainProcessManagerWorkerWith 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 "domain 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 $ do+            advanced <- advanceDomainProcessManager options manager recorded input+            case advanced of+              Left err -> pure (Left err)+              Right (_, managerResult, action) -> do+                summary <-+                  dispatchDomainProcessManagerSummary+                    options+                    manager+                    correlationId+                    (recorded ^. #eventId)+                    (action ^. #commands)+                pure (Right (managerResult, summary))+          case outcome of+            Left (_, storeError) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              pure (ackForThrownStoreError (workerOptions ^. #transientRetryDelay) storeError)+            Right (Left err) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              decideForFailures+                workerOptions+                DispatcherProcessManager+                (manager ^. #name)+                correlationId+                recorded+                attemptCount+                [DispatchFailure (-1) managerStreamName err]+            Right (Right (managerResult, summary)) ->+              ackForDomainSummary+                workerOptions+                DispatcherProcessManager+                (manager ^. #name)+                correlationId+                recorded+                attemptCount+                (stateDuplicateCount managerResult)+                summary+      finalizeAck decision+      pure decision+ -- | Run a process manager as a live subscription draining a Shibuya adapter with -- 'defaultWorkerOptions'. --@@ -642,6 +1047,32 @@     attemptCount     failures +-- | Convert a strict payload-free domain dispatch summary into worker metrics+-- and one acknowledgement decision.+ackForDomainSummary ::+  (IOE :> es, Store :> es) =>+  WorkerOptions es msg ->+  DispatcherKind ->+  Text ->+  Text ->+  RecordedEvent ->+  Int ->+  Int64 ->+  DomainDispatchSummary ->+  Eff es AckDecision+ackForDomainSummary workerOptions dispatcherKind dispatcherName correlationId sourceEvent attemptCount extraDuplicates summary = do+  let dispatchFailures = summary ^. #failures+  recordDispatchDuplicate (workerOptions ^. #metrics) (extraDuplicates + summary ^. #duplicates)+  recordDispatchFailed (workerOptions ^. #metrics) (fromIntegral (length dispatchFailures))+  decideForFailures+    workerOptions+    dispatcherKind+    dispatcherName+    correlationId+    sourceEvent+    attemptCount+    dispatchFailures+ stateDuplicateCount :: PMStateResult target -> Int64 stateDuplicateCount = \case   PMStateDuplicate {} -> 1@@ -693,6 +1124,48 @@   Eff es Bool eventAlreadyIn _options streamName eventId =   eventExistsInStream streamName eventId++-- | Return the first candidate id already present in the target stream.+-- Candidate order is significant: callers put the current append id first and+-- immutable historical identities after it.+firstExistingEventId ::+  (Store :> es) =>+  RunCommandOptions ->+  StoreTypes.StreamName ->+  NonEmpty EventId ->+  Eff es (Maybe EventId)+firstExistingEventId options streamName = go . NonEmpty.toList+  where+    go = \case+      [] -> pure Nothing+      candidate : rest -> do+        exists <- eventAlreadyIn options streamName candidate+        if exists then pure (Just candidate) else go rest++-- | Probe deterministic identities in order, dispatch only when none exists,+-- and fold a concurrent append of the primary identity into a duplicate.+dispatchDeduplicatedCommand ::+  (Store :> es) =>+  RunCommandOptions ->+  StoreTypes.StreamName ->+  NonEmpty EventId ->+  (EventId -> result) ->+  (CommandError -> result) ->+  (value -> result) ->+  Eff es (Either CommandError value) ->+  Eff es result+dispatchDeduplicatedCommand options streamName probes duplicate failure success dispatch = do+  existing <- firstExistingEventId options streamName probes+  case existing of+    Just matchedId -> pure (duplicate matchedId)+    Nothing -> do+      outcome <- dispatch+      case outcome of+        Right value -> pure (success value)+        Left err -> do+          let primaryId = NonEmpty.head probes+          benign <- confirmBenignDuplicate streamName primaryId err+          pure (if benign then duplicate primaryId else failure err)  -- | Decide whether a failed append is a benign duplicate of the write just -- attempted: whether @ourId@ is genuinely present in @streamName@.
src/Keiro/Projection.hs view
@@ -20,18 +20,30 @@   ( -- * Inline projections     InlineProjection (..),     runCommandWithProjections,+    runDomainCommandWithProjections,+    ProjectionCommandOutcome (..),+    runCommandWithCatalogProjections,+    DomainProjectionCommandOutcome (..),+    runDomainCommandWithCatalogProjections,      -- * Asynchronous projections     AsyncProjection (..),     AsyncApplyOutcome (..),+    CatalogAsyncApplyOutcome (..),     applyAsyncProjection,+    applyAsyncProjectionFromCatalog,     applyAsyncProjectionUnfenced,     pruneAsyncProjectionDedupBefore,+    countAsyncProjectionDedupForBefore,+    pruneAsyncProjectionDedupForBefore,+    recordProjectionGlobalPositionDistance,     recordProjectionLag,   ) where  import Contravariant.Extras (contrazip2)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty import Data.UUID (UUID) import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)@@ -40,50 +52,62 @@ import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Keiki.Core (BoolAlg, RegFile)-import Keiro.Command (CommandError, CommandResult, RunCommandOptions, runCommandWithSqlEvents)+import Keiro.Command+  ( CommandError,+    CommandResult,+    DomainCommandHandler,+    DomainCommandOutcome,+    DomainSqlCommandOutcome (..),+    RunCommandOptions,+    SqlCommandOutcome (..),+    SqlTransactionDecision (..),+    runCommandWithSqlEvents,+    runCommandWithSqlEventsControlled,+    runDomainCommandWithSqlEvents,+    runDomainCommandWithSqlEventsControlled,+  ) import Keiro.EventStream (EventStream) import Keiro.EventStream.Validate (ValidatedEventStream) import Keiro.Prelude-import Keiro.ReadModel (readSubscriptionPosition, storeHeadPosition)+import Keiro.Projection.Catalog+  ( PhysicalTargets,+    ProjectionHandler (..),+    ProjectionId,+    ProjectionRevisionId,+    ProjectionSet,+    RebuildGroupId,+    RevisionLiveDelivery (..),+    RevisionLiveHandler (..),+    SourceId,+    ValidatedProjectionCatalog,+    asyncProjectionRebuildGroup,+    asyncProjectionRegistrations,+    catalogRevisionLiveHandler,+    typedInlineProjectionsForGroup,+    typedProjectionRebuildGroups,+  )+import Keiro.Projection.Types+import Keiro.ReadModel (storeHeadPosition, subscriptionPositionFromInventory)+import Keiro.ReadModel.Rebuild.Group+  ( ProjectionWriteFence (..),+    RebuildRunId,+    lockProjectionGroupsTx,+  ) import Keiro.Stream (Stream) import Keiro.Telemetry (KeiroMetrics) import Keiro.Telemetry qualified as Telemetry import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource) import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Subscription+  ( SubscriptionName (..),+    subscriptionCheckpointInventory,+  ) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (EventId (..), GlobalPosition (..), RecordedEvent) 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.-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' — 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)- -- | The database-visible result of one asynchronous projection attempt. data AsyncApplyOutcome   = AsyncApplied@@ -91,10 +115,48 @@   | AsyncFenced   deriving stock (Generic, Eq, Show) +-- | Result of a catalog-fenced inline command. A fenced result proves the+-- append transaction was rolled back, so neither its events nor any projection+-- SQL committed.+data ProjectionCommandOutcome target+  = ProjectionCommandApplied !(CommandResult target)+  | ProjectionCommandFenced !RebuildGroupId !RebuildRunId+  | ProjectionCommandGroupUnregistered !RebuildGroupId+  | ProjectionCommandServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId+  | ProjectionCommandServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text+  | ProjectionCommandCatalogMismatch !SourceId+  deriving stock (Generic, Eq, Show)++-- | Outcome-aware counterpart to 'ProjectionCommandOutcome'. Successful+-- selected silent decisions are applied outcomes even though no projection+-- handler ran. Fence outcomes prove an accepted append was rolled back.+data DomainProjectionCommandOutcome target co rejection noOp+  = DomainProjectionCommandApplied !(DomainCommandOutcome target co rejection noOp)+  | DomainProjectionCommandFenced !RebuildGroupId !RebuildRunId+  | DomainProjectionCommandGroupUnregistered !RebuildGroupId+  | DomainProjectionCommandServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId+  | DomainProjectionCommandServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text+  | DomainProjectionCommandCatalogMismatch !SourceId+  deriving stock (Generic, Eq, Show)++-- | Catalog-aware result of one asynchronous projection application.+data CatalogAsyncApplyOutcome+  = CatalogAsyncApplied+  | CatalogAsyncDuplicate+  | CatalogAsyncFenced !RebuildGroupId !RebuildRunId+  | CatalogAsyncGroupUnregistered !RebuildGroupId+  | CatalogAsyncServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId+  | CatalogAsyncServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text+  | CatalogAsyncProjectionUnknown !ProjectionId+  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.+--+-- This compatibility runner does not consult catalog rebuild-group fences. New+-- managed callers should use 'runCommandWithCatalogProjections'. 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) =>@@ -122,6 +184,165 @@       )   pure (fmap Prelude.fst result) +-- | Run a domain command and apply every supplied inline projection to an+-- accepted event batch in the append transaction. Typed rejection and no-op+-- decisions return directly without invoking any projection.+runDomainCommandWithProjections ::+  forall phi rs s ci co rejection noOp es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  [InlineProjection co] ->+  Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp))+runDomainCommandWithProjections options handler targetStream command projections = do+  outcome <-+    runDomainCommandWithSqlEvents+      options+      handler+      targetStream+      command+      ( \pairs _appendResult ->+          traverse_+            ( \projection ->+                traverse_+                  (\(event, recorded) -> (projection ^. #apply) event recorded)+                  pairs+            )+            projections+      )+  pure (fmap Prelude.fst outcome)++-- | Run a command through the typed source view derived from one validated+-- catalog. Every distinct rebuild group is locked in stable ID order inside the+-- append transaction before any projection handler runs. A rebuilding or failed+-- group condemns that transaction and returns a typed fence outcome.+runCommandWithCatalogProjections ::+  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 ->+  ValidatedProjectionCatalog ->+  ProjectionSet co ->+  Eff es (Either CommandError (ProjectionCommandOutcome (EventStream phi rs s ci co)))+runCommandWithCatalogProjections options eventStream targetStream command catalog projectionSet = do+  if Prelude.null groups+    then pure (Right (ProjectionCommandCatalogMismatch (projectionSet ^. #projectionSource)))+    else do+      outcome <-+        runCommandWithSqlEventsControlled+          options+          eventStream+          targetStream+          command+          (\pairs _appendResult -> applyCatalogProjectionsTx catalog projectionSet groups pairs)+      pure (fmap toProjectionOutcome outcome)+  where+    groups = typedProjectionRebuildGroups catalog projectionSet++    toProjectionOutcome = \case+      SqlCommandNoOp result -> ProjectionCommandApplied result+      SqlCommandCommitted result _ -> ProjectionCommandApplied result+      SqlCommandRolledBack fence -> fenceOutcome fence++    fenceOutcome = \case+      ProjectionWritesAllowed _ ->+        error "runCommandWithCatalogProjections: rolled back with writes allowed"+      ProjectionWriteFenced groupId runId -> ProjectionCommandFenced groupId runId+      ProjectionWriteGroupUnregistered groupId -> ProjectionCommandGroupUnregistered groupId+      ProjectionServingRevisionUnavailable groupId revisionId ->+        ProjectionCommandServingRevisionUnavailable groupId revisionId+      ProjectionServingBindingInvalid groupId revisionId detail ->+        ProjectionCommandServingBindingInvalid groupId revisionId detail++-- | Domain-aware catalog projection runner. Accepted commands retain existing+-- catalog fence semantics. Typed rejection/no-op decisions invoke neither the+-- fence transaction nor projection handlers and carry no fabricated projection+-- result.+runDomainCommandWithCatalogProjections ::+  forall phi rs s ci co rejection noOp es.+  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  DomainCommandHandler phi rs s ci co rejection noOp ->+  Stream (EventStream phi rs s ci co) ->+  ci ->+  ValidatedProjectionCatalog ->+  ProjectionSet co ->+  Eff es (Either CommandError (DomainProjectionCommandOutcome (EventStream phi rs s ci co) co rejection noOp))+runDomainCommandWithCatalogProjections options handler targetStream command catalog projectionSet = do+  if Prelude.null groups+    then pure (Right (DomainProjectionCommandCatalogMismatch (projectionSet ^. #projectionSource)))+    else do+      outcome <-+        runDomainCommandWithSqlEventsControlled+          options+          handler+          targetStream+          command+          (\pairs _appendResult -> applyCatalogProjectionsTx catalog projectionSet groups pairs)+      pure (fmap toProjectionOutcome outcome)+  where+    groups = typedProjectionRebuildGroups catalog projectionSet++    toProjectionOutcome = \case+      DomainSqlCommandSilent outcome -> DomainProjectionCommandApplied outcome+      DomainSqlCommandCommitted outcome _ -> DomainProjectionCommandApplied outcome+      DomainSqlCommandRolledBack fence -> fenceOutcome fence++    fenceOutcome = \case+      ProjectionWritesAllowed _ ->+        error "runDomainCommandWithCatalogProjections: rolled back with writes allowed"+      ProjectionWriteFenced groupId runId -> DomainProjectionCommandFenced groupId runId+      ProjectionWriteGroupUnregistered groupId -> DomainProjectionCommandGroupUnregistered groupId+      ProjectionServingRevisionUnavailable groupId revisionId ->+        DomainProjectionCommandServingRevisionUnavailable groupId revisionId+      ProjectionServingBindingInvalid groupId revisionId detail ->+        DomainProjectionCommandServingBindingInvalid groupId revisionId detail++applyCatalogProjectionsTx ::+  ValidatedProjectionCatalog ->+  ProjectionSet co ->+  [RebuildGroupId] ->+  [(co, RecordedEvent)] ->+  Tx.Transaction (SqlTransactionDecision ProjectionWriteFence)+applyCatalogProjectionsTx catalog projectionSet groups pairs = do+  fence <- lockProjectionGroupsTx catalog groups+  case fence of+    ProjectionWritesAllowed bindings -> do+      traverse_ applyBinding bindings+      pure (CommitSqlTransaction fence)+    _ -> pure (RollbackSqlTransaction fence)+  where+    applyBinding binding =+      case binding ^. #writeRevisionId of+        Nothing ->+          traverse_+            ( \projection ->+                traverse_+                  (\(event, recorded) -> (projection ^. #apply) event recorded)+                  pairs+            )+            (typedInlineProjectionsForGroup catalog projectionSet (binding ^. #writeGroupId))+        Just revisionId ->+          traverse_+            ( \wantedDelivery ->+                case catalogRevisionLiveHandler catalog revisionId wantedDelivery of+                  Nothing -> error "applyCatalogProjectionsTx: validated revision delivery disappeared"+                  Just handler ->+                    traverse_+                      (\(_, recorded) -> (handler ^. #runRevisionLive) (binding ^. #writePhysicalTargets) recorded)+                      pairs+            )+            [ RevisionInlineDelivery (definition ^. #projectionId) (projection ^. #name)+            | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions),+              definition ^. #rebuildGroup == binding ^. #writeGroupId,+              handler <- NonEmpty.toList (definition ^. #handlers),+              InlineHandler projection _ <- [handler]+            ]+ -- | Apply one event to a live 'AsyncProjection', returning a distinct outcome -- for a successful application, a retained dedup key, or a rebuild fence. --@@ -132,6 +353,9 @@ -- retry after promotion. Ack-coupled Kiroku delivery preserves the checkpoint -- when its handler does not acknowledge success. --+-- This compatibility path consults only the legacy single-read-model registry.+-- Catalog-managed workers should use 'applyAsyncProjectionFromCatalog'.+-- -- 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@@ -148,6 +372,84 @@     Just "live" -> applyAsyncProjectionUnfenced projection recorded     _ -> pure AsyncFenced +-- | Apply one async handler through its validated catalog identity and the same+-- rebuild-group row lock used by inline commands and rebuild preparation.+-- Fenced outcomes perform no dedup insert or target write; an ack-coupled worker+-- must therefore leave its subscription checkpoint unchanged.+applyAsyncProjectionFromCatalog ::+  ValidatedProjectionCatalog ->+  ProjectionId ->+  AsyncProjection ->+  RecordedEvent ->+  Tx.Transaction CatalogAsyncApplyOutcome+applyAsyncProjectionFromCatalog catalog projectionId projection recorded =+  case List.find matchesRegistration (asyncProjectionRegistrations catalog) of+    Nothing -> pure (CatalogAsyncProjectionUnknown projectionId)+    Just registration -> do+      let groupId =+            fromMaybe+              (error "applyAsyncProjectionFromCatalog: validated async registration has no projection group")+              (asyncProjectionRebuildGroup catalog projectionId (projection ^. #name))+      fence <- lockProjectionGroupsTx catalog [groupId]+      case fence of+        ProjectionWritesAllowed [binding] -> do+          outcome <-+            case binding ^. #writeRevisionId of+              Nothing -> applyAsyncProjectionUnfenced projection recorded+              Just revisionId ->+                case catalogRevisionLiveHandler+                  catalog+                  revisionId+                  ( RevisionSubscriptionDelivery+                      projectionId+                      (registration ^. #subscriptionId)+                      (registration ^. #dedupKeyId)+                  ) of+                  Nothing ->+                    error "applyAsyncProjectionFromCatalog: validated revision delivery disappeared"+                  Just handler ->+                    applyRevisionAsyncProjectionUnfenced+                      projection+                      (binding ^. #writePhysicalTargets)+                      handler+                      recorded+          pure $ case outcome of+            AsyncApplied -> CatalogAsyncApplied+            AsyncDuplicate -> CatalogAsyncDuplicate+            AsyncFenced ->+              error "applyAsyncProjectionUnfenced returned a fenced outcome"+        ProjectionWritesAllowed _ ->+          error "applyAsyncProjectionFromCatalog: one group lock returned an unexpected binding set"+        ProjectionWriteFenced fencedGroup runId ->+          pure (CatalogAsyncFenced fencedGroup runId)+        ProjectionWriteGroupUnregistered missingGroup ->+          pure (CatalogAsyncGroupUnregistered missingGroup)+        ProjectionServingRevisionUnavailable missingGroup revisionId ->+          pure (CatalogAsyncServingRevisionUnavailable missingGroup revisionId)+        ProjectionServingBindingInvalid invalidGroup revisionId detail ->+          pure (CatalogAsyncServingBindingInvalid invalidGroup revisionId detail)+  where+    matchesRegistration registration =+      registration ^. #projectionId == projectionId+        Prelude.&& registration ^. #projectionName == projection ^. #name++applyRevisionAsyncProjectionUnfenced ::+  AsyncProjection ->+  PhysicalTargets ->+  RevisionLiveHandler ->+  RecordedEvent ->+  Tx.Transaction AsyncApplyOutcome+applyRevisionAsyncProjectionUnfenced projection physicalTargets handler recorded = do+  inserted <-+    Tx.statement+      (projection ^. #name, eventIdToUuid ((projection ^. #idempotencyKey) recorded))+      insertProjectionDedupStmt+  if inserted+    then do+      (handler ^. #runRevisionLive) physicalTargets recorded+      pure AsyncApplied+    else pure AsyncDuplicate+ -- | Apply one event without consulting the read-model registry fence. -- -- This is exclusively the rebuild replay entry point: it retains normal dedup@@ -177,30 +479,69 @@   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.+-- | Count one projection's dedup rows older than a timestamp. This is the+-- read-only operator preview for 'pruneAsyncProjectionDedupForBefore'.+countAsyncProjectionDedupForBefore ::+  (Store :> es) =>+  Text ->+  UTCTime ->+  Eff es Int64+countAsyncProjectionDedupForBefore projectionName cutoff =+  runTransaction+    $ Tx.statement (projectionName, cutoff) countProjectionDedupForBeforeStmt++-- | Age out one named projection's dedup rows older than the supplied+-- timestamp. Scoping the mutation keeps unrelated projection redelivery+-- windows independent.+pruneAsyncProjectionDedupForBefore ::+  (Store :> es) =>+  Text ->+  UTCTime ->+  Eff es Int64+pruneAsyncProjectionDedupForBefore projectionName cutoff =+  runTransaction+    $ Tx.statement (projectionName, cutoff) pruneProjectionDedupForBeforeStmt++-- | Record the non-negative global position distance between the newest visible+-- event and the slowest durable member checkpoint for one async projection. A+-- global position is an opaque cursor, so this is not an exact count of relevant+-- events for filtered, category, or sharded consumers. Tail hard-deletion leaves+-- a caught-up projection at distance zero because an authoritative append+-- counter that includes deleted events is not actionable backlog. -- -- 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.+-- calls once per drain pass after applying a batch. The preferred and legacy+-- gauges record the same value during the 0.11 compatibility interval.+recordProjectionGlobalPositionDistance ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  AsyncProjection ->+  Eff es ()+recordProjectionGlobalPositionDistance metrics projection = do+  inventory <- subscriptionCheckpointInventory+  visibleHead <- storeHeadPosition+  let checkpoint =+        fromMaybe (GlobalPosition 0)+          $ subscriptionPositionFromInventory+            (SubscriptionName (projection ^. #subscriptionName))+            inventory+      distance = globalPositionDistance visibleHead checkpoint+  Telemetry.recordProjectionGlobalPositionDistance metrics distance+  Telemetry.recordProjectionLag metrics distance++-- | Deprecated compatibility name for 'recordProjectionGlobalPositionDistance'.+{-# DEPRECATED recordProjectionLag "Use recordProjectionGlobalPositionDistance; the value is a global position distance, not an event count." #-} recordProjectionLag ::   (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)+recordProjectionLag = recordProjectionGlobalPositionDistance --- | The non-negative gap between the log head and a checkpoint, in events.-positionGap :: GlobalPosition -> GlobalPosition -> Int64-positionGap (GlobalPosition headP) (GlobalPosition checkP) = max 0 (headP Prelude.- checkP)+-- | The non-negative distance between two opaque global positions.+globalPositionDistance :: GlobalPosition -> GlobalPosition -> Int64+globalPositionDistance (GlobalPosition headP) (GlobalPosition checkP) = max 0 (headP Prelude.- checkP)  insertProjectionDedupStmt :: Statement (Text, UUID) Bool insertProjectionDedupStmt =@@ -236,6 +577,35 @@     WHERE applied_at < $1     """     (E.param (E.nonNullable E.timestamptz))+    D.rowsAffected++countProjectionDedupForBeforeStmt :: Statement (Text, UTCTime) Int64+countProjectionDedupForBeforeStmt =+  preparable+    """+    SELECT count(*)::bigint+    FROM keiro.keiro_projection_dedup+    WHERE projection_name = $1+      AND applied_at < $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )+    (D.singleRow (D.column (D.nonNullable D.int8)))++pruneProjectionDedupForBeforeStmt :: Statement (Text, UTCTime) Int64+pruneProjectionDedupForBeforeStmt =+  preparable+    """+    DELETE FROM keiro.keiro_projection_dedup+    WHERE projection_name = $1+      AND applied_at < $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.timestamptz))+    )     D.rowsAffected  eventIdToUuid :: EventId -> UUID
+ src/Keiro/Projection/Catalog.hs view
@@ -0,0 +1,3742 @@+-- | A typed, closed-world declaration of a service's projection fleet.+--+-- A catalog keeps four identities separate:+--+-- * a query model is the typed read contract exposed to callers;+-- * a target is one application-owned PostgreSQL table;+-- * a rebuild group is the set of targets that move through one lifecycle;+-- * a projection is one ordered owner of one or more targets.+--+-- Construct a 'ProjectionCatalog', validate it with+-- 'validateProjectionCatalog', and pass only the resulting+-- 'ValidatedProjectionCatalog' to registration, replay, or operational code.+-- Validation is pure and accumulates deterministic diagnostics. It proves the+-- relationships declared in the supplied catalog; it cannot inspect arbitrary+-- SQL handlers or discover application tables that were not declared.+module Keiro.Projection.Catalog+  ( -- * Validated identities+    ProjectionId,+    TargetId,+    RebuildGroupId,+    SourceId,+    QueryModelId,+    SubscriptionId,+    DedupKeyId,+    ProjectionRevisionId,+    ExternalReadContractId,+    ExternalReadContractVersion (..),+    TargetGenerationId (..),+    TargetSchemaVersion (..),+    ClaimSite,+    CatalogIdentityError (..),+    mkProjectionId,+    mkTargetId,+    mkRebuildGroupId,+    mkSourceId,+    mkQueryModelId,+    mkSubscriptionId,+    mkDedupKeyId,+    mkProjectionRevisionId,+    mkExternalReadContractId,+    mkClaimSite,+    projectionIdText,+    targetIdText,+    rebuildGroupIdText,+    sourceIdText,+    queryModelIdText,+    subscriptionIdText,+    dedupKeyIdText,+    projectionRevisionIdText,+    externalReadContractIdText,+    externalReadContractVersionValue,+    claimSiteText,++    -- * Declarations+    QualifiedTable (..),+    PhysicalTargets,+    PhysicalTargetMapError (..),+    mkPhysicalTargets,+    physicalTargetMap,+    resolvePhysicalTarget,+    PromotionObjectKind (..),+    PromotionObjectName (..),+    TargetSchemaViolation (..),+    TargetSchemaEvidence (..),+    TargetProvisioningContext (..),+    TargetProvisioner (..),+    RevisionLiveDelivery (..),+    RevisionLiveHandler (..),+    RevisionReplayAdapter (..),+    RevisionVerification (..),+    StreamClearCount (..),+    StreamScopedReplay (..),+    ProjectionRevision (..),+    QualifiedFunction (..),+    QualifiedSqlType (..),+    SqlFunctionArgument (..),+    ExternalReadContract (..),+    externalReadFunctionName,+    TargetResetPolicy (..),+    TargetDeclaration (..),+    RebuildVerification (..),+    RebuildGroupDeclaration (..),+    SourceScope (..),+    SourceDeclaration (..),+    SubscriptionDeclaration (..),+    missingCheckpointPolicyText,+    DedupKeyDeclaration (..),+    QueryModelBinding (..),+    SomeQueryModelBinding (..),+    LiveOnlyReason (..),+    ReplayDecodeError (..),+    ReplayDecodeResult (..),+    ReplayAdapter (..),+    replayAdapterFromCodec,+    ProjectionReplayPolicy (..),+    ProjectionHandler (..),+    ProjectionDefinition (..),+    ProjectionSet (..),+    SomeProjectionSet (..),+    ProjectionCatalog (..),+    emptyProjectionCatalog,++    -- * Validation+    Validation (..),+    CatalogDiagnosticCode (..),+    CatalogDiagnostic (..),+    diagnosticCodeText,+    ValidatedProjectionCatalog,+    validateProjectionCatalog,+    useProjectionCatalog,+    useProjectionCatalogM,++    -- * Derived views+    CatalogInventory (..),+    InventorySource (..),+    InventoryTarget (..),+    InventoryGroup (..),+    InventoryProjection (..),+    InventoryQueryModel (..),+    InventoryQueryFreshness (..),+    InventoryQueryCursor (..),+    InventorySubscription (..),+    InventoryDedupKey (..),+    InventoryHandler (..),+    InventoryTargetProvisioner (..),+    InventoryRevisionHandler (..),+    InventoryStreamScopedReplay (..),+    InventoryProjectionRevision (..),+    ExternalReadContractKind (..),+    InventoryExternalReadContract (..),+    ProjectionHandlerCapability (..),+    ResolvedQuerySupply (..),+    CatalogFingerprint,+    catalogFingerprintText,+    GroupSliceFingerprint,+    groupSliceFingerprintText,+    CatalogEvolution (..),+    CatalogRegistration (..),+    AsyncProjectionRegistration (..),+    CatalogAsyncDedupSpec (..),+    ReplayAdapterMetadata (..),+    CatalogReplayAdapter,+    catalogReplayAdapterProjectionId,+    catalogReplayAdapterSourceId,+    catalogReplayAdapterGroupId,+    catalogReplayAdapterOrder,+    runCatalogReplayAdapter,+    typedInlineProjections,+    typedInlineProjectionsForGroup,+    typedProjectionRebuildGroups,+    asyncProjectionRebuildGroup,+    resolvedQuerySupplies,+    catalogInventory,+    catalogFingerprint,+    groupSliceFingerprint,+    catalogRegistrations,+    catalogProjectionRevisions,+    catalogProjectionRevision,+    catalogRevisionLiveHandler,+    catalogStreamScopedReplay,+    catalogExternalReadContracts,+    asyncProjectionRegistrations,+    catalogAsyncIdempotencyKeys,+    replayAdapterMetadata,+    catalogReplayAdapters,+    catalogRebuildVerifications,+    renderCatalogInventory,+    compareCatalogBaseline,++    -- * Explicit unmanaged compatibility boundary+    UnmanagedInlineProjections,+    unmanagedInlineProjections,+    getUnmanagedInlineProjections,+    UnmanagedAsyncProjection,+    unmanagedAsyncProjection,+    getUnmanagedAsyncProjection,+    UnmanagedReadModel,+    unmanagedReadModel,+    getUnmanagedReadModel,+  )+where++import Data.Graph (SCC (..), stronglyConnComp)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.UUID (UUID)+import Hasql.Transaction qualified as Tx+import Keiro.Codec (Codec (..), decodeRecorded)+import Keiro.Prelude+import Keiro.Projection.Catalog.Preimage (Preimage (..), hashPreimage)+import Keiro.Projection.Types (AsyncProjection, InlineProjection)+import Keiro.ReadModel+  ( HeadScope (..),+    QueryFreshness (..),+    ReadModel,+    readModelDefaultFreshness,+  )+import Kiroku.Store.Subscription.Types (MissingCheckpointPolicy (..))+import Kiroku.Store.Types (CategoryName (..), EventId, RecordedEvent, StreamName)++newtype ProjectionId = ProjectionId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype TargetId = TargetId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype RebuildGroupId = RebuildGroupId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype SourceId = SourceId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype QueryModelId = QueryModelId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype SubscriptionId = SubscriptionId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype DedupKeyId = DedupKeyId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype ProjectionRevisionId = ProjectionRevisionId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype ExternalReadContractId = ExternalReadContractId Text+  deriving stock (Eq, Ord, Show, Generic)++newtype ExternalReadContractVersion = ExternalReadContractVersion Int+  deriving stock (Eq, Ord, Show, Generic)++newtype TargetGenerationId = TargetGenerationId UUID+  deriving stock (Eq, Ord, Show, Generic)++newtype TargetSchemaVersion = TargetSchemaVersion Text+  deriving stock (Eq, Ord, Show, Generic)++newtype ClaimSite = ClaimSite Text+  deriving stock (Eq, Ord, Show, Generic)++-- | A stable catalog identity was empty or contained surrounding whitespace.+data CatalogIdentityError+  = EmptyCatalogIdentity+  | CatalogIdentityHasSurroundingWhitespace !Text+  deriving stock (Eq, Ord, Show, Generic)++mkProjectionId :: Text -> Either CatalogIdentityError ProjectionId+mkProjectionId = mkIdentity ProjectionId++mkTargetId :: Text -> Either CatalogIdentityError TargetId+mkTargetId = mkIdentity TargetId++mkRebuildGroupId :: Text -> Either CatalogIdentityError RebuildGroupId+mkRebuildGroupId = mkIdentity RebuildGroupId++mkSourceId :: Text -> Either CatalogIdentityError SourceId+mkSourceId = mkIdentity SourceId++mkQueryModelId :: Text -> Either CatalogIdentityError QueryModelId+mkQueryModelId = mkIdentity QueryModelId++mkSubscriptionId :: Text -> Either CatalogIdentityError SubscriptionId+mkSubscriptionId = mkIdentity SubscriptionId++mkDedupKeyId :: Text -> Either CatalogIdentityError DedupKeyId+mkDedupKeyId = mkIdentity DedupKeyId++mkProjectionRevisionId :: Text -> Either CatalogIdentityError ProjectionRevisionId+mkProjectionRevisionId = mkIdentity ProjectionRevisionId++mkExternalReadContractId :: Text -> Either CatalogIdentityError ExternalReadContractId+mkExternalReadContractId = mkIdentity ExternalReadContractId++mkClaimSite :: Text -> Either CatalogIdentityError ClaimSite+mkClaimSite = mkIdentity ClaimSite++projectionIdText :: ProjectionId -> Text+projectionIdText (ProjectionId value) = value++targetIdText :: TargetId -> Text+targetIdText (TargetId value) = value++rebuildGroupIdText :: RebuildGroupId -> Text+rebuildGroupIdText (RebuildGroupId value) = value++sourceIdText :: SourceId -> Text+sourceIdText (SourceId value) = value++queryModelIdText :: QueryModelId -> Text+queryModelIdText (QueryModelId value) = value++subscriptionIdText :: SubscriptionId -> Text+subscriptionIdText (SubscriptionId value) = value++dedupKeyIdText :: DedupKeyId -> Text+dedupKeyIdText (DedupKeyId value) = value++projectionRevisionIdText :: ProjectionRevisionId -> Text+projectionRevisionIdText (ProjectionRevisionId value) = value++externalReadContractIdText :: ExternalReadContractId -> Text+externalReadContractIdText (ExternalReadContractId value) = value++externalReadContractVersionValue :: ExternalReadContractVersion -> Int+externalReadContractVersionValue (ExternalReadContractVersion value) = value++claimSiteText :: ClaimSite -> Text+claimSiteText (ClaimSite value) = value++mkIdentity :: (Text -> identity) -> Text -> Either CatalogIdentityError identity+mkIdentity constructor value+  | Text.null value = Left EmptyCatalogIdentity+  | Text.strip value /= value = Left (CatalogIdentityHasSurroundingWhitespace value)+  | otherwise = Right (constructor value)++-- | An application-owned PostgreSQL table. Keiro treats both parts as opaque+-- identifiers and quotes them when later plans execute lifecycle SQL.+data QualifiedTable = QualifiedTable+  { schemaName :: !Text,+    tableName :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | A complete logical-target to physical-table binding supplied to every+-- revision handler. Construction is closed-world: missing and unexpected+-- targets are reported together before application SQL can run.+newtype PhysicalTargets = PhysicalTargets (Map TargetId QualifiedTable)+  deriving stock (Eq, Ord, Show, Generic)++data PhysicalTargetMapError+  = MissingPhysicalTarget !TargetId+  | UnexpectedPhysicalTarget !TargetId+  deriving stock (Eq, Ord, Show, Generic)++mkPhysicalTargets :: [TargetId] -> Map TargetId QualifiedTable -> Either (NonEmpty PhysicalTargetMapError) PhysicalTargets+mkPhysicalTargets expected supplied =+  case NonEmpty.nonEmpty errors of+    Nothing -> Right (PhysicalTargets supplied)+    Just failures -> Left failures+  where+    expectedSet = Set.fromList expected+    suppliedSet = Map.keysSet supplied+    errors =+      [ MissingPhysicalTarget targetId+      | targetId <- Set.toAscList (expectedSet `Set.difference` suppliedSet)+      ]+        <> [ UnexpectedPhysicalTarget targetId+           | targetId <- Set.toAscList (suppliedSet `Set.difference` expectedSet)+           ]++physicalTargetMap :: PhysicalTargets -> Map TargetId QualifiedTable+physicalTargetMap (PhysicalTargets targets) = targets++resolvePhysicalTarget :: TargetId -> PhysicalTargets -> Maybe QualifiedTable+resolvePhysicalTarget targetId (PhysicalTargets targets) = Map.lookup targetId targets++data PromotionObjectKind+  = PromotionIndex+  | PromotionConstraint+  | PromotionOwnedSequence+  deriving stock (Eq, Ord, Show, Generic)++-- | One generation-local object name and the canonical serving name it must+-- receive during promotion. Declaration order is durable cutover identity.+data PromotionObjectName = PromotionObjectName+  { objectKind :: !PromotionObjectKind,+    generationName :: !Text,+    canonicalName :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++data TargetSchemaViolation = TargetSchemaViolation+  { violationCode :: !Text,+    violationDetail :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | PostgreSQL evidence captured after provisioning and compared again under+-- cutover locks. The catalog snapshot is canonically rendered by the+-- application validator and intentionally remains opaque to Keiro.+data TargetSchemaEvidence = TargetSchemaEvidence+  { relationOid :: !Int64,+    observedShapeFingerprint :: !Text,+    observedPromotionObjects :: ![PromotionObjectName],+    catalogSnapshot :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++data TargetProvisioningContext = TargetProvisioningContext+  { targetId :: !TargetId,+    generationId :: !TargetGenerationId,+    servingTable :: !QualifiedTable,+    stagingTable :: !QualifiedTable+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | Application-owned schema provisioning and validation for one logical+-- target under one projection revision. A missing validator is representable+-- only so closed-world catalog validation can produce a stable diagnostic.+data TargetProvisioner = TargetProvisioner+  { provisionerId :: !Text,+    provisionerVersion :: !Int,+    schemaVersion :: !TargetSchemaVersion,+    expectedShapeId :: !Text,+    provisionTarget :: !(TargetProvisioningContext -> Tx.Transaction ()),+    validatorId :: !Text,+    validatorVersion :: !Int,+    validateTarget :: !(Maybe (TargetProvisioningContext -> Tx.Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence))),+    promotionObjectNames :: ![PromotionObjectName]+  }+  deriving stock (Generic)++-- | The exact catalog delivery boundary implemented by one revision handler.+-- Revision selection may change physical SQL, but it must not turn an async+-- subscription effect into command-time work (or vice versa).+data RevisionLiveDelivery+  = RevisionInlineDelivery !ProjectionId !Text+  | RevisionSubscriptionDelivery !ProjectionId !SubscriptionId !DedupKeyId+  deriving stock (Eq, Ord, Show, Generic)++data RevisionLiveHandler = RevisionLiveHandler+  { handlerId :: !Text,+    handlerVersion :: !Int,+    delivery :: !RevisionLiveDelivery,+    requiredTargets :: ![TargetId],+    runRevisionLive :: !(PhysicalTargets -> RecordedEvent -> Tx.Transaction ())+  }+  deriving stock (Generic)++data RevisionReplayAdapter = RevisionReplayAdapter+  { adapterId :: !Text,+    adapterVersion :: !Int,+    requiredTargets :: ![TargetId],+    runRevisionReplay :: !(PhysicalTargets -> RecordedEvent -> Tx.Transaction (Either ReplayDecodeError Bool))+  }+  deriving stock (Generic)++data RevisionVerification = RevisionVerification+  { revisionVerificationId :: !Text,+    revisionVerificationVersion :: !Int,+    requiredTargets :: ![TargetId],+    runRevisionVerification :: !(PhysicalTargets -> Tx.Transaction (Either Text ()))+  }+  deriving stock (Generic)++-- | One target row-count observation returned by a stream-scoped clearer.+-- The runner requires exactly the targets declared by the policy so previews+-- and outcomes cannot silently omit a physical target.+data StreamClearCount = StreamClearCount+  { targetId :: !TargetId,+    clearedRows :: !Int64+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | Application-owned row-per-stream repair policy for one projection under a+-- projection revision. Keiro owns the transaction, locks, history admission,+-- ordering, and deduplication backfill; the application owns row selection,+-- event decoding/application, and semantic verification.+data StreamScopedReplay = StreamScopedReplay+  { streamProjectionId :: !ProjectionId,+    streamOwnedTargets :: !(NonEmpty TargetId),+    clearerId :: !Text,+    clearerVersion :: !Int,+    clearStreamRows :: !(PhysicalTargets -> StreamName -> Tx.Transaction (Either Text [StreamClearCount])),+    streamReplayId :: !Text,+    streamReplayVersion :: !Int,+    replayStreamEvent :: !(PhysicalTargets -> RecordedEvent -> Tx.Transaction (Either ReplayDecodeError Bool)),+    streamVerificationId :: !Text,+    streamVerificationVersion :: !Int,+    verifyStreamRows :: !(PhysicalTargets -> StreamName -> Tx.Transaction (Either Text ())),+    affectedAsyncDedup :: ![DedupKeyId],+    claimSite :: !ClaimSite+  }+  deriving stock (Generic)++data ProjectionRevision = ProjectionRevision+  { revisionId :: !ProjectionRevisionId,+    rebuildGroup :: !RebuildGroupId,+    targetProvisioners :: !(Map TargetId TargetProvisioner),+    liveHandlers :: ![RevisionLiveHandler],+    replayAdapters :: ![RevisionReplayAdapter],+    revisionVerifications :: ![RevisionVerification],+    streamScopedReplays :: ![StreamScopedReplay],+    claimSite :: !ClaimSite+  }+  deriving stock (Generic)++-- | A fully qualified application-owned PostgreSQL function. Keiro quotes both+-- identifiers when generating a keyed wrapper and never accepts raw SQL here.+data QualifiedFunction = QualifiedFunction+  { functionSchema :: !Text,+    functionName :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | A fully qualified PostgreSQL type used in a public function signature.+-- Keeping schema and type names separate lets the SQL generator quote them+-- independently instead of interpolating an unchecked type expression.+data QualifiedSqlType = QualifiedSqlType+  { typeSchema :: !Text,+    typeName :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++data SqlFunctionArgument = SqlFunctionArgument+  { argumentName :: !Text,+    argumentType :: !QualifiedSqlType+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | One versioned, privilege-enforced SQL read surface. The public function+-- name is derived from the contract identity and version so it cannot drift+-- from catalog identity. Keyed implementations remain application-owned;+-- Keiro owns only the guarded outer wrapper.+data ExternalReadContract+  = AllRowsExternalRead+      { readContractId :: !ExternalReadContractId,+        contractVersion :: !ExternalReadContractVersion,+        queryModelId :: !QueryModelId,+        resultContractType :: !QualifiedSqlType,+        resultShapeHash :: !Text,+        compatibleRevisions :: !(NonEmpty ProjectionRevisionId),+        surfaceGeneration :: !Int,+        claimSite :: !ClaimSite+      }+  | KeyedExternalRead+      { readContractId :: !ExternalReadContractId,+        contractVersion :: !ExternalReadContractVersion,+        queryModelId :: !QueryModelId,+        arguments :: ![SqlFunctionArgument],+        resultContractType :: !QualifiedSqlType,+        privateImplementation :: !QualifiedFunction,+        privateImplementationVersion :: !Int,+        resultShapeHash :: !Text,+        compatibleRevisions :: !(NonEmpty ProjectionRevisionId),+        surfaceGeneration :: !Int,+        claimSite :: !ClaimSite+      }+  deriving stock (Eq, Ord, Show, Generic)++-- | Stable public function name in @keiro_read@. Contract identifiers are+-- validated as lower-case SQL identifiers before this value reaches SQL.+externalReadFunctionName :: ExternalReadContract -> Text+externalReadFunctionName contract =+  externalReadContractIdText (contract ^. #readContractId)+    <> "_v"+    <> Text.pack (show (externalReadContractVersionValue (contract ^. #contractVersion)))++-- | How a target is prepared before replay. This is deliberately independent+-- from whether a projection handler is replay-safe.+data TargetResetPolicy+  = ClearBeforeReplay+  | PreserveAndReconcile+  deriving stock (Eq, Ord, Show, Generic)++data TargetDeclaration = TargetDeclaration+  { targetId :: !TargetId,+    qualifiedTable :: !QualifiedTable,+    resetPolicy :: !TargetResetPolicy,+    dependsOn :: ![TargetId],+    claimSite :: !ClaimSite+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | An application-owned, read-only proof run after replay and before+-- promotion. Identity and version are durable parts of the catalog contract;+-- the transaction closure is deliberately excluded from rendered inventory.+data RebuildVerification = RebuildVerification+  { verificationId :: !Text,+    verificationVersion :: !Text,+    verifyRebuild :: !(Tx.Transaction (Either Text ()))+  }+  deriving stock (Generic)++-- | Targets are listed in their declared deterministic preparation order.+-- Validation rejects an empty list, duplicates, unknown targets, and a list+-- inconsistent with target dependencies.+data RebuildGroupDeclaration = RebuildGroupDeclaration+  { rebuildGroupId :: !RebuildGroupId,+    orderedTargets :: ![TargetId],+    verificationHooks :: ![RebuildVerification],+    claimSite :: !ClaimSite+  }+  deriving stock (Generic)++data SourceScope+  = AllStreams+  | CategorySource !CategoryName+  deriving stock (Eq, Ord, Show, Generic)++-- | A source's codec fingerprint is stable application metadata, normally a+-- schema version plus the owning codec/fold identity. Function closures are+-- never included in catalog fingerprints.+data SourceDeclaration = SourceDeclaration+  { sourceId :: !SourceId,+    sourceScope :: !SourceScope,+    codecFingerprint :: !Text,+    claimSite :: !ClaimSite+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | Catalog identity and startup lifecycle for one async subscription.+data SubscriptionDeclaration = SubscriptionDeclaration+  { subscriptionId :: !SubscriptionId,+    subscriptionName :: !Text,+    subscriptionSource :: !SourceId,+    -- | Kiroku policy used only when the exact durable member row is absent.+    checkpointOnMissing :: !MissingCheckpointPolicy,+    claimSite :: !ClaimSite+  }+  deriving stock (Eq, Show, Generic)++instance Ord SubscriptionDeclaration where+  compare left right =+    compare+      ( left ^. #subscriptionId,+        left ^. #subscriptionName,+        left ^. #subscriptionSource,+        missingCheckpointPolicyRank (left ^. #checkpointOnMissing),+        left ^. #claimSite+      )+      ( right ^. #subscriptionId,+        right ^. #subscriptionName,+        right ^. #subscriptionSource,+        missingCheckpointPolicyRank (right ^. #checkpointOnMissing),+        right ^. #claimSite+      )++-- | Stable runtime spelling used by fingerprints and operator reports.+missingCheckpointPolicyText :: MissingCheckpointPolicy -> Text+missingCheckpointPolicyText = \case+  FromBeginning -> "FromBeginning"+  FromCurrentHead -> "FromCurrentHead"+  FailIfMissing -> "FailIfMissing"++missingCheckpointPolicyRank :: MissingCheckpointPolicy -> Int+missingCheckpointPolicyRank = \case+  FromBeginning -> 0+  FromCurrentHead -> 1+  FailIfMissing -> 2++data DedupKeyDeclaration = DedupKeyDeclaration+  { dedupKeyId :: !DedupKeyId,+    dedupName :: !Text,+    claimSite :: !ClaimSite+  }+  deriving stock (Eq, Ord, Show, Generic)++data QueryModelBinding q r = QueryModelBinding+  { queryModelId :: !QueryModelId,+    readModel :: !(ReadModel q r),+    rebuildGroup :: !RebuildGroupId,+    observedTargets :: ![TargetId],+    claimSite :: !ClaimSite+  }+  deriving stock (Generic)++data SomeQueryModelBinding+  = forall q r. SomeQueryModelBinding (QueryModelBinding q r)++newtype LiveOnlyReason = LiveOnlyReason Text+  deriving stock (Eq, Ord, Show, Generic)++newtype ReplayDecodeError = ReplayDecodeError Text+  deriving stock (Eq, Ord, Show, Generic)++-- | A replay decoder is total over every event read from its declared source.+data ReplayDecodeResult event+  = ReplayIrrelevant+  | ReplayRelevant !event+  | ReplayDecodeFailure !ReplayDecodeError+  deriving stock (Eq, Show, Generic)++data ReplayAdapter event = ReplayAdapter+  { decodeForReplay :: !(RecordedEvent -> ReplayDecodeResult event),+    applyForReplay :: !(event -> RecordedEvent -> Tx.Transaction ())+  }+  deriving stock (Generic)++-- | Build a total replay adapter from the same authoritative codec used by the+-- event stream. Event types outside the codec are irrelevant; owned event types+-- that fail decoding are structured replay failures.+replayAdapterFromCodec ::+  Codec event ->+  (event -> RecordedEvent -> Tx.Transaction ()) ->+  ReplayAdapter event+replayAdapterFromCodec codec replayApply =+  ReplayAdapter+    { decodeForReplay = \recorded ->+        if recorded ^. #eventType `List.elem` NonEmpty.toList (codec ^. #eventTypes)+          then case decodeRecorded codec recorded of+            Left err -> ReplayDecodeFailure (ReplayDecodeError (Text.pack (show err)))+            Right event -> ReplayRelevant event+          else ReplayIrrelevant,+      applyForReplay = replayApply+    }++data ProjectionReplayPolicy event+  = Replayable !(ReplayAdapter event)+  | LiveOnly !LiveOnlyReason+  deriving stock (Generic)++-- | One projection definition may contain several explicitly ordered handlers.+-- This is composed ownership: the definition is still the single owner of all+-- its targets.+data ProjectionHandler event+  = InlineHandler !(InlineProjection event) !ClaimSite+  | AsyncHandler !AsyncProjection !SubscriptionId !DedupKeyId !ClaimSite+  deriving stock (Generic)++data ProjectionDefinition event = ProjectionDefinition+  { projectionId :: !ProjectionId,+    rebuildGroup :: !RebuildGroupId,+    ownedTargets :: !(NonEmpty TargetId),+    replayPolicy :: !(ProjectionReplayPolicy event),+    handlers :: !(NonEmpty (ProjectionHandler event)),+    claimSite :: !ClaimSite+  }+  deriving stock (Generic)++-- | A typed handle retained by the application. Passing this same value to+-- 'typedInlineProjections' preserves the event type without 'Typeable' casts.+data ProjectionSet event = ProjectionSet+  { projectionSource :: !SourceId,+    projectionDefinitions :: !(NonEmpty (ProjectionDefinition event)),+    claimSite :: !ClaimSite+  }+  deriving stock (Generic)++data SomeProjectionSet+  = forall event. SomeProjectionSet (ProjectionSet event)++data ProjectionCatalog = ProjectionCatalog+  { sources :: ![SourceDeclaration],+    targets :: ![TargetDeclaration],+    rebuildGroups :: ![RebuildGroupDeclaration],+    projectionRevisions :: ![ProjectionRevision],+    externalReadContracts :: ![ExternalReadContract],+    subscriptions :: ![SubscriptionDeclaration],+    dedupKeys :: ![DedupKeyDeclaration],+    queryModels :: ![SomeQueryModelBinding],+    projectionSets :: ![SomeProjectionSet]+  }+  deriving stock (Generic)++emptyProjectionCatalog :: ProjectionCatalog+emptyProjectionCatalog =+  ProjectionCatalog+    { sources = [],+      targets = [],+      rebuildGroups = [],+      projectionRevisions = [],+      externalReadContracts = [],+      subscriptions = [],+      dedupKeys = [],+      queryModels = [],+      projectionSets = []+    }++data Validation err value+  = Failure !err+  | Success !value+  deriving stock (Eq, Show, Generic)++data CatalogDiagnosticCode+  = DuplicateProjectionId+  | DuplicateProjectionRevisionId+  | DuplicateTargetId+  | DuplicateQualifiedTable+  | DuplicateRebuildGroupId+  | DuplicateSourceId+  | DuplicateQueryModelId+  | DuplicateQueryModelRegistryName+  | EmptyQueryObservedTargets+  | QueryModelWithoutSupplier+  | QueryModelWithMultipleSuppliers+  | QueryWaitWithoutCompatibleCursor+  | QueryWaitWithAmbiguousCursor+  | DuplicateSubscriptionId+  | DuplicateSubscriptionName+  | DuplicateDedupKeyId+  | DuplicateDedupName+  | DuplicateGroupTarget+  | UnknownSourceReference+  | UnknownTargetReference+  | UnknownGroupReference+  | UnknownTargetDependency+  | UnknownSubscriptionReference+  | UnknownDedupKeyReference+  | UnknownQueryModelReference+  | UnknownRevisionReference+  | UnknownTargetProvisioner+  | AsyncHandlerSubscriptionMismatch+  | AsyncHandlerDedupMismatch+  | TargetWithoutOwner+  | TargetWithMultipleOwners+  | ProjectionCrossesRebuildGroups+  | TargetDependencyCycle+  | TargetOrderViolatesDependency+  | EmptyRebuildGroup+  | QueryModelOutsideRebuildGroup+  | ClearTargetRequiresReplayableOwner+  | ReplayableClearTargetStartsAtCurrentHead+  | MixedResetGroupRequiresReplayAdapter+  | AmbiguousSourceOrdering+  | DuplicateRebuildVerificationId+  | InvalidRebuildVerificationIdentity+  | ProjectionRevisionWithoutLiveHandler+  | ProjectionRevisionWithoutReplayAdapter+  | ProjectionRevisionTargetSetDrift+  | ProjectionRevisionMissingSchemaValidation+  | ProjectionRevisionPhysicalTargetsNotTotal+  | ProjectionRevisionLiveCapabilityMismatch+  | ProjectionRevisionLiveTargetOwnershipMismatch+  | InvalidProjectionRevisionIdentity+  | DuplicateExternalReadContractVersion+  | DuplicateExternalReadFunctionName+  | UnknownExternalReadQueryModel+  | ExternalReadShapeMismatch+  | ExternalReadRevisionOwnershipMismatch+  | InvalidExternalReadContractIdentity+  | InvalidExternalReadSqlIdentifier+  | InvalidExternalReadSqlType+  | ExternalReadImplementationCollision+  | ExternalReadSurfaceGenerationRegression+  | ExternalReadImmutableSignatureDrift+  | DuplicateStreamScopedReplayProjection+  | UnknownStreamScopedReplayProjection+  | StreamScopedReplayGroupMismatch+  | StreamScopedReplayTargetSetMismatch+  | StreamScopedReplayDedupMismatch+  | InvalidStreamScopedReplayIdentity+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++diagnosticCodeText :: CatalogDiagnosticCode -> Text+diagnosticCodeText = \case+  DuplicateProjectionId -> "catalog.duplicate-projection-id"+  DuplicateProjectionRevisionId -> "catalog.duplicate-projection-revision-id"+  DuplicateTargetId -> "catalog.duplicate-target-id"+  DuplicateQualifiedTable -> "catalog.duplicate-qualified-table"+  DuplicateRebuildGroupId -> "catalog.duplicate-rebuild-group-id"+  DuplicateSourceId -> "catalog.duplicate-source-id"+  DuplicateQueryModelId -> "catalog.duplicate-query-model-id"+  DuplicateQueryModelRegistryName -> "catalog.duplicate-query-model-registry-name"+  EmptyQueryObservedTargets -> "catalog.query-model-empty-observed-targets"+  QueryModelWithoutSupplier -> "catalog.query-model-without-supplier"+  QueryModelWithMultipleSuppliers -> "catalog.query-model-with-multiple-suppliers"+  QueryWaitWithoutCompatibleCursor -> "catalog.query-wait-without-compatible-cursor"+  QueryWaitWithAmbiguousCursor -> "catalog.query-wait-with-ambiguous-cursor"+  DuplicateSubscriptionId -> "catalog.duplicate-subscription-id"+  DuplicateSubscriptionName -> "catalog.duplicate-subscription-name"+  DuplicateDedupKeyId -> "catalog.duplicate-dedup-key-id"+  DuplicateDedupName -> "catalog.duplicate-dedup-name"+  DuplicateGroupTarget -> "catalog.duplicate-group-target"+  UnknownSourceReference -> "catalog.unknown-source-reference"+  UnknownTargetReference -> "catalog.unknown-target-reference"+  UnknownGroupReference -> "catalog.unknown-group-reference"+  UnknownTargetDependency -> "catalog.unknown-target-dependency"+  UnknownSubscriptionReference -> "catalog.unknown-subscription-reference"+  UnknownDedupKeyReference -> "catalog.unknown-dedup-key-reference"+  UnknownQueryModelReference -> "catalog.unknown-query-model-reference"+  UnknownRevisionReference -> "catalog.unknown-revision-reference"+  UnknownTargetProvisioner -> "catalog.unknown-target-provisioner"+  AsyncHandlerSubscriptionMismatch -> "catalog.async-handler-subscription-mismatch"+  AsyncHandlerDedupMismatch -> "catalog.async-handler-dedup-mismatch"+  TargetWithoutOwner -> "catalog.target-without-owner"+  TargetWithMultipleOwners -> "catalog.target-with-multiple-owners"+  ProjectionCrossesRebuildGroups -> "catalog.projection-crosses-rebuild-groups"+  TargetDependencyCycle -> "catalog.target-dependency-cycle"+  TargetOrderViolatesDependency -> "catalog.target-order-violates-dependency"+  EmptyRebuildGroup -> "catalog.empty-rebuild-group"+  QueryModelOutsideRebuildGroup -> "catalog.query-model-outside-rebuild-group"+  ClearTargetRequiresReplayableOwner -> "catalog.clear-target-requires-replayable-owner"+  ReplayableClearTargetStartsAtCurrentHead -> "catalog.replayable-clear-target-starts-at-current-head"+  MixedResetGroupRequiresReplayAdapter -> "catalog.mixed-reset-group-requires-replay-adapter"+  AmbiguousSourceOrdering -> "catalog.ambiguous-source-ordering"+  DuplicateRebuildVerificationId -> "catalog.duplicate-rebuild-verification-id"+  InvalidRebuildVerificationIdentity -> "catalog.invalid-rebuild-verification-identity"+  ProjectionRevisionWithoutLiveHandler -> "catalog.projection-revision-without-live-handler"+  ProjectionRevisionWithoutReplayAdapter -> "catalog.projection-revision-without-replay-adapter"+  ProjectionRevisionTargetSetDrift -> "catalog.projection-revision-target-set-drift"+  ProjectionRevisionMissingSchemaValidation -> "catalog.projection-revision-missing-schema-validation"+  ProjectionRevisionPhysicalTargetsNotTotal -> "catalog.projection-revision-physical-targets-not-total"+  ProjectionRevisionLiveCapabilityMismatch -> "catalog.projection-revision-live-capability-mismatch"+  ProjectionRevisionLiveTargetOwnershipMismatch -> "catalog.projection-revision-live-target-ownership-mismatch"+  InvalidProjectionRevisionIdentity -> "catalog.invalid-projection-revision-identity"+  DuplicateExternalReadContractVersion -> "catalog.external-read-duplicate-contract-version"+  DuplicateExternalReadFunctionName -> "catalog.external-read-duplicate-function-name"+  UnknownExternalReadQueryModel -> "catalog.external-read-unknown-query-model"+  ExternalReadShapeMismatch -> "catalog.external-read-shape-mismatch"+  ExternalReadRevisionOwnershipMismatch -> "catalog.external-read-revision-ownership-mismatch"+  InvalidExternalReadContractIdentity -> "catalog.external-read-invalid-contract-identity"+  InvalidExternalReadSqlIdentifier -> "catalog.external-read-invalid-sql-identifier"+  InvalidExternalReadSqlType -> "catalog.external-read-invalid-sql-type"+  ExternalReadImplementationCollision -> "catalog.external-read-implementation-collision"+  ExternalReadSurfaceGenerationRegression -> "catalog.external-read-surface-generation-regression"+  ExternalReadImmutableSignatureDrift -> "catalog.external-read-immutable-signature-drift"+  DuplicateStreamScopedReplayProjection -> "catalog.stream-replay-duplicate-projection"+  UnknownStreamScopedReplayProjection -> "catalog.stream-replay-unknown-projection"+  StreamScopedReplayGroupMismatch -> "catalog.stream-replay-group-mismatch"+  StreamScopedReplayTargetSetMismatch -> "catalog.stream-replay-target-set-mismatch"+  StreamScopedReplayDedupMismatch -> "catalog.stream-replay-dedup-mismatch"+  InvalidStreamScopedReplayIdentity -> "catalog.stream-replay-invalid-identity"++data CatalogDiagnostic = CatalogDiagnostic+  { diagnosticCode :: !CatalogDiagnosticCode,+    diagnosticIdentity :: !Text,+    diagnosticSites :: ![ClaimSite],+    diagnosticMessage :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventorySource = InventorySource+  { sourceId :: !SourceId,+    sourceScope :: !SourceScope,+    codecFingerprint :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryTarget = InventoryTarget+  { targetId :: !TargetId,+    qualifiedTable :: !QualifiedTable,+    resetPolicy :: !TargetResetPolicy,+    dependsOn :: ![TargetId],+    owner :: !ProjectionId+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryGroup = InventoryGroup+  { rebuildGroupId :: !RebuildGroupId,+    orderedTargets :: ![TargetId],+    verifications :: ![(Text, Text)]+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryHandler+  = InventoryInlineHandler !Text+  | InventoryAsyncHandler !Text !SubscriptionId !DedupKeyId+  deriving stock (Eq, Ord, Show, Generic)++data InventoryProjection = InventoryProjection+  { projectionId :: !ProjectionId,+    sourceId :: !SourceId,+    rebuildGroupId :: !RebuildGroupId,+    ownedTargets :: ![TargetId],+    replayDisposition :: !Text,+    handlers :: ![InventoryHandler]+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryTargetProvisioner = InventoryTargetProvisioner+  { targetId :: !TargetId,+    provisionerId :: !Text,+    provisionerVersion :: !Int,+    schemaVersion :: !TargetSchemaVersion,+    expectedShapeId :: !Text,+    validatorId :: !Text,+    validatorVersion :: !Int,+    promotionObjectNames :: ![PromotionObjectName]+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryRevisionHandler = InventoryRevisionHandler+  { handlerId :: !Text,+    handlerVersion :: !Int,+    delivery :: !(Maybe RevisionLiveDelivery),+    requiredTargets :: ![TargetId]+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryStreamScopedReplay = InventoryStreamScopedReplay+  { projectionId :: !ProjectionId,+    ownedTargets :: ![TargetId],+    clearerId :: !Text,+    clearerVersion :: !Int,+    replayId :: !Text,+    replayVersion :: !Int,+    verificationId :: !Text,+    verificationVersion :: !Int,+    affectedAsyncDedup :: ![DedupKeyId]+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryProjectionRevision = InventoryProjectionRevision+  { revisionId :: !ProjectionRevisionId,+    rebuildGroupId :: !RebuildGroupId,+    targetProvisioners :: ![InventoryTargetProvisioner],+    liveHandlers :: ![InventoryRevisionHandler],+    replayAdapters :: ![InventoryRevisionHandler],+    verifications :: ![InventoryRevisionHandler],+    streamScopedReplays :: ![InventoryStreamScopedReplay]+  }+  deriving stock (Eq, Ord, Show, Generic)++data ExternalReadContractKind+  = InventoryAllRowsExternalRead+  | InventoryKeyedExternalRead+  deriving stock (Eq, Ord, Show, Generic)++data InventoryExternalReadContract = InventoryExternalReadContract+  { readContractId :: !ExternalReadContractId,+    contractVersion :: !ExternalReadContractVersion,+    queryModelId :: !QueryModelId,+    rebuildGroupId :: !RebuildGroupId,+    functionName :: !Text,+    contractKind :: !ExternalReadContractKind,+    arguments :: ![SqlFunctionArgument],+    resultContractType :: !QualifiedSqlType,+    privateImplementation :: !(Maybe QualifiedFunction),+    privateImplementationVersion :: !(Maybe Int),+    resultShapeHash :: !Text,+    compatibleRevisions :: !(NonEmpty ProjectionRevisionId),+    surfaceGeneration :: !Int+  }+  deriving stock (Eq, Ord, Show, Generic)++data InventoryQueryModel = InventoryQueryModel+  { queryModelId :: !QueryModelId,+    registryName :: !Text,+    version :: !Int,+    shapeHash :: !Text,+    rebuildGroupId :: !RebuildGroupId,+    observedTargets :: ![TargetId],+    freshness :: !InventoryQueryFreshness,+    cursor :: !(Maybe InventoryQueryCursor)+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | Canonical query freshness. Position targets and polling durations are+-- per-call execution data, so the catalog records only the position-wait kind.+data InventoryQueryFreshness+  = InventoryImmediate+  | InventoryWaitForHead !HeadScope+  | InventoryWaitForPosition+  deriving stock (Eq, Ord, Show, Generic)++-- | The one durable cursor derived from the supplying projection, when that+-- projection exposes exactly one compatible subscription authority.+data InventoryQueryCursor = InventoryQueryCursor+  { subscriptionId :: !SubscriptionId,+    subscriptionName :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | Operator-visible subscription identity, source, and absent-row policy.+data InventorySubscription = InventorySubscription+  { subscriptionId :: !SubscriptionId,+    subscriptionName :: !Text,+    sourceId :: !SourceId,+    checkpointOnMissing :: !MissingCheckpointPolicy+  }+  deriving stock (Eq, Show, Generic)++instance Ord InventorySubscription where+  compare left right =+    compare+      ( left ^. #subscriptionId,+        left ^. #subscriptionName,+        left ^. #sourceId,+        missingCheckpointPolicyRank (left ^. #checkpointOnMissing)+      )+      ( right ^. #subscriptionId,+        right ^. #subscriptionName,+        right ^. #sourceId,+        missingCheckpointPolicyRank (right ^. #checkpointOnMissing)+      )++data InventoryDedupKey = InventoryDedupKey+  { dedupKeyId :: !DedupKeyId,+    dedupName :: !Text+  }+  deriving stock (Eq, Ord, Show, Generic)++data CatalogInventory = CatalogInventory+  { inventorySources :: ![InventorySource],+    inventoryTargets :: ![InventoryTarget],+    inventoryGroups :: ![InventoryGroup],+    inventoryProjections :: ![InventoryProjection],+    inventoryProjectionRevisions :: ![InventoryProjectionRevision],+    inventoryExternalReadContracts :: ![InventoryExternalReadContract],+    inventoryQueryModels :: ![InventoryQueryModel],+    inventorySubscriptions :: ![InventorySubscription],+    inventoryDedupKeys :: ![InventoryDedupKey]+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | One live-handler capability retained by the supplying projection. This+-- normalized view contains identities and policies only; executable closures+-- stay in the original typed catalog.+data ProjectionHandlerCapability+  = InlineCapability+      { capabilityHandlerName :: !Text+      }+  | SubscriptionCapability+      { capabilityHandlerName :: !Text,+        capabilitySubscriptionId :: !SubscriptionId,+        capabilitySubscriptionName :: !Text,+        capabilitySourceId :: !SourceId,+        capabilityCheckpointOnMissing :: !MissingCheckpointPolicy,+        capabilityDedupKeyId :: !DedupKeyId,+        capabilityDedupName :: !Text+      }+  deriving stock (Eq, Show, Generic)++-- | The single catalog projection that supplies a typed query model. Target+-- ownership is authoritative: all observed targets must resolve to this owner+-- in the same rebuild group before validation succeeds.+data ResolvedQuerySupply = ResolvedQuerySupply+  { resolvedQueryModelId :: !QueryModelId,+    resolvedProjectionId :: !ProjectionId,+    resolvedRebuildGroupId :: !RebuildGroupId,+    resolvedObservedTargets :: !(NonEmpty TargetId),+    resolvedSourceId :: !SourceId,+    resolvedHandlerCapabilities :: !(NonEmpty ProjectionHandlerCapability),+    resolvedQueryFreshness :: !InventoryQueryFreshness,+    resolvedQueryCursor :: !(Maybe InventoryQueryCursor)+  }+  deriving stock (Eq, Show, Generic)++newtype CatalogFingerprint = CatalogFingerprint Text+  deriving stock (Eq, Ord, Show, Generic)++catalogFingerprintText :: CatalogFingerprint -> Text+catalogFingerprintText (CatalogFingerprint value) = value++-- | Identity of the catalog facts owned by one rebuild group.+newtype GroupSliceFingerprint = GroupSliceFingerprint Text+  deriving stock (Eq, Ord, Show, Generic)++groupSliceFingerprintText :: GroupSliceFingerprint -> Text+groupSliceFingerprintText (GroupSliceFingerprint value) = value++data ValidatedProjectionCatalog = ValidatedProjectionCatalog+  { originalCatalog :: !ProjectionCatalog,+    validatedInventory :: !CatalogInventory,+    validatedFingerprint :: !CatalogFingerprint,+    projectionFacts :: ![ProjectionFacts],+    validatedQuerySupplies :: ![ResolvedQuerySupply],+    validatedProjectionIdsBySource :: !(Map SourceId (Set.Set ProjectionId)),+    validatedRevisionIndex :: !(Map ProjectionRevisionId ProjectionRevision),+    validatedRevisionLiveHandlerIndex :: !(Map (ProjectionRevisionId, RevisionLiveDelivery) RevisionLiveHandler),+    validatedAsyncRegistrations :: ![AsyncProjectionRegistration]+  }+  deriving stock (Generic)++data CatalogEvolution+  = SourceRemoved !SourceId+  | TargetRemoved !TargetId+  | RebuildGroupRemoved !RebuildGroupId+  | ProjectionRemoved !ProjectionId+  | ProjectionRevisionRemoved !ProjectionRevisionId+  | ExternalReadContractRemoved !ExternalReadContractId !ExternalReadContractVersion+  | QueryModelRemoved !QueryModelId+  | SubscriptionRemoved !SubscriptionId+  | DedupKeyRemoved !DedupKeyId+  | TargetOwnerChanged !TargetId !ProjectionId !ProjectionId+  | TargetGroupChanged !TargetId !RebuildGroupId !RebuildGroupId+  deriving stock (Eq, Ord, Show, Generic)++data CatalogRegistration = CatalogRegistration+  { queryModelId :: !QueryModelId,+    registryName :: !Text,+    version :: !Int,+    shapeHash :: !Text,+    rebuildGroupId :: !RebuildGroupId+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | Runtime async registration projected from a validated catalog.+data AsyncProjectionRegistration = AsyncProjectionRegistration+  { projectionId :: !ProjectionId,+    projectionName :: !Text,+    subscriptionId :: !SubscriptionId,+    subscriptionName :: !Text,+    checkpointOnMissing :: !MissingCheckpointPolicy,+    dedupKeyId :: !DedupKeyId,+    dedupName :: !Text+  }+  deriving stock (Eq, Show, Generic)++instance Ord AsyncProjectionRegistration where+  compare left right =+    compare+      ( left ^. #projectionId,+        left ^. #projectionName,+        left ^. #subscriptionId,+        left ^. #subscriptionName,+        missingCheckpointPolicyRank (left ^. #checkpointOnMissing),+        left ^. #dedupKeyId,+        left ^. #dedupName+      )+      ( right ^. #projectionId,+        right ^. #projectionName,+        right ^. #subscriptionId,+        right ^. #subscriptionName,+        missingCheckpointPolicyRank (right ^. #checkpointOnMissing),+        right ^. #dedupKeyId,+        right ^. #dedupName+      )++-- | One replayable async handler's redelivery identity. Promotion uses this+-- to re-seed dedup rows and advance the declared checkpoint for one rebuild+-- group. Membership matches rebuild preparation: every replayable definition+-- in the group, independent of target reset policy. Plan 256 consumes this+-- same view for versioned cutover.+data CatalogAsyncDedupSpec = CatalogAsyncDedupSpec+  { specDedupKeyId :: !DedupKeyId,+    specSubscriptionName :: !Text,+    specDedupName :: !Text,+    specSourceId :: !SourceId,+    specSourceScope :: !SourceScope,+    specIdempotencyKey :: !(RecordedEvent -> EventId)+  }+  deriving stock (Generic)++data ReplayAdapterMetadata = ReplayAdapterMetadata+  { projectionId :: !ProjectionId,+    sourceId :: !SourceId,+    rebuildGroupId :: !RebuildGroupId,+    replayable :: !Bool+  }+  deriving stock (Eq, Ord, Show, Generic)++-- | One existential replay closure in deterministic catalog order. The event+-- type stays sealed inside this value, so the runner can route raw history+-- without weakening the typed live projection API.+data CatalogReplayAdapter+  = forall event.+    CatalogReplayAdapter+      !ProjectionId+      !SourceId+      !RebuildGroupId+      !Int+      !(ReplayAdapter event)++catalogReplayAdapterProjectionId :: CatalogReplayAdapter -> ProjectionId+catalogReplayAdapterProjectionId (CatalogReplayAdapter projectionId _ _ _ _) = projectionId++catalogReplayAdapterSourceId :: CatalogReplayAdapter -> SourceId+catalogReplayAdapterSourceId (CatalogReplayAdapter _ sourceId _ _ _) = sourceId++catalogReplayAdapterGroupId :: CatalogReplayAdapter -> RebuildGroupId+catalogReplayAdapterGroupId (CatalogReplayAdapter _ _ groupId _ _) = groupId++catalogReplayAdapterOrder :: CatalogReplayAdapter -> Int+catalogReplayAdapterOrder (CatalogReplayAdapter _ _ _ adapterOrder _) = adapterOrder++-- | Evaluate a raw event through one total adapter. @Right False@ is an+-- irrelevant event; @Right True@ means the replay transaction applied it.+runCatalogReplayAdapter ::+  CatalogReplayAdapter ->+  RecordedEvent ->+  Tx.Transaction (Either ReplayDecodeError Bool)+runCatalogReplayAdapter (CatalogReplayAdapter _ _ _ _ adapter) recorded =+  case (adapter ^. #decodeForReplay) recorded of+    ReplayIrrelevant -> pure (Right False)+    ReplayRelevant event -> do+      (adapter ^. #applyForReplay) event recorded+      pure (Right True)+    ReplayDecodeFailure decodeError -> pure (Left decodeError)++data ProjectionFacts = ProjectionFacts+  { factProjectionId :: !ProjectionId,+    factSourceId :: !SourceId,+    factGroupId :: !RebuildGroupId,+    factTargets :: ![TargetId],+    factReplayable :: !Bool,+    factHandlers :: ![HandlerFacts],+    factSite :: !ClaimSite+  }++data HandlerFacts+  = InlineFacts !Text !ClaimSite+  | AsyncFacts !Text !Text !Text !SubscriptionId !DedupKeyId !ClaimSite++data QueryFacts = QueryFacts+  { factQueryModelId :: !QueryModelId,+    factRegistryName :: !Text,+    factVersion :: !Int,+    factShapeHash :: !Text,+    factQueryGroup :: !RebuildGroupId,+    factObservedTargets :: ![TargetId],+    factQueryFreshness :: !QueryFreshness,+    factQuerySite :: !ClaimSite+  }++validateProjectionCatalog ::+  ProjectionCatalog ->+  Validation (NonEmpty CatalogDiagnostic) ValidatedProjectionCatalog+validateProjectionCatalog catalog =+  case NonEmpty.nonEmpty diagnostics of+    Just errors -> Failure errors+    Nothing ->+      let inventory = buildInventory catalog facts queryFacts+          querySupplies = buildResolvedQuerySupplies inventory+          projectionIdsBySource =+            Map.fromListWith+              Set.union+              [ (factSourceId fact, Set.singleton (factProjectionId fact))+              | fact <- facts+              ]+       in Success+            ValidatedProjectionCatalog+              { originalCatalog = catalog,+                validatedInventory = inventory,+                validatedFingerprint = fingerprintInventory inventory,+                projectionFacts = facts,+                validatedQuerySupplies = querySupplies,+                validatedProjectionIdsBySource = projectionIdsBySource,+                validatedRevisionIndex =+                  Map.fromList+                    [ (revision ^. #revisionId, revision)+                    | revision <- catalog ^. #projectionRevisions+                    ],+                validatedRevisionLiveHandlerIndex =+                  Map.fromList+                    [ ((revision ^. #revisionId, handler ^. #delivery), handler)+                    | revision <- catalog ^. #projectionRevisions,+                      handler <- revision ^. #liveHandlers+                    ],+                validatedAsyncRegistrations = deriveAsyncProjectionRegistrations facts inventory+              }+  where+    facts = collectProjectionFacts (catalog ^. #projectionSets)+    queryFacts = collectQueryFacts (catalog ^. #queryModels)+    diagnostics =+      List.sort+        ( duplicateDiagnostics catalog facts queryFacts+            <> referenceDiagnostics catalog facts queryFacts+            <> ownershipDiagnostics catalog facts+            <> groupDiagnostics catalog facts queryFacts+            <> querySupplyDiagnostics catalog facts queryFacts+            <> queryFreshnessDiagnostics catalog facts queryFacts+            <> replayDiagnostics catalog facts+            <> sourceOrderingDiagnostics catalog facts+            <> verificationDiagnostics catalog+            <> projectionRevisionDiagnostics catalog facts+            <> streamScopedReplayDiagnostics catalog facts+            <> externalReadContractDiagnostics catalog queryFacts+        )++-- | Validate and invoke a consumer only on success. This is the pure boundary+-- used by startup code to ensure invalid catalogs trigger no registration or+-- rebuild callback.+useProjectionCatalog ::+  ProjectionCatalog ->+  (ValidatedProjectionCatalog -> result) ->+  Validation (NonEmpty CatalogDiagnostic) result+useProjectionCatalog catalog consume =+  case validateProjectionCatalog catalog of+    Failure errors -> Failure errors+    Success validated -> Success (consume validated)++-- | Monadic form of 'useProjectionCatalog'. The callback is not evaluated when+-- validation fails, so startup registration and rebuild effects stay behind the+-- validated boundary.+useProjectionCatalogM ::+  (Monad effect) =>+  ProjectionCatalog ->+  (ValidatedProjectionCatalog -> effect result) ->+  effect (Validation (NonEmpty CatalogDiagnostic) result)+useProjectionCatalogM catalog consume =+  case validateProjectionCatalog catalog of+    Failure errors -> pure (Failure errors)+    Success validated -> Success <$> consume validated++typedInlineProjections ::+  ValidatedProjectionCatalog ->+  ProjectionSet event ->+  [InlineProjection event]+typedInlineProjections validated projectionSet+  | projectionSetBelongs validated projectionSet =+      [ projection+      | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions),+        handler <- NonEmpty.toList (definition ^. #handlers),+        InlineHandler projection _ <- [handler]+      ]+  | otherwise = []++-- | Typed compatibility handlers owned by one rebuild group. Revision-aware+-- writers use this only for a legacy group; version-managed groups dispatch+-- through the selected 'RevisionLiveHandler' instead.+typedInlineProjectionsForGroup ::+  ValidatedProjectionCatalog ->+  ProjectionSet event ->+  RebuildGroupId ->+  [InlineProjection event]+typedInlineProjectionsForGroup validated projectionSet wantedGroup+  | projectionSetBelongs validated projectionSet =+      [ projection+      | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions),+        definition ^. #rebuildGroup == wantedGroup,+        handler <- NonEmpty.toList (definition ^. #handlers),+        InlineHandler projection _ <- [handler]+      ]+  | otherwise = []++-- | Distinct rebuild groups touched by the same validated typed source handle,+-- in stable lock order. A handle that does not belong to this catalog yields no+-- groups, matching 'typedInlineProjections'.+typedProjectionRebuildGroups ::+  ValidatedProjectionCatalog ->+  ProjectionSet event ->+  [RebuildGroupId]+typedProjectionRebuildGroups validated projectionSet+  | projectionSetBelongs validated projectionSet =+      List.sort+        . Set.toList+        . Set.fromList+        $ [ definition ^. #rebuildGroup+          | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions)+          ]+  | otherwise = []++-- | Resolve one validated async handler to the group whose fence it must lock.+-- Both the stable projection ID and the existing physical projection name must+-- match, preventing a caller from pairing catalog metadata with another+-- handler closure.+asyncProjectionRebuildGroup ::+  ValidatedProjectionCatalog ->+  ProjectionId ->+  Text ->+  Maybe RebuildGroupId+asyncProjectionRebuildGroup validated wantedProjectionId wantedProjectionName = do+  registration <-+    List.find+      ( \entry ->+          entry ^. #projectionId == wantedProjectionId+            && entry ^. #projectionName == wantedProjectionName+      )+      (asyncProjectionRegistrations validated)+  projection <-+    List.find+      ((== registration ^. #projectionId) . (^. #projectionId))+      (validated ^. #validatedInventory . #inventoryProjections)+  pure (projection ^. #rebuildGroupId)++-- | Resolve every query model to its sole projection owner. Results are sorted+-- by query-model identity and are available only after whole-catalog+-- validation has established the ownership and rebuild-group invariants.+resolvedQuerySupplies :: ValidatedProjectionCatalog -> [ResolvedQuerySupply]+resolvedQuerySupplies = validatedQuerySupplies++projectionSetBelongs ::+  ValidatedProjectionCatalog ->+  ProjectionSet event ->+  Bool+projectionSetBelongs validated projectionSet =+  expectedIds `Set.isSubsetOf` validatedIds+  where+    expectedIds =+      Set.fromList+        [ definition ^. #projectionId+        | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions)+        ]+    validatedIds =+      Map.findWithDefault+        Set.empty+        (projectionSet ^. #projectionSource)+        (validated ^. #validatedProjectionIdsBySource)++catalogInventory :: ValidatedProjectionCatalog -> CatalogInventory+catalogInventory = validatedInventory++catalogFingerprint :: ValidatedProjectionCatalog -> CatalogFingerprint+catalogFingerprint = validatedFingerprint++-- | Fingerprint the catalog facts that preparation, replay, promotion, and+-- query transitions for one rebuild group depend on. Unrelated catalog slices+-- do not affect this identity.+groupSliceFingerprint ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  Maybe GroupSliceFingerprint+groupSliceFingerprint validated wantedGroup = do+  group <- List.find ((== wantedGroup) . (^. #rebuildGroupId)) (inventory ^. #inventoryGroups)+  pure+    . GroupSliceFingerprint+    . hashPreimage "slice-v6"+    $ PRecord+      "keiro/catalog-group-slice/v6"+      [ PText (rebuildGroupIdText wantedGroup),+        groupPreimage group,+        PList (targetPreimage <$> targets),+        PList (projectionPreimage <$> projections),+        PList (projectionRevisionPreimage <$> revisions),+        PList (externalReadContractPreimage <$> externalReadContracts),+        PList (sourcePreimage <$> sources),+        PList (queryPreimage <$> queries),+        PList (subscriptionPreimage <$> subscriptions),+        PList (dedupPreimage <$> dedupKeys)+      ]+  where+    inventory = catalogInventory validated+    groupTargets = maybe [] (^. #orderedTargets) $ List.find ((== wantedGroup) . (^. #rebuildGroupId)) (inventory ^. #inventoryGroups)+    targets =+      mapMaybe+        (\wanted -> List.find ((== wanted) . (^. #targetId)) (inventory ^. #inventoryTargets))+        groupTargets+    projections =+      filter+        ((== wantedGroup) . (^. #rebuildGroupId))+        (inventory ^. #inventoryProjections)+    revisions =+      filter+        ((== wantedGroup) . (^. #rebuildGroupId))+        (inventory ^. #inventoryProjectionRevisions)+    externalReadContracts =+      filter+        ((== wantedGroup) . (^. #rebuildGroupId))+        (inventory ^. #inventoryExternalReadContracts)+    sourceIds = Set.fromList (map (^. #sourceId) projections)+    sources = filter ((`Set.member` sourceIds) . (^. #sourceId)) (inventory ^. #inventorySources)+    queries =+      filter+        ((== wantedGroup) . (^. #rebuildGroupId))+        (inventory ^. #inventoryQueryModels)+    asyncHandlers =+      [ (subscriptionId, dedupId)+      | projection <- projections,+        InventoryAsyncHandler _ subscriptionId dedupId <- projection ^. #handlers+      ]+    subscriptionIds = Set.fromList (map fst asyncHandlers)+    dedupIds = Set.fromList (map snd asyncHandlers)+    subscriptions =+      filter+        ((`Set.member` subscriptionIds) . (^. #subscriptionId))+        (inventory ^. #inventorySubscriptions)+    dedupKeys =+      filter+        ((`Set.member` dedupIds) . (^. #dedupKeyId))+        (inventory ^. #inventoryDedupKeys)++catalogRegistrations :: ValidatedProjectionCatalog -> [CatalogRegistration]+catalogRegistrations validated =+  [ CatalogRegistration+      { queryModelId = binding ^. #queryModelId,+        registryName = binding ^. #registryName,+        version = binding ^. #version,+        shapeHash = binding ^. #shapeHash,+        rebuildGroupId = binding ^. #rebuildGroupId+      }+  | binding <- validated ^. #validatedInventory . #inventoryQueryModels+  ]++-- | Executable revision contracts from the validated catalog. Declaration+-- order is preserved; callers that need one identity should use+-- 'catalogProjectionRevision'.+catalogProjectionRevisions :: ValidatedProjectionCatalog -> [ProjectionRevision]+catalogProjectionRevisions validated =+  validated ^. #originalCatalog . #projectionRevisions++catalogProjectionRevision ::+  ValidatedProjectionCatalog ->+  ProjectionRevisionId ->+  Maybe ProjectionRevision+catalogProjectionRevision validated wanted =+  Map.lookup wanted (validated ^. #validatedRevisionIndex)++catalogRevisionLiveHandler ::+  ValidatedProjectionCatalog ->+  ProjectionRevisionId ->+  RevisionLiveDelivery ->+  Maybe RevisionLiveHandler+catalogRevisionLiveHandler validated revisionId delivery =+  Map.lookup+    (revisionId, delivery)+    (validated ^. #validatedRevisionLiveHandlerIndex)++-- | Resolve one validated stream-scoped policy from the exact projection+-- revision that currently serves a group.+catalogStreamScopedReplay ::+  ValidatedProjectionCatalog ->+  ProjectionRevisionId ->+  ProjectionId ->+  Maybe StreamScopedReplay+catalogStreamScopedReplay validated revisionId wantedProjection = do+  revision <- catalogProjectionRevision validated revisionId+  List.find+    ((== wantedProjection) . (^. #streamProjectionId))+    (revision ^. #streamScopedReplays)++-- | Runtime declarations retained behind the validated boundary. Results are+-- sorted so registration is independent of source declaration order.+catalogExternalReadContracts :: ValidatedProjectionCatalog -> [ExternalReadContract]+catalogExternalReadContracts validated =+  List.sort (validated ^. #originalCatalog . #externalReadContracts)++asyncProjectionRegistrations ::+  ValidatedProjectionCatalog ->+  [AsyncProjectionRegistration]+asyncProjectionRegistrations = validatedAsyncRegistrations++deriveAsyncProjectionRegistrations ::+  [ProjectionFacts] ->+  CatalogInventory ->+  [AsyncProjectionRegistration]+deriveAsyncProjectionRegistrations facts inventory =+  List.sort+    [ AsyncProjectionRegistration+        { projectionId = factProjectionId projection,+          projectionName = projectionName,+          subscriptionId = subscriptionId,+          subscriptionName = subscription ^. #subscriptionName,+          checkpointOnMissing = subscription ^. #checkpointOnMissing,+          dedupKeyId = dedupKeyId,+          dedupName = dedupNameFor dedupKeyId+        }+    | projection <- facts,+      AsyncFacts projectionName _ _ subscriptionId dedupKeyId _ <- factHandlers projection,+      Just subscription <- [subscriptionFor subscriptionId]+    ]+  where+    subscriptionFor ref =+      List.find ((== ref) . (^. #subscriptionId)) (inventory ^. #inventorySubscriptions)+    dedupNameFor ref =+      fromMaybe+        ""+        ( List.lookup+            ref+            [ (entry ^. #dedupKeyId, entry ^. #dedupName)+            | entry <- inventory ^. #inventoryDedupKeys+            ]+        )++-- | Redelivery identities for replayable async definitions in one group,+-- preserving projection-set, definition, and handler declaration order.+catalogAsyncIdempotencyKeys ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  [CatalogAsyncDedupSpec]+catalogAsyncIdempotencyKeys validated wantedGroup =+  concatMap specsForSet (validated ^. #originalCatalog . #projectionSets)+  where+    inventory = validated ^. #validatedInventory++    specsForSet (SomeProjectionSet projectionSet) =+      [ CatalogAsyncDedupSpec+          { specDedupKeyId = dedupKeyId,+            specSubscriptionName = subscriptionNameFor subscriptionId,+            specDedupName = dedupNameFor dedupKeyId,+            specSourceId = sourceId,+            specSourceScope = sourceScopeFor sourceId,+            specIdempotencyKey = projection ^. #idempotencyKey+          }+      | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions),+        definition ^. #rebuildGroup == wantedGroup,+        Replayable {} <- [definition ^. #replayPolicy],+        AsyncHandler projection subscriptionId dedupKeyId _ <-+          NonEmpty.toList (definition ^. #handlers)+      ]+      where+        sourceId = projectionSet ^. #projectionSource++    subscriptionNameFor ref =+      fromMaybe+        (error "catalogAsyncIdempotencyKeys: validated subscription missing from inventory")+        ( (^. #subscriptionName)+            <$> List.find+              ((== ref) . (^. #subscriptionId))+              (inventory ^. #inventorySubscriptions)+        )++    dedupNameFor ref =+      fromMaybe+        (error "catalogAsyncIdempotencyKeys: validated dedup key missing from inventory")+        ( (^. #dedupName)+            <$> List.find+              ((== ref) . (^. #dedupKeyId))+              (inventory ^. #inventoryDedupKeys)+        )++    sourceScopeFor ref =+      fromMaybe+        (error "catalogAsyncIdempotencyKeys: validated source missing from inventory")+        ( (^. #sourceScope)+            <$> List.find+              ((== ref) . (^. #sourceId))+              (inventory ^. #inventorySources)+        )++replayAdapterMetadata :: ValidatedProjectionCatalog -> [ReplayAdapterMetadata]+replayAdapterMetadata validated =+  List.sort+    [ ReplayAdapterMetadata+        { projectionId = factProjectionId fact,+          sourceId = factSourceId fact,+          rebuildGroupId = factGroupId fact,+          replayable = factReplayable fact+        }+    | fact <- validated ^. #projectionFacts+    ]++-- | Replayable definitions for one group, preserving projection-set and+-- definition declaration order. Live-only definitions are intentionally+-- absent from the replay fleet.+catalogReplayAdapters ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  [CatalogReplayAdapter]+catalogReplayAdapters validated wantedGroup =+  List.zipWith assignOrder [0 ..] unordered+  where+    unordered =+      concatMap adaptersForSet (validated ^. #originalCatalog . #projectionSets)++    adaptersForSet (SomeProjectionSet projectionSet) =+      [ CatalogReplayAdapter+          (definition ^. #projectionId)+          (projectionSet ^. #projectionSource)+          (definition ^. #rebuildGroup)+          0+          adapter+      | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions),+        definition ^. #rebuildGroup == wantedGroup,+        Replayable adapter <- [definition ^. #replayPolicy]+      ]++    assignOrder adapterOrder (CatalogReplayAdapter projectionId sourceId groupId _ adapter) =+      CatalogReplayAdapter projectionId sourceId groupId adapterOrder adapter++catalogRebuildVerifications ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  [RebuildVerification]+catalogRebuildVerifications validated wantedGroup =+  concat+    [ group ^. #verificationHooks+    | group <- validated ^. #originalCatalog . #rebuildGroups,+      group ^. #rebuildGroupId == wantedGroup+    ]++renderCatalogInventory :: ValidatedProjectionCatalog -> Text+renderCatalogInventory = renderInventory . catalogInventory++compareCatalogBaseline :: CatalogInventory -> CatalogInventory -> [CatalogEvolution]+compareCatalogBaseline previous current =+  List.sort+    ( removedEntries SourceRemoved (^. #sourceId) (previous ^. #inventorySources) (current ^. #inventorySources)+        <> removedEntries TargetRemoved (^. #targetId) (previous ^. #inventoryTargets) (current ^. #inventoryTargets)+        <> removedEntries RebuildGroupRemoved (^. #rebuildGroupId) (previous ^. #inventoryGroups) (current ^. #inventoryGroups)+        <> removedEntries ProjectionRemoved (^. #projectionId) (previous ^. #inventoryProjections) (current ^. #inventoryProjections)+        <> removedEntries ProjectionRevisionRemoved (^. #revisionId) (previous ^. #inventoryProjectionRevisions) (current ^. #inventoryProjectionRevisions)+        <> removedEntries+          (uncurry ExternalReadContractRemoved)+          (\entry -> (entry ^. #readContractId, entry ^. #contractVersion))+          (previous ^. #inventoryExternalReadContracts)+          (current ^. #inventoryExternalReadContracts)+        <> removedEntries QueryModelRemoved (^. #queryModelId) (previous ^. #inventoryQueryModels) (current ^. #inventoryQueryModels)+        <> removedEntries SubscriptionRemoved (^. #subscriptionId) (previous ^. #inventorySubscriptions) (current ^. #inventorySubscriptions)+        <> removedEntries DedupKeyRemoved (^. #dedupKeyId) (previous ^. #inventoryDedupKeys) (current ^. #inventoryDedupKeys)+        <> ownerChanges+        <> groupChanges+    )+  where+    previousTargets = Map.fromList [(entry ^. #targetId, entry) | entry <- previous ^. #inventoryTargets]+    currentTargets = Map.fromList [(entry ^. #targetId, entry) | entry <- current ^. #inventoryTargets]+    ownerChanges =+      [ TargetOwnerChanged targetId (old ^. #owner) (new ^. #owner)+      | (targetId, old) <- Map.toList previousTargets,+        Just new <- [Map.lookup targetId currentTargets],+        old ^. #owner /= new ^. #owner+      ]+    previousGroups = targetGroups previous+    currentGroups = targetGroups current+    groupChanges =+      [ TargetGroupChanged targetId oldGroup newGroup+      | (targetId, oldGroup) <- Map.toList previousGroups,+        Just newGroup <- [Map.lookup targetId currentGroups],+        oldGroup /= newGroup+      ]++removedEntries ::+  (Ord key) =>+  (key -> evolution) ->+  (entry -> key) ->+  [entry] ->+  [entry] ->+  [evolution]+removedEntries constructor key previous current =+  [ constructor previousKey+  | previousEntry <- previous,+    let previousKey = key previousEntry,+    previousKey `Set.notMember` currentKeys+  ]+  where+    currentKeys = Set.fromList (map key current)++targetGroups :: CatalogInventory -> Map TargetId RebuildGroupId+targetGroups inventory =+  Map.fromList+    [ (targetId, group ^. #rebuildGroupId)+    | group <- inventory ^. #inventoryGroups,+      targetId <- group ^. #orderedTargets+    ]++duplicateDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [QueryFacts] -> [CatalogDiagnostic]+duplicateDiagnostics catalog facts queryFacts =+  duplicateBy DuplicateSourceId sourceIdText (^. #sourceId) (^. #claimSite) (catalog ^. #sources)+    <> duplicateBy DuplicateTargetId targetIdText (^. #targetId) (^. #claimSite) (catalog ^. #targets)+    <> duplicateBy DuplicateQualifiedTable renderQualifiedTable (^. #qualifiedTable) (^. #claimSite) (catalog ^. #targets)+    <> duplicateBy DuplicateRebuildGroupId rebuildGroupIdText (^. #rebuildGroupId) (^. #claimSite) (catalog ^. #rebuildGroups)+    <> duplicateBy DuplicateSubscriptionId subscriptionIdText (^. #subscriptionId) (^. #claimSite) (catalog ^. #subscriptions)+    <> duplicateBy DuplicateSubscriptionName (\value -> value) (^. #subscriptionName) (^. #claimSite) (catalog ^. #subscriptions)+    <> duplicateBy DuplicateDedupKeyId dedupKeyIdText (^. #dedupKeyId) (^. #claimSite) (catalog ^. #dedupKeys)+    <> duplicateBy DuplicateDedupName (\value -> value) (^. #dedupName) (^. #claimSite) (catalog ^. #dedupKeys)+    <> duplicateBy DuplicateProjectionId projectionIdText factProjectionId factSite facts+    <> duplicateBy DuplicateProjectionRevisionId projectionRevisionIdText (^. #revisionId) (^. #claimSite) (catalog ^. #projectionRevisions)+    <> duplicateBy+      DuplicateExternalReadContractVersion+      renderExternalReadContractKey+      externalReadContractKey+      (^. #claimSite)+      (catalog ^. #externalReadContracts)+    <> duplicateBy+      DuplicateExternalReadFunctionName+      id+      externalReadFunctionName+      (^. #claimSite)+      (catalog ^. #externalReadContracts)+    <> duplicateBy DuplicateQueryModelId queryModelIdText factQueryModelId factQuerySite queryFacts+    <> duplicateBy DuplicateQueryModelRegistryName (\value -> value) factRegistryName factQuerySite queryFacts+    <> concatMap duplicateTargetsInGroup (catalog ^. #rebuildGroups)++renderExternalReadContractKey :: (ExternalReadContractId, ExternalReadContractVersion) -> Text+renderExternalReadContractKey (contractId, version) =+  externalReadContractIdText contractId+    <> "/v"+    <> Text.pack (show (externalReadContractVersionValue version))++externalReadContractKey :: ExternalReadContract -> (ExternalReadContractId, ExternalReadContractVersion)+externalReadContractKey contract =+  (contract ^. #readContractId, contract ^. #contractVersion)++duplicateBy ::+  (Ord key) =>+  CatalogDiagnosticCode ->+  (key -> Text) ->+  (value -> key) ->+  (value -> ClaimSite) ->+  [value] ->+  [CatalogDiagnostic]+duplicateBy code renderKey key site values =+  [ diagnostic code (renderKey duplicateKey) sites "identity is declared more than once"+  | (duplicateKey, claims) <- Map.toList grouped,+    let sites = List.sort (List.nub (map site claims)),+    List.length claims > 1+  ]+  where+    grouped = Map.fromListWith (<>) [(key value, [value]) | value <- values]++duplicateTargetsInGroup :: RebuildGroupDeclaration -> [CatalogDiagnostic]+duplicateTargetsInGroup group =+  [ diagnostic+      DuplicateGroupTarget+      (targetIdText targetId)+      [group ^. #claimSite]+      ("target occurs more than once in rebuild group " <> rebuildGroupIdText (group ^. #rebuildGroupId))+  | targetId <- duplicates (group ^. #orderedTargets)+  ]++referenceDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [QueryFacts] -> [CatalogDiagnostic]+referenceDiagnostics catalog facts queryFacts =+  groupTargetReferences+    <> targetDependencies+    <> subscriptionSourceReferences+    <> projectionReferences+    <> queryReferences+  where+    sourceIds = Set.fromList [source ^. #sourceId | source <- catalog ^. #sources]+    targetIds = Set.fromList [target ^. #targetId | target <- catalog ^. #targets]+    groupIds = Set.fromList [group ^. #rebuildGroupId | group <- catalog ^. #rebuildGroups]+    subscriptionIds = Set.fromList [subscription ^. #subscriptionId | subscription <- catalog ^. #subscriptions]+    dedupIds = Set.fromList [key ^. #dedupKeyId | key <- catalog ^. #dedupKeys]+    subscriptionsById = Map.fromList [(subscription ^. #subscriptionId, subscription) | subscription <- catalog ^. #subscriptions]+    dedupById = Map.fromList [(key ^. #dedupKeyId, key) | key <- catalog ^. #dedupKeys]+    queriesByRegistryName = Map.fromList [(factRegistryName query, query) | query <- queryFacts]+    groupTargetReferences =+      [ diagnostic UnknownTargetReference (targetIdText targetId) [group ^. #claimSite] "rebuild group references an unknown target"+      | group <- catalog ^. #rebuildGroups,+        targetId <- group ^. #orderedTargets,+        targetId `Set.notMember` targetIds+      ]+    targetDependencies =+      [ diagnostic UnknownTargetDependency (targetIdText dependency) [target ^. #claimSite] ("target dependency is unknown for " <> targetIdText (target ^. #targetId))+      | target <- catalog ^. #targets,+        dependency <- target ^. #dependsOn,+        dependency `Set.notMember` targetIds+      ]+    subscriptionSourceReferences =+      [ diagnostic UnknownSourceReference (sourceIdText sourceId) [subscription ^. #claimSite] "subscription references an unknown source"+      | subscription <- catalog ^. #subscriptions,+        let sourceId = subscription ^. #subscriptionSource,+        sourceId `Set.notMember` sourceIds+      ]+    projectionReferences = concatMap projectionReference facts+    projectionReference fact =+      [ diagnostic UnknownSourceReference (sourceIdText (factSourceId fact)) [factSite fact] "projection set references an unknown source"+      | factSourceId fact `Set.notMember` sourceIds+      ]+        <> [ diagnostic UnknownGroupReference (rebuildGroupIdText (factGroupId fact)) [factSite fact] "projection references an unknown rebuild group"+           | factGroupId fact `Set.notMember` groupIds+           ]+        <> [ diagnostic UnknownTargetReference (targetIdText targetId) [factSite fact] ("projection references an unknown target: " <> projectionIdText (factProjectionId fact))+           | targetId <- factTargets fact,+             targetId `Set.notMember` targetIds+           ]+        <> concatMap (handlerReference fact) (factHandlers fact)+    handlerReference _ (InlineFacts _ _) = []+    handlerReference fact (AsyncFacts projectionName readModelName liveSubscriptionName subscriptionId dedupId site) =+      [ diagnostic UnknownSubscriptionReference (subscriptionIdText subscriptionId) [site] "async handler references an unknown subscription"+      | subscriptionId `Set.notMember` subscriptionIds+      ]+        <> [ diagnostic UnknownDedupKeyReference (dedupKeyIdText dedupId) [site] "async handler references an unknown dedup key"+           | dedupId `Set.notMember` dedupIds+           ]+        <> [ diagnostic UnknownQueryModelReference readModelName [site] "async handler references an unknown query-model registry name"+           | readModelName `Map.notMember` queriesByRegistryName+           ]+        <> [ diagnostic AsyncHandlerSubscriptionMismatch projectionName [site, subscription ^. #claimSite] "async handler subscription name or source differs from its catalog declaration"+           | Just subscription <- [Map.lookup subscriptionId subscriptionsById],+             subscription ^. #subscriptionName /= liveSubscriptionName+               || subscription ^. #subscriptionSource /= factSourceId fact+           ]+        <> [ diagnostic AsyncHandlerDedupMismatch projectionName [site, key ^. #claimSite] "async handler name must match its declared deduplication name"+           | Just key <- [Map.lookup dedupId dedupById],+             key ^. #dedupName /= projectionName+           ]+        <> [ diagnostic QueryModelOutsideRebuildGroup readModelName [site, factQuerySite query] "async handler's query model belongs to another rebuild group"+           | Just query <- [Map.lookup readModelName queriesByRegistryName],+             factQueryGroup query /= factGroupId fact+           ]+    queryReferences = concatMap queryReference queryFacts+    queryReference query =+      [ diagnostic UnknownGroupReference (rebuildGroupIdText (factQueryGroup query)) [factQuerySite query] "query model references an unknown rebuild group"+      | factQueryGroup query `Set.notMember` groupIds+      ]+        <> [ diagnostic UnknownTargetReference (targetIdText targetId) [factQuerySite query] "query model references an unknown target"+           | targetId <- factObservedTargets query,+             targetId `Set.notMember` targetIds+           ]++ownershipDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [CatalogDiagnostic]+ownershipDiagnostics catalog facts =+  concatMap targetOwnership (catalog ^. #targets)+  where+    targetOwnership target =+      case [fact | fact <- facts, target ^. #targetId `List.elem` factTargets fact] of+        [] ->+          [ diagnostic TargetWithoutOwner (targetIdText (target ^. #targetId)) [target ^. #claimSite] "declared target has no projection owner"+          ]+        [_] -> []+        owners ->+          [ diagnostic+              TargetWithMultipleOwners+              (targetIdText (target ^. #targetId))+              (map factSite owners)+              ( "target has multiple independent projection owners: "+                  <> Text.intercalate ", " (map (projectionIdText . factProjectionId) owners)+              )+          ]++groupDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [QueryFacts] -> [CatalogDiagnostic]+groupDiagnostics catalog facts queryFacts =+  emptyGroups+    <> groupMembership+    <> crossGroupProjections+    <> dependencyCycles+    <> dependencyOrder+    <> queryCoverage+  where+    groups = catalog ^. #rebuildGroups+    targets = catalog ^. #targets+    groupByTarget =+      Map.fromListWith+        (<>)+        [ (targetId, [group ^. #rebuildGroupId])+        | group <- groups,+          targetId <- group ^. #orderedTargets+        ]+    emptyGroups =+      [ diagnostic EmptyRebuildGroup (rebuildGroupIdText (group ^. #rebuildGroupId)) [group ^. #claimSite] "rebuild group must contain at least one target"+      | group <- groups,+        null (group ^. #orderedTargets)+      ]+    groupMembership =+      [ diagnostic+          ProjectionCrossesRebuildGroups+          (targetIdText (target ^. #targetId))+          (target ^. #claimSite : [group ^. #claimSite | group <- groups, target ^. #targetId `List.elem` group ^. #orderedTargets])+          "target must belong to exactly one rebuild group"+      | target <- targets,+        List.length (Map.findWithDefault [] (target ^. #targetId) groupByTarget) /= 1+      ]+    crossGroupProjections =+      [ diagnostic+          ProjectionCrossesRebuildGroups+          (projectionIdText (factProjectionId fact))+          [factSite fact]+          "one transactional projection may write targets from only its declared rebuild group"+      | fact <- facts,+        let targetGroupsForProjection =+              Set.fromList+                [ groupId+                | targetId <- factTargets fact,+                  groupId <- Map.findWithDefault [] targetId groupByTarget+                ],+        targetGroupsForProjection /= Set.singleton (factGroupId fact)+      ]+    dependencyCycles =+      [ diagnostic+          TargetDependencyCycle+          (Text.intercalate "," (List.sort (map (targetIdText . (^. #targetId)) cycleTargets)))+          (map (^. #claimSite) cycleTargets)+          "target dependencies contain a cycle"+      | CyclicSCC cycleTargets <- stronglyConnComp graph+      ]+    graph =+      [ (target, target ^. #targetId, filter (`Map.member` targetMap) (target ^. #dependsOn))+      | target <- targets+      ]+    targetMap = Map.fromList [(target ^. #targetId, target) | target <- targets]+    dependencyOrder = concatMap groupOrderDiagnostics groups+    groupOrderDiagnostics group =+      [ diagnostic+          TargetOrderViolatesDependency+          (targetIdText (target ^. #targetId))+          [target ^. #claimSite, group ^. #claimSite]+          ("dependency " <> targetIdText dependency <> " must precede target in rebuild group")+      | (position, targetId) <- List.zip [0 :: Int ..] (group ^. #orderedTargets),+        Just target <- [Map.lookup targetId targetMap],+        dependency <- target ^. #dependsOn,+        Just dependencyPosition <- [List.elemIndex dependency (group ^. #orderedTargets)],+        dependencyPosition >= position+      ]+    queryCoverage =+      [ diagnostic+          QueryModelOutsideRebuildGroup+          (queryModelIdText (factQueryModelId query))+          [factQuerySite query]+          "query model observes a target not covered by its rebuild group"+      | query <- queryFacts,+        let covered =+              Set.fromList+                [ targetId+                | group <- groups,+                  group ^. #rebuildGroupId == factQueryGroup query,+                  targetId <- group ^. #orderedTargets+                ],+        any (`Set.notMember` covered) (factObservedTargets query)+      ]++-- | Validate the relation later exposed by 'resolvedQuerySupplies'. Existing+-- declaration/reference/ownership diagnostics remain the primary errors for+-- malformed identities, so this layer stays quiet until those prerequisites+-- are individually valid.+querySupplyDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [QueryFacts] -> [CatalogDiagnostic]+querySupplyDiagnostics catalog facts queryFacts = concatMap diagnosticsForQuery queryFacts+  where+    declaredTargets = Set.fromList [target ^. #targetId | target <- catalog ^. #targets]+    declaredGroups = Set.fromList [group ^. #rebuildGroupId | group <- catalog ^. #rebuildGroups]+    ownersByTarget =+      Map.fromListWith+        (<>)+        [ (targetId, [fact])+        | fact <- facts,+          targetId <- factTargets fact+        ]+    groupsByTarget =+      Map.fromListWith+        (<>)+        [ (targetId, [group ^. #rebuildGroupId])+        | group <- catalog ^. #rebuildGroups,+          targetId <- group ^. #orderedTargets+        ]+    projectionCounts = Map.fromListWith (+) [(factProjectionId fact, 1 :: Int) | fact <- facts]+    queryCounts = Map.fromListWith (+) [(factQueryModelId query, 1 :: Int) | query <- queryFacts]++    diagnosticsForQuery query+      | Map.findWithDefault 0 (factQueryModelId query) queryCounts /= 1 = []+      | null targets =+          [ diagnostic+              EmptyQueryObservedTargets+              queryIdentity+              [factQuerySite query]+              "catalog-bound query model must observe at least one target"+          ]+      | not prerequisitesValid = []+      | otherwise =+          case List.sort (Set.toList supplierIds) of+            [] ->+              [ diagnostic+                  QueryModelWithoutSupplier+                  queryIdentity+                  [factQuerySite query]+                  "query model's observed targets do not resolve to one projection supplier"+              ]+            [_] -> []+            suppliers ->+              [ diagnostic+                  QueryModelWithMultipleSuppliers+                  queryIdentity+                  (factQuerySite query : map factSite supplierFacts)+                  ( "query model's observed targets span several projection suppliers: "+                      <> Text.intercalate ", " (map projectionIdText suppliers)+                  )+              ]+      where+        targets = factObservedTargets query+        queryIdentity = queryModelIdText (factQueryModelId query)+        targetOwners = [owners | targetId <- targets, let owners = Map.findWithDefault [] targetId ownersByTarget]+        supplierFacts = List.nubBy (\left right -> factProjectionId left == factProjectionId right) (concat targetOwners)+        supplierIds = Set.fromList (map factProjectionId supplierFacts)+        prerequisitesValid =+          factQueryGroup query `Set.member` declaredGroups+            && all (`Set.member` declaredTargets) targets+            && all ((== 1) . List.length) targetOwners+            && all+              ( \targetId ->+                  Map.findWithDefault [] targetId groupsByTarget == [factQueryGroup query]+              )+              targets+            && all+              ( \case+                  [owner] -> factGroupId owner == factQueryGroup query+                  _ -> False+              )+              targetOwners+            && all+              ( \case+                  [owner] -> Map.findWithDefault 0 (factProjectionId owner) projectionCounts == 1+                  _ -> False+              )+              targetOwners++data QueryCursorCandidate+  = QueryCursorCandidate+      !SubscriptionId+      !Text+      !SourceScope+      !ClaimSite+  deriving stock (Eq, Ord, Show)++-- | Validate only query policies that actually wait. Immediate queries remain+-- valid for inline, subscription, and composed owners; they merely expose no+-- per-call cursor when the owner has zero or several durable authorities.+queryFreshnessDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [QueryFacts] -> [CatalogDiagnostic]+queryFreshnessDiagnostics catalog facts = concatMap diagnosticsForQuery+  where+    diagnosticsForQuery query =+      case factQueryFreshness query of+        Immediate -> []+        requested ->+          case queryOwner facts query of+            Nothing -> []+            Just owner ->+              let allCandidates = queryCursorCandidates catalog owner+                  compatible = compatibleQueryCursors requested allCandidates+                  sites =+                    List.sort+                      . List.nub+                      $ factQuerySite query+                        : factSite owner+                        : map handlerClaimSite (factHandlers owner)+                  detail = cursorDiagnosticDetail requested owner allCandidates+               in case compatible of+                    [] ->+                      [ diagnostic+                          QueryWaitWithoutCompatibleCursor+                          (queryModelIdText (factQueryModelId query))+                          sites+                          ( "query wait has no compatible durable cursor; "+                              <> detail+                              <> "; remedy: use Immediate or give the supplying owner one compatible subscription"+                          )+                      ]+                    [_] -> []+                    _ ->+                      [ diagnostic+                          QueryWaitWithAmbiguousCursor+                          (queryModelIdText (factQueryModelId query))+                          sites+                          ( "query wait has several compatible durable cursors; "+                              <> detail+                              <> "; remedy: leave exactly one compatible subscription or use Immediate"+                          )+                      ]++queryOwner :: [ProjectionFacts] -> QueryFacts -> Maybe ProjectionFacts+queryOwner facts query =+  case List.nubBy+    (\left right -> factProjectionId left == factProjectionId right)+    [ fact+    | targetId <- factObservedTargets query,+      fact <- facts,+      targetId `List.elem` factTargets fact+    ] of+    [owner]+      | not (null (factObservedTargets query)),+        factGroupId owner == factQueryGroup query,+        all (`List.elem` factTargets owner) (factObservedTargets query) ->+          Just owner+    _ -> Nothing++queryCursorCandidates :: ProjectionCatalog -> ProjectionFacts -> [QueryCursorCandidate]+queryCursorCandidates catalog owner =+  List.nubBy sameCursor+    . List.sortOn candidateKey+    $ mapMaybe candidateFor (factHandlers owner)+  where+    subscriptionsById =+      Map.fromList+        [ (subscription ^. #subscriptionId, subscription)+        | subscription <- catalog ^. #subscriptions+        ]+    sourcesById =+      Map.fromList+        [ (source ^. #sourceId, source)+        | source <- catalog ^. #sources+        ]++    candidateFor (InlineFacts _ _) = Nothing+    candidateFor (AsyncFacts _ _ _ subscriptionId _ site) = do+      subscription <- Map.lookup subscriptionId subscriptionsById+      source <- Map.lookup (subscription ^. #subscriptionSource) sourcesById+      pure+        ( QueryCursorCandidate+            subscriptionId+            (subscription ^. #subscriptionName)+            (source ^. #sourceScope)+            site+        )++    candidateKey (QueryCursorCandidate subscriptionId name scope _) =+      (subscriptionId, name, scope)+    sameCursor left right = candidateKey left == candidateKey right++compatibleQueryCursors :: QueryFreshness -> [QueryCursorCandidate] -> [QueryCursorCandidate]+compatibleQueryCursors freshness =+  case freshness of+    Immediate -> id+    WaitForPosition _ -> id+    WaitForHead scope -> filter (headScopeReachable scope . candidateScope)+  where+    candidateScope (QueryCursorCandidate _ _ scope _) = scope++headScopeReachable :: HeadScope -> SourceScope -> Bool+headScopeReachable EntireVisibleLog AllStreams = True+headScopeReachable EntireVisibleLog CategorySource {} = False+headScopeReachable CategoryVisibleHead {} AllStreams = True+headScopeReachable (CategoryVisibleHead wanted) (CategorySource (CategoryName actual)) =+  wanted == actual++cursorDiagnosticDetail :: QueryFreshness -> ProjectionFacts -> [QueryCursorCandidate] -> Text+cursorDiagnosticDetail requested owner candidates =+  "query freshness="+    <> queryFreshnessText requested+    <> ", owner="+    <> projectionIdText (factProjectionId owner)+    <> ", delivery capabilities="+    <> Text.intercalate "," (map renderHandlerFact (factHandlers owner))+    <> ", cursor candidates="+    <> if null candidates+      then "none"+      else Text.intercalate "," (map renderCursorCandidate candidates)++queryFreshnessText :: QueryFreshness -> Text+queryFreshnessText Immediate = "immediate"+queryFreshnessText (WaitForHead scope) = "wait-for-head(" <> headScopeText scope <> ")"+queryFreshnessText WaitForPosition {} = "wait-for-position"++headScopeText :: HeadScope -> Text+headScopeText EntireVisibleLog = "entire-visible-log"+headScopeText (CategoryVisibleHead category) = "category-visible-head:" <> category++renderHandlerFact :: HandlerFacts -> Text+renderHandlerFact (InlineFacts name _) = "inline:" <> name+renderHandlerFact (AsyncFacts name _ _ subscriptionId _ _) =+  "subscription:" <> name <> "/" <> subscriptionIdText subscriptionId++renderCursorCandidate :: QueryCursorCandidate -> Text+renderCursorCandidate (QueryCursorCandidate subscriptionId name scope _) =+  subscriptionIdText subscriptionId+    <> "/"+    <> name+    <> "/"+    <> renderScope scope++handlerClaimSite :: HandlerFacts -> ClaimSite+handlerClaimSite (InlineFacts _ site) = site+handlerClaimSite (AsyncFacts _ _ _ _ _ site) = site++replayDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [CatalogDiagnostic]+replayDiagnostics catalog facts =+  clearRequiresReplay <> currentHeadAfterClear <> mixedGroupReplay+  where+    ownerFor targetId = List.find (\fact -> targetId `List.elem` factTargets fact) facts+    targets = catalog ^. #targets+    groups = catalog ^. #rebuildGroups+    targetMap = Map.fromList [(target ^. #targetId, target) | target <- targets]+    subscriptionsById = Map.fromList [(subscription ^. #subscriptionId, subscription) | subscription <- catalog ^. #subscriptions]+    clearRequiresReplay =+      [ diagnostic+          ClearTargetRequiresReplayableOwner+          (targetIdText (target ^. #targetId))+          [target ^. #claimSite, factSite owner]+          "a clear-before-replay target requires a replayable owner"+      | target <- targets,+        target ^. #resetPolicy == ClearBeforeReplay,+        Just owner <- [ownerFor (target ^. #targetId)],+        not (factReplayable owner)+      ]+    currentHeadAfterClear =+      [ diagnostic+          ReplayableClearTargetStartsAtCurrentHead+          (subscriptionIdText subscriptionId <> "/" <> targetIdText targetId)+          [subscription ^. #claimSite, target ^. #claimSite, handlerSite, factSite fact]+          ( "subscription "+              <> subscription ^. #subscriptionName+              <> " ("+              <> subscriptionIdText subscriptionId+              <> ") uses FromCurrentHead for replayable target "+              <> targetIdText targetId+              <> " with ClearBeforeReplay; seeding the current head would skip the history required to rebuild the cleared target"+          )+      | fact <- facts,+        factReplayable fact,+        AsyncFacts _ _ _ subscriptionId _ handlerSite <- factHandlers fact,+        Just subscription <- [Map.lookup subscriptionId subscriptionsById],+        subscription ^. #checkpointOnMissing == FromCurrentHead,+        targetId <- factTargets fact,+        Just target <- [Map.lookup targetId targetMap],+        target ^. #resetPolicy == ClearBeforeReplay+      ]+    mixedGroupReplay =+      [ diagnostic+          MixedResetGroupRequiresReplayAdapter+          (projectionIdText (factProjectionId owner))+          [group ^. #claimSite, factSite owner]+          "preserve-and-reconcile targets in a mixed group require explicit replay adapters"+      | group <- groups,+        let groupTargets = mapMaybeTarget group,+        any ((== ClearBeforeReplay) . (^. #resetPolicy)) groupTargets,+        any ((== PreserveAndReconcile) . (^. #resetPolicy)) groupTargets,+        target <- groupTargets,+        target ^. #resetPolicy == PreserveAndReconcile,+        Just owner <- [ownerFor (target ^. #targetId)],+        not (factReplayable owner)+      ]+    mapMaybeTarget group =+      [ target+      | targetId <- group ^. #orderedTargets,+        Just target <- [Map.lookup targetId targetMap]+      ]++sourceOrderingDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [CatalogDiagnostic]+sourceOrderingDiagnostics catalog facts =+  [ diagnostic+      AmbiguousSourceOrdering+      (rebuildGroupIdText groupId)+      (map factSite groupFacts)+      "one rebuild group cannot combine an all-stream source with category sources"+  | groupId <- Set.toList (Set.fromList (map factGroupId facts)),+    let groupFacts = filter ((== groupId) . factGroupId) facts,+    let scopes =+          [ source ^. #sourceScope+          | fact <- groupFacts,+            source <- catalog ^. #sources,+            source ^. #sourceId == factSourceId fact+          ],+    AllStreams `List.elem` scopes,+    any isCategory scopes+  ]+  where+    isCategory (CategorySource _) = True+    isCategory AllStreams = False++verificationDiagnostics :: ProjectionCatalog -> [CatalogDiagnostic]+verificationDiagnostics catalog = concatMap verificationGroupDiagnostics (catalog ^. #rebuildGroups)+  where+    verificationGroupDiagnostics group = duplicateIds group <> invalidIdentities group++    duplicateIds group =+      [ diagnostic+          DuplicateRebuildVerificationId+          verificationId+          [group ^. #claimSite]+          "verification identities must be unique within a rebuild group"+      | verificationId <- duplicates (map (^. #verificationId) (group ^. #verificationHooks))+      ]++    invalidIdentities group =+      [ diagnostic+          InvalidRebuildVerificationIdentity+          (hook ^. #verificationId)+          [group ^. #claimSite]+          "verification identity and version must be non-empty and have no surrounding whitespace"+      | hook <- group ^. #verificationHooks,+        invalid (hook ^. #verificationId) || invalid (hook ^. #verificationVersion)+      ]++    invalid value = Text.null value || Text.strip value /= value++projectionRevisionDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [CatalogDiagnostic]+projectionRevisionDiagnostics catalog facts =+  concatMap revisionDiagnostics revisions+  where+    revisions = catalog ^. #projectionRevisions+    groupsById =+      Map.fromList+        [ (group ^. #rebuildGroupId, group)+        | group <- catalog ^. #rebuildGroups+        ]+    declaredTargets = Set.fromList [target ^. #targetId | target <- catalog ^. #targets]++    revisionDiagnostics revision =+      unknownGroup+        <> unknownProvisioners+        <> targetSetDrift+        <> missingLive+        <> missingReplay+        <> missingValidation+        <> nonTotalMappings+        <> liveCapabilityMismatch+        <> liveTargetOwnershipMismatch+        <> invalidIdentities+      where+        revisionIdentity = projectionRevisionIdText (revision ^. #revisionId)+        site = revision ^. #claimSite+        provisionerEntries = Map.toAscList (revision ^. #targetProvisioners)+        provisionedTargets = Map.keysSet (revision ^. #targetProvisioners)+        maybeGroup = Map.lookup (revision ^. #rebuildGroup) groupsById+        expectedTargets =+          Set.fromList $ maybe [] (^. #orderedTargets) maybeGroup+        groupFacts = filter ((== revision ^. #rebuildGroup) . factGroupId) facts+        expectedLive = List.sort (concatMap factLiveDeliveries groupFacts)+        actualLive = List.sort (map (^. #delivery) (revision ^. #liveHandlers))+        expectedTargetsByDelivery =+          Map.fromList+            [ (deliveryCapability, Set.fromList (factTargets fact))+            | fact <- groupFacts,+              deliveryCapability <- factLiveDeliveries fact+            ]+        unknownGroup =+          [ diagnostic+              UnknownGroupReference+              (rebuildGroupIdText (revision ^. #rebuildGroup))+              [site]+              ("projection revision " <> revisionIdentity <> " references an unknown rebuild group")+          | isNothing maybeGroup+          ]+        unknownProvisioners =+          [ diagnostic+              UnknownTargetProvisioner+              (targetIdText targetId)+              [site]+              ("projection revision " <> revisionIdentity <> " supplies a provisioner for an unknown target")+          | (targetId, _) <- provisionerEntries,+            targetId `Set.notMember` declaredTargets+          ]+        targetSetDrift =+          [ diagnostic+              ProjectionRevisionTargetSetDrift+              revisionIdentity+              [site, group ^. #claimSite]+              ( "projection revision target set differs from rebuild group; expected="+                  <> renderTargetSet expectedTargets+                  <> ", supplied="+                  <> renderTargetSet provisionedTargets+              )+          | Just group <- [maybeGroup],+            provisionedTargets /= expectedTargets+          ]+        missingLive =+          [ diagnostic+              ProjectionRevisionWithoutLiveHandler+              revisionIdentity+              [site]+              "projection revision must supply at least one live handler"+          | null (revision ^. #liveHandlers)+          ]+        missingReplay =+          [ diagnostic+              ProjectionRevisionWithoutReplayAdapter+              revisionIdentity+              [site]+              "projection revision must supply at least one replay adapter"+          | null (revision ^. #replayAdapters)+          ]+        missingValidation =+          [ diagnostic+              ProjectionRevisionMissingSchemaValidation+              (revisionIdentity <> "/" <> targetIdText targetId)+              [site]+              "target provisioner must supply schema validation before replay or promotion"+          | (targetId, provisioner) <- provisionerEntries,+            isNothing (provisioner ^. #validateTarget)+          ]+        nonTotalMappings =+          [ diagnostic+              ProjectionRevisionPhysicalTargetsNotTotal+              (revisionIdentity <> "/" <> handlerIdentity)+              [site]+              ( "revision closure requires a non-total physical target set; expected="+                  <> renderTargetSet expectedTargets+                  <> ", required="+                  <> renderTargetSet required+              )+          | (handlerIdentity, requiredTargets) <- revisionRequirements revision,+            let required = Set.fromList requiredTargets,+            isJust maybeGroup,+            required /= expectedTargets+          ]+        liveCapabilityMismatch =+          [ diagnostic+              ProjectionRevisionLiveCapabilityMismatch+              revisionIdentity+              (site : map factSite groupFacts)+              ( "revision live deliveries differ from the catalog owners; expected="+                  <> renderLiveDeliveries expectedLive+                  <> ", supplied="+                  <> renderLiveDeliveries actualLive+              )+          | expectedLive /= actualLive+          ]+        liveTargetOwnershipMismatch =+          [ diagnostic+              ProjectionRevisionLiveTargetOwnershipMismatch+              (revisionIdentity <> "/" <> handler ^. #handlerId)+              (site : maybe [] (pure . factSite) (factForDelivery (handler ^. #delivery)))+              ( "revision live handler targets differ from its supplying projection; expected="+                  <> renderTargetSet owned+                  <> ", supplied="+                  <> renderTargetSet supplied+              )+          | handler <- revision ^. #liveHandlers,+            Just owned <- [Map.lookup (handler ^. #delivery) expectedTargetsByDelivery],+            let supplied = Set.fromList (handler ^. #requiredTargets),+            supplied /= owned+          ]+        invalidIdentities =+          [ diagnostic+              InvalidProjectionRevisionIdentity+              (revisionIdentity <> "/" <> identity)+              [site]+              "revision, provisioner, validator, handler, adapter, and verification identities must be non-empty and have no surrounding whitespace; numeric versions must be positive"+          | (identity, version) <- revisionIdentities revision,+            invalid identity || version < 1+          ]++    factLiveDeliveries fact =+      [ case handler of+          InlineFacts name _ -> RevisionInlineDelivery (factProjectionId fact) name+          AsyncFacts _ _ _ subscriptionId dedupId _ ->+            RevisionSubscriptionDelivery (factProjectionId fact) subscriptionId dedupId+      | handler <- factHandlers fact+      ]+    factForDelivery wanted =+      List.find (List.elem wanted . factLiveDeliveries) facts+    renderLiveDeliveries = Text.intercalate "," . map renderRevisionLiveDelivery+    invalid value = Text.null value || Text.strip value /= value+    renderTargetSet = Text.intercalate "," . map targetIdText . Set.toAscList++streamScopedReplayDiagnostics :: ProjectionCatalog -> [ProjectionFacts] -> [CatalogDiagnostic]+streamScopedReplayDiagnostics catalog facts =+  concatMap revisionDiagnostics (catalog ^. #projectionRevisions)+  where+    factsById = Map.fromList [(factProjectionId fact, fact) | fact <- facts]++    revisionDiagnostics revision =+      duplicatePolicies <> concatMap (policyDiagnostics revision) policies+      where+        policies = revision ^. #streamScopedReplays+        duplicatePolicies =+          [ diagnostic+              DuplicateStreamScopedReplayProjection+              ( projectionRevisionIdText (revision ^. #revisionId)+                  <> "/"+                  <> projectionIdText projectionId+              )+              (List.sort [policy ^. #claimSite | policy <- policies, policy ^. #streamProjectionId == projectionId])+              "a projection revision may declare at most one stream-scoped repair policy per projection"+          | projectionId <- duplicates (map (^. #streamProjectionId) policies)+          ]++    policyDiagnostics revision policy =+      case Map.lookup (policy ^. #streamProjectionId) factsById of+        Nothing ->+          [ diagnostic+              UnknownStreamScopedReplayProjection+              identity+              [policy ^. #claimSite, revision ^. #claimSite]+              "stream-scoped repair policy references a projection absent from the catalog"+          ]+            <> invalidIdentityDiagnostics+        Just fact ->+          policyGroupDiagnostics fact+            <> targetDiagnostics fact+            <> dedupDiagnostics fact+            <> invalidIdentityDiagnostics+      where+        identity =+          projectionRevisionIdText (revision ^. #revisionId)+            <> "/"+            <> projectionIdText (policy ^. #streamProjectionId)+        invalidIdentityDiagnostics =+          [ diagnostic+              InvalidStreamScopedReplayIdentity+              (identity <> "/" <> stableIdentity)+              [policy ^. #claimSite]+              "stream clearer, replay, and verification identities must be non-empty without surrounding whitespace and versions must be positive"+          | (stableIdentity, version) <-+              [ (policy ^. #clearerId, policy ^. #clearerVersion),+                (policy ^. #streamReplayId, policy ^. #streamReplayVersion),+                (policy ^. #streamVerificationId, policy ^. #streamVerificationVersion)+              ],+            Text.null stableIdentity || Text.strip stableIdentity /= stableIdentity || version < 1+          ]+        policyGroupDiagnostics fact =+          [ diagnostic+              StreamScopedReplayGroupMismatch+              identity+              [policy ^. #claimSite, factSite fact, revision ^. #claimSite]+              "stream-scoped repair projection and projection revision must own the same rebuild group"+          | factGroupId fact /= revision ^. #rebuildGroup+          ]+        targetDiagnostics fact =+          [ diagnostic+              StreamScopedReplayTargetSetMismatch+              identity+              [policy ^. #claimSite, factSite fact, revision ^. #claimSite]+              "stream-scoped repair targets must exactly match the projection-owned targets and be unique"+          | let supplied = NonEmpty.toList (policy ^. #streamOwnedTargets)+                suppliedSet = Set.fromList supplied+                expectedSet = Set.fromList (factTargets fact)+                revisionTargets = Map.keysSet (revision ^. #targetProvisioners),+            suppliedSet /= expectedSet+              || List.length supplied /= Set.size suppliedSet+              || not (suppliedSet `Set.isSubsetOf` revisionTargets)+          ]+        dedupDiagnostics fact =+          [ diagnostic+              StreamScopedReplayDedupMismatch+              identity+              [policy ^. #claimSite, factSite fact]+              "affected async dedup identities must exactly match the projection's async handlers and be unique"+          | let supplied = policy ^. #affectedAsyncDedup+                suppliedSet = Set.fromList supplied+                expectedSet =+                  Set.fromList+                    [ dedupId+                    | AsyncFacts _ _ _ _ dedupId _ <- factHandlers fact+                    ],+            suppliedSet /= expectedSet || List.length supplied /= Set.size suppliedSet+          ]++externalReadContractDiagnostics :: ProjectionCatalog -> [QueryFacts] -> [CatalogDiagnostic]+externalReadContractDiagnostics catalog queryFacts =+  concatMap contractDiagnostics contracts+    <> implementationCollisions+    <> immutableSignatureDrift+    <> surfaceGenerationRegressions+  where+    contracts = catalog ^. #externalReadContracts+    queriesById = Map.fromList [(factQueryModelId query, query) | query <- queryFacts]+    revisionsById =+      Map.fromList+        [ (revision ^. #revisionId, revision)+        | revision <- catalog ^. #projectionRevisions+        ]++    contractDiagnostics contract =+      identityDiagnostics contract+        <> sqlIdentifierDiagnostics contract+        <> sqlTypeDiagnostics contract+        <> queryDiagnostics contract+        <> revisionDiagnostics contract++    identityDiagnostics contract =+      [ diagnostic+          InvalidExternalReadContractIdentity+          (renderExternalReadContractKey (externalReadContractKey contract))+          [contract ^. #claimSite]+          "contract identity, version, surface generation, result shape, and implementation version must be valid positive immutable facts"+      | invalidIdentity contract+      ]++    invalidIdentity contract =+      invalidText (externalReadContractIdText (contract ^. #readContractId))+        || externalReadContractVersionValue (contract ^. #contractVersion) < 1+        || contract ^. #surfaceGeneration < 1+        || invalidText (contract ^. #resultShapeHash)+        || case contract of+          AllRowsExternalRead {} -> False+          KeyedExternalRead {privateImplementationVersion = implementationVersion} ->+            implementationVersion < 1++    sqlIdentifierDiagnostics contract =+      [ diagnostic+          InvalidExternalReadSqlIdentifier+          (renderExternalReadContractKey (externalReadContractKey contract) <> "/" <> identity)+          [contract ^. #claimSite]+          "generated SQL identifiers must be lower-case PostgreSQL identifiers containing only letters, digits, and underscores"+      | identity <- contractSqlIdentifiers contract,+        not (safeSqlIdentifier identity)+      ]++    sqlTypeDiagnostics contract =+      [ diagnostic+          InvalidExternalReadSqlType+          (renderExternalReadContractKey (externalReadContractKey contract) <> "/" <> renderQualifiedType sqlType)+          [contract ^. #claimSite]+          "SQL contract types must be represented by separately validated schema and type identifiers"+      | sqlType <- contractSqlTypes contract,+        not (safeQualifiedType sqlType)+      ]++    queryDiagnostics contract =+      case Map.lookup (contract ^. #queryModelId) queriesById of+        Nothing ->+          [ diagnostic+              UnknownExternalReadQueryModel+              (queryModelIdText (contract ^. #queryModelId))+              [contract ^. #claimSite]+              "external read contract references a query model absent from the catalog"+          ]+        Just query ->+          [ diagnostic+              ExternalReadShapeMismatch+              (renderExternalReadContractKey (externalReadContractKey contract))+              [contract ^. #claimSite, factQuerySite query]+              ( "external read result shape does not match query model; expected="+                  <> factShapeHash query+                  <> ", supplied="+                  <> contract ^. #resultShapeHash+              )+          | contract ^. #resultShapeHash /= factShapeHash query+          ]++    revisionDiagnostics contract =+      concatMap (oneRevision contract) (NonEmpty.toList (contract ^. #compatibleRevisions))++    oneRevision contract revisionId =+      case Map.lookup revisionId revisionsById of+        Nothing ->+          [ diagnostic+              UnknownRevisionReference+              ( externalReadContractIdText (contract ^. #readContractId)+                  <> "/"+                  <> projectionRevisionIdText revisionId+              )+              [contract ^. #claimSite]+              "external read contract references a projection revision absent from the catalog"+          ]+        Just revision ->+          case Map.lookup (contract ^. #queryModelId) queriesById of+            Nothing -> []+            Just query ->+              [ diagnostic+                  ExternalReadRevisionOwnershipMismatch+                  ( externalReadContractIdText (contract ^. #readContractId)+                      <> "/"+                      <> projectionRevisionIdText revisionId+                  )+                  [contract ^. #claimSite, revision ^. #claimSite, factQuerySite query]+                  "compatible revision must own the query model's rebuild group and every observed target"+              | revision ^. #rebuildGroup /= factQueryGroup query+                  || not+                    ( Set.fromList (factObservedTargets query)+                        `Set.isSubsetOf` Map.keysSet (revision ^. #targetProvisioners)+                    )+              ]++    implementationCollisions =+      [ diagnostic+          ExternalReadImplementationCollision+          (renderQualifiedFunction function)+          (List.sort (map (^. #claimSite) claims))+          "a private keyed implementation may be owned by only one external read contract and may not occupy keiro_read"+      | (function, claims) <- Map.toList implementations,+        List.length claims > 1 || function ^. #functionSchema == "keiro_read"+      ]+      where+        implementations =+          Map.fromListWith+            (<>)+            [ (implementation, [contract])+            | contract@KeyedExternalRead {privateImplementation = implementation} <- contracts+            ]++    immutableSignatureDrift =+      [ diagnostic+          ExternalReadImmutableSignatureDrift+          (renderExternalReadContractKey key)+          (List.sort (map (^. #claimSite) claims))+          "the same contract identity/version declares more than one immutable public SQL signature"+      | (key, claims) <- Map.toList contractsByKey,+        List.length (List.nub (map externalReadImmutableSignature claims)) > 1+      ]+      where+        contractsByKey = Map.fromListWith (<>) [(externalReadContractKey contract, [contract]) | contract <- contracts]++    surfaceGenerationRegressions =+      [ diagnostic+          ExternalReadSurfaceGenerationRegression+          ( externalReadContractIdText contractId+              <> "/v"+              <> Text.pack (show (externalReadContractVersionValue (later ^. #contractVersion)))+          )+          [earlier ^. #claimSite, later ^. #claimSite]+          "a later contract version cannot declare a lower surface generation"+      | (contractId, sameId) <- Map.toList contractsById,+        earlier <- sameId,+        later <- sameId,+        earlier ^. #contractVersion < later ^. #contractVersion,+        earlier ^. #surfaceGeneration > later ^. #surfaceGeneration+      ]+      where+        contractsById = Map.fromListWith (<>) [(contract ^. #readContractId, [contract]) | contract <- contracts]++    invalidText value = Text.null value || Text.strip value /= value++contractSqlIdentifiers :: ExternalReadContract -> [Text]+contractSqlIdentifiers contract =+  externalReadContractIdText (contract ^. #readContractId)+    : externalReadFunctionName contract+    : case contract of+      AllRowsExternalRead {} -> []+      KeyedExternalRead {privateImplementation = implementation, arguments = keyedArguments} ->+        implementation ^. #functionSchema+          : implementation ^. #functionName+          : map (^. #argumentName) keyedArguments++contractSqlTypes :: ExternalReadContract -> [QualifiedSqlType]+contractSqlTypes AllRowsExternalRead {resultContractType = resultType} = [resultType]+contractSqlTypes KeyedExternalRead {resultContractType = resultType, arguments = keyedArguments} = resultType : map (^. #argumentType) keyedArguments++safeSqlIdentifier :: Text -> Bool+safeSqlIdentifier value =+  case Text.uncons value of+    Nothing -> False+    Just (first, rest) ->+      lower first && Text.all (\character -> lower character || digit character || character == '_') rest+  where+    lower character = character >= 'a' && character <= 'z'+    digit character = character >= '0' && character <= '9'++safeQualifiedType :: QualifiedSqlType -> Bool+safeQualifiedType sqlType =+  safeSqlIdentifier (sqlType ^. #typeSchema)+    && safeSqlIdentifier (sqlType ^. #typeName)++renderQualifiedType :: QualifiedSqlType -> Text+renderQualifiedType sqlType = sqlType ^. #typeSchema <> "." <> sqlType ^. #typeName++renderQualifiedFunction :: QualifiedFunction -> Text+renderQualifiedFunction function = function ^. #functionSchema <> "." <> function ^. #functionName++externalReadImmutableSignature :: ExternalReadContract -> Preimage+externalReadImmutableSignature contract =+  PRecord+    "external-read-immutable-signature"+    [ PText (externalReadFunctionName contract),+      PText (queryModelIdText (contract ^. #queryModelId)),+      PText (contract ^. #resultShapeHash),+      qualifiedSqlTypePreimage (contract ^. #resultContractType),+      case contract of+        AllRowsExternalRead {} -> PRecord "all-rows" []+        KeyedExternalRead {arguments = keyedArguments} ->+          PRecord+            "keyed"+            [PList (sqlFunctionArgumentPreimage <$> keyedArguments)]+    ]++revisionRequirements :: ProjectionRevision -> [(Text, [TargetId])]+revisionRequirements revision =+  [ (adapter ^. #adapterId, adapter ^. #requiredTargets)+  | adapter <- revision ^. #replayAdapters+  ]+    <> [ (verification ^. #revisionVerificationId, verification ^. #requiredTargets)+       | verification <- revision ^. #revisionVerifications+       ]++revisionIdentities :: ProjectionRevision -> [(Text, Int)]+revisionIdentities revision =+  [ (projectionRevisionIdText (revision ^. #revisionId), 1)+  ]+    <> [ (provisioner ^. #provisionerId, provisioner ^. #provisionerVersion)+       | provisioner <- Map.elems (revision ^. #targetProvisioners)+       ]+    <> [ (targetSchemaVersionText (provisioner ^. #schemaVersion), 1)+       | provisioner <- Map.elems (revision ^. #targetProvisioners)+       ]+    <> [ (provisioner ^. #expectedShapeId, 1)+       | provisioner <- Map.elems (revision ^. #targetProvisioners)+       ]+    <> [ (provisioner ^. #validatorId, provisioner ^. #validatorVersion)+       | provisioner <- Map.elems (revision ^. #targetProvisioners)+       ]+    <> [ (name, 1)+       | provisioner <- Map.elems (revision ^. #targetProvisioners),+         promotionObject <- provisioner ^. #promotionObjectNames,+         name <- [promotionObject ^. #generationName, promotionObject ^. #canonicalName]+       ]+    <> [ (handler ^. #handlerId, handler ^. #handlerVersion)+       | handler <- revision ^. #liveHandlers+       ]+    <> [ (adapter ^. #adapterId, adapter ^. #adapterVersion)+       | adapter <- revision ^. #replayAdapters+       ]+    <> [ (verification ^. #revisionVerificationId, verification ^. #revisionVerificationVersion)+       | verification <- revision ^. #revisionVerifications+       ]++collectProjectionFacts :: [SomeProjectionSet] -> [ProjectionFacts]+collectProjectionFacts projectionSetEntries =+  [ ProjectionFacts+      { factProjectionId = definition ^. #projectionId,+        factSourceId = projectionSet ^. #projectionSource,+        factGroupId = definition ^. #rebuildGroup,+        factTargets = NonEmpty.toList (definition ^. #ownedTargets),+        factReplayable = case definition ^. #replayPolicy of+          Replayable _ -> True+          LiveOnly _ -> False,+        factHandlers = map handlerFacts (NonEmpty.toList (definition ^. #handlers)),+        factSite = definition ^. #claimSite+      }+  | SomeProjectionSet projectionSet <- projectionSetEntries,+    definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions)+  ]++handlerFacts :: ProjectionHandler event -> HandlerFacts+handlerFacts (InlineHandler projection site) =+  InlineFacts (projection ^. #name) site+handlerFacts (AsyncHandler projection subscriptionId dedupId site) =+  AsyncFacts+    (projection ^. #name)+    (projection ^. #readModelName)+    (projection ^. #subscriptionName)+    subscriptionId+    dedupId+    site++collectQueryFacts :: [SomeQueryModelBinding] -> [QueryFacts]+collectQueryFacts bindings =+  [ QueryFacts+      { factQueryModelId = binding ^. #queryModelId,+        factRegistryName = model ^. #name,+        factVersion = model ^. #version,+        factShapeHash = model ^. #shapeHash,+        factQueryGroup = binding ^. #rebuildGroup,+        factObservedTargets = binding ^. #observedTargets,+        factQueryFreshness = readModelDefaultFreshness model,+        factQuerySite = binding ^. #claimSite+      }+  | SomeQueryModelBinding binding <- bindings,+    let model = binding ^. #readModel+  ]++buildInventory :: ProjectionCatalog -> [ProjectionFacts] -> [QueryFacts] -> CatalogInventory+buildInventory catalog facts queryFacts =+  CatalogInventory+    { inventorySources =+        List.sort+          [ InventorySource (source ^. #sourceId) (source ^. #sourceScope) (source ^. #codecFingerprint)+          | source <- catalog ^. #sources+          ],+      inventoryTargets =+        List.sort+          [ InventoryTarget+              { targetId = target ^. #targetId,+                qualifiedTable = target ^. #qualifiedTable,+                resetPolicy = target ^. #resetPolicy,+                dependsOn = List.sort (target ^. #dependsOn),+                owner = ownerOf (target ^. #targetId)+              }+          | target <- catalog ^. #targets+          ],+      inventoryGroups =+        List.sort+          [ InventoryGroup+              { rebuildGroupId = group ^. #rebuildGroupId,+                orderedTargets = group ^. #orderedTargets,+                verifications =+                  [ (hook ^. #verificationId, hook ^. #verificationVersion)+                  | hook <- group ^. #verificationHooks+                  ]+              }+          | group <- catalog ^. #rebuildGroups+          ],+      inventoryProjections = List.sort (map inventoryProjection facts),+      inventoryProjectionRevisions =+        List.sort (map inventoryProjectionRevision (catalog ^. #projectionRevisions)),+      inventoryExternalReadContracts =+        List.sort+          [ inventoryExternalReadContract (factQueryGroup query) contract+          | contract <- catalog ^. #externalReadContracts,+            let query =+                  fromMaybe+                    (error "buildInventory: validated external read contract has no query model")+                    (List.find ((== contract ^. #queryModelId) . factQueryModelId) queryFacts)+          ],+      inventoryQueryModels =+        List.sort+          [ InventoryQueryModel+              { queryModelId = factQueryModelId query,+                registryName = factRegistryName query,+                version = factVersion query,+                shapeHash = factShapeHash query,+                rebuildGroupId = factQueryGroup query,+                observedTargets = List.sort (factObservedTargets query),+                freshness = inventoryQueryFreshness (factQueryFreshness query),+                cursor = resolvedInventoryCursor catalog owner (factQueryFreshness query)+              }+          | query <- queryFacts,+            let owner =+                  fromMaybe+                    (error "buildInventory: validated query has no projection owner")+                    (queryOwner facts query)+          ],+      inventorySubscriptions =+        List.sort+          [ InventorySubscription+              { subscriptionId = subscription ^. #subscriptionId,+                subscriptionName = subscription ^. #subscriptionName,+                sourceId = subscription ^. #subscriptionSource,+                checkpointOnMissing = subscription ^. #checkpointOnMissing+              }+          | subscription <- catalog ^. #subscriptions+          ],+      inventoryDedupKeys =+        List.sort+          [ InventoryDedupKey (key ^. #dedupKeyId) (key ^. #dedupName)+          | key <- catalog ^. #dedupKeys+          ]+    }+  where+    ownerOf targetId =+      case [factProjectionId fact | fact <- facts, targetId `List.elem` factTargets fact] of+        owner : _ -> owner+        [] -> error "buildInventory: validated target has no owner"++inventoryExternalReadContract :: RebuildGroupId -> ExternalReadContract -> InventoryExternalReadContract+inventoryExternalReadContract groupId contract =+  InventoryExternalReadContract+    { readContractId = contract ^. #readContractId,+      contractVersion = contract ^. #contractVersion,+      queryModelId = contract ^. #queryModelId,+      rebuildGroupId = groupId,+      functionName = externalReadFunctionName contract,+      contractKind = case contract of+        AllRowsExternalRead {} -> InventoryAllRowsExternalRead+        KeyedExternalRead {} -> InventoryKeyedExternalRead,+      arguments = case contract of+        AllRowsExternalRead {} -> []+        KeyedExternalRead {arguments = keyedArguments} -> keyedArguments,+      resultContractType = contract ^. #resultContractType,+      privateImplementation = case contract of+        AllRowsExternalRead {} -> Nothing+        KeyedExternalRead {privateImplementation = implementation} -> Just implementation,+      privateImplementationVersion = case contract of+        AllRowsExternalRead {} -> Nothing+        KeyedExternalRead {privateImplementationVersion = implementationVersion} -> Just implementationVersion,+      resultShapeHash = contract ^. #resultShapeHash,+      compatibleRevisions =+        NonEmpty.fromList (List.sort (NonEmpty.toList (contract ^. #compatibleRevisions))),+      surfaceGeneration = contract ^. #surfaceGeneration+    }++buildResolvedQuerySupplies :: CatalogInventory -> [ResolvedQuerySupply]+buildResolvedQuerySupplies inventory =+  List.sortOn+    (^. #resolvedQueryModelId)+    [ ResolvedQuerySupply+        { resolvedQueryModelId = query ^. #queryModelId,+          resolvedProjectionId = ownerId,+          resolvedRebuildGroupId = query ^. #rebuildGroupId,+          resolvedObservedTargets = requireNonEmpty "query observed targets" (query ^. #observedTargets),+          resolvedSourceId = projection ^. #sourceId,+          resolvedHandlerCapabilities =+            requireNonEmpty+              "projection handler capabilities"+              (map (handlerCapability projection) (projection ^. #handlers)),+          resolvedQueryFreshness = query ^. #freshness,+          resolvedQueryCursor = query ^. #cursor+        }+    | query <- inventory ^. #inventoryQueryModels,+      let ownerId = ownerForQuery query,+      let projection = requireLookup "query projection owner" ownerId projectionById+    ]+  where+    ownerByTarget =+      Map.fromList+        [ (target ^. #targetId, target ^. #owner)+        | target <- inventory ^. #inventoryTargets+        ]+    projectionById =+      Map.fromList+        [ (projection ^. #projectionId, projection)+        | projection <- inventory ^. #inventoryProjections+        ]+    subscriptionById =+      Map.fromList+        [ (subscription ^. #subscriptionId, subscription)+        | subscription <- inventory ^. #inventorySubscriptions+        ]+    dedupById =+      Map.fromList+        [ (dedupKey ^. #dedupKeyId, dedupKey)+        | dedupKey <- inventory ^. #inventoryDedupKeys+        ]++    ownerForQuery query =+      case Set.toList . Set.fromList $ map ownerForTarget (query ^. #observedTargets) of+        [ownerId] -> ownerId+        _ -> error "buildResolvedQuerySupplies: validated query does not have one owner"++    ownerForTarget targetId = requireLookup "observed target owner" targetId ownerByTarget++    handlerCapability projection = \case+      InventoryInlineHandler name ->+        InlineCapability+          { capabilityHandlerName = name+          }+      InventoryAsyncHandler name subscriptionId dedupKeyId ->+        let subscription = requireLookup "handler subscription" subscriptionId subscriptionById+            dedupKey = requireLookup "handler dedup key" dedupKeyId dedupById+         in SubscriptionCapability+              { capabilityHandlerName = name,+                capabilitySubscriptionId = subscriptionId,+                capabilitySubscriptionName = subscription ^. #subscriptionName,+                capabilitySourceId = projection ^. #sourceId,+                capabilityCheckpointOnMissing = subscription ^. #checkpointOnMissing,+                capabilityDedupKeyId = dedupKeyId,+                capabilityDedupName = dedupKey ^. #dedupName+              }++    requireLookup label key values =+      fromMaybe+        (error ("buildResolvedQuerySupplies: missing " <> label))+        (Map.lookup key values)++    requireNonEmpty label values =+      fromMaybe+        (error ("buildResolvedQuerySupplies: empty " <> label))+        (NonEmpty.nonEmpty values)++inventoryQueryFreshness :: QueryFreshness -> InventoryQueryFreshness+inventoryQueryFreshness Immediate = InventoryImmediate+inventoryQueryFreshness (WaitForHead scope) = InventoryWaitForHead scope+inventoryQueryFreshness WaitForPosition {} = InventoryWaitForPosition++resolvedInventoryCursor ::+  ProjectionCatalog ->+  ProjectionFacts ->+  QueryFreshness ->+  Maybe InventoryQueryCursor+resolvedInventoryCursor catalog owner freshness =+  case compatibleQueryCursors freshness (queryCursorCandidates catalog owner) of+    [QueryCursorCandidate subscriptionId name _ _] ->+      Just+        InventoryQueryCursor+          { subscriptionId = subscriptionId,+            subscriptionName = name+          }+    _ -> Nothing++inventoryProjection :: ProjectionFacts -> InventoryProjection+inventoryProjection fact =+  InventoryProjection+    { projectionId = factProjectionId fact,+      sourceId = factSourceId fact,+      rebuildGroupId = factGroupId fact,+      ownedTargets = List.sort (factTargets fact),+      replayDisposition = if factReplayable fact then "replayable" else "live-only",+      handlers = map inventoryHandler (factHandlers fact)+    }++inventoryHandler :: HandlerFacts -> InventoryHandler+inventoryHandler (InlineFacts name _) = InventoryInlineHandler name+inventoryHandler (AsyncFacts name _ _ subscriptionId dedupId _) =+  InventoryAsyncHandler name subscriptionId dedupId++inventoryProjectionRevision :: ProjectionRevision -> InventoryProjectionRevision+inventoryProjectionRevision revision =+  InventoryProjectionRevision+    { revisionId = revision ^. #revisionId,+      rebuildGroupId = revision ^. #rebuildGroup,+      targetProvisioners =+        [ InventoryTargetProvisioner+            { targetId = targetId,+              provisionerId = provisioner ^. #provisionerId,+              provisionerVersion = provisioner ^. #provisionerVersion,+              schemaVersion = provisioner ^. #schemaVersion,+              expectedShapeId = provisioner ^. #expectedShapeId,+              validatorId = provisioner ^. #validatorId,+              validatorVersion = provisioner ^. #validatorVersion,+              promotionObjectNames = provisioner ^. #promotionObjectNames+            }+        | (targetId, provisioner) <- Map.toAscList (revision ^. #targetProvisioners)+        ],+      liveHandlers =+        [ InventoryRevisionHandler+            (handler ^. #handlerId)+            (handler ^. #handlerVersion)+            (Just (handler ^. #delivery))+            (List.sort (handler ^. #requiredTargets))+        | handler <- revision ^. #liveHandlers+        ],+      replayAdapters =+        [ InventoryRevisionHandler+            (adapter ^. #adapterId)+            (adapter ^. #adapterVersion)+            Nothing+            (List.sort (adapter ^. #requiredTargets))+        | adapter <- revision ^. #replayAdapters+        ],+      verifications =+        [ InventoryRevisionHandler+            (verification ^. #revisionVerificationId)+            (verification ^. #revisionVerificationVersion)+            Nothing+            (List.sort (verification ^. #requiredTargets))+        | verification <- revision ^. #revisionVerifications+        ],+      streamScopedReplays =+        List.sort+          [ InventoryStreamScopedReplay+              { projectionId = policy ^. #streamProjectionId,+                ownedTargets = List.sort (NonEmpty.toList (policy ^. #streamOwnedTargets)),+                clearerId = policy ^. #clearerId,+                clearerVersion = policy ^. #clearerVersion,+                replayId = policy ^. #streamReplayId,+                replayVersion = policy ^. #streamReplayVersion,+                verificationId = policy ^. #streamVerificationId,+                verificationVersion = policy ^. #streamVerificationVersion,+                affectedAsyncDedup = List.sort (policy ^. #affectedAsyncDedup)+              }+          | policy <- revision ^. #streamScopedReplays+          ]+    }++fingerprintInventory :: CatalogInventory -> CatalogFingerprint+fingerprintInventory = CatalogFingerprint . hashPreimage "catalog-v7" . inventoryPreimage++inventoryPreimage :: CatalogInventory -> Preimage+inventoryPreimage inventory =+  PRecord+    "keiro/catalog-inventory/v7"+    [ PList (sourcePreimage <$> inventory ^. #inventorySources),+      PList (targetPreimage <$> inventory ^. #inventoryTargets),+      PList (groupPreimage <$> inventory ^. #inventoryGroups),+      PList (projectionPreimage <$> inventory ^. #inventoryProjections),+      PList (projectionRevisionPreimage <$> inventory ^. #inventoryProjectionRevisions),+      PList (externalReadContractPreimage <$> inventory ^. #inventoryExternalReadContracts),+      PList (queryPreimage <$> inventory ^. #inventoryQueryModels),+      PList (subscriptionPreimage <$> inventory ^. #inventorySubscriptions),+      PList (dedupPreimage <$> inventory ^. #inventoryDedupKeys)+    ]++sourcePreimage :: InventorySource -> Preimage+sourcePreimage source =+  PRecord+    "source"+    [ PText (sourceIdText (source ^. #sourceId)),+      PText (renderScope (source ^. #sourceScope)),+      PText (source ^. #codecFingerprint)+    ]++targetPreimage :: InventoryTarget -> Preimage+targetPreimage target =+  PRecord+    "target"+    [ PText (targetIdText (target ^. #targetId)),+      PText (target ^. #qualifiedTable . #schemaName),+      PText (target ^. #qualifiedTable . #tableName),+      PText (renderReset (target ^. #resetPolicy)),+      PList (PText . targetIdText <$> target ^. #dependsOn),+      PText (projectionIdText (target ^. #owner))+    ]++groupPreimage :: InventoryGroup -> Preimage+groupPreimage group =+  PRecord+    "group"+    [ PText (rebuildGroupIdText (group ^. #rebuildGroupId)),+      PList (PText . targetIdText <$> group ^. #orderedTargets),+      PList+        [ PRecord "verification" [PText verificationId, PText verificationVersion]+        | (verificationId, verificationVersion) <- group ^. #verifications+        ]+    ]++projectionPreimage :: InventoryProjection -> Preimage+projectionPreimage projection =+  PRecord+    "projection"+    [ PText (projectionIdText (projection ^. #projectionId)),+      PText (sourceIdText (projection ^. #sourceId)),+      PText (rebuildGroupIdText (projection ^. #rebuildGroupId)),+      PList (PText . targetIdText <$> projection ^. #ownedTargets),+      PText (projection ^. #replayDisposition),+      PList (handlerPreimage <$> projection ^. #handlers)+    ]++projectionRevisionPreimage :: InventoryProjectionRevision -> Preimage+projectionRevisionPreimage revision =+  PRecord+    "projection-revision"+    [ PText (projectionRevisionIdText (revision ^. #revisionId)),+      PText (rebuildGroupIdText (revision ^. #rebuildGroupId)),+      PList (targetProvisionerPreimage <$> revision ^. #targetProvisioners),+      PList (revisionHandlerPreimage "live-handler" <$> revision ^. #liveHandlers),+      PList (revisionHandlerPreimage "replay-adapter" <$> revision ^. #replayAdapters),+      PList (revisionHandlerPreimage "revision-verification" <$> revision ^. #verifications),+      PList (streamScopedReplayPreimage <$> revision ^. #streamScopedReplays)+    ]++streamScopedReplayPreimage :: InventoryStreamScopedReplay -> Preimage+streamScopedReplayPreimage policy =+  PRecord+    "stream-scoped-replay"+    [ PText (projectionIdText (policy ^. #projectionId)),+      PList (PText . targetIdText <$> policy ^. #ownedTargets),+      PText (policy ^. #clearerId),+      PText (Text.pack (show (policy ^. #clearerVersion))),+      PText (policy ^. #replayId),+      PText (Text.pack (show (policy ^. #replayVersion))),+      PText (policy ^. #verificationId),+      PText (Text.pack (show (policy ^. #verificationVersion))),+      PList (PText . dedupKeyIdText <$> policy ^. #affectedAsyncDedup)+    ]++targetProvisionerPreimage :: InventoryTargetProvisioner -> Preimage+targetProvisionerPreimage provisioner =+  PRecord+    "target-provisioner"+    [ PText (targetIdText (provisioner ^. #targetId)),+      PText (provisioner ^. #provisionerId),+      PText (Text.pack (show (provisioner ^. #provisionerVersion))),+      PText (targetSchemaVersionText (provisioner ^. #schemaVersion)),+      PText (provisioner ^. #expectedShapeId),+      PText (provisioner ^. #validatorId),+      PText (Text.pack (show (provisioner ^. #validatorVersion))),+      PList (promotionObjectPreimage <$> provisioner ^. #promotionObjectNames)+    ]++promotionObjectPreimage :: PromotionObjectName -> Preimage+promotionObjectPreimage object =+  PRecord+    "promotion-object"+    [ PText (promotionObjectKindText (object ^. #objectKind)),+      PText (object ^. #generationName),+      PText (object ^. #canonicalName)+    ]++revisionHandlerPreimage :: Text -> InventoryRevisionHandler -> Preimage+revisionHandlerPreimage tag handler =+  PRecord+    tag+    [ PText (handler ^. #handlerId),+      PText (Text.pack (show (handler ^. #handlerVersion))),+      maybe (PRecord "no-delivery" []) revisionLiveDeliveryPreimage (handler ^. #delivery),+      PList (PText . targetIdText <$> handler ^. #requiredTargets)+    ]++revisionLiveDeliveryPreimage :: RevisionLiveDelivery -> Preimage+revisionLiveDeliveryPreimage = \case+  RevisionInlineDelivery projectionId handlerName ->+    PRecord+      "inline-delivery"+      [PText (projectionIdText projectionId), PText handlerName]+  RevisionSubscriptionDelivery projectionId subscriptionId dedupId ->+    PRecord+      "subscription-delivery"+      [ PText (projectionIdText projectionId),+        PText (subscriptionIdText subscriptionId),+        PText (dedupKeyIdText dedupId)+      ]++externalReadContractPreimage :: InventoryExternalReadContract -> Preimage+externalReadContractPreimage contract =+  PRecord+    "external-read-contract"+    [ PText (externalReadContractIdText (contract ^. #readContractId)),+      PText (Text.pack (show (externalReadContractVersionValue (contract ^. #contractVersion)))),+      PText (queryModelIdText (contract ^. #queryModelId)),+      PText (rebuildGroupIdText (contract ^. #rebuildGroupId)),+      PText (contract ^. #functionName),+      PText+        ( case contract ^. #contractKind of+            InventoryAllRowsExternalRead -> "all-rows"+            InventoryKeyedExternalRead -> "keyed"+        ),+      PList (sqlFunctionArgumentPreimage <$> contract ^. #arguments),+      qualifiedSqlTypePreimage (contract ^. #resultContractType),+      maybe (PRecord "no-private-implementation" []) qualifiedFunctionPreimage (contract ^. #privateImplementation),+      maybe (PRecord "no-private-implementation-version" []) (PText . Text.pack . show) (contract ^. #privateImplementationVersion),+      PText (contract ^. #resultShapeHash),+      PList+        ( PText . projectionRevisionIdText+            <$> NonEmpty.toList (contract ^. #compatibleRevisions)+        ),+      PText (Text.pack (show (contract ^. #surfaceGeneration)))+    ]++sqlFunctionArgumentPreimage :: SqlFunctionArgument -> Preimage+sqlFunctionArgumentPreimage argument =+  PRecord+    "sql-function-argument"+    [ PText (argument ^. #argumentName),+      qualifiedSqlTypePreimage (argument ^. #argumentType)+    ]++qualifiedSqlTypePreimage :: QualifiedSqlType -> Preimage+qualifiedSqlTypePreimage sqlType =+  PRecord "qualified-sql-type" [PText (sqlType ^. #typeSchema), PText (sqlType ^. #typeName)]++qualifiedFunctionPreimage :: QualifiedFunction -> Preimage+qualifiedFunctionPreimage function =+  PRecord "qualified-function" [PText (function ^. #functionSchema), PText (function ^. #functionName)]++handlerPreimage :: InventoryHandler -> Preimage+handlerPreimage = \case+  InventoryInlineHandler name -> PRecord "inline" [PText name]+  InventoryAsyncHandler name subscriptionId dedupId ->+    PRecord+      "async"+      [ PText name,+        PText (subscriptionIdText subscriptionId),+        PText (dedupKeyIdText dedupId)+      ]++queryPreimage :: InventoryQueryModel -> Preimage+queryPreimage query =+  PRecord+    "query"+    [ PText (queryModelIdText (query ^. #queryModelId)),+      PText (query ^. #registryName),+      PText (Text.pack (show (query ^. #version))),+      PText (query ^. #shapeHash),+      PText (rebuildGroupIdText (query ^. #rebuildGroupId)),+      PList (PText . targetIdText <$> query ^. #observedTargets),+      inventoryFreshnessPreimage (query ^. #freshness),+      maybe (PRecord "no-cursor" []) inventoryCursorPreimage (query ^. #cursor)+    ]++inventoryFreshnessPreimage :: InventoryQueryFreshness -> Preimage+inventoryFreshnessPreimage InventoryImmediate = PRecord "immediate" []+inventoryFreshnessPreimage (InventoryWaitForHead scope) =+  PRecord "wait-for-head" [PText (headScopeText scope)]+inventoryFreshnessPreimage InventoryWaitForPosition = PRecord "wait-for-position" []++inventoryCursorPreimage :: InventoryQueryCursor -> Preimage+inventoryCursorPreimage queryCursor =+  PRecord+    "durable-cursor"+    [ PText (subscriptionIdText (queryCursor ^. #subscriptionId)),+      PText (queryCursor ^. #subscriptionName)+    ]++subscriptionPreimage :: InventorySubscription -> Preimage+subscriptionPreimage subscription =+  PRecord+    "subscription"+    [ PText (subscriptionIdText (subscription ^. #subscriptionId)),+      PText (subscription ^. #subscriptionName),+      PText (sourceIdText (subscription ^. #sourceId)),+      PText (missingCheckpointPolicyText (subscription ^. #checkpointOnMissing))+    ]++dedupPreimage :: InventoryDedupKey -> Preimage+dedupPreimage key =+  PRecord+    "dedup"+    [ PText (dedupKeyIdText (key ^. #dedupKeyId)),+      PText (key ^. #dedupName)+    ]++-- | Render the inventory for operators. This human-readable text is not the+-- fingerprint preimage; identity uses the canonical tree encoding above.+renderInventory :: CatalogInventory -> Text+renderInventory inventory =+  Text.unlines+    ( map renderSource (inventory ^. #inventorySources)+        <> map renderTarget (inventory ^. #inventoryTargets)+        <> map renderGroup (inventory ^. #inventoryGroups)+        <> map renderProjection (inventory ^. #inventoryProjections)+        <> map renderProjectionRevision (inventory ^. #inventoryProjectionRevisions)+        <> map renderExternalReadContract (inventory ^. #inventoryExternalReadContracts)+        <> map renderQuery (inventory ^. #inventoryQueryModels)+        <> map renderSubscription (inventory ^. #inventorySubscriptions)+        <> map renderDedup (inventory ^. #inventoryDedupKeys)+    )+  where+    renderSource :: InventorySource -> Text+    renderSource source =+      Text.intercalate+        "|"+        [ "source",+          sourceIdText (source ^. #sourceId),+          renderScope (source ^. #sourceScope),+          source ^. #codecFingerprint+        ]+    renderTarget :: InventoryTarget -> Text+    renderTarget target =+      Text.intercalate+        "|"+        [ "target",+          targetIdText (target ^. #targetId),+          target ^. (#qualifiedTable . #schemaName),+          target ^. (#qualifiedTable . #tableName),+          renderReset (target ^. #resetPolicy),+          commaSeparated targetIdText (target ^. #dependsOn),+          projectionIdText (target ^. #owner)+        ]+    renderGroup :: InventoryGroup -> Text+    renderGroup group =+      Text.intercalate+        "|"+        [ "group",+          rebuildGroupIdText (group ^. #rebuildGroupId),+          commaSeparated targetIdText (group ^. #orderedTargets),+          Text.intercalate+            ","+            [ verificationId <> "@" <> verificationVersion+            | (verificationId, verificationVersion) <- group ^. #verifications+            ]+        ]+    renderProjection :: InventoryProjection -> Text+    renderProjection projection =+      Text.intercalate+        "|"+        [ "projection",+          projectionIdText (projection ^. #projectionId),+          sourceIdText (projection ^. #sourceId),+          rebuildGroupIdText (projection ^. #rebuildGroupId),+          commaSeparated targetIdText (projection ^. #ownedTargets),+          projection ^. #replayDisposition,+          Text.intercalate "," (map renderHandler (projection ^. #handlers))+        ]+    renderProjectionRevision :: InventoryProjectionRevision -> Text+    renderProjectionRevision revision =+      Text.intercalate+        "|"+        [ "projection-revision",+          projectionRevisionIdText (revision ^. #revisionId),+          rebuildGroupIdText (revision ^. #rebuildGroupId),+          Text.intercalate "," (map renderTargetProvisioner (revision ^. #targetProvisioners)),+          Text.intercalate "," (map (renderRevisionHandler "live") (revision ^. #liveHandlers)),+          Text.intercalate "," (map (renderRevisionHandler "replay") (revision ^. #replayAdapters)),+          Text.intercalate "," (map (renderRevisionHandler "verify") (revision ^. #verifications))+        ]+    renderExternalReadContract :: InventoryExternalReadContract -> Text+    renderExternalReadContract contract =+      Text.intercalate+        "|"+        [ "external-read-contract",+          externalReadContractIdText (contract ^. #readContractId),+          Text.pack (show (externalReadContractVersionValue (contract ^. #contractVersion))),+          queryModelIdText (contract ^. #queryModelId),+          rebuildGroupIdText (contract ^. #rebuildGroupId),+          contract ^. #functionName,+          case contract ^. #contractKind of+            InventoryAllRowsExternalRead -> "all-rows"+            InventoryKeyedExternalRead -> "keyed",+          Text.intercalate "," (map renderArgument (contract ^. #arguments)),+          renderQualifiedType (contract ^. #resultContractType),+          maybe "-" renderQualifiedFunction (contract ^. #privateImplementation),+          maybe "-" (Text.pack . show) (contract ^. #privateImplementationVersion),+          contract ^. #resultShapeHash,+          Text.intercalate+            ","+            (map projectionRevisionIdText (NonEmpty.toList (contract ^. #compatibleRevisions))),+          Text.pack (show (contract ^. #surfaceGeneration))+        ]+    renderArgument :: SqlFunctionArgument -> Text+    renderArgument argument = argument ^. #argumentName <> ":" <> renderQualifiedType (argument ^. #argumentType)+    renderQuery :: InventoryQueryModel -> Text+    renderQuery query =+      Text.intercalate+        "|"+        [ "query",+          queryModelIdText (query ^. #queryModelId),+          query ^. #registryName,+          Text.pack (show (query ^. #version)),+          query ^. #shapeHash,+          rebuildGroupIdText (query ^. #rebuildGroupId),+          commaSeparated targetIdText (query ^. #observedTargets),+          renderInventoryFreshness (query ^. #freshness),+          maybe "no-cursor" renderInventoryCursor (query ^. #cursor)+        ]+    renderSubscription :: InventorySubscription -> Text+    renderSubscription subscription =+      Text.intercalate+        "|"+        [ "subscription",+          subscriptionIdText (subscription ^. #subscriptionId),+          subscription ^. #subscriptionName,+          sourceIdText (subscription ^. #sourceId),+          missingCheckpointPolicyText (subscription ^. #checkpointOnMissing)+        ]+    renderDedup :: InventoryDedupKey -> Text+    renderDedup key =+      Text.intercalate+        "|"+        ["dedup", dedupKeyIdText (key ^. #dedupKeyId), key ^. #dedupName]++renderInventoryFreshness :: InventoryQueryFreshness -> Text+renderInventoryFreshness InventoryImmediate = "immediate"+renderInventoryFreshness (InventoryWaitForHead scope) =+  "wait-for-head:" <> headScopeText scope+renderInventoryFreshness InventoryWaitForPosition = "wait-for-position"++renderInventoryCursor :: InventoryQueryCursor -> Text+renderInventoryCursor queryCursor =+  "cursor:"+    <> subscriptionIdText (queryCursor ^. #subscriptionId)+    <> "/"+    <> queryCursor ^. #subscriptionName++renderScope :: SourceScope -> Text+renderScope AllStreams = "$all"+renderScope (CategorySource (CategoryName category)) = "category:" <> category++renderReset :: TargetResetPolicy -> Text+renderReset ClearBeforeReplay = "clear-before-replay"+renderReset PreserveAndReconcile = "preserve-and-reconcile"++renderQualifiedTable :: QualifiedTable -> Text+renderQualifiedTable table = table ^. #schemaName <> "." <> table ^. #tableName++renderHandler :: InventoryHandler -> Text+renderHandler (InventoryInlineHandler name) = "inline:" <> name+renderHandler (InventoryAsyncHandler name subscriptionId dedupId) =+  Text.intercalate ":" ["async", name, subscriptionIdText subscriptionId, dedupKeyIdText dedupId]++renderTargetProvisioner :: InventoryTargetProvisioner -> Text+renderTargetProvisioner provisioner =+  Text.intercalate+    "@"+    [ targetIdText (provisioner ^. #targetId),+      provisioner ^. #provisionerId <> ":" <> Text.pack (show (provisioner ^. #provisionerVersion)),+      targetSchemaVersionText (provisioner ^. #schemaVersion),+      provisioner ^. #expectedShapeId,+      provisioner ^. #validatorId <> ":" <> Text.pack (show (provisioner ^. #validatorVersion)),+      Text.intercalate ";" (map renderPromotionObject (provisioner ^. #promotionObjectNames))+    ]++renderRevisionHandler :: Text -> InventoryRevisionHandler -> Text+renderRevisionHandler kind handler =+  Text.intercalate+    ":"+    [ kind,+      handler ^. #handlerId,+      Text.pack (show (handler ^. #handlerVersion)),+      maybe "-" renderRevisionLiveDelivery (handler ^. #delivery),+      commaSeparated targetIdText (handler ^. #requiredTargets)+    ]++renderRevisionLiveDelivery :: RevisionLiveDelivery -> Text+renderRevisionLiveDelivery = \case+  RevisionInlineDelivery projectionId handlerName ->+    Text.intercalate "/" ["inline", projectionIdText projectionId, handlerName]+  RevisionSubscriptionDelivery projectionId subscriptionId dedupId ->+    Text.intercalate+      "/"+      [ "subscription",+        projectionIdText projectionId,+        subscriptionIdText subscriptionId,+        dedupKeyIdText dedupId+      ]++renderPromotionObject :: PromotionObjectName -> Text+renderPromotionObject object =+  Text.intercalate+    ":"+    [ promotionObjectKindText (object ^. #objectKind),+      object ^. #generationName,+      object ^. #canonicalName+    ]++promotionObjectKindText :: PromotionObjectKind -> Text+promotionObjectKindText PromotionIndex = "index"+promotionObjectKindText PromotionConstraint = "constraint"+promotionObjectKindText PromotionOwnedSequence = "owned-sequence"++targetSchemaVersionText :: TargetSchemaVersion -> Text+targetSchemaVersionText (TargetSchemaVersion value) = value++commaSeparated :: (value -> Text) -> [value] -> Text+commaSeparated render = Text.intercalate "," . map render++diagnostic :: CatalogDiagnosticCode -> Text -> [ClaimSite] -> Text -> CatalogDiagnostic+diagnostic code identity sites message =+  CatalogDiagnostic+    { diagnosticCode = code,+      diagnosticIdentity = identity,+      diagnosticSites = List.sort (List.nub sites),+      diagnosticMessage = message+    }++duplicates :: (Ord value) => [value] -> [value]+duplicates =+  map NonEmpty.head+    . filter ((> 1) . NonEmpty.length)+    . NonEmpty.group+    . List.sort++newtype UnmanagedInlineProjections event+  = UnmanagedInlineProjections [InlineProjection event]++-- | Mark an existing hand-maintained inline list as outside catalog validation.+unmanagedInlineProjections :: [InlineProjection event] -> UnmanagedInlineProjections event+unmanagedInlineProjections = UnmanagedInlineProjections++getUnmanagedInlineProjections :: UnmanagedInlineProjections event -> [InlineProjection event]+getUnmanagedInlineProjections (UnmanagedInlineProjections projections) = projections++newtype UnmanagedAsyncProjection+  = UnmanagedAsyncProjection AsyncProjection++-- | Mark a legacy async projection as outside catalog validation.+unmanagedAsyncProjection :: AsyncProjection -> UnmanagedAsyncProjection+unmanagedAsyncProjection = UnmanagedAsyncProjection++getUnmanagedAsyncProjection :: UnmanagedAsyncProjection -> AsyncProjection+getUnmanagedAsyncProjection (UnmanagedAsyncProjection projection) = projection++newtype UnmanagedReadModel q r+  = UnmanagedReadModel (ReadModel q r)++-- | Mark a legacy read model as outside catalog validation.+unmanagedReadModel :: ReadModel q r -> UnmanagedReadModel q r+unmanagedReadModel = UnmanagedReadModel++getUnmanagedReadModel :: UnmanagedReadModel q r -> ReadModel q r+getUnmanagedReadModel (UnmanagedReadModel model) = model
+ src/Keiro/Projection/Catalog/Operations.hs view
@@ -0,0 +1,1455 @@+-- | Operator-neutral inventory and rebuild actions derived from one validated+-- projection catalog. This module deliberately contains no command parser,+-- renderer, confirmation policy, or database connection details.+module Keiro.Projection.Catalog.Operations+  ( ProjectionCatalogOperations,+    projectionCatalogOperations,+    CatalogInventoryReport (..),+    RebuildPreview (..),+    RegisteredRebuildPreview (..),+    CatalogAdoptionGroupPreview (..),+    CatalogAdoptionRegistrationPreview (..),+    CatalogAdoptionOrphanPreview (..),+    CatalogAdoptionReport (..),+    CatalogAdoptionOutcome (..),+    CatalogRunReport (..),+    CatalogVersionedStartOptions (..),+    CatalogVersionedRunReport (..),+    CatalogRetiredGenerationsReport (..),+    CatalogRetiredDropReport (..),+    CatalogExternalReadRetirementReport (..),+    CatalogStreamReprojectionPreview (..),+    CatalogStreamReprojectionReport (..),+    CatalogOpsError (..),+    catalogInventoryReport,+    previewGroupRebuild,+    previewRegisteredGroupRebuild,+    previewCatalogAdoption,+    adoptCatalogGroups,+    startGroupRebuild,+    inspectGroupRebuild,+    resumeGroupRebuild,+    abandonGroupRebuild,+    startVersionedGroupRebuild,+    inspectVersionedGroupRebuild,+    resumeVersionedGroupRebuild,+    abandonVersionedGroupRebuild,+    listRetiredGenerations,+    previewRetiredGenerationDrop,+    dropRetiredGeneration,+    inspectExternalReadContract,+    retireExternalReadContract,+    previewStreamReprojection,+    reprojectCatalogStream,+  )+where++import Data.Aeson qualified as Aeson+import Data.Bifunctor (first)+import Data.Int (Int32)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Time (DiffTime)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, (:>))+import Keiro.Prelude+import Keiro.Projection.Catalog+import Keiro.ReadModel (HeadScope (..))+import Keiro.ReadModel.External qualified as External+import Keiro.ReadModel.Rebuild+  ( CatalogAdoptionError (..),+    CatalogRebuildError,+    GroupAdoptionClass (..),+    GroupLifecycleStatus (..),+    GroupRebuildMetadata,+    OrphanedRegistration (..),+    RebuildAdapterProgress,+    RebuildFailure,+    RebuildFailureEvidence,+    RebuildOptions,+    RebuildRunId,+    RebuildRunReport,+    RebuildRunStatus (..),+    RebuildSourceProgress,+    RebuildVerificationProgress,+    RegistrationAdoption (..),+    RegistrationAdoptionAction (..),+    StreamReprojectionError (..),+    StreamReprojectionReport,+    StreamReprojectionRequest (..),+    VersionedRebuildError,+    VersionedRebuildReport,+    VersionedRebuildRequest (..),+    VersionedRetiredDropResult,+    VersionedRetiredGenerationPreview,+    VersionedTargetGeneration,+    VersionedTargetMode,+    abandonCatalogRebuild,+    abandonVersionedRebuild,+    beginVersionedRebuild,+    dropVersionedRetiredGeneration,+    inspectCatalogRebuild,+    inspectVersionedRebuild,+    listVersionedRetiredGenerations,+    lookupProjectionGroupStatus,+    lookupProjectionRebuildGroup,+    preCanonicalRunSliceSentinel,+    previewVersionedRetiredDrop,+    rebuildRunIdText,+    reprojectStream,+    resumeCatalogRebuild,+    resumeVersionedRebuild,+    startCatalogRebuild,+  )+import Keiro.ReadModel.Rebuild qualified as Rebuild+import Keiro.ReadModel.Rebuild.Stream (validateStreamReprojectionAdmission)+import Kiroku.Store qualified as Kiroku+import Kiroku.Store.Effect (Store)+import Kiroku.Store.HistoryRetention+  ( HistoryRetentionLeaseRequest (..),+    mkHistoryRetentionLeaseDuration,+    mkHistoryRetentionLeaseOwner,+    mkHistoryRetentionLeaseReason,+  )+import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..), StreamName (..), StreamVersion (..))++newtype ProjectionCatalogOperations = ProjectionCatalogOperations ValidatedProjectionCatalog+  deriving stock (Generic)++projectionCatalogOperations :: ValidatedProjectionCatalog -> ProjectionCatalogOperations+projectionCatalogOperations = ProjectionCatalogOperations++-- | Stable, JSON-friendly envelope for the complete heterogeneous inventory.+data CatalogInventoryReport = CatalogInventoryReport+  { reportSchema :: !Text,+    catalogFingerprint :: !Text,+    groupSlices :: ![(RebuildGroupId, Text)],+    inventory :: !CatalogInventory+  }+  deriving stock (Eq, Show, Generic)++-- | A pure, non-mutating description of one group rebuild. Every list is+-- selected from the validated catalog; no caller-supplied reset or handler list+-- can enter the report.+data RebuildPreview = RebuildPreview+  { reportSchema :: !Text,+    rebuildGroupId :: !RebuildGroupId,+    catalogFingerprint :: !Text,+    sliceFingerprint :: !Text,+    targets :: ![InventoryTarget],+    sources :: ![InventorySource],+    projections :: ![InventoryProjection],+    queryModels :: ![InventoryQueryModel],+    subscriptionResets :: ![InventorySubscription],+    dedupResets :: ![InventoryDedupKey],+    verifications :: ![(Text, Text)],+    lockScope :: ![RebuildGroupId],+    capturedHeadStrategy :: !Text,+    destructive :: !Bool+  }+  deriving stock (Eq, Show, Generic)++-- | The pure preview plus an optional read-only lifecycle observation. A+-- missing row means the validated group has not been registered yet.+data RegisteredRebuildPreview = RegisteredRebuildPreview+  { reportSchema :: !Text,+    preview :: !RebuildPreview,+    registeredState :: !(Maybe GroupRebuildMetadata),+    registeredSliceMatches :: !(Maybe Bool)+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionGroupPreview = CatalogAdoptionGroupPreview+  { rebuildGroupId :: !RebuildGroupId,+    classification :: !GroupAdoptionClass,+    storedSlice :: !(Maybe Text),+    currentSlice :: !Text,+    inScope :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionRegistrationPreview = CatalogAdoptionRegistrationPreview+  { registryName :: !Text,+    rebuildGroupId :: !RebuildGroupId,+    action :: !RegistrationAdoptionAction,+    inScope :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionOrphanPreview = CatalogAdoptionOrphanPreview+  { registryName :: !Text,+    boundGroupId :: !RebuildGroupId,+    inScope :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionReport = CatalogAdoptionReport+  { reportSchema :: !Text,+    catalogFingerprint :: !Text,+    requestedGroups :: ![RebuildGroupId],+    groups :: ![CatalogAdoptionGroupPreview],+    registrations :: ![CatalogAdoptionRegistrationPreview],+    orphanedRegistrations :: ![CatalogAdoptionOrphanPreview],+    removedGroups :: ![RebuildGroupId],+    outOfScopeChangedGroups :: ![RebuildGroupId]+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionOutcome = CatalogAdoptionOutcome+  { reportSchema :: !Text,+    adoptedGroups :: ![GroupRebuildMetadata],+    registrationOutcomes :: ![RegistrationAdoption],+    removedOrphans :: ![OrphanedRegistration]+  }+  deriving stock (Eq, Show, Generic)++-- | Versioned operations envelope around the runtime runner's structured+-- progress report.+data CatalogRunReport = CatalogRunReport+  { reportSchema :: !Text,+    run :: !RebuildRunReport+  }+  deriving stock (Eq, Show, Generic)++data CatalogVersionedStartOptions = CatalogVersionedStartOptions+  { rebuildRunId :: !RebuildRunId,+    rebuildGroupId :: !RebuildGroupId,+    servingRevisionId :: !ProjectionRevisionId,+    candidateRevisionId :: !ProjectionRevisionId,+    targetMode :: !VersionedTargetMode,+    replayPageSize :: !Int32,+    cutoverThreshold :: !Int64,+    cutoverLockTimeoutMs :: !Int64,+    promotionDedupLimit :: !Int64,+    retentionDuration :: !DiffTime,+    requestedBy :: !Text,+    requestReason :: !Text+  }+  deriving stock (Eq, Show, Generic)++data CatalogVersionedRunReport = CatalogVersionedRunReport+  { reportSchema :: !Text,+    run :: !VersionedRebuildReport+  }+  deriving stock (Eq, Show, Generic)++data CatalogRetiredGenerationsReport = CatalogRetiredGenerationsReport+  { reportSchema :: !Text,+    generations :: ![VersionedTargetGeneration]+  }+  deriving stock (Eq, Show, Generic)++data CatalogRetiredDropReport+  = CatalogRetiredDropPreview !VersionedRetiredGenerationPreview+  | CatalogRetiredDropOutcome !VersionedRetiredDropResult+  deriving stock (Eq, Show, Generic)++data CatalogExternalReadRetirementReport = CatalogExternalReadRetirementReport+  { reportSchema :: !Text,+    contractId :: !ExternalReadContractId,+    contractVersion :: !ExternalReadContractVersion,+    publicFunction :: !Text,+    currentState :: !Text,+    surfaceGeneration :: !Int,+    dependentObjects :: ![Text],+    executeGrants :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++data CatalogStreamReprojectionPreview = CatalogStreamReprojectionPreview+  { reportSchema :: !Text,+    rebuildGroupId :: !RebuildGroupId,+    projectionId :: !ProjectionId,+    streamName :: !StreamName,+    servingRevisionId :: !ProjectionRevisionId,+    targets :: ![InventoryTarget],+    affectedDedup :: ![InventoryDedupKey],+    streamVersion :: !(Maybe StreamVersion),+    eventCount :: !(Maybe Int64),+    expectedDedupClaims :: !(Maybe Integer),+    maxEvents :: !Int64,+    softDeleted :: !Bool,+    truncateBefore :: !(Maybe StreamVersion),+    eligible :: !Bool,+    refusal :: !(Maybe Text),+    forceOperation :: !Text+  }+  deriving stock (Eq, Show, Generic)++data CatalogStreamReprojectionReport = CatalogStreamReprojectionReport+  { reportSchema :: !Text,+    repair :: !StreamReprojectionReport+  }+  deriving stock (Eq, Show, Generic)++data CatalogOpsError+  = CatalogOpsUnknownGroup !RebuildGroupId+  | CatalogOpsRunSliceMismatch !RebuildRunId !Text !Text+  | CatalogOpsAdoptionRefused !CatalogAdoptionError+  | CatalogOpsRebuildError !CatalogRebuildError+  | CatalogOpsVersionedError !VersionedRebuildError+  | CatalogOpsExternalReadRetirementError !External.ExternalReadRetirementError+  | CatalogOpsStreamReprojectionError !StreamReprojectionError+  | CatalogOpsInvalidVersionedRequest !Text+  deriving stock (Eq, Show, Generic)++catalogInventoryReport :: ProjectionCatalogOperations -> CatalogInventoryReport+catalogInventoryReport (ProjectionCatalogOperations catalog) =+  CatalogInventoryReport+    { reportSchema = "keiro/catalog-inventory/v2",+      catalogFingerprint = catalogFingerprintText (Keiro.Projection.Catalog.catalogFingerprint catalog),+      groupSlices =+        [ (groupId, sliceFor groupId)+        | groupId <- (^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups)+        ],+      inventory = catalogInventory catalog+    }+  where+    sliceFor groupId =+      maybe+        (error "catalogInventoryReport: inventory group has no slice")+        groupSliceFingerprintText+        (Keiro.Projection.Catalog.groupSliceFingerprint catalog groupId)++previewGroupRebuild ::+  ProjectionCatalogOperations ->+  RebuildGroupId ->+  Either CatalogOpsError RebuildPreview+previewGroupRebuild (ProjectionCatalogOperations catalog) wantedGroup = do+  group <-+    maybe+      (Left (CatalogOpsUnknownGroup wantedGroup))+      Right+      (List.find ((== wantedGroup) . (^. #rebuildGroupId)) groups)+  pure+    RebuildPreview+      { reportSchema = "keiro/catalog-rebuild-preview/v2",+        rebuildGroupId = wantedGroup,+        catalogFingerprint = catalogFingerprintText (Keiro.Projection.Catalog.catalogFingerprint catalog),+        sliceFingerprint =+          maybe+            (error "previewGroupRebuild: inventory group has no slice")+            groupSliceFingerprintText+            (Keiro.Projection.Catalog.groupSliceFingerprint catalog wantedGroup),+        targets = groupTargets,+        sources = groupSources,+        projections = groupProjections,+        queryModels = filter ((== wantedGroup) . (^. #rebuildGroupId)) (inventory ^. #inventoryQueryModels),+        subscriptionResets = groupSubscriptions,+        dedupResets = groupDedupKeys,+        verifications = group ^. #verifications,+        lockScope = [wantedGroup],+        capturedHeadStrategy = "capture one immutable store head after acquiring the group fence",+        destructive = any ((== ClearBeforeReplay) . (^. #resetPolicy)) groupTargets+      }+  where+    inventory = catalogInventory catalog+    groups = inventory ^. #inventoryGroups+    groupTargets =+      mapMaybe+        (\targetId -> List.find ((== targetId) . (^. #targetId)) (inventory ^. #inventoryTargets))+        (maybe [] (^. #orderedTargets) (List.find ((== wantedGroup) . (^. #rebuildGroupId)) groups))+    groupProjections = filter ((== wantedGroup) . (^. #rebuildGroupId)) (inventory ^. #inventoryProjections)+    wantedSourceIds = List.nub (map (^. #sourceId) groupProjections)+    groupSources = filter ((`elem` wantedSourceIds) . (^. #sourceId)) (inventory ^. #inventorySources)+    asyncIdentities =+      [ (subscriptionId, dedupKeyId)+      | projection <- groupProjections,+        InventoryAsyncHandler _ subscriptionId dedupKeyId <- projection ^. #handlers+      ]+    wantedSubscriptionIds = List.nub (map fst asyncIdentities)+    wantedDedupIds = List.nub (map snd asyncIdentities)+    groupSubscriptions = filter ((`elem` wantedSubscriptionIds) . (^. #subscriptionId)) (inventory ^. #inventorySubscriptions)+    groupDedupKeys = filter ((`elem` wantedDedupIds) . (^. #dedupKeyId)) (inventory ^. #inventoryDedupKeys)++previewRegisteredGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildGroupId ->+  Eff es (Either CatalogOpsError RegisteredRebuildPreview)+previewRegisteredGroupRebuild operations wantedGroup =+  case previewGroupRebuild operations wantedGroup of+    Left err -> pure (Left err)+    Right purePreview -> do+      state <- lookupProjectionRebuildGroup wantedGroup+      let expectedSlice = purePreview ^. #sliceFingerprint+      pure+        ( Right+            RegisteredRebuildPreview+              { reportSchema = "keiro/catalog-registered-rebuild-preview/v1",+                preview = purePreview,+                registeredState = state,+                registeredSliceMatches = ((== expectedSlice) . (^. #sliceFingerprint)) <$> state+              }+        )++previewCatalogAdoption ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  NonEmpty RebuildGroupId ->+  Eff es (Either CatalogOpsError CatalogAdoptionReport)+previewCatalogAdoption (ProjectionCatalogOperations catalog) wantedGroups =+  case List.find (`Set.notMember` catalogGroupIds) requested of+    Just groupId -> pure (Left (CatalogOpsAdoptionRefused (AdoptGroupNotInCatalog groupId)))+    Nothing -> do+      plan <- Rebuild.previewCatalogAdoption catalog+      pure+        ( Right+            CatalogAdoptionReport+              { reportSchema = "keiro/catalog-adoption-preview/v2",+                catalogFingerprint = catalogFingerprintText (Keiro.Projection.Catalog.catalogFingerprint catalog),+                requestedGroups = requested,+                groups = map (groupPreview catalog requestedSet) (plan ^. #groupStates),+                registrations = map (registrationPreview requestedSet) (plan ^. #registrations),+                orphanedRegistrations = map (orphanPreview requestedSet) (plan ^. #orphanedRegistrations),+                removedGroups = plan ^. #removedGroups,+                outOfScopeChangedGroups =+                  [ groupId+                  | (groupId, adoptionClass) <- plan ^. #groupStates,+                    changedAdoptionClass adoptionClass,+                    Set.notMember groupId requestedSet+                  ]+              }+        )+  where+    requested = List.sort (Set.toList requestedSet)+    requestedSet = Set.fromList (NonEmpty.toList wantedGroups)+    catalogGroupIds =+      Set.fromList+        (map (^. #rebuildGroupId) (catalogInventory catalog ^. #inventoryGroups))++adoptCatalogGroups ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  NonEmpty RebuildGroupId ->+  Eff es (Either CatalogOpsError CatalogAdoptionOutcome)+adoptCatalogGroups (ProjectionCatalogOperations catalog) groups =+  Rebuild.adoptCatalogGroups catalog groups <&> \case+    Left err -> Left (CatalogOpsAdoptionRefused err)+    Right result ->+      Right+        CatalogAdoptionOutcome+          { reportSchema = "keiro/catalog-adoption-outcome/v2",+            adoptedGroups = result ^. #adoptedGroups,+            registrationOutcomes = result ^. #registrationOutcomes,+            removedOrphans = result ^. #removedOrphans+          }++startGroupRebuild ::+  (IOE :> es, Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildGroupId ->+  RebuildOptions ->+  Eff es (Either CatalogOpsError CatalogRunReport)+startGroupRebuild operations@(ProjectionCatalogOperations catalog) groupId options =+  case previewGroupRebuild operations groupId of+    Left err -> pure (Left err)+    Right _ -> wrapRun <$> startCatalogRebuild catalog groupId options++inspectGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildRunId ->+  Eff es (Either CatalogOpsError CatalogRunReport)+inspectGroupRebuild (ProjectionCatalogOperations catalog) runId =+  inspectCatalogRebuild runId <&> \case+    Left err -> Left (CatalogOpsRebuildError err)+    Right report+      | report ^. #groupSliceFingerprint == preCanonicalRunSliceSentinel ->+          Right (catalogRunReport report)+      | otherwise ->+          case Keiro.Projection.Catalog.groupSliceFingerprint catalog (report ^. #rebuildGroupId) of+            Nothing -> Left (CatalogOpsUnknownGroup (report ^. #rebuildGroupId))+            Just currentSlice ->+              let expected = groupSliceFingerprintText currentSlice+                  actual = report ^. #groupSliceFingerprint+               in if actual == expected+                    then Right (catalogRunReport report)+                    else Left (CatalogOpsRunSliceMismatch runId expected actual)++resumeGroupRebuild ::+  (IOE :> es, Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildRunId ->+  RebuildOptions ->+  Eff es (Either CatalogOpsError CatalogRunReport)+resumeGroupRebuild (ProjectionCatalogOperations catalog) runId options =+  wrapRun <$> resumeCatalogRebuild catalog runId options++abandonGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildRunId ->+  RebuildFailure ->+  Eff es (Either CatalogOpsError CatalogRunReport)+abandonGroupRebuild (ProjectionCatalogOperations catalog) runId failure =+  wrapRun <$> abandonCatalogRebuild catalog runId failure++startVersionedGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  CatalogVersionedStartOptions ->+  Eff es (Either CatalogOpsError CatalogVersionedRunReport)+startVersionedGroupRebuild operations@(ProjectionCatalogOperations catalog) options =+  case versionedRequestFor operations options of+    Left err -> pure (Left err)+    Right request ->+      beginVersionedRebuild catalog request >>= \case+        Left err -> pure (Left (CatalogOpsVersionedError err))+        Right _ -> inspectVersionedGroupRebuild operations (options ^. #rebuildRunId)++inspectVersionedGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildRunId ->+  Eff es (Either CatalogOpsError CatalogVersionedRunReport)+inspectVersionedGroupRebuild (ProjectionCatalogOperations _) runId =+  inspectVersionedRebuild runId <&> mapVersionedRun++resumeVersionedGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildRunId ->+  Eff es (Either CatalogOpsError CatalogVersionedRunReport)+resumeVersionedGroupRebuild (ProjectionCatalogOperations catalog) runId =+  resumeVersionedRebuild catalog runId <&> mapVersionedRun++abandonVersionedGroupRebuild ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  RebuildRunId ->+  Eff es (Either CatalogOpsError CatalogVersionedRunReport)+abandonVersionedGroupRebuild operations runId =+  abandonVersionedRebuild runId >>= \case+    Left err -> pure (Left (CatalogOpsVersionedError err))+    Right _ -> inspectVersionedGroupRebuild operations runId++listRetiredGenerations ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  Eff es CatalogRetiredGenerationsReport+listRetiredGenerations _ =+  CatalogRetiredGenerationsReport "keiro/catalog-retired-generations/v1"+    <$> listVersionedRetiredGenerations++previewRetiredGenerationDrop ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  TargetGenerationId ->+  Eff es (Either CatalogOpsError CatalogRetiredDropReport)+previewRetiredGenerationDrop (ProjectionCatalogOperations catalog) generationId =+  previewVersionedRetiredDrop catalog generationId+    <&> first CatalogOpsVersionedError+    <&> fmap CatalogRetiredDropPreview++dropRetiredGeneration ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  TargetGenerationId ->+  Eff es (Either CatalogOpsError CatalogRetiredDropReport)+dropRetiredGeneration (ProjectionCatalogOperations catalog) generationId =+  dropVersionedRetiredGeneration catalog generationId+    <&> first CatalogOpsVersionedError+    <&> fmap CatalogRetiredDropOutcome++inspectExternalReadContract ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  ExternalReadContractId ->+  ExternalReadContractVersion ->+  Eff es (Either CatalogOpsError CatalogExternalReadRetirementReport)+inspectExternalReadContract _ contractId contractVersion =+  External.previewExternalReadContractRetirement contractId contractVersion+    <&> first CatalogOpsExternalReadRetirementError+    <&> fmap (externalReadRetirementReport "keiro/catalog-external-read-inspection/v1")++retireExternalReadContract ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  ExternalReadContractId ->+  ExternalReadContractVersion ->+  Eff es (Either CatalogOpsError CatalogExternalReadRetirementReport)+retireExternalReadContract _ contractId contractVersion =+  External.retireExternalReadContract contractId contractVersion+    <&> first CatalogOpsExternalReadRetirementError+    <&> fmap (externalReadRetirementReport "keiro/catalog-external-read-retirement/v1")++previewStreamReprojection ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  StreamReprojectionRequest ->+  Eff es (Either CatalogOpsError CatalogStreamReprojectionPreview)+previewStreamReprojection (ProjectionCatalogOperations catalog) request+  | request ^. #pageSize <= 0 =+      pure+        ( Left+            ( CatalogOpsStreamReprojectionError+                (StreamReprojectionInvalidPageSize (request ^. #pageSize))+            )+        )+  | request ^. #maxEvents <= 0 =+      pure+        ( Left+            ( CatalogOpsStreamReprojectionError+                (StreamReprojectionInvalidMaxEvents (request ^. #maxEvents))+            )+        )+  | otherwise = do+      metadata <- lookupProjectionRebuildGroup (request ^. #rebuildGroupId)+      status <- lookupProjectionGroupStatus (request ^. #rebuildGroupId)+      case status >>= (^. #servingRevisionId) of+        Nothing ->+          pure+            ( Left+                ( CatalogOpsStreamReprojectionError+                    ( case status of+                        Nothing -> StreamReprojectionGroupUnregistered (request ^. #rebuildGroupId)+                        Just observed ->+                          StreamReprojectionGroupUnavailable+                            (request ^. #rebuildGroupId)+                            (observed ^. #lifecyclePhase)+                            (observed ^. #readsAllowed)+                            (observed ^. #writesAllowed)+                    )+                )+            )+        Just revisionId ->+          case catalogProjectionRevision catalog revisionId of+            Nothing ->+              pure+                ( Left+                    ( CatalogOpsStreamReprojectionError+                        (StreamReprojectionServingRevisionUnavailable (request ^. #rebuildGroupId) revisionId)+                    )+                )+            Just _ ->+              case validateStreamReprojectionAdmission catalog request of+                Left err -> pure (Left (CatalogOpsStreamReprojectionError err))+                Right () ->+                  case catalogStreamScopedReplay catalog revisionId (request ^. #projectionId) of+                    Nothing ->+                      pure+                        ( Left+                            ( CatalogOpsStreamReprojectionError+                                (StreamReprojectionPolicyUnavailable revisionId (request ^. #projectionId))+                            )+                        )+                    Just policy -> do+                      streamInfo <- Kiroku.getStream (request ^. #streamName)+                      let inventory = catalogInventory catalog+                          wantedTargets = Set.fromList (NonEmpty.toList (policy ^. #streamOwnedTargets))+                          wantedDedup = Set.fromList (policy ^. #affectedAsyncDedup)+                          targets =+                            filter+                              ((`Set.member` wantedTargets) . (^. #targetId))+                              (inventory ^. #inventoryTargets)+                          dedup =+                            filter+                              ((`Set.member` wantedDedup) . (^. #dedupKeyId))+                              (inventory ^. #inventoryDedupKeys)+                          refusal = previewRefusal metadata status streamInfo+                      pure+                        ( Right+                            CatalogStreamReprojectionPreview+                              { reportSchema = "keiro/catalog-stream-reprojection-preview/v2",+                                rebuildGroupId = request ^. #rebuildGroupId,+                                projectionId = request ^. #projectionId,+                                streamName = request ^. #streamName,+                                servingRevisionId = revisionId,+                                targets,+                                affectedDedup = dedup,+                                streamVersion = (^. #version) <$> streamInfo,+                                eventCount = streamEventCount <$> streamInfo,+                                expectedDedupClaims =+                                  (\info -> toInteger (streamEventCount info) * toInteger (length dedup))+                                    <$> streamInfo,+                                maxEvents = request ^. #maxEvents,+                                softDeleted = maybe False (isJust . (^. #deletedAt)) streamInfo,+                                truncateBefore = (^. #truncateBefore) <$> streamInfo,+                                eligible = isNothing refusal,+                                refusal,+                                forceOperation =+                                  "rebuild reproject-stream "+                                    <> rebuildGroupIdText (request ^. #rebuildGroupId)+                                    <> " "+                                    <> projectionIdText (request ^. #projectionId)+                                    <> " "+                                    <> streamNameText (request ^. #streamName)+                                    <> " --page-size "+                                    <> Text.pack (show (request ^. #pageSize))+                                    <> " --max-events "+                                    <> Text.pack (show (request ^. #maxEvents))+                                    <> " --force"+                              }+                        )+  where+    previewRefusal maybeMetadata maybeStatus streamInfo =+      case maybeStatus of+        Just observed+          | isJust (observed ^. #activeRunId) -> Just "active-rebuild"+          | not (sliceMatches maybeMetadata) -> Just "slice-drift"+          | observed ^. #lifecyclePhase /= "serving-versioned"+              || not (observed ^. #readsAllowed)+              || not (observed ^. #writesAllowed) ->+              Just "group-unavailable"+        _ -> case streamInfo of+          Nothing -> Just "stream-missing"+          Just info+            | isJust (info ^. #deletedAt) -> Just "stream-soft-deleted"+            | info ^. #truncateBefore > StreamVersion 0 -> Just "stream-truncated"+            | streamEventCount info > request ^. #maxEvents -> Just "stream-event-limit-exceeded"+            | otherwise -> Nothing++    sliceMatches maybeMetadata =+      case (maybeMetadata, groupSliceFingerprint catalog (request ^. #rebuildGroupId)) of+        (Just registered, Just current) ->+          registered ^. #sliceFingerprint == groupSliceFingerprintText current+        _ -> False++    streamNameText (StreamName value) = value++    streamEventCount info =+      case info ^. #version of+        StreamVersion value -> value++reprojectCatalogStream ::+  (Store :> es) =>+  ProjectionCatalogOperations ->+  StreamReprojectionRequest ->+  Eff es (Either CatalogOpsError CatalogStreamReprojectionReport)+reprojectCatalogStream (ProjectionCatalogOperations catalog) request =+  reprojectStream catalog request+    <&> first CatalogOpsStreamReprojectionError+    <&> fmap (CatalogStreamReprojectionReport "keiro/catalog-stream-reprojection-outcome/v2")++externalReadRetirementReport :: Text -> External.ExternalReadRetirementPreview -> CatalogExternalReadRetirementReport+externalReadRetirementReport reportSchema retirement =+  CatalogExternalReadRetirementReport+    { reportSchema,+      contractId = retirement ^. #contractId,+      contractVersion = retirement ^. #contractVersion,+      publicFunction = retirement ^. #publicFunction,+      currentState = retirement ^. #currentState,+      surfaceGeneration = retirement ^. #surfaceGeneration,+      dependentObjects = retirement ^. #dependentObjects,+      executeGrants = retirement ^. #executeGrants+    }++mapVersionedRun ::+  Either VersionedRebuildError VersionedRebuildReport ->+  Either CatalogOpsError CatalogVersionedRunReport+mapVersionedRun =+  first CatalogOpsVersionedError+    . fmap (CatalogVersionedRunReport "keiro/catalog-versioned-rebuild-run/v1")++versionedRequestFor ::+  ProjectionCatalogOperations ->+  CatalogVersionedStartOptions ->+  Either CatalogOpsError VersionedRebuildRequest+versionedRequestFor (ProjectionCatalogOperations catalog) options = do+  servingRevision <-+    maybe+      (Left (CatalogOpsInvalidVersionedRequest "serving revision is absent from the catalog"))+      Right+      (catalogProjectionRevision catalog (options ^. #servingRevisionId))+  let inventoryTargets = catalogInventory catalog ^. #inventoryTargets+      bindings =+        Map.fromList+          [ (target ^. #targetId, target ^. #qualifiedTable)+          | target <- inventoryTargets+          ]+  servingTargets <-+    first (CatalogOpsInvalidVersionedRequest . Text.pack . show) $+      mkPhysicalTargets+        (Map.keys (servingRevision ^. #targetProvisioners))+        bindings+  owner <-+    first (CatalogOpsInvalidVersionedRequest . Text.pack . show) $+      mkHistoryRetentionLeaseOwner+        ("keiro-rebuild/" <> rebuildRunIdText (options ^. #rebuildRunId))+  reason <-+    first (CatalogOpsInvalidVersionedRequest . Text.pack . show) $+      mkHistoryRetentionLeaseReason (options ^. #requestReason)+  duration <-+    first (CatalogOpsInvalidVersionedRequest . Text.pack . show) $+      mkHistoryRetentionLeaseDuration (options ^. #retentionDuration)+  pure+    VersionedRebuildRequest+      { rebuildRunId = options ^. #rebuildRunId,+        rebuildGroupId = options ^. #rebuildGroupId,+        servingRevisionId = options ^. #servingRevisionId,+        candidateRevisionId = options ^. #candidateRevisionId,+        servingTargets,+        targetMode = options ^. #targetMode,+        replayPageSize = options ^. #replayPageSize,+        cutoverThreshold = options ^. #cutoverThreshold,+        cutoverLockTimeoutMs = options ^. #cutoverLockTimeoutMs,+        promotionDedupLimit = options ^. #promotionDedupLimit,+        retentionLeaseRequest = HistoryRetentionLeaseRequest owner reason duration,+        requestedBy = options ^. #requestedBy,+        requestReason = options ^. #requestReason+      }++wrapRun :: Either CatalogRebuildError RebuildRunReport -> Either CatalogOpsError CatalogRunReport+wrapRun = \case+  Left err -> Left (CatalogOpsRebuildError err)+  Right report -> Right (catalogRunReport report)++catalogRunReport :: RebuildRunReport -> CatalogRunReport+catalogRunReport report =+  CatalogRunReport+    { reportSchema = "keiro/catalog-rebuild-run/v1",+      run = report+    }++instance Aeson.ToJSON CatalogInventoryReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "catalogFingerprint" Aeson..= (report ^. #catalogFingerprint),+        "groupSlices"+          Aeson..= [ Aeson.object+                       [ "groupId" Aeson..= rebuildGroupIdText groupId,+                         "sliceFingerprint" Aeson..= slice+                       ]+                   | (groupId, slice) <- report ^. #groupSlices+                   ],+        "inventory" Aeson..= inventoryValue (report ^. #inventory)+      ]++instance Aeson.ToJSON RebuildPreview where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "groupId" Aeson..= rebuildGroupIdText (report ^. #rebuildGroupId),+        "catalogFingerprint" Aeson..= (report ^. #catalogFingerprint),+        "sliceFingerprint" Aeson..= (report ^. #sliceFingerprint),+        "targets" Aeson..= map targetValue (report ^. #targets),+        "sources" Aeson..= map sourceValue (report ^. #sources),+        "projections" Aeson..= map projectionValue (report ^. #projections),+        "queryModels" Aeson..= map queryModelValue (report ^. #queryModels),+        "subscriptionResets" Aeson..= map subscriptionValue (report ^. #subscriptionResets),+        "dedupResets" Aeson..= map dedupValue (report ^. #dedupResets),+        "verifications" Aeson..= map verificationValue (report ^. #verifications),+        "lockScope" Aeson..= map rebuildGroupIdText (report ^. #lockScope),+        "capturedHeadStrategy" Aeson..= (report ^. #capturedHeadStrategy),+        "destructive" Aeson..= (report ^. #destructive)+      ]++instance Aeson.ToJSON RegisteredRebuildPreview where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "preview" Aeson..= (report ^. #preview),+        "registeredState" Aeson..= fmap groupMetadataValue (report ^. #registeredState),+        "registeredSliceMatches" Aeson..= (report ^. #registeredSliceMatches)+      ]++instance Aeson.ToJSON CatalogAdoptionReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "catalogFingerprint" Aeson..= (report ^. #catalogFingerprint),+        "requestedGroups" Aeson..= map rebuildGroupIdText (report ^. #requestedGroups),+        "groups" Aeson..= map adoptionGroupValue (report ^. #groups),+        "registrations" Aeson..= map adoptionRegistrationValue (report ^. #registrations),+        "orphanedRegistrations" Aeson..= map adoptionOrphanPreviewValue (report ^. #orphanedRegistrations),+        "removedGroups" Aeson..= map rebuildGroupIdText (report ^. #removedGroups),+        "outOfScopeChangedGroups" Aeson..= map rebuildGroupIdText (report ^. #outOfScopeChangedGroups)+      ]++instance Aeson.ToJSON CatalogAdoptionOutcome where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "adoptedGroups" Aeson..= map groupMetadataValue (report ^. #adoptedGroups),+        "registrationOutcomes" Aeson..= map registrationOutcomeValue (report ^. #registrationOutcomes),+        "removedOrphans" Aeson..= map removedOrphanValue (report ^. #removedOrphans)+      ]++instance Aeson.ToJSON CatalogRunReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "run" Aeson..= runValue (report ^. #run)+      ]++instance Aeson.ToJSON CatalogVersionedRunReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "run" Aeson..= versionedRunValue (report ^. #run)+      ]++instance Aeson.ToJSON CatalogRetiredGenerationsReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "generations" Aeson..= map versionedGenerationValue (report ^. #generations)+      ]++instance Aeson.ToJSON CatalogRetiredDropReport where+  toJSON = \case+    CatalogRetiredDropPreview previewReport ->+      Aeson.object+        [ "schema" Aeson..= ("keiro/catalog-retired-drop-preview/v1" :: Text),+          "generation" Aeson..= versionedGenerationValue (previewReport ^. #generation),+          "activeRunId" Aeson..= fmap rebuildRunIdText (previewReport ^. #activeRunId),+          "supportedReadContracts" Aeson..= (previewReport ^. #supportedReadContracts),+          "externalDependencies" Aeson..= (previewReport ^. #externalDependencies),+          "droppable" Aeson..= (previewReport ^. #droppable)+        ]+    CatalogRetiredDropOutcome outcome ->+      Aeson.object+        [ "schema" Aeson..= ("keiro/catalog-retired-drop-outcome/v1" :: Text),+          "generation" Aeson..= versionedGenerationValue (outcome ^. #generation),+          "alreadyDropped" Aeson..= (outcome ^. #alreadyDropped)+        ]++instance Aeson.ToJSON CatalogExternalReadRetirementReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "contractId" Aeson..= externalReadContractIdText (report ^. #contractId),+        "contractVersion" Aeson..= externalReadContractVersionValue (report ^. #contractVersion),+        "publicFunction" Aeson..= (report ^. #publicFunction),+        "currentState" Aeson..= (report ^. #currentState),+        "surfaceGeneration" Aeson..= (report ^. #surfaceGeneration),+        "dependentObjects" Aeson..= (report ^. #dependentObjects),+        "executeGrants" Aeson..= (report ^. #executeGrants)+      ]++instance Aeson.ToJSON CatalogStreamReprojectionPreview where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "groupId" Aeson..= rebuildGroupIdText (report ^. #rebuildGroupId),+        "projectionId" Aeson..= projectionIdText (report ^. #projectionId),+        "streamName" Aeson..= streamNameValue (report ^. #streamName),+        "servingRevisionId" Aeson..= projectionRevisionIdText (report ^. #servingRevisionId),+        "targets" Aeson..= map targetValue (report ^. #targets),+        "affectedDedup" Aeson..= map dedupValue (report ^. #affectedDedup),+        "streamVersion" Aeson..= fmap streamVersionValue (report ^. #streamVersion),+        "eventCount" Aeson..= (report ^. #eventCount),+        "expectedDedupClaims" Aeson..= (report ^. #expectedDedupClaims),+        "maxEvents" Aeson..= (report ^. #maxEvents),+        "softDeleted" Aeson..= (report ^. #softDeleted),+        "truncateBefore" Aeson..= fmap streamVersionValue (report ^. #truncateBefore),+        "eligible" Aeson..= (report ^. #eligible),+        "refusal" Aeson..= (report ^. #refusal),+        "forceOperation" Aeson..= (report ^. #forceOperation)+      ]++instance Aeson.ToJSON CatalogStreamReprojectionReport where+  toJSON report =+    Aeson.object+      [ "schema" Aeson..= (report ^. #reportSchema),+        "repair" Aeson..= streamReprojectionValue (report ^. #repair)+      ]++streamReprojectionValue :: StreamReprojectionReport -> Aeson.Value+streamReprojectionValue report =+  Aeson.object+    [ "groupId" Aeson..= rebuildGroupIdText (report ^. #rebuildGroupId),+      "projectionId" Aeson..= projectionIdText (report ^. #projectionId),+      "streamName" Aeson..= streamNameValue (report ^. #streamName),+      "servingRevisionId" Aeson..= projectionRevisionIdText (report ^. #servingRevisionId),+      "streamVersion" Aeson..= streamVersionValue (report ^. #streamVersion),+      "maxEvents" Aeson..= (report ^. #maxEvents),+      "clearedRows"+        Aeson..= [ Aeson.object+                     [ "targetId" Aeson..= targetIdText (count ^. #targetId),+                       "rows" Aeson..= (count ^. #clearedRows)+                     ]+                 | count <- report ^. #clearedRows+                 ],+      "replayedEvents" Aeson..= (report ^. #replayedEvents),+      "appliedEvents" Aeson..= (report ^. #appliedEvents),+      "dedupInserted" Aeson..= (report ^. #dedupInserted),+      "dedupExisting" Aeson..= (report ^. #dedupExisting),+      "verified" Aeson..= (report ^. #verified)+    ]++streamNameValue :: StreamName -> Text+streamNameValue (StreamName value) = value++streamVersionValue :: StreamVersion -> Int64+streamVersionValue (StreamVersion value) = value++inventoryValue :: CatalogInventory -> Aeson.Value+inventoryValue catalog =+  Aeson.object+    [ "sources" Aeson..= map sourceValue (catalog ^. #inventorySources),+      "targets" Aeson..= map targetValue (catalog ^. #inventoryTargets),+      "groups" Aeson..= map groupValue (catalog ^. #inventoryGroups),+      "projections" Aeson..= map projectionValue (catalog ^. #inventoryProjections),+      "projectionRevisions" Aeson..= map projectionRevisionValue (catalog ^. #inventoryProjectionRevisions),+      "queryModels" Aeson..= map queryModelValue (catalog ^. #inventoryQueryModels),+      "subscriptions" Aeson..= map subscriptionValue (catalog ^. #inventorySubscriptions),+      "dedupKeys" Aeson..= map dedupValue (catalog ^. #inventoryDedupKeys)+    ]++projectionRevisionValue :: InventoryProjectionRevision -> Aeson.Value+projectionRevisionValue revision =+  Aeson.object+    [ "revisionId" Aeson..= projectionRevisionIdText (revision ^. #revisionId),+      "groupId" Aeson..= rebuildGroupIdText (revision ^. #rebuildGroupId),+      "targetProvisioners" Aeson..= map targetProvisionerValue (revision ^. #targetProvisioners),+      "liveHandlers" Aeson..= map revisionHandlerValue (revision ^. #liveHandlers),+      "replayAdapters" Aeson..= map revisionHandlerValue (revision ^. #replayAdapters),+      "verifications" Aeson..= map revisionHandlerValue (revision ^. #verifications),+      "streamScopedReplays" Aeson..= map streamScopedReplayValue (revision ^. #streamScopedReplays)+    ]++targetProvisionerValue :: InventoryTargetProvisioner -> Aeson.Value+targetProvisionerValue provisioner =+  Aeson.object+    [ "targetId" Aeson..= targetIdText (provisioner ^. #targetId),+      "provisionerId" Aeson..= (provisioner ^. #provisionerId),+      "provisionerVersion" Aeson..= (provisioner ^. #provisionerVersion),+      "schemaVersion" Aeson..= schemaVersionText (provisioner ^. #schemaVersion),+      "expectedShapeId" Aeson..= (provisioner ^. #expectedShapeId),+      "validatorId" Aeson..= (provisioner ^. #validatorId),+      "validatorVersion" Aeson..= (provisioner ^. #validatorVersion),+      "promotionObjects"+        Aeson..= [ Aeson.object+                     [ "kind" Aeson..= Text.pack (show (object ^. #objectKind)),+                       "generationName" Aeson..= (object ^. #generationName),+                       "canonicalName" Aeson..= (object ^. #canonicalName)+                     ]+                 | object <- provisioner ^. #promotionObjectNames+                 ]+    ]++revisionHandlerValue :: InventoryRevisionHandler -> Aeson.Value+revisionHandlerValue handler =+  Aeson.object+    [ "id" Aeson..= (handler ^. #handlerId),+      "version" Aeson..= (handler ^. #handlerVersion),+      "delivery" Aeson..= fmap revisionLiveDeliveryValue (handler ^. #delivery),+      "requiredTargets" Aeson..= map targetIdText (handler ^. #requiredTargets)+    ]++revisionLiveDeliveryValue :: RevisionLiveDelivery -> Aeson.Value+revisionLiveDeliveryValue = \case+  RevisionInlineDelivery projectionId handlerName ->+    Aeson.object+      [ "kind" Aeson..= ("inline" :: Text),+        "projectionId" Aeson..= projectionIdText projectionId,+        "handlerName" Aeson..= handlerName+      ]+  RevisionSubscriptionDelivery projectionId subscriptionId dedupId ->+    Aeson.object+      [ "kind" Aeson..= ("subscription" :: Text),+        "projectionId" Aeson..= projectionIdText projectionId,+        "subscriptionId" Aeson..= subscriptionIdText subscriptionId,+        "dedupKeyId" Aeson..= dedupKeyIdText dedupId+      ]++streamScopedReplayValue :: InventoryStreamScopedReplay -> Aeson.Value+streamScopedReplayValue replay =+  Aeson.object+    [ "projectionId" Aeson..= projectionIdText (replay ^. #projectionId),+      "ownedTargets" Aeson..= map targetIdText (replay ^. #ownedTargets),+      "clearer" Aeson..= identityValue (replay ^. #clearerId) (replay ^. #clearerVersion),+      "replay" Aeson..= identityValue (replay ^. #replayId) (replay ^. #replayVersion),+      "verification" Aeson..= identityValue (replay ^. #verificationId) (replay ^. #verificationVersion),+      "affectedAsyncDedup" Aeson..= map dedupKeyIdText (replay ^. #affectedAsyncDedup)+    ]+  where+    identityValue identityText identityVersion =+      Aeson.object+        [ "id" Aeson..= identityText,+          "version" Aeson..= identityVersion+        ]++sourceValue :: InventorySource -> Aeson.Value+sourceValue source =+  Aeson.object+    [ "sourceId" Aeson..= sourceIdText (source ^. #sourceId),+      "scope" Aeson..= sourceScopeValue (source ^. #sourceScope),+      "codecFingerprint" Aeson..= (source ^. #codecFingerprint)+    ]++sourceScopeValue :: SourceScope -> Aeson.Value+sourceScopeValue = \case+  AllStreams -> Aeson.object ["kind" Aeson..= ("all-streams" :: Text)]+  CategorySource (CategoryName category) ->+    Aeson.object+      [ "kind" Aeson..= ("category" :: Text),+        "category" Aeson..= category+      ]++targetValue :: InventoryTarget -> Aeson.Value+targetValue target =+  Aeson.object+    [ "targetId" Aeson..= targetIdText (target ^. #targetId),+      "table" Aeson..= qualifiedTableValue (target ^. #qualifiedTable),+      "resetPolicy" Aeson..= resetPolicyText (target ^. #resetPolicy),+      "dependsOn" Aeson..= map targetIdText (target ^. #dependsOn),+      "owner" Aeson..= projectionIdText (target ^. #owner)+    ]++qualifiedTableValue :: QualifiedTable -> Aeson.Value+qualifiedTableValue table =+  Aeson.object+    [ "schema" Aeson..= (table ^. #schemaName),+      "table" Aeson..= (table ^. #tableName)+    ]++resetPolicyText :: TargetResetPolicy -> Text+resetPolicyText = \case+  ClearBeforeReplay -> "clear-before-replay"+  PreserveAndReconcile -> "preserve-and-reconcile"++groupValue :: InventoryGroup -> Aeson.Value+groupValue group =+  Aeson.object+    [ "groupId" Aeson..= rebuildGroupIdText (group ^. #rebuildGroupId),+      "orderedTargets" Aeson..= map targetIdText (group ^. #orderedTargets),+      "verifications" Aeson..= map verificationValue (group ^. #verifications)+    ]++verificationValue :: (Text, Text) -> Aeson.Value+verificationValue (identity, version) =+  Aeson.object+    [ "id" Aeson..= identity,+      "version" Aeson..= version+    ]++projectionValue :: InventoryProjection -> Aeson.Value+projectionValue projection =+  Aeson.object+    [ "projectionId" Aeson..= projectionIdText (projection ^. #projectionId),+      "sourceId" Aeson..= sourceIdText (projection ^. #sourceId),+      "groupId" Aeson..= rebuildGroupIdText (projection ^. #rebuildGroupId),+      "ownedTargets" Aeson..= map targetIdText (projection ^. #ownedTargets),+      "replayPolicy" Aeson..= (projection ^. #replayDisposition),+      "handlers" Aeson..= map handlerValue (projection ^. #handlers)+    ]++handlerValue :: InventoryHandler -> Aeson.Value+handlerValue = \case+  InventoryInlineHandler name ->+    Aeson.object+      [ "kind" Aeson..= ("inline" :: Text),+        "name" Aeson..= name+      ]+  InventoryAsyncHandler name subscriptionId dedupKeyId ->+    Aeson.object+      [ "kind" Aeson..= ("async" :: Text),+        "name" Aeson..= name,+        "subscriptionId" Aeson..= subscriptionIdText subscriptionId,+        "dedupKeyId" Aeson..= dedupKeyIdText dedupKeyId+      ]++queryModelValue :: InventoryQueryModel -> Aeson.Value+queryModelValue queryModel =+  Aeson.object+    [ "queryModelId" Aeson..= queryModelIdText (queryModel ^. #queryModelId),+      "registryName" Aeson..= (queryModel ^. #registryName),+      "version" Aeson..= (queryModel ^. #version),+      "shapeHash" Aeson..= (queryModel ^. #shapeHash),+      "groupId" Aeson..= rebuildGroupIdText (queryModel ^. #rebuildGroupId),+      "observedTargets" Aeson..= map targetIdText (queryModel ^. #observedTargets),+      "freshness" Aeson..= queryFreshnessValue (queryModel ^. #freshness),+      "cursor" Aeson..= fmap queryCursorValue (queryModel ^. #cursor)+    ]++queryFreshnessValue :: InventoryQueryFreshness -> Aeson.Value+queryFreshnessValue InventoryImmediate =+  Aeson.object ["kind" Aeson..= ("immediate" :: Text)]+queryFreshnessValue (InventoryWaitForHead scope) =+  Aeson.object+    [ "kind" Aeson..= ("wait-for-head" :: Text),+      "scope" Aeson..= headScopeValue scope+    ]+queryFreshnessValue InventoryWaitForPosition =+  Aeson.object ["kind" Aeson..= ("wait-for-position" :: Text)]++headScopeValue :: HeadScope -> Aeson.Value+headScopeValue EntireVisibleLog =+  Aeson.object ["kind" Aeson..= ("entire-visible-log" :: Text)]+headScopeValue (CategoryVisibleHead category) =+  Aeson.object+    [ "kind" Aeson..= ("category-visible-head" :: Text),+      "category" Aeson..= category+    ]++queryCursorValue :: InventoryQueryCursor -> Aeson.Value+queryCursorValue queryCursor =+  Aeson.object+    [ "subscriptionId" Aeson..= subscriptionIdText (queryCursor ^. #subscriptionId),+      "subscriptionName" Aeson..= (queryCursor ^. #subscriptionName)+    ]++subscriptionValue :: InventorySubscription -> Aeson.Value+subscriptionValue subscription =+  Aeson.object+    [ "subscriptionId" Aeson..= subscriptionIdText (subscription ^. #subscriptionId),+      "name" Aeson..= (subscription ^. #subscriptionName),+      "sourceId" Aeson..= sourceIdText (subscription ^. #sourceId),+      "checkpointOnMissing" Aeson..= missingCheckpointPolicyText (subscription ^. #checkpointOnMissing)+    ]++dedupValue :: InventoryDedupKey -> Aeson.Value+dedupValue dedupKey =+  Aeson.object+    [ "dedupKeyId" Aeson..= dedupKeyIdText (dedupKey ^. #dedupKeyId),+      "name" Aeson..= (dedupKey ^. #dedupName)+    ]++groupMetadataValue :: GroupRebuildMetadata -> Aeson.Value+groupMetadataValue metadata =+  Aeson.object+    [ "groupId" Aeson..= rebuildGroupIdText (metadata ^. #rebuildGroupId),+      "sliceFingerprint" Aeson..= (metadata ^. #sliceFingerprint),+      "status" Aeson..= lifecycleStatusText (metadata ^. #status),+      "activeRunId" Aeson..= fmap rebuildRunIdText (metadata ^. #activeRunId),+      "requestedBy" Aeson..= (metadata ^. #requestedBy),+      "requestReason" Aeson..= (metadata ^. #requestReason),+      "startedAt" Aeson..= (metadata ^. #startedAt),+      "completedAt" Aeson..= (metadata ^. #completedAt),+      "failedAt" Aeson..= (metadata ^. #failedAt),+      "failureCode" Aeson..= (metadata ^. #failureCode),+      "failureDetail" Aeson..= (metadata ^. #failureDetail)+    ]++groupPreview :: ValidatedProjectionCatalog -> Set.Set RebuildGroupId -> (RebuildGroupId, GroupAdoptionClass) -> CatalogAdoptionGroupPreview+groupPreview catalog requestedSet (groupId, adoptionClass) =+  CatalogAdoptionGroupPreview+    { rebuildGroupId = groupId,+      classification = adoptionClass,+      storedSlice = case adoptionClass of+        AdoptionNew -> Nothing+        AdoptionUnchanged -> Just current+        AdoptionSliceChanged stored _ -> Just stored+        AdoptionStaleFormat stored -> Just stored,+      currentSlice = current,+      inScope = Set.member groupId requestedSet+    }+  where+    current =+      maybe+        (error "groupPreview: catalog inventory group has no slice")+        groupSliceFingerprintText+        (Keiro.Projection.Catalog.groupSliceFingerprint catalog groupId)++registrationPreview :: Set.Set RebuildGroupId -> RegistrationAdoption -> CatalogAdoptionRegistrationPreview+registrationPreview requestedSet registration =+  CatalogAdoptionRegistrationPreview+    { registryName = registration ^. #registryName,+      rebuildGroupId = registration ^. #rebuildGroupId,+      action = registration ^. #action,+      inScope = Set.member (registration ^. #rebuildGroupId) requestedSet+    }++orphanPreview :: Set.Set RebuildGroupId -> OrphanedRegistration -> CatalogAdoptionOrphanPreview+orphanPreview requestedSet orphan =+  CatalogAdoptionOrphanPreview+    { registryName = orphan ^. #registryName,+      boundGroupId = orphan ^. #boundGroupId,+      inScope = Set.member (orphan ^. #boundGroupId) requestedSet+    }++adoptionGroupValue :: CatalogAdoptionGroupPreview -> Aeson.Value+adoptionGroupValue group =+  Aeson.object+    [ "groupId" Aeson..= rebuildGroupIdText (group ^. #rebuildGroupId),+      "state" Aeson..= adoptionStateText (group ^. #classification),+      "storedSlice" Aeson..= (group ^. #storedSlice),+      "currentSlice" Aeson..= (group ^. #currentSlice),+      "inScope" Aeson..= (group ^. #inScope)+    ]++adoptionRegistrationValue :: CatalogAdoptionRegistrationPreview -> Aeson.Value+adoptionRegistrationValue registration =+  Aeson.object+    [ "name" Aeson..= (registration ^. #registryName),+      "groupId" Aeson..= rebuildGroupIdText (registration ^. #rebuildGroupId),+      "action" Aeson..= registrationActionText (registration ^. #action),+      "inScope" Aeson..= (registration ^. #inScope)+    ]++adoptionOrphanPreviewValue :: CatalogAdoptionOrphanPreview -> Aeson.Value+adoptionOrphanPreviewValue orphan =+  Aeson.object+    [ "name" Aeson..= (orphan ^. #registryName),+      "groupId" Aeson..= rebuildGroupIdText (orphan ^. #boundGroupId),+      "inScope" Aeson..= (orphan ^. #inScope)+    ]++registrationOutcomeValue :: RegistrationAdoption -> Aeson.Value+registrationOutcomeValue registration =+  Aeson.object+    [ "name" Aeson..= (registration ^. #registryName),+      "groupId" Aeson..= rebuildGroupIdText (registration ^. #rebuildGroupId),+      "outcome" Aeson..= registrationOutcomeText (registration ^. #action)+    ]++removedOrphanValue :: OrphanedRegistration -> Aeson.Value+removedOrphanValue orphan =+  Aeson.object+    [ "name" Aeson..= (orphan ^. #registryName),+      "groupId" Aeson..= rebuildGroupIdText (orphan ^. #boundGroupId),+      "outcome" Aeson..= ("orphaned-old-name" :: Text)+    ]++registrationActionText :: RegistrationAdoptionAction -> Text+registrationActionText = \case+  RegistrationUpdate -> "update"+  RegistrationInsert -> "insert"++registrationOutcomeText :: RegistrationAdoptionAction -> Text+registrationOutcomeText = \case+  RegistrationUpdate -> "adopted"+  RegistrationInsert -> "inserted"++changedAdoptionClass :: GroupAdoptionClass -> Bool+changedAdoptionClass = \case+  AdoptionSliceChanged {} -> True+  AdoptionStaleFormat {} -> True+  AdoptionNew -> False+  AdoptionUnchanged -> False++adoptionStateText :: GroupAdoptionClass -> Text+adoptionStateText = \case+  AdoptionNew -> "new"+  AdoptionUnchanged -> "unchanged"+  AdoptionSliceChanged {} -> "slice-changed"+  AdoptionStaleFormat {} -> "stale-format"++lifecycleStatusText :: GroupLifecycleStatus -> Text+lifecycleStatusText = \case+  GroupLive -> "live"+  GroupRebuilding -> "rebuilding"+  GroupFailed -> "failed"+  UnknownGroupStatus value -> value++versionedRunValue :: VersionedRebuildReport -> Aeson.Value+versionedRunValue report =+  Aeson.object+    [ "runId" Aeson..= rebuildRunIdText (report ^. #rebuildRunId),+      "groupId" Aeson..= rebuildGroupIdText (report ^. #rebuildGroupId),+      "phase" Aeson..= Text.pack (show (report ^. #phase)),+      "servingRevisionId" Aeson..= projectionRevisionIdText (report ^. #servingRevisionId),+      "candidateRevisionId" Aeson..= projectionRevisionIdText (report ^. #candidateRevisionId),+      "servingEpoch" Aeson..= (report ^. #servingEpoch),+      "capturedHead" Aeson..= globalPositionValue (report ^. #capturedHead),+      "pageSize" Aeson..= (report ^. #replayPageSize),+      "cutoverThreshold" Aeson..= (report ^. #cutoverThreshold),+      "cutoverLockTimeoutMs" Aeson..= (report ^. #cutoverLockTimeoutMs),+      "promotionDedupLimit" Aeson..= (report ^. #promotionDedupLimit),+      "stagedDedupCount" Aeson..= (report ^. #stagedDedupCount),+      "dedupProvisionalHead" Aeson..= fmap globalPositionValue (report ^. #dedupProvisionalHead),+      "promotionPrepared" Aeson..= (report ^. #promotionPrepared),+      "sources" Aeson..= map versionedSourceValue (report ^. #sources),+      "servingGenerations" Aeson..= map versionedGenerationValue (report ^. #servingGenerations),+      "candidateGenerations" Aeson..= map versionedGenerationValue (report ^. #candidateGenerations)+    ]++versionedSourceValue :: Rebuild.VersionedSourceProgress -> Aeson.Value+versionedSourceValue progress =+  Aeson.object+    [ "sourceId" Aeson..= sourceIdText (progress ^. #sourceId),+      "scope" Aeson..= sourceScopeValue (progress ^. #sourceScope),+      "cursor" Aeson..= globalPositionValue (progress ^. #cursorPosition),+      "target" Aeson..= globalPositionValue (progress ^. #targetPosition),+      "exhaustedThrough" Aeson..= fmap globalPositionValue (progress ^. #exhaustedThrough),+      "eventCount" Aeson..= (progress ^. #eventCount)+    ]++versionedGenerationValue :: VersionedTargetGeneration -> Aeson.Value+versionedGenerationValue generation =+  Aeson.object+    [ "generationId" Aeson..= generationIdText (generation ^. #generationId),+      "groupId" Aeson..= rebuildGroupIdText (generation ^. #rebuildGroupId),+      "targetId" Aeson..= targetIdText (generation ^. #targetId),+      "revisionId" Aeson..= projectionRevisionIdText (generation ^. #revisionId),+      "physicalTable" Aeson..= qualifiedTableValue (generation ^. #physicalTable),+      "relationOid" Aeson..= (generation ^. #relationOid),+      "schemaVersion" Aeson..= schemaVersionText (generation ^. #schemaVersion),+      "expectedShapeId" Aeson..= (generation ^. #expectedShapeId),+      "observedShapeFingerprint" Aeson..= (generation ^. #observedShapeFingerprint),+      "lifecycle" Aeson..= Text.pack (show (generation ^. #lifecycle))+    ]++generationIdText :: TargetGenerationId -> Text+generationIdText (TargetGenerationId value) = UUID.toText value++schemaVersionText :: TargetSchemaVersion -> Text+schemaVersionText (TargetSchemaVersion value) = value++runValue :: RebuildRunReport -> Aeson.Value+runValue report =+  Aeson.object+    [ "runId" Aeson..= rebuildRunIdText (report ^. #rebuildRunId),+      "groupId" Aeson..= rebuildGroupIdText (report ^. #rebuildGroupId),+      "catalogFingerprint" Aeson..= (report ^. #catalogFingerprint),+      "groupSliceFingerprint" Aeson..= (report ^. #groupSliceFingerprint),+      "contractFingerprint" Aeson..= (report ^. #contractFingerprint),+      "runnerFormat" Aeson..= (report ^. #runnerFormatVersion),+      "capturedHead" Aeson..= globalPositionValue (report ^. #capturedHead),+      "pageSize" Aeson..= (report ^. #configuredPageSize),+      "status" Aeson..= runStatusText (report ^. #runStatus),+      "failure" Aeson..= fmap failureValue (report ^. #failureEvidence),+      "sources" Aeson..= map sourceProgressValue (report ^. #sources),+      "adapters" Aeson..= map adapterProgressValue (report ^. #adapters),+      "verifications" Aeson..= map verificationProgressValue (report ^. #verifications)+    ]++runStatusText :: RebuildRunStatus -> Text+runStatusText = \case+  RebuildRunRunning -> "running"+  RebuildRunFailed -> "failed"+  RebuildRunVerified -> "verified"+  RebuildRunPromoted -> "promoted"+  UnknownRebuildRunStatus value -> value++failureValue :: RebuildFailureEvidence -> Aeson.Value+failureValue failure =+  Aeson.object+    [ "code" Aeson..= (failure ^. #failureCode),+      "detail" Aeson..= (failure ^. #failureDetail),+      "sourceId" Aeson..= fmap sourceIdText (failure ^. #failureSourceId),+      "projectionId" Aeson..= (failure ^. #failureProjectionId),+      "position" Aeson..= fmap globalPositionValue (failure ^. #failurePosition)+    ]++sourceProgressValue :: RebuildSourceProgress -> Aeson.Value+sourceProgressValue progress =+  Aeson.object+    [ "sourceId" Aeson..= sourceIdText (progress ^. #sourceId),+      "scope" Aeson..= sourceScopeValue (progress ^. #sourceScope),+      "cursor" Aeson..= globalPositionValue (progress ^. #cursorPosition),+      "target" Aeson..= globalPositionValue (progress ^. #targetPosition),+      "exhaustedThrough" Aeson..= fmap globalPositionValue (progress ^. #exhaustedThrough),+      "eventCount" Aeson..= (progress ^. #eventCount)+    ]++adapterProgressValue :: RebuildAdapterProgress -> Aeson.Value+adapterProgressValue progress =+  Aeson.object+    [ "sourceId" Aeson..= sourceIdText (progress ^. #sourceId),+      "projectionId" Aeson..= (progress ^. #projectionId),+      "order" Aeson..= (progress ^. #adapterOrder),+      "evaluationCount" Aeson..= (progress ^. #evaluationCount),+      "applyCount" Aeson..= (progress ^. #applyCount),+      "completedThrough" Aeson..= fmap globalPositionValue (progress ^. #completedThrough)+    ]++verificationProgressValue :: RebuildVerificationProgress -> Aeson.Value+verificationProgressValue progress =+  Aeson.object+    [ "id" Aeson..= (progress ^. #verificationId),+      "version" Aeson..= (progress ^. #verificationVersion),+      "status" Aeson..= (progress ^. #verificationStatus),+      "detail" Aeson..= (progress ^. #verificationDetail)+    ]++globalPositionValue :: GlobalPosition -> Int64+globalPositionValue (GlobalPosition value) = value
+ src/Keiro/Projection/Catalog/Preimage.hs view
@@ -0,0 +1,65 @@+{-# OPTIONS_HADDOCK hide #-}++-- | Canonical, injection-proof preimage encoding for catalog fingerprints.+module Keiro.Projection.Catalog.Preimage+  ( Preimage (..),+    renderPreimage,+    hashPreimage,+  )+where++import Crypto.Hash.SHA256 qualified as SHA256+import Data.ByteString qualified as ByteString+import Data.ByteString.Base16 qualified as Base16+import Data.ByteString.Builder qualified as Builder+import Data.ByteString.Lazy qualified as LazyByteString+import Data.Text.Encoding qualified as Text+import Keiro.Prelude++-- | A typed tree whose rendering preserves every structural boundary.+--+-- Every node starts with a distinguishing letter. Every variable-length+-- payload is preceded by its exact byte length or child count, so the rendering+-- is a prefix code over trees: two distinct trees first differ at a node where+-- the constructor letter, declared length, or payload bytes differ.+data Preimage+  = PText !Text+  | PList ![Preimage]+  | PRecord !Text ![Preimage]+  deriving stock (Eq, Ord, Show, Generic)++renderPreimage :: Preimage -> ByteString.ByteString+renderPreimage =+  LazyByteString.toStrict+    . Builder.toLazyByteString+    . renderBuilder++hashPreimage :: Text -> Preimage -> Text+hashPreimage prefix preimage =+  prefix+    <> ":"+    <> Text.decodeUtf8 (Base16.encode (SHA256.hash (renderPreimage preimage)))++renderBuilder :: Preimage -> Builder.Builder+renderBuilder = \case+  PText value ->+    let bytes = Text.encodeUtf8 value+     in Builder.char8 't'+          <> Builder.intDec (ByteString.length bytes)+          <> Builder.char8 ':'+          <> Builder.byteString bytes+  PList values ->+    Builder.char8 'l'+      <> Builder.intDec (length values)+      <> Builder.char8 ':'+      <> foldMap renderBuilder values+  PRecord tag fields ->+    let bytes = Text.encodeUtf8 tag+     in Builder.char8 'r'+          <> Builder.intDec (ByteString.length bytes)+          <> Builder.char8 ':'+          <> Builder.byteString bytes+          <> Builder.char8 'n'+          <> Builder.intDec (length fields)+          <> Builder.char8 ':'+          <> foldMap renderBuilder fields
+ src/Keiro/Projection/Types.hs view
@@ -0,0 +1,39 @@+{-# OPTIONS_HADDOCK hide #-}++module Keiro.Projection.Types+  ( InlineProjection (..),+    AsyncProjection (..),+  )+where++import Keiro.Prelude+import Kiroku.Store.Types (EventId, RecordedEvent)+import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | A projection applied in the same SQL transaction as its source event+-- append. A failing handler rolls back both the append and the target writes.+data InlineProjection event = InlineProjection+  { -- | Stable operational name used in inventory and diagnostics.+    name :: !Text,+    -- | Apply one decoded domain event together with its durable event-store+    -- envelope.+    apply :: !(event -> RecordedEvent -> Tx.Transaction ())+  }+  deriving stock (Generic)++-- | An at-least-once projection applied from a recorded event. The physical+-- name remains the compatibility dedup identity; catalog-aware callers also+-- bind it to stable logical subscription, dedup, projection, and group IDs.+data AsyncProjection = AsyncProjection+  { -- | Physical projection name used by the dedup table.+    name :: !Text,+    -- | Legacy read-model registry name used by unmanaged fencing.+    readModelName :: !Text,+    -- | Delivery checkpoint identity used by unmanaged workers.+    subscriptionName :: !Text,+    -- | Apply one recorded event to application-owned targets.+    applyRecorded :: !(RecordedEvent -> Tx.Transaction ()),+    -- | Stable event identity inserted transactionally before application.+    idempotencyKey :: !(RecordedEvent -> EventId)+  }+  deriving stock (Generic)
src/Keiro/ReadModel.hs view
@@ -1,23 +1,25 @@--- | Querying the read side, with explicit consistency.+-- | Querying the read side, with explicit freshness. -- -- 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.+-- mid-rebuild model), then honours its default 'QueryFreshness' before running+-- the query in a transaction. ----- The consistency modes trade freshness against latency:+-- The truthful modes describe the actual operation: ----- * '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'.+-- * 'Immediate' — execute without polling.+-- * 'WaitForHead' — capture one visible whole-store or category head and wait+--   for the model's durable cursor to reach it.+-- * 'WaitForPosition' — wait for a concrete caller-supplied 'GlobalPosition'. --+-- Waiting modes require 'DurableQueryCursor'; a cursorless model fails with+-- 'ReadModelMissingCursor' before polling, including through 'waitFor' and the+-- deprecated 'runQueryWith' waiting overrides. Define new models through+-- 'ReadModelBlueprint' and the truthful builders. 'ConsistencyMode', direct+-- waiting fields, and 'runQueryWith' remain deprecated 0.12 compatibility and+-- are removed in 0.13.+-- -- Schema lifecycle (registration, status transitions) lives in -- "Keiro.ReadModel.Schema", which is re-exported here. --@@ -27,9 +29,22 @@ module Keiro.ReadModel   ( -- * Definition     ReadModel (..),+    ReadModelBlueprint (..),+    QueryCursorAuthority (..),+    ReadModelDefinitionError (..),+    immediateReadModel,+    headWaitingReadModel,+    positionWaitingReadModel,+    readModelCursorAuthority,+    readModelDefaultFreshness,     qualifiedTableName, -    -- * Consistency+    -- * Freshness+    QueryFreshness (..),+    HeadScope (..),+    defaultHeadWaitOptions,++    -- * Deprecated consistency compatibility     ConsistencyMode (..),     StrongScope (..),     PositionWaitOptions (..),@@ -37,8 +52,10 @@      -- * Querying     runQuery,+    runQueryWithFreshness,     runQueryWith,     waitFor,+    subscriptionPositionFromInventory,     readSubscriptionPosition,     storeHeadPosition,     categoryHeadPosition,@@ -63,7 +80,13 @@ import Keiro.ReadModel.Schema import Keiro.Telemetry (KeiroMetrics, recordProjectionWaitTimeouts) import Kiroku.Store.Effect (Store)-import Kiroku.Store.Read (readAllBackward)+import Kiroku.Store.Read (visibleGlobalHeadPosition)+import Kiroku.Store.Subscription+  ( SubscriptionCheckpoint (..),+    SubscriptionCheckpointInventory (..),+    SubscriptionName (..),+    subscriptionCheckpointInventory,+  ) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (GlobalPosition (..)) import "hasql-transaction" Hasql.Transaction qualified as Tx@@ -80,12 +103,14 @@ --   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'.+-- * 'subscriptionName' — deprecated compatibility storage for the cursor that+--   tracks how far the projection worker has consumed the event log. New code+--   uses 'ReadModelBlueprint.cursorAuthority'. -- * '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.+-- * 'defaultConsistency' — deprecated compatibility representation of the+--   'QueryFreshness' used by 'runQuery'.+-- * 'strongScope' — deprecated compatibility representation of 'HeadScope'. -- * 'query' — the SQL read, as a 'Hasql.Transaction.Transaction'. data ReadModel q r = ReadModel   { name :: !Text,@@ -100,6 +125,145 @@   }   deriving stock (Generic) +-- | The cursor capability available to a read model. Immediate queries need no+-- cursor. Head and caller-position waits require exactly one durable cursor+-- whose checkpoint represents the projection supplying the model.+data QueryCursorAuthority+  = NoQueryCursor+  | DurableQueryCursor !Text+  deriving stock (Generic, Eq, Show)++-- | Honest construction input for a 'ReadModel'. It contains query identity,+-- schema, and SQL without requiring legacy consistency fields or a fictional+-- subscription name for an inline model.+data ReadModelBlueprint q r = ReadModelBlueprint+  { name :: !Text,+    tableName :: !Text,+    schema :: !Text,+    version :: !Int,+    shapeHash :: !Text,+    cursorAuthority :: !QueryCursorAuthority,+    query :: !(q -> Tx.Transaction r)+  }+  deriving stock (Generic)++-- | Why an honest read-model definition could not be constructed.+data ReadModelDefinitionError+  = -- | A waiting default was requested for a model without a durable cursor:+    -- model name and requested freshness.+    ReadModelDefinitionMissingCursor !Text !QueryFreshness+  | -- | A position-waiting default omitted its required target: model name.+    ReadModelDefinitionMissingPosition !Text+  deriving stock (Generic, Eq, Show)++-- | What, if anything, a query waits for before executing its SQL.+data QueryFreshness+  = Immediate+  | WaitForHead !HeadScope+  | WaitForPosition !PositionWaitOptions+  deriving stock (Generic, Eq, Show)++-- | The visible event-log boundary captured by 'WaitForHead'.+data HeadScope+  = EntireVisibleLog+  | CategoryVisibleHead !Text+  deriving stock (Generic, Eq, Ord, Show)++-- | Build a read model whose default query executes immediately. The model may+-- still retain a durable cursor for caller-selected position waits.+immediateReadModel :: ReadModelBlueprint q r -> ReadModel q r+immediateReadModel = blueprintReadModel Immediate++-- | Build a read model whose default query waits for a captured visible head.+-- A durable cursor is required because an inline-only model has nothing that+-- can advance while the query waits.+headWaitingReadModel ::+  HeadScope ->+  ReadModelBlueprint q r ->+  Either ReadModelDefinitionError (ReadModel q r)+headWaitingReadModel scope blueprint =+  requireCursor blueprint (WaitForHead scope)++-- | Build a read model whose default query waits for a concrete caller-supplied+-- position. Both a durable cursor and a non-'Nothing' target are required.+positionWaitingReadModel ::+  PositionWaitOptions ->+  ReadModelBlueprint q r ->+  Either ReadModelDefinitionError (ReadModel q r)+positionWaitingReadModel options blueprint =+  case options ^. #target of+    Nothing -> Left (ReadModelDefinitionMissingPosition (blueprint ^. #name))+    Just _ -> requireCursor blueprint (WaitForPosition options)++-- | Recover the honest cursor capability from the compatibility representation.+-- Values built directly with the legacy record retain their named subscription.+readModelCursorAuthority :: ReadModel q r -> QueryCursorAuthority+readModelCursorAuthority readModel+  | readModel ^. #subscriptionName == noQueryCursorSentinel = NoQueryCursor+  | otherwise = DurableQueryCursor (readModel ^. #subscriptionName)++-- | Translate the legacy default into its exact operational freshness.+readModelDefaultFreshness :: ReadModel q r -> QueryFreshness+readModelDefaultFreshness readModel =+  legacyOverrideFreshness (readModel ^. #defaultConsistency) readModel++-- | Translate a legacy consistency mode into the exact operational freshness.+-- The historical @PositionWait@ with no target is immediate. Strong resolves its+-- head scope from the model's compatibility 'strongScope' field.+legacyOverrideFreshness :: ConsistencyMode -> ReadModel q r -> QueryFreshness+legacyOverrideFreshness Strong readModel =+  WaitForHead (legacyHeadScope (readModel ^. #strongScope))+legacyOverrideFreshness Eventual _ = Immediate+legacyOverrideFreshness (PositionWait options) _ =+  case options ^. #target of+    Nothing -> Immediate+    Just _ -> WaitForPosition options++blueprintReadModel :: QueryFreshness -> ReadModelBlueprint q r -> ReadModel q r+blueprintReadModel freshness blueprint =+  ReadModel+    { name = blueprint ^. #name,+      tableName = blueprint ^. #tableName,+      schema = blueprint ^. #schema,+      subscriptionName = cursorText (blueprint ^. #cursorAuthority),+      version = blueprint ^. #version,+      shapeHash = blueprint ^. #shapeHash,+      defaultConsistency = legacyConsistency freshness,+      strongScope = legacyStrongScope freshness,+      query = blueprint ^. #query+    }++requireCursor ::+  ReadModelBlueprint q r ->+  QueryFreshness ->+  Either ReadModelDefinitionError (ReadModel q r)+requireCursor blueprint freshness =+  case blueprint ^. #cursorAuthority of+    NoQueryCursor ->+      Left (ReadModelDefinitionMissingCursor (blueprint ^. #name) freshness)+    DurableQueryCursor _ -> Right (blueprintReadModel freshness blueprint)++cursorText :: QueryCursorAuthority -> Text+cursorText NoQueryCursor = noQueryCursorSentinel+cursorText (DurableQueryCursor cursor) = cursor++noQueryCursorSentinel :: Text+noQueryCursorSentinel = "\NULkeiro:no-query-cursor"++legacyConsistency :: QueryFreshness -> ConsistencyMode+legacyConsistency Immediate = Eventual+legacyConsistency WaitForHead {} = Strong+legacyConsistency (WaitForPosition options) = PositionWait options++legacyStrongScope :: QueryFreshness -> StrongScope+legacyStrongScope (WaitForHead EntireVisibleLog) = EntireLog+legacyStrongScope (WaitForHead (CategoryVisibleHead category)) = CategoryHead category+legacyStrongScope _ = EntireLog++legacyHeadScope :: StrongScope -> HeadScope+legacyHeadScope EntireLog = EntireVisibleLog+legacyHeadScope (CategoryHead category) = CategoryVisibleHead category+ -- | 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)@.@@ -109,8 +273,8 @@  -- | 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+-- 'Strong' waits for the model's subscription to reach the visible 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.@@ -124,8 +288,8 @@  -- | 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+-- 'EntireLog' captures the newest visible whole-store event 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@@ -153,15 +317,42 @@   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.+-- every 10ms, for the visible store head captured at query start. defaultStrongWaitOptions :: PositionWaitOptions-defaultStrongWaitOptions =+defaultStrongWaitOptions = defaultHeadWaitOptions++-- | Default options for a captured-head wait: five seconds total, polling+-- every 10ms. 'WaitForHead' supplies the captured target at execution time.+defaultHeadWaitOptions :: PositionWaitOptions+defaultHeadWaitOptions =   PositionWaitOptions     { target = Nothing,       timeoutMicros = 5000000,       pollMicros = 10000     } +{-# DEPRECATED ConsistencyMode "Use QueryFreshness. ConsistencyMode remains through the 0.12 compatibility window and is removed in 0.13." #-}++{-# DEPRECATED Strong "Use WaitForHead. Strong is a bounded captured-head wait, not linearizability; it is removed in 0.13." #-}++{-# DEPRECATED Eventual "Use Immediate. Eventual means only that the query does not wait; it is removed in 0.13." #-}++{-# DEPRECATED PositionWait "Use WaitForPosition with a concrete target. Legacy PositionWait Nothing remains immediate through 0.12 and is removed in 0.13." #-}++{-# DEPRECATED StrongScope "Use HeadScope. StrongScope remains through the 0.12 compatibility window and is removed in 0.13." #-}++{-# DEPRECATED EntireLog "Use EntireVisibleLog. EntireLog remains through the 0.12 compatibility window and is removed in 0.13." #-}++{-# DEPRECATED CategoryHead "Use CategoryVisibleHead. CategoryHead remains through the 0.12 compatibility window and is removed in 0.13." #-}++{-# DEPRECATED defaultStrongWaitOptions "Use defaultHeadWaitOptions. The legacy name is removed in 0.13." #-}++{-# DEPRECATED subscriptionName "Use ReadModelBlueprint.cursorAuthority and readModelCursorAuthority. The legacy record field is removed in 0.13." #-}++{-# DEPRECATED defaultConsistency "Use ReadModelBlueprint builders and readModelDefaultFreshness. The legacy record field is removed in 0.13." #-}++{-# DEPRECATED strongScope "Use HeadScope through the ReadModelBlueprint builders. The legacy record field is removed in 0.13." #-}+ -- | Why a read-model query could not run. data ReadModelError   = -- | No registry row exists for the model. Register it once at projection@@ -177,10 +368,17 @@   | -- | The model is registered but not 'Live' (e.g. rebuilding or     --       abandoned): name and current status.     ReadModelNotLive !Text !ReadModelStatus+  | -- | A wait was requested for a model with no durable cursor:+    --       model name and requested freshness.+    ReadModelMissingCursor !Text !QueryFreshness+  | -- | A truthful position wait omitted its required target: model name.+    ReadModelMissingPosition !Text   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 default freshness. The compatibility record+-- representation is decoded by 'readModelDefaultFreshness'; validation and+-- execution preserve the exact legacy behavior for directly constructed 0.11+-- values. runQuery ::   (IOE :> es, Store :> es) =>   Maybe KeiroMetrics ->@@ -188,11 +386,26 @@   q ->   Eff es (Either ReadModelError r) runQuery metrics readModel =-  runQueryWith metrics (readModel ^. #defaultConsistency) readModel+  runQueryWithFreshness metrics (readModelDefaultFreshness readModel) readModel +-- | Query a read model with an honest freshness override. 'Immediate' runs+-- after schema and liveness validation without polling. Waiting modes require+-- a durable cursor; 'WaitForPosition' additionally requires a concrete target.+runQueryWithFreshness ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  QueryFreshness ->+  ReadModel q r ->+  q ->+  Eff es (Either ReadModelError r)+runQueryWithFreshness metrics freshness readModel input =+  runValidatedQuery readModel input (waitForFreshness metrics freshness 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.+-- default. The override is translated into its exact 'QueryFreshness' and run+-- through the truthful execution path. Waiting overrides on a cursorless model+-- fail fast with 'ReadModelMissingCursor'; models with durable cursors preserve+-- their exact 0.11 behavior. runQueryWith ::   (IOE :> es, Store :> es) =>   Maybe KeiroMetrics ->@@ -200,19 +413,31 @@   ReadModel q r ->   q ->   Eff es (Either ReadModelError r)-runQueryWith metrics consistency readModel input = do+runQueryWith metrics consistency readModel =+  runQueryWithFreshness metrics (legacyOverrideFreshness consistency readModel) readModel+{-# DEPRECATED runQueryWith "Use runQueryWithFreshness. The legacy override is removed in 0.13." #-}++runValidatedQuery ::+  (Store :> es) =>+  ReadModel q r ->+  q ->+  Eff es (Either ReadModelError ()) ->+  Eff es (Either ReadModelError r)+runValidatedQuery readModel input waitAction = do   schemaCheck <- ensureReadModel readModel   case schemaCheck of     Left err -> pure (Left err)     Right () -> do-      waitResult <- waitIfNeeded metrics consistency readModel+      waitResult <- waitAction       case waitResult of         Left err -> pure (Left err)         Right () -> Right <$> runTransaction ((readModel ^. #query) input) --- | Block until the model's subscription has advanced to @targetPosition@,+-- | Block until the model's durable cursor has advanced to @targetPosition@, -- polling at 'pollMicros' intervals. Returns @Right ()@ once caught up, or--- 'ReadModelWaitTimeout' if 'timeoutMicros' elapses first.+-- 'ReadModelWaitTimeout' if 'timeoutMicros' elapses first. A model without a+-- durable cursor fails fast with 'ReadModelMissingCursor' without polling or+-- recording a timeout metric. waitFor ::   (IOE :> es, Store :> es) =>   Maybe KeiroMetrics ->@@ -220,12 +445,26 @@   ReadModel q r ->   GlobalPosition ->   Eff es (Either ReadModelError ())-waitFor metrics options readModel targetPosition = do+waitFor metrics options readModel targetPosition =+  withCursor+    (WaitForPosition (options & #target ?~ targetPosition))+    readModel+    (\cursor -> waitForCursor metrics options readModel cursor targetPosition)++waitForCursor ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  PositionWaitOptions ->+  ReadModel q r ->+  Text ->+  GlobalPosition ->+  Eff es (Either ReadModelError ())+waitForCursor metrics options readModel cursor targetPosition = do   started <- liftIO getCurrentTime   poll started (GlobalPosition 0)   where     poll started observed = do-      current <- readSubscriptionPosition (readModel ^. #subscriptionName)+      current <- readSubscriptionPosition cursor       let observed' = fromMaybe observed current       if observed' >= targetPosition         then pure (Right ())@@ -280,51 +519,83 @@             (metadata ^. #shapeHash)         ) -waitIfNeeded ::+waitForFreshness ::   (IOE :> es, Store :> es) =>   Maybe KeiroMetrics ->-  ConsistencyMode ->+  QueryFreshness ->   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-waitIfNeeded _ Eventual _ = pure (Right ())-waitIfNeeded metrics (PositionWait options) readModel =+waitForFreshness _ Immediate _ = pure (Right ())+waitForFreshness metrics requested@(WaitForHead scope) readModel =+  withCursor requested readModel $ \cursor -> do+    target <- case scope of+      EntireVisibleLog -> storeHeadPosition+      CategoryVisibleHead category -> categoryHeadPosition category+    waitForCursor+      metrics+      (defaultHeadWaitOptions & #target ?~ target)+      readModel+      cursor+      target+waitForFreshness metrics requested@(WaitForPosition options) readModel =   case options ^. #target of-    Nothing -> pure (Right ())-    Just targetPosition -> waitFor metrics options readModel targetPosition+    Nothing -> pure (Left (ReadModelMissingPosition (readModel ^. #name)))+    Just targetPosition ->+      withCursor requested readModel $ \cursor ->+        waitForCursor metrics options readModel cursor targetPosition +withCursor ::+  (Applicative f) =>+  QueryFreshness ->+  ReadModel q r ->+  (Text -> f (Either ReadModelError ())) ->+  f (Either ReadModelError ())+withCursor requested readModel action =+  case readModelCursorAuthority readModel of+    NoQueryCursor ->+      pure (Left (ReadModelMissingCursor (readModel ^. #name) requested))+    DurableQueryCursor cursor -> action cursor+ readSubscriptionPosition ::   (Store :> es) =>   Text ->   Eff es (Maybe GlobalPosition) readSubscriptionPosition subscriptionName =-  runTransaction-    $ Tx.statement subscriptionName lookupSubscriptionPositionStmt+  subscriptionPositionFromInventory (SubscriptionName subscriptionName)+    <$> subscriptionCheckpointInventory -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)))+-- | Derive one subscription's durable position from a captured inventory.+-- Consumer-group members share the subscription name, so the subscription-wide+-- position is the slowest member's checkpoint. A missing durable row is+-- represented by 'Nothing', not a synthetic position zero.+subscriptionPositionFromInventory ::+  SubscriptionName ->+  SubscriptionCheckpointInventory ->+  Maybe GlobalPosition+subscriptionPositionFromInventory wanted inventory =+  minimumMay+    [ position+    | SubscriptionCheckpoint name _member position _updatedAt <-+        Vector.toList (checkpoints inventory),+      name == wanted+    ]+  where+    minimumMay [] = Nothing+    minimumMay positions = Just (Prelude.minimum positions) --- | 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 newest visible event in the @$all@ stream, or+-- @GlobalPosition 0@ when no event is visible. This is deliberately not+-- Kiroku's authoritative @$all@ append counter (the inventory's+-- 'Kiroku.Store.Subscription.storePosition'), which counts hard-deleted+-- events: subscription checkpoints advance only at delivered batch tails, so+-- after tail hard-deletion (for example workflow GC) the authoritative+-- counter is unreachable until an unrelated append lands, while the visible+-- head is reachable by any caught-up subscription. Kiroku observes this with+-- one payload-free statement through its public Store effect;+-- @Keiro.ReadModel.Rebuild.finishRebuild@ guards transactionally on the same+-- visible-head basis. storeHeadPosition :: (Store :> es) => Eff es GlobalPosition-storeHeadPosition = do-  recent <- readAllBackward (GlobalPosition 0) 1-  pure $ case Vector.toList recent of-    (event : _) -> event ^. #globalPosition-    [] -> GlobalPosition 0+storeHeadPosition = visibleGlobalHeadPosition  -- | The latest global position originating in a Kiroku category, or -- @GlobalPosition 0@ when that category has no events. This deliberately reads
+ src/Keiro/ReadModel/External.hs view
@@ -0,0 +1,1104 @@+{-# LANGUAGE MultilineStrings #-}++-- | Managed, versioned PostgreSQL functions for out-of-process read-model+-- consumers. Callers receive execute-only functions in @keiro_read@; target+-- tables and the private binding views remain inaccessible.+module Keiro.ReadModel.External+  ( ExternalReadReconciliationError (..),+    ExternalReadRetirementError (..),+    ExternalReadRetirementPreview (..),+    reconcileExternalReadContracts,+    reconcileExternalReadContractsTx,+    reconcileExternalReadContractsForGroupsTx,+    previewExternalReadContractRetirement,+    retireExternalReadContract,+  )+where++import Contravariant.Extras (contrazip18, contrazip2, contrazip4, contrazip9)+import Control.Monad (join)+import Data.Foldable (toList)+import Data.Functor (($>))+import Data.Int (Int32)+import Data.List qualified as List+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.Text.Encoding qualified as Text.Encoding+import Effectful (Eff, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Connection (qualifyTable, quoteIdentifier)+import Keiro.Prelude+import Keiro.Projection.Catalog+  ( ExternalReadContractId,+    ExternalReadContractKind (..),+    ExternalReadContractVersion,+    QualifiedFunction (..),+    QualifiedSqlType (..),+    RebuildGroupId,+    ValidatedProjectionCatalog,+    catalogInventory,+    externalReadContractIdText,+    externalReadContractVersionValue,+    projectionRevisionIdText,+    queryModelIdText,+    rebuildGroupIdText,+  )+import Keiro.Projection.Catalog qualified as Catalog+import Keiro.Projection.Catalog.Preimage (Preimage (..), hashPreimage)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Transaction (runTransaction)+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude (fromIntegral, map, maximum, not, zip, (&&))+import Prelude qualified++data ExternalReadReconciliationError+  = ExternalReadAllRowsRequiresSingleTarget !ExternalReadContractId !ExternalReadContractVersion+  | ExternalReadResultTypeMissing !QualifiedSqlType+  | ExternalReadResultTypeNotComposite !QualifiedSqlType+  | ExternalReadPrivateImplementationMissing !QualifiedFunction+  | ExternalReadPrivateImplementationResultMismatch !QualifiedFunction !QualifiedSqlType+  | ExternalReadImmutableSignatureConflict !ExternalReadContractId !ExternalReadContractVersion+  | ExternalReadDefinitionGenerationConflict !ExternalReadContractId !ExternalReadContractVersion !Int+  | ExternalReadSurfaceDowngrade !ExternalReadContractId !ExternalReadContractVersion !Int !Int+  | ExternalReadRetiredContractCannotReactivate !ExternalReadContractId !ExternalReadContractVersion+  | ExternalReadUnmanagedObjectCollision !Text !Text !Text+  | ExternalReadManagedObjectOwnershipConflict !Text !Text !Text+  deriving stock (Eq, Show, Generic)++data ExternalReadRetirementError+  = ExternalReadRetirementUnknown !ExternalReadContractId !ExternalReadContractVersion+  | ExternalReadRetirementAlreadyRetired !ExternalReadContractId !ExternalReadContractVersion+  deriving stock (Eq, Show, Generic)++data ExternalReadRetirementPreview = ExternalReadRetirementPreview+  { contractId :: !ExternalReadContractId,+    contractVersion :: !ExternalReadContractVersion,+    publicFunction :: !Text,+    currentState :: !Text,+    surfaceGeneration :: !Int,+    dependentObjects :: ![Text],+    executeGrants :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++data ExternalReadSpec = ExternalReadSpec+  { contractId :: !ExternalReadContractId,+    contractVersion :: !ExternalReadContractVersion,+    queryModelId :: !Catalog.QueryModelId,+    groupId :: !RebuildGroupId,+    functionName :: !Text,+    contractKind :: !ExternalReadContractKind,+    argumentNames :: ![Text],+    argumentTypes :: ![QualifiedSqlType],+    resultType :: !QualifiedSqlType,+    resultShapeHash :: !Text,+    compatibleRevisions :: ![Catalog.ProjectionRevisionId],+    privateImplementation :: !(Maybe QualifiedFunction),+    privateImplementationVersion :: !(Maybe Int),+    surfaceGeneration :: !Int,+    bindingTable :: !(Maybe Catalog.QualifiedTable)+  }+  deriving stock (Eq, Show, Generic)++data PersistedContract = PersistedContract+  { immutableSignatureHash :: !Text,+    definitionHash :: !Text,+    surfaceGeneration :: !Int,+    state :: !Text+  }+  deriving stock (Eq, Show, Generic)++data PersistedContractKey = PersistedContractKey+  { contractIdText :: !Text,+    contractVersionValue :: !Int,+    groupIdText :: !Text,+    surfaceGeneration :: !Int,+    state :: !Text+  }+  deriving stock (Eq, Show, Generic)++data ManagedObject = ManagedObject+  { managedBy :: !Text,+    definitionHash :: !Text,+    surfaceGeneration :: !Int+  }+  deriving stock (Eq, Show, Generic)++data ContractState = ContractState+  { stateName :: !Text,+    servingShapeHash :: !(Maybe Text)+  }+  deriving stock (Generic)++reconcileExternalReadContracts ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  Eff es (Either ExternalReadReconciliationError ())+reconcileExternalReadContracts catalog =+  runTransaction (reconcileExternalReadContractsTx catalog)++-- | Reconcile the complete catalog. Any refusal condemns the transaction, so+-- partially-created functions, views, metadata, and revokes never escape.+reconcileExternalReadContractsTx ::+  ValidatedProjectionCatalog ->+  Tx.Transaction (Either ExternalReadReconciliationError ())+reconcileExternalReadContractsTx catalog =+  reconcileExternalReadContractsForGroupsTx catalog Nothing++-- | Reconcile only contracts owned by the selected groups. @Nothing@ means+-- the complete catalog and also marks superseded absent contracts pending+-- retirement. A selected adoption never mutates another group's surface.+reconcileExternalReadContractsForGroupsTx ::+  ValidatedProjectionCatalog ->+  Maybe (Set RebuildGroupId) ->+  Tx.Transaction (Either ExternalReadReconciliationError ())+reconcileExternalReadContractsForGroupsTx catalog selectedGroups =+  case externalReadSpecs catalog selectedGroups of+    Left err -> Tx.condemn $> Left err+    Right specs -> do+      result <- reconcileSpecs specs+      case result of+        Left err -> Tx.condemn $> Left err+        Right () -> do+          markAbsentPending specs+          pure (Right ())+  where+    reconcileSpecs = \case+      [] -> pure (Right ())+      spec : rest -> do+        result <- reconcileOne spec+        case result of+          Left err -> pure (Left err)+          Right () -> reconcileSpecs rest++    reconcileOne spec = do+      let signatureHash = immutableSignature spec+          requestedDefinitionHash = definitionIdentity spec+          requestedGeneration = spec ^. #surfaceGeneration+      existing <-+        Tx.statement+          (externalReadKey spec)+          lookupContractStmt+      case existing of+        Just persisted+          | persisted ^. #immutableSignatureHash /= signatureHash ->+              pure (Left (ExternalReadImmutableSignatureConflict (spec ^. #contractId) (spec ^. #contractVersion)))+          | persisted ^. #surfaceGeneration > requestedGeneration ->+              pure+                ( Left+                    ( ExternalReadSurfaceDowngrade+                        (spec ^. #contractId)+                        (spec ^. #contractVersion)+                        (persisted ^. #surfaceGeneration)+                        requestedGeneration+                    )+                )+          | persisted ^. #state == "retired" ->+              pure (Left (ExternalReadRetiredContractCannotReactivate (spec ^. #contractId) (spec ^. #contractVersion)))+          | persisted ^. #surfaceGeneration == requestedGeneration,+            persisted ^. #definitionHash /= requestedDefinitionHash ->+              pure+                ( Left+                    ( ExternalReadDefinitionGenerationConflict+                        (spec ^. #contractId)+                        (spec ^. #contractVersion)+                        requestedGeneration+                    )+                )+        _ -> do+          typeExists <- Tx.statement (renderSqlType (spec ^. #resultType)) typeExistsStmt+          if not typeExists+            then pure (Left (ExternalReadResultTypeMissing (spec ^. #resultType)))+            else do+              resultColumns <- Tx.statement (renderSqlType (spec ^. #resultType)) compositeTypeColumnsStmt+              if Prelude.null resultColumns+                then pure (Left (ExternalReadResultTypeNotComposite (spec ^. #resultType)))+                else do+                  implementationResult <- verifyPrivateImplementation spec+                  case implementationResult of+                    Left err -> pure (Left err)+                    Right () -> do+                      contractState <- resolveContractState spec+                      persisted <-+                        Tx.statement+                          (contractParams spec signatureHash requestedDefinitionHash contractState)+                          upsertContractStmt+                      if not persisted+                        then+                          pure+                            ( Left+                                ( ExternalReadDefinitionGenerationConflict+                                    (spec ^. #contractId)+                                    (spec ^. #contractVersion)+                                    requestedGeneration+                                )+                            )+                        else do+                          if contractState ^. #stateName == "active"+                            then reconcileObjects spec resultColumns requestedDefinitionHash+                            else pure (Right ())++    verifyPrivateImplementation spec =+      case spec ^. #privateImplementation of+        Nothing -> pure (Right ())+        Just implementation -> do+          resultMatches <-+            Tx.statement+              (privateRegprocedure spec implementation, renderSqlType (spec ^. #resultType))+              privateImplementationResultStmt+          case resultMatches of+            Nothing -> pure (Left (ExternalReadPrivateImplementationMissing implementation))+            Just False ->+              pure+                ( Left+                    ( ExternalReadPrivateImplementationResultMismatch+                        implementation+                        (spec ^. #resultType)+                    )+                )+            Just True -> do+              Tx.sql+                ( Text.Encoding.encodeUtf8+                    ( "REVOKE ALL ON FUNCTION "+                        <> qualifyFunction implementation+                        <> "("+                        <> Text.intercalate ", " (map qualifySqlType (spec ^. #argumentTypes))+                        <> ") FROM PUBLIC"+                    )+                )+              pure (Right ())++    reconcileObjects spec resultColumns requestedDefinitionHash = do+      typeRecorded <-+        upsertManagedObject+          spec+          (spec ^. #resultType . #typeSchema)+          (spec ^. #resultType . #typeName)+          "contract-type"+          (externalReadObjectKey spec)+          "consumer"+          requestedDefinitionHash+      if not typeRecorded+        then pure (Left (ExternalReadManagedObjectOwnershipConflict (spec ^. #resultType . #typeSchema) (spec ^. #resultType . #typeName) "contract-type"))+        else do+          bindingResult <- reconcileBinding spec resultColumns requestedDefinitionHash+          case bindingResult of+            Left err -> pure (Left err)+            Right () -> reconcileWrapper spec requestedDefinitionHash++    reconcileBinding spec resultColumns requestedDefinitionHash =+      case bindingViewSql spec resultColumns of+        Nothing -> pure (Right ())+        Just sql -> do+          let objectName = bindingViewName spec+          ownership <- managedObjectAuthority "keiro" objectName "binding-view" "" (Left (qualifyTable "keiro" objectName))+          case ownership of+            Left err -> pure (Left err)+            Right () -> do+              Tx.sql (Text.Encoding.encodeUtf8 sql)+              Tx.sql (Text.Encoding.encodeUtf8 ("REVOKE ALL ON " <> qualifyTable "keiro" objectName <> " FROM PUBLIC"))+              recorded <- upsertManagedObject spec "keiro" objectName "binding-view" "" "keiro" requestedDefinitionHash+              pure+                $ if recorded+                  then Right ()+                  else Left (ExternalReadManagedObjectOwnershipConflict "keiro" objectName "binding-view")++    reconcileWrapper spec requestedDefinitionHash = do+      let objectName = spec ^. #functionName+          signature = Text.intercalate "," (map renderSqlType (spec ^. #argumentTypes))+          regprocedure = publicRegprocedure spec+      ownership <- managedObjectAuthority "keiro_read" objectName "wrapper-function" signature (Right regprocedure)+      case ownership of+        Left err -> pure (Left err)+        Right () -> do+          Tx.sql (Text.Encoding.encodeUtf8 (wrapperSql spec))+          Tx.sql+            ( Text.Encoding.encodeUtf8+                ( "REVOKE ALL ON FUNCTION "+                    <> qualifyTable "keiro_read" objectName+                    <> "("+                    <> Text.intercalate ", " (map qualifySqlType (spec ^. #argumentTypes))+                    <> ") FROM PUBLIC"+                )+            )+          recorded <- upsertManagedObject spec "keiro_read" objectName "wrapper-function" signature "keiro" requestedDefinitionHash+          pure+            $ if recorded+              then Right ()+              else Left (ExternalReadManagedObjectOwnershipConflict "keiro_read" objectName "wrapper-function")++    managedObjectAuthority schema objectName kind signature existenceProbe = do+      managed <- Tx.statement (schema, objectName, kind, signature) lookupManagedObjectStmt+      exists <-+        case existenceProbe of+          Left relation -> Tx.statement relation relationExistsStmt+          Right procedure -> Tx.statement procedure functionExistsStmt+      pure $ case managed of+        Nothing+          | exists -> Left (ExternalReadUnmanagedObjectCollision schema objectName kind)+        Just object+          | object ^. #managedBy /= "keiro" -> Left (ExternalReadManagedObjectOwnershipConflict schema objectName kind)+        _ -> Right ()++    upsertManagedObject spec schema objectName kind signature managedBy definitionHash =+      Tx.statement+        ( schema,+          objectName,+          kind,+          signature,+          externalReadContractIdText (spec ^. #contractId),+          fromIntegral (externalReadContractVersionValue (spec ^. #contractVersion)),+          managedBy,+          definitionHash,+          fromIntegral (spec ^. #surfaceGeneration)+        )+        upsertManagedObjectStmt++    resolveContractState spec = do+      servingRevision <- Tx.statement (rebuildGroupIdText (spec ^. #groupId)) servingRevisionStmt+      pure+        $ case servingRevision of+          Just revision+            | revision `List.elem` map projectionRevisionIdText (spec ^. #compatibleRevisions) ->+                ContractState "active" (Just (spec ^. #resultShapeHash))+          _ -> ContractState "candidate" Nothing++    markAbsentPending specs = do+      persisted <- Tx.statement () listPersistedContractsStmt+      let selected groupId = maybe True (Set.member groupId) selectedGroups+          specKeys = Set.fromList (map externalReadKey specs)+          maximumGeneration = maximumMaybe (map (^. #surfaceGeneration) specs)+      for_ persisted $ \row ->+        case Catalog.mkRebuildGroupId (row ^. #groupIdText) of+          Left _ -> pure ()+          Right groupId ->+            when+              ( selected groupId+                  && (row ^. #contractIdText, fromIntegral (row ^. #contractVersionValue)) `Set.notMember` specKeys+                  && row ^. #state /= "retired"+                  && maybe False (row ^. #surfaceGeneration <) maximumGeneration+              )+              ( Tx.statement+                  (row ^. #contractIdText, fromIntegral (row ^. #contractVersionValue))+                  markContractPendingRetirementStmt+              )++externalReadSpecs ::+  ValidatedProjectionCatalog ->+  Maybe (Set RebuildGroupId) ->+  Either ExternalReadReconciliationError [ExternalReadSpec]+externalReadSpecs catalog selectedGroups =+  traverse toSpec selectedContracts+  where+    inventory = catalogInventory catalog+    queriesById =+      Map.fromList+        [ (query ^. #queryModelId, query)+        | query <- inventory ^. #inventoryQueryModels+        ]+    targetsById =+      Map.fromList+        [ (target ^. #targetId, target)+        | target <- inventory ^. #inventoryTargets+        ]+    selectedContracts =+      [ contract+      | contract <- inventory ^. #inventoryExternalReadContracts,+        maybe True (Set.member (contract ^. #rebuildGroupId)) selectedGroups+      ]++    toSpec contract = do+      binding <-+        case contract ^. #contractKind of+          InventoryKeyedExternalRead -> Right Nothing+          InventoryAllRowsExternalRead ->+            case Map.lookup (contract ^. #queryModelId) queriesById >>= singleton . (^. #observedTargets) of+              Just targetId -> Right ((^. #qualifiedTable) <$> Map.lookup targetId targetsById)+              Nothing -> Left (ExternalReadAllRowsRequiresSingleTarget (contract ^. #readContractId) (contract ^. #contractVersion))+      pure+        ExternalReadSpec+          { contractId = contract ^. #readContractId,+            contractVersion = contract ^. #contractVersion,+            queryModelId = contract ^. #queryModelId,+            groupId = contract ^. #rebuildGroupId,+            functionName = contract ^. #functionName,+            contractKind = contract ^. #contractKind,+            argumentNames = map (^. #argumentName) (contract ^. #arguments),+            argumentTypes = map (^. #argumentType) (contract ^. #arguments),+            resultType = contract ^. #resultContractType,+            resultShapeHash = contract ^. #resultShapeHash,+            compatibleRevisions = List.sort (toList (contract ^. #compatibleRevisions)),+            privateImplementation = contract ^. #privateImplementation,+            privateImplementationVersion = contract ^. #privateImplementationVersion,+            surfaceGeneration = contract ^. #surfaceGeneration,+            bindingTable = binding+          }++    singleton = \case+      [value] -> Just value+      _ -> Nothing++immutableSignature :: ExternalReadSpec -> Text+immutableSignature spec =+  hashPreimage "external-read-signature-v1"+    $ PRecord+      "external-read-signature"+      [ PText (externalReadContractIdText (spec ^. #contractId)),+        PText (Text.pack (show (externalReadContractVersionValue (spec ^. #contractVersion)))),+        PText (queryModelIdText (spec ^. #queryModelId)),+        PText (spec ^. #functionName),+        PText (contractKindText (spec ^. #contractKind)),+        PList (map PText (spec ^. #argumentNames)),+        PList (map (PText . renderSqlType) (spec ^. #argumentTypes)),+        PText (renderSqlType (spec ^. #resultType)),+        PText (maybe "" renderFunction (spec ^. #privateImplementation))+      ]++definitionIdentity :: ExternalReadSpec -> Text+definitionIdentity spec =+  hashPreimage "external-read-definition-v1"+    $ PRecord+      "external-read-definition"+      [ PText (immutableSignature spec),+        PText (spec ^. #resultShapeHash),+        PList (map (PText . projectionRevisionIdText) (spec ^. #compatibleRevisions)),+        PText (maybe "" (Text.pack . show) (spec ^. #privateImplementationVersion)),+        PText (Text.pack (show (spec ^. #surfaceGeneration))),+        PText (maybe "" renderTable (spec ^. #bindingTable))+      ]++bindingViewName :: ExternalReadSpec -> Text+bindingViewName spec = "external_read_" <> spec ^. #functionName <> "_binding"++bindingViewSql :: ExternalReadSpec -> [Text] -> Maybe Text+bindingViewSql spec resultColumns = do+  table <- spec ^. #bindingTable+  pure+    ( "CREATE OR REPLACE VIEW "+        <> qualifyTable "keiro" (bindingViewName spec)+        <> " WITH (security_barrier = true, security_invoker = false) AS SELECT "+        <> Text.intercalate ", " (map (qualifyColumn "source") resultColumns)+        <> " FROM "+        <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+        <> " AS "+        <> quoteIdentifier "source"+    )+  where+    qualifyColumn alias column = quoteIdentifier alias <> "." <> quoteIdentifier column++wrapperSql :: ExternalReadSpec -> Text+wrapperSql spec =+  Text.unlines+    [ "CREATE OR REPLACE FUNCTION " <> qualifyTable "keiro_read" (spec ^. #functionName) <> "(" <> argumentDeclarations <> ")",+      "RETURNS SETOF " <> qualifySqlType (spec ^. #resultType),+      "LANGUAGE plpgsql",+      "SECURITY DEFINER",+      "SET search_path = pg_catalog",+      "AS $keiro_external_read$",+      body,+      "$keiro_external_read$"+    ]+  where+    argumentDeclarations =+      Text.intercalate+        ", "+        [ quoteIdentifier name <> " " <> qualifySqlType sqlType+        | (name, sqlType) <- zip (spec ^. #argumentNames) (spec ^. #argumentTypes)+        ]+    arguments = Text.intercalate ", " (map quoteIdentifier (spec ^. #argumentNames))+    source =+      case spec ^. #privateImplementation of+        Nothing -> qualifyTable "keiro" (bindingViewName spec)+        Just implementation -> qualifyFunction implementation <> "(" <> arguments <> ")"+    guardSql =+      "  PERFORM "+        <> qualifyTable "keiro_read" "guard_external_read_v1"+        <> "("+        <> quoteLiteral (externalReadContractIdText (spec ^. #contractId))+        <> ", "+        <> Text.pack (show (externalReadContractVersionValue (spec ^. #contractVersion)))+        <> ");"+    body =+      case spec ^. #contractKind of+        InventoryAllRowsExternalRead ->+          Text.unlines+            [ "DECLARE",+              "  returned_rows bigint;",+              "BEGIN",+              guardSql,+              "  RETURN QUERY SELECT * FROM " <> source <> " LIMIT " <> Text.pack (show allRowsExternalReadFetchLimit) <> ";",+              "  GET DIAGNOSTICS returned_rows = ROW_COUNT;",+              "  IF returned_rows > " <> Text.pack (show allRowsExternalReadLimit) <> " THEN",+              "    RAISE EXCEPTION USING",+              "      ERRCODE = 'KR004',",+              "      MESSAGE = 'all-row external read exceeds its bounded result limit',",+              "      DETAIL = " <> quoteLiteral allRowsLimitDetail <> ";",+              "  END IF;",+              "END"+            ]+        InventoryKeyedExternalRead ->+          Text.unlines+            [ "BEGIN",+              guardSql,+              "  RETURN QUERY SELECT * FROM " <> source <> ";",+              "END"+            ]+    allRowsLimitDetail =+      "contract="+        <> externalReadContractIdText (spec ^. #contractId)+        <> " version="+        <> Text.pack (show (externalReadContractVersionValue (spec ^. #contractVersion)))+        <> " row_limit="+        <> Text.pack (show allRowsExternalReadLimit)++allRowsExternalReadLimit :: Int+allRowsExternalReadLimit = 100++allRowsExternalReadFetchLimit :: Int+allRowsExternalReadFetchLimit = 101++contractParams ::+  ExternalReadSpec ->+  Text ->+  Text ->+  ContractState ->+  ( Text,+    Int32,+    Text,+    Text,+    Text,+    Text,+    [Text],+    [Text],+    Text,+    Text,+    Maybe Text,+    [Text],+    Maybe Text,+    Maybe Int32,+    Text,+    Text,+    Int32,+    Text+  )+contractParams spec signatureHash definitionHash contractState =+  ( externalReadContractIdText (spec ^. #contractId),+    fromIntegral (externalReadContractVersionValue (spec ^. #contractVersion)),+    queryModelIdText (spec ^. #queryModelId),+    rebuildGroupIdText (spec ^. #groupId),+    spec ^. #functionName,+    contractKindText (spec ^. #contractKind),+    spec ^. #argumentNames,+    map renderSqlType (spec ^. #argumentTypes),+    renderSqlType (spec ^. #resultType),+    spec ^. #resultShapeHash,+    contractState ^. #servingShapeHash,+    map projectionRevisionIdText (spec ^. #compatibleRevisions),+    renderFunction <$> spec ^. #privateImplementation,+    fromIntegral <$> spec ^. #privateImplementationVersion,+    signatureHash,+    definitionHash,+    fromIntegral (spec ^. #surfaceGeneration),+    contractState ^. #stateName+  )++contractKindText :: ExternalReadContractKind -> Text+contractKindText InventoryAllRowsExternalRead = "all-rows"+contractKindText InventoryKeyedExternalRead = "keyed"++externalReadKey :: ExternalReadSpec -> (Text, Int32)+externalReadKey spec =+  ( externalReadContractIdText (spec ^. #contractId),+    fromIntegral (externalReadContractVersionValue (spec ^. #contractVersion))+  )++renderSqlType :: QualifiedSqlType -> Text+renderSqlType sqlType = sqlType ^. #typeSchema <> "." <> sqlType ^. #typeName++qualifySqlType :: QualifiedSqlType -> Text+qualifySqlType sqlType = qualifyTable (sqlType ^. #typeSchema) (sqlType ^. #typeName)++renderFunction :: QualifiedFunction -> Text+renderFunction function = function ^. #functionSchema <> "." <> function ^. #functionName++qualifyFunction :: QualifiedFunction -> Text+qualifyFunction function = qualifyTable (function ^. #functionSchema) (function ^. #functionName)++renderTable :: Catalog.QualifiedTable -> Text+renderTable table = table ^. #schemaName <> "." <> table ^. #tableName++quoteLiteral :: Text -> Text+quoteLiteral value = "'" <> Text.replace "'" "''" value <> "'"++publicRegprocedure :: ExternalReadSpec -> Text+publicRegprocedure spec =+  "keiro_read."+    <> spec+    ^. #functionName+    <> "("+    <> Text.intercalate "," (map renderSqlType (spec ^. #argumentTypes))+    <> ")"++privateRegprocedure :: ExternalReadSpec -> QualifiedFunction -> Text+privateRegprocedure spec implementation =+  renderFunction implementation+    <> "("+    <> Text.intercalate "," (map renderSqlType (spec ^. #argumentTypes))+    <> ")"++externalReadObjectKey :: ExternalReadSpec -> Text+externalReadObjectKey spec =+  externalReadContractIdText (spec ^. #contractId)+    <> "/v"+    <> Text.pack (show (externalReadContractVersionValue (spec ^. #contractVersion)))++maximumMaybe :: [Int] -> Maybe Int+maximumMaybe = \case+  [] -> Nothing+  values -> Just (maximum values)++previewExternalReadContractRetirement ::+  (Store :> es) =>+  ExternalReadContractId ->+  ExternalReadContractVersion ->+  Eff es (Either ExternalReadRetirementError ExternalReadRetirementPreview)+previewExternalReadContractRetirement contractId contractVersion =+  runTransaction (previewRetirementTx contractId contractVersion)++retireExternalReadContract ::+  (Store :> es) =>+  ExternalReadContractId ->+  ExternalReadContractVersion ->+  Eff es (Either ExternalReadRetirementError ExternalReadRetirementPreview)+retireExternalReadContract contractId contractVersion =+  runTransaction $ do+    retirementPreview <- previewRetirementTx contractId contractVersion+    case retirementPreview of+      Left err -> Tx.condemn $> Left err+      Right value+        | value ^. #currentState == "retired" ->+            Tx.condemn $> Left (ExternalReadRetirementAlreadyRetired contractId contractVersion)+        | otherwise -> do+            Tx.statement (retirementKey contractId contractVersion) retireContractStmt+            pure (Right (value & #currentState .~ "retired"))++previewRetirementTx ::+  ExternalReadContractId ->+  ExternalReadContractVersion ->+  Tx.Transaction (Either ExternalReadRetirementError ExternalReadRetirementPreview)+previewRetirementTx contractId contractVersion = do+  summary <- Tx.statement (retirementKey contractId contractVersion) retirementSummaryStmt+  case summary of+    Nothing -> pure (Left (ExternalReadRetirementUnknown contractId contractVersion))+    Just (functionName, state, generation) -> do+      dependencies <- Tx.statement (retirementKey contractId contractVersion) retirementDependenciesStmt+      grants <- Tx.statement (retirementKey contractId contractVersion) retirementGrantsStmt+      pure+        ( Right+            ExternalReadRetirementPreview+              { contractId,+                contractVersion,+                publicFunction = "keiro_read." <> functionName,+                currentState = state,+                surfaceGeneration = fromIntegral generation,+                dependentObjects = dependencies,+                executeGrants = grants+              }+        )++retirementKey :: ExternalReadContractId -> ExternalReadContractVersion -> (Text, Int32)+retirementKey contractId contractVersion =+  ( externalReadContractIdText contractId,+    fromIntegral (externalReadContractVersionValue contractVersion)+  )++lookupContractStmt :: Statement (Text, Int32) (Maybe PersistedContract)+lookupContractStmt =+  preparable+    """+    SELECT immutable_signature_hash, definition_hash, surface_generation, state+    FROM keiro.keiro_external_read_contracts+    WHERE contract_id = $1 AND contract_version = $2+    FOR UPDATE+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    ( D.rowMaybe+        ( PersistedContract+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+            <*> D.column (D.nonNullable D.text)+        )+    )++upsertContractStmt ::+  Statement+    ( Text,+      Int32,+      Text,+      Text,+      Text,+      Text,+      [Text],+      [Text],+      Text,+      Text,+      Maybe Text,+      [Text],+      Maybe Text,+      Maybe Int32,+      Text,+      Text,+      Int32,+      Text+    )+    Bool+upsertContractStmt =+  preparable+    """+    INSERT INTO keiro.keiro_external_read_contracts+      (contract_id, contract_version, query_model_id, group_id,+       public_function_name, contract_kind, argument_names, argument_types,+       result_type, result_shape_hash, serving_shape_hash,+       compatible_revision_ids, private_implementation,+       private_implementation_version, immutable_signature_hash,+       definition_hash, surface_generation, state)+    VALUES+      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,+       $13, $14, $15, $16, $17, $18)+    ON CONFLICT (contract_id, contract_version) DO UPDATE+    SET query_model_id = EXCLUDED.query_model_id,+        group_id = EXCLUDED.group_id,+        public_function_name = EXCLUDED.public_function_name,+        contract_kind = EXCLUDED.contract_kind,+        argument_names = EXCLUDED.argument_names,+        argument_types = EXCLUDED.argument_types,+        result_type = EXCLUDED.result_type,+        result_shape_hash = EXCLUDED.result_shape_hash,+        serving_shape_hash = EXCLUDED.serving_shape_hash,+        compatible_revision_ids = EXCLUDED.compatible_revision_ids,+        private_implementation = EXCLUDED.private_implementation,+        private_implementation_version = EXCLUDED.private_implementation_version,+        definition_hash = EXCLUDED.definition_hash,+        surface_generation = EXCLUDED.surface_generation,+        state = EXCLUDED.state,+        retired_at = NULL,+        updated_at = now()+    WHERE keiro.keiro_external_read_contracts.immutable_signature_hash = EXCLUDED.immutable_signature_hash+      AND keiro.keiro_external_read_contracts.surface_generation <= EXCLUDED.surface_generation+      AND keiro.keiro_external_read_contracts.state <> 'retired'+    RETURNING TRUE+    """+    ( contrazip18+        (E.param (E.nonNullable E.text))+        (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.text))+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+    )+    (maybe False Prelude.id <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++lookupManagedObjectStmt :: Statement (Text, Text, Text, Text) (Maybe ManagedObject)+lookupManagedObjectStmt =+  preparable+    """+    SELECT managed_by, definition_hash, surface_generation+    FROM keiro.keiro_managed_read_objects+    WHERE object_schema = $1 AND object_name = $2+      AND object_kind = $3 AND object_signature = $4+    FOR UPDATE+    """+    ( 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.rowMaybe+        ( ManagedObject+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+        )+    )++upsertManagedObjectStmt :: Statement (Text, Text, Text, Text, Text, Int32, Text, Text, Int32) Bool+upsertManagedObjectStmt =+  preparable+    """+    INSERT INTO keiro.keiro_managed_read_objects+      (object_schema, object_name, object_kind, object_signature,+       contract_id, contract_version, managed_by, definition_hash,+       surface_generation, state)+    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active')+    ON CONFLICT (object_schema, object_name, object_kind, object_signature)+    DO UPDATE+    SET contract_id = EXCLUDED.contract_id,+        contract_version = EXCLUDED.contract_version,+        definition_hash = EXCLUDED.definition_hash,+        surface_generation = EXCLUDED.surface_generation,+        state = 'active',+        retired_at = NULL,+        updated_at = now()+    WHERE keiro.keiro_managed_read_objects.managed_by = EXCLUDED.managed_by+      AND keiro.keiro_managed_read_objects.surface_generation <= EXCLUDED.surface_generation+    RETURNING TRUE+    """+    ( contrazip9+        (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))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (maybe False Prelude.id <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++typeExistsStmt :: Statement Text Bool+typeExistsStmt =+  preparable+    "SELECT pg_catalog.to_regtype($1) IS NOT NULL"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++compositeTypeColumnsStmt :: Statement Text [Text]+compositeTypeColumnsStmt =+  preparable+    """+    SELECT attributes.attname::text+    FROM pg_catalog.pg_type AS types+    JOIN pg_catalog.pg_attribute AS attributes+      ON attributes.attrelid = types.typrelid+    WHERE types.oid = pg_catalog.to_regtype($1)+      AND types.typtype = 'c'+      AND attributes.attnum > 0+      AND NOT attributes.attisdropped+    ORDER BY attributes.attnum+    """+    (E.param (E.nonNullable E.text))+    (D.rowList (D.column (D.nonNullable D.text)))++relationExistsStmt :: Statement Text Bool+relationExistsStmt =+  preparable+    "SELECT pg_catalog.to_regclass($1) IS NOT NULL"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++privateImplementationResultStmt :: Statement (Text, Text) (Maybe Bool)+privateImplementationResultStmt =+  preparable+    """+    SELECT procedures.proretset+       AND procedures.prorettype = pg_catalog.to_regtype($2)+    FROM pg_catalog.pg_proc AS procedures+    WHERE procedures.oid = pg_catalog.to_regprocedure($1)+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe (D.column (D.nonNullable D.bool)))++functionExistsStmt :: Statement Text Bool+functionExistsStmt =+  preparable+    "SELECT pg_catalog.to_regprocedure($1) IS NOT NULL"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++servingRevisionStmt :: Statement Text (Maybe Text)+servingRevisionStmt =+  preparable+    "SELECT serving_revision_id FROM keiro.keiro_projection_rebuild_groups WHERE group_id = $1"+    (E.param (E.nonNullable E.text))+    (join <$> D.rowMaybe (D.column (D.nullable D.text)))++listPersistedContractsStmt :: Statement () [PersistedContractKey]+listPersistedContractsStmt =+  preparable+    """+    SELECT contract_id, contract_version, group_id, surface_generation, state+    FROM keiro.keiro_external_read_contracts+    ORDER BY contract_id, contract_version+    FOR UPDATE+    """+    E.noParams+    ( D.rowList+        ( PersistedContractKey+            <$> D.column (D.nonNullable D.text)+            <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+            <*> D.column (D.nonNullable D.text)+            <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+            <*> D.column (D.nonNullable D.text)+        )+    )++markContractPendingRetirementStmt :: Statement (Text, Int32) ()+markContractPendingRetirementStmt =+  preparable+    """+    WITH changed AS (+      UPDATE keiro.keiro_external_read_contracts+      SET state = 'pending-retirement', updated_at = now()+      WHERE contract_id = $1 AND contract_version = $2+        AND state <> 'retired'+      RETURNING contract_id, contract_version+    )+    UPDATE keiro.keiro_managed_read_objects AS objects+    SET state = 'pending-retirement', updated_at = now()+    FROM changed+    WHERE objects.contract_id = changed.contract_id+      AND objects.contract_version = changed.contract_version+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    D.noResult++retirementSummaryStmt :: Statement (Text, Int32) (Maybe (Text, Text, Int32))+retirementSummaryStmt =+  preparable+    """+    SELECT public_function_name, state, surface_generation+    FROM keiro.keiro_external_read_contracts+    WHERE contract_id = $1 AND contract_version = $2+    FOR UPDATE+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    ( D.rowMaybe+        ( (,,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.int4)+        )+    )++retirementDependenciesStmt :: Statement (Text, Int32) [Text]+retirementDependenciesStmt =+  preparable+    """+    WITH owned_objects AS (+      SELECT 'pg_catalog.pg_class'::pg_catalog.regclass::oid AS class_id,+             classes.oid AS object_id+      FROM keiro.keiro_managed_read_objects AS objects+      JOIN pg_catalog.pg_class AS classes+        ON classes.relnamespace = pg_catalog.to_regnamespace(objects.object_schema)+       AND classes.relname = objects.object_name+      WHERE objects.contract_id = $1 AND objects.contract_version = $2+        AND objects.object_kind = 'binding-view'+      UNION+      SELECT 'pg_catalog.pg_proc'::pg_catalog.regclass::oid AS class_id,+             pg_catalog.to_regprocedure(+               pg_catalog.format(+                 '%I.%I(%s)',+                 objects.object_schema,+                 objects.object_name,+                 objects.object_signature+               )+             )::oid AS object_id+      FROM keiro.keiro_managed_read_objects AS objects+      WHERE objects.contract_id = $1 AND objects.contract_version = $2+        AND objects.object_kind = 'wrapper-function'+    )+    SELECT DISTINCT pg_catalog.pg_describe_object(+      dependencies.classid,+      dependencies.objid,+      dependencies.objsubid+    )+    FROM pg_catalog.pg_depend AS dependencies+    JOIN owned_objects+      ON owned_objects.class_id = dependencies.refclassid+     AND owned_objects.object_id = dependencies.refobjid+    WHERE dependencies.deptype NOT IN ('i', 'e')+      AND dependencies.objid <> dependencies.refobjid+    ORDER BY 1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowList (D.column (D.nonNullable D.text)))++retirementGrantsStmt :: Statement (Text, Int32) [Text]+retirementGrantsStmt =+  preparable+    """+    SELECT DISTINCT+      coalesce(grantees.rolname, 'PUBLIC') || ':' || privileges.privilege_type+    FROM keiro.keiro_external_read_contracts AS contracts+    JOIN pg_catalog.pg_proc AS procedures+      ON procedures.oid = pg_catalog.to_regprocedure(+        pg_catalog.format(+          '%I.%I(%s)',+          'keiro_read',+          contracts.public_function_name,+          pg_catalog.array_to_string(contracts.argument_types, ',')+        )+      )+    CROSS JOIN LATERAL pg_catalog.aclexplode(+      coalesce(+        procedures.proacl,+        pg_catalog.acldefault('f', procedures.proowner)+      )+    ) AS privileges+    LEFT JOIN pg_catalog.pg_roles AS grantees+      ON grantees.oid = privileges.grantee+    WHERE contracts.contract_id = $1 AND contracts.contract_version = $2+      AND privileges.privilege_type = 'EXECUTE'+    ORDER BY 1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowList (D.column (D.nonNullable D.text)))++retireContractStmt :: Statement (Text, Int32) ()+retireContractStmt =+  preparable+    """+    WITH changed AS (+      UPDATE keiro.keiro_external_read_contracts+      SET state = 'retired', retired_at = now(), updated_at = now()+      WHERE contract_id = $1 AND contract_version = $2+      RETURNING contract_id, contract_version+    )+    UPDATE keiro.keiro_managed_read_objects AS objects+    SET state = 'retired', retired_at = now(), updated_at = now()+    FROM changed+    WHERE objects.contract_id = changed.contract_id+      AND objects.contract_version = changed.contract_version+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    D.noResult
src/Keiro/ReadModel/Rebuild.hs view
@@ -1,14 +1,26 @@ -- | The supported offline read-model rebuild lifecycle. ----- Use this checklist rather than composing the low-level status transitions:+-- New applications register a 'Keiro.Projection.Catalog.ValidatedProjectionCatalog'+-- with 'registerProjectionCatalog', call 'beginGroupRebuild' for one atomic+-- target group, replay through the dedicated catalog runner, and promote only+-- with its opaque 'GroupCompletionToken'. Preparation derives target reset,+-- dedup, and subscription state from the catalog; abandonment records evidence+-- and keeps the group fenced. --+-- The following single-read-model checklist is the unmanaged compatibility+-- path for existing callers. It cannot coordinate multiple targets and accepts+-- caller-supplied projection names:+-- -- 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.+--    checkpoint for a model with a durable cursor. A cursorless inline model has+--    no checkpoint to reset and pairs this call with an empty projection-name+--    list. Both paths prevent 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.@@ -22,11 +34,102 @@ -- -- 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.+-- must not checkpoint an 'Keiro.Projection.AsyncFenced' event. For schema-changing+-- online reconstruction, use the schema-versioned target lifecycle exported below:+-- it keeps the serving revision live while replay populates application-provisioned+-- candidate generations and then promotes the complete group atomically. module Keiro.ReadModel.Rebuild-  ( RebuildError (..),+  ( -- * Catalog rebuild groups+    preCanonicalRunSliceSentinel,+    RebuildRunId,+    mkRebuildRunId,+    rebuildRunIdText,+    RebuildRequest (..),+    RebuildFailure (..),+    GroupLifecycleStatus (..),+    GroupRebuildMetadata (..),+    CatalogRegistrationError (..),+    GroupAdoptionClass (..),+    RegistrationAdoptionAction (..),+    RegistrationAdoption (..),+    OrphanedRegistration (..),+    CatalogAdoptionPlan (..),+    CatalogAdoptionResult (..),+    CatalogAdoptionError (..),+    RebuildStartError (..),+    GroupTransitionError (..),+    ProjectionWriteFence (..),+    GroupPreparation (..),+    GroupRebuildHandle,+    groupRebuildHandleGroup,+    groupRebuildHandleRun,+    groupRebuildHandleSliceFingerprint,+    groupRebuildHandlePreparation,+    groupRebuildHandleResetCheckpointKeys,+    GroupCompletionToken,+    registerProjectionCatalog,+    previewCatalogAdoption,+    adoptCatalogGroups,+    lookupProjectionRebuildGroup,+    ServingPositionBasis (..),+    ProjectionGroupStatusV1 (..),+    listProjectionGroupStatuses,+    lookupProjectionGroupStatus,+    beginGroupRebuild,+    resetDeclaredSubscriptions,+    insertProjectionDedupBatchStmt,+    finishGroupRebuild,+    abandonGroupRebuild,++    -- * Schema-versioned target lifecycle+    VersionedTargetMode (..),+    VersionedRebuildRequest (..),+    VersionedRebuildError (..),+    VersionedGenerationLifecycle (..),+    VersionedTargetGeneration (..),+    VersionedLeaseEvidence (..),+    VersionedRebuildHandle (..),+    VersionedRebuildPhase (..),+    VersionedSourceProgress (..),+    VersionedRebuildReport (..),+    VersionedAbandonResult (..),+    VersionedRetiredGenerationPreview (..),+    VersionedRetiredDropResult (..),+    beginVersionedRebuild,+    applyVersionedReplayEvent,+    verifyVersionedCandidate,+    resumeVersionedRebuild,+    inspectVersionedRebuild,+    abandonVersionedRebuild,+    listVersionedRetiredGenerations,+    previewVersionedRetiredDrop,+    dropVersionedRetiredGeneration,++    -- * Targeted stream reprojection+    StreamReprojectionRequest (..),+    StreamReprojectionError (..),+    StreamReprojectionReport (..),+    reprojectStream,++    -- * Catalog history runner+    RebuildOptions (..),+    defaultRebuildOptions,+    CatalogRebuildError (..),+    RebuildRunStatus (..),+    RebuildFailureEvidence (..),+    RebuildSourceProgress (..),+    RebuildAdapterProgress (..),+    RebuildVerificationProgress (..),+    RebuildRunReport (..),+    AsyncDedupBackfill (..),+    collectAsyncDedupBackfill,+    startCatalogRebuild,+    resumeCatalogRebuild,+    inspectCatalogRebuild,+    abandonCatalogRebuild,++    -- * Unmanaged single-read-model compatibility+    RebuildError (..),     startRebuild,     finishRebuild,     rebuild,@@ -35,7 +138,7 @@   ) where -import Contravariant.Extras (contrazip2)+import Data.List.NonEmpty qualified as NonEmpty import Data.Text.Encoding qualified as TE import Effectful (Eff, (:>)) import Hasql.Decoders qualified as D@@ -43,7 +146,15 @@ import Hasql.Statement (Statement, preparable) import Keiro.Prelude import Keiro.ReadModel+import Keiro.ReadModel.Rebuild.Group+import Keiro.ReadModel.Rebuild.Runner+import Keiro.ReadModel.Rebuild.Status+import Keiro.ReadModel.Rebuild.Stream+import Keiro.ReadModel.Rebuild.Versioned import Kiroku.Store.Effect (Store)+import Kiroku.Store.SQL (visibleGlobalHeadPositionStmt)+import Kiroku.Store.Subscription.Checkpoint (resetSubscriptionCheckpointsTx)+import Kiroku.Store.Subscription.Types (SubscriptionName (..)) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (GlobalPosition (..)) import "hasql-transaction" Hasql.Transaction qualified as Tx@@ -56,14 +167,16 @@   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.+-- keys for its feeding async projections, and, when it has a durable cursor,+-- reset every member of that subscription to the supplied replay position. A+-- cursorless model has no checkpoint to reset, so this deliberately skips the+-- reset; pair that inline-only shape with an empty projection-name list. -- -- 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.+-- keeps the table truncate transactional. Kiroku's ordinary checkpoint save is+-- monotonic, so this helper uses the owning library's explicitly named reset+-- transaction inside the same fence. startRebuild ::   (Store :> es) =>   ReadModel q r ->@@ -76,9 +189,14 @@     Tx.sql (TE.encodeUtf8 ("TRUNCATE TABLE " <> qualifiedTableName readModel))     unless (null projectionNames) $       Tx.statement projectionNames deleteProjectionDedupStmt-    Tx.statement-      (readModel ^. #subscriptionName, globalPositionToInt replayFrom)-      resetSubscriptionCheckpointStmt+    case readModelCursorAuthority readModel of+      NoQueryCursor -> pure ()+      DurableQueryCursor cursor -> do+        _ <-+          resetSubscriptionCheckpointsTx+            (NonEmpty.singleton (SubscriptionName cursor))+            replayFrom+        pure ()     pure metadata  -- | Promote a completed rebuild in the same transaction as its safety check.@@ -96,7 +214,7 @@   Eff es (Either RebuildError ReadModelMetadata) finishRebuild readModel projectionNames replayFrom =   runTransaction $ do-    headPosition <- Tx.statement () storeHeadPositionStmt+    headPosition <- Tx.statement () visibleGlobalHeadPositionStmt     applyCount <-       if null projectionNames         then pure 0@@ -160,31 +278,3 @@     """     (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--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)))--globalPositionToInt :: GlobalPosition -> Int64-globalPositionToInt (GlobalPosition position) = position
+ src/Keiro/ReadModel/Rebuild/Group.hs view
@@ -0,0 +1,1703 @@+{-# OPTIONS_HADDOCK hide #-}++-- | Internal implementation shared by the public rebuild lifecycle and the+-- catalog-derived live-writer paths. The public facade is+-- "Keiro.ReadModel.Rebuild".+module Keiro.ReadModel.Rebuild.Group+  ( preCanonicalRunSliceSentinel,+    canonicalSlicePrefix,+    RebuildRunId,+    mkRebuildRunId,+    rebuildRunIdText,+    RebuildRequest (..),+    RebuildFailure (..),+    GroupLifecycleStatus (..),+    GroupRebuildMetadata (..),+    CatalogRegistrationError (..),+    GroupAdoptionClass (..),+    RegistrationAdoptionAction (..),+    RegistrationAdoption (..),+    OrphanedRegistration (..),+    CatalogAdoptionPlan (..),+    CatalogAdoptionResult (..),+    CatalogAdoptionError (..),+    RebuildStartError (..),+    GroupTransitionError (..),+    ProjectionWriteBinding (..),+    ProjectionWriteFence (..),+    ProjectionRepairFence (..),+    GroupPreparation (..),+    groupPreparationFor,+    GroupRebuildHandle,+    groupRebuildHandleGroup,+    groupRebuildHandleRun,+    groupRebuildHandleSliceFingerprint,+    groupRebuildHandlePreparation,+    groupRebuildHandleResetCheckpointKeys,+    GroupCompletionToken,+    completionTokenForHandle,+    groupRebuildHandleFor,+    registerProjectionCatalog,+    previewCatalogAdoption,+    adoptCatalogGroups,+    lookupProjectionRebuildGroup,+    beginGroupRebuild,+    resetDeclaredSubscriptions,+    insertProjectionDedupBatchStmt,+    finishGroupRebuild,+    finishGroupRebuildTx,+    abandonGroupRebuild,+    abandonPreCanonicalGroupRebuild,+    lockProjectionGroupsTx,+    lockProjectionGroupForRepairTx,+  )+where++import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)+import Data.Functor (($>))+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe, maybeToList)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.UUID (UUID)+import Data.Vector qualified as Vector+import Effectful (Eff, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Connection (qualifyTable)+import Keiro.Prelude+import Keiro.Projection.Catalog+  ( CatalogRegistration (..),+    GroupSliceFingerprint,+    PhysicalTargets,+    ProjectionRevisionId,+    QualifiedTable (..),+    RebuildGroupId,+    TargetResetPolicy (..),+    ValidatedProjectionCatalog,+    asyncProjectionRegistrations,+    catalogInventory,+    catalogRegistrations,+    groupSliceFingerprint,+    groupSliceFingerprintText,+    mkPhysicalTargets,+    mkRebuildGroupId,+    mkTargetId,+    projectionRevisionIdText,+    rebuildGroupIdText,+    replayAdapterMetadata,+  )+import Keiro.Projection.Catalog qualified as Catalog+import Keiro.ReadModel.External qualified as External+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Subscription.Checkpoint+  ( SubscriptionCheckpointResetReport (..),+    resetSubscriptionCheckpointsTx,+  )+import Kiroku.Store.Subscription.Types+  ( SubscriptionCheckpointKey,+    SubscriptionName (..),+  )+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (GlobalPosition (..))+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude (not, null, (&&), (||))+import Prelude qualified++-- | Sentinel that migration 0024 stamps into+-- @keiro_projection_rebuild_runs.group_slice_fingerprint@ for runs begun+-- before canonical slice identity existed. Handled only by the recovery+-- paths: always abandonable, never resumable, never a valid identity.+preCanonicalRunSliceSentinel :: Text+preCanonicalRunSliceSentinel = "$pre-canonical"++-- | Prefix of the current canonical group-slice format (ADR-32).+canonicalSlicePrefix :: Text+canonicalSlicePrefix = "slice-v6:"++-- | Stable operator-supplied identity for one rebuild attempt.+newtype RebuildRunId = RebuildRunId Text+  deriving stock (Eq, Ord, Show, Generic)++mkRebuildRunId :: Text -> Either Text RebuildRunId+mkRebuildRunId value+  | Text.null value = Left "rebuild run id must not be empty"+  | Text.strip value /= value = Left "rebuild run id must not have surrounding whitespace"+  | otherwise = Right (RebuildRunId value)++rebuildRunIdText :: RebuildRunId -> Text+rebuildRunIdText (RebuildRunId value) = value++data RebuildRequest = RebuildRequest+  { rebuildRunId :: !RebuildRunId,+    requestedBy :: !Text,+    requestReason :: !Text,+    replayFrom :: !GlobalPosition+  }+  deriving stock (Eq, Show, Generic)++data RebuildFailure = RebuildFailure+  { failureCode :: !Text,+    failureDetail :: !Text+  }+  deriving stock (Eq, Show, Generic)++data GroupLifecycleStatus+  = GroupLive+  | GroupRebuilding+  | GroupFailed+  | UnknownGroupStatus !Text+  deriving stock (Eq, Ord, Show, Generic)++data GroupRebuildMetadata = GroupRebuildMetadata+  { rebuildGroupId :: !RebuildGroupId,+    sliceFingerprint :: !Text,+    status :: !GroupLifecycleStatus,+    activeRunId :: !(Maybe RebuildRunId),+    requestedBy :: !(Maybe Text),+    requestReason :: !(Maybe Text),+    startedAt :: !(Maybe UTCTime),+    completedAt :: !(Maybe UTCTime),+    failedAt :: !(Maybe UTCTime),+    failureCode :: !(Maybe Text),+    failureDetail :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)++data CatalogRegistrationError+  = RegisteredGroupSliceDrift !RebuildGroupId !Text !Text+  | RegisteredGroupStaleFingerprint !RebuildGroupId !Text+  | RegisteredQueryModelDrift !Text !Text+  | RegisteredQueryModelNotLive !Text+  | RegisteredExternalReadContract !External.ExternalReadReconciliationError+  deriving stock (Eq, Show, Generic)++data GroupAdoptionClass+  = AdoptionNew+  | AdoptionUnchanged+  | AdoptionSliceChanged !Text !Text+  | AdoptionStaleFormat !Text+  deriving stock (Eq, Show, Generic)++-- | What adoption did, or in a preview will do, for one catalog registration.+data RegistrationAdoptionAction+  = RegistrationUpdate+  | RegistrationInsert+  deriving stock (Eq, Show, Generic)++data RegistrationAdoption = RegistrationAdoption+  { registryName :: !Text,+    rebuildGroupId :: !RebuildGroupId,+    action :: !RegistrationAdoptionAction+  }+  deriving stock (Eq, Show, Generic)++-- | A registry row bound to a catalog group whose name no registration in the+-- complete validated catalog claims.+data OrphanedRegistration = OrphanedRegistration+  { registryName :: !Text,+    boundGroupId :: !RebuildGroupId+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionPlan = CatalogAdoptionPlan+  { groupStates :: ![(RebuildGroupId, GroupAdoptionClass)],+    removedGroups :: ![RebuildGroupId],+    registrations :: ![RegistrationAdoption],+    orphanedRegistrations :: ![OrphanedRegistration]+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionResult = CatalogAdoptionResult+  { adoptedGroups :: ![GroupRebuildMetadata],+    registrationOutcomes :: ![RegistrationAdoption],+    removedOrphans :: ![OrphanedRegistration]+  }+  deriving stock (Eq, Show, Generic)++data CatalogAdoptionError+  = AdoptGroupNotInCatalog !RebuildGroupId+  | AdoptGroupUnregistered !RebuildGroupId+  | AdoptGroupNotLive !RebuildGroupId !GroupLifecycleStatus !(Maybe RebuildRunId)+  | AdoptExternalReadContract !External.ExternalReadReconciliationError+  deriving stock (Eq, Show, Generic)++data RebuildStartError+  = RebuildGroupNotInCatalog !RebuildGroupId+  | RebuildGroupUnregistered !RebuildGroupId+  | RebuildGroupSliceDrift !RebuildGroupId !Text !Text+  | RebuildGroupNotLive !RebuildGroupId !GroupLifecycleStatus !(Maybe RebuildRunId)+  | -- | At least one catalog-declared subscription had no persisted member to reset.+    RebuildSubscriptionCheckpointsMissing !RebuildGroupId ![SubscriptionName]+  deriving stock (Eq, Show, Generic)++data GroupTransitionError+  = RebuildHandleNoLongerActive !RebuildGroupId !RebuildRunId+  | RebuildCompletionTokenMismatch !RebuildGroupId !RebuildRunId+  deriving stock (Eq, Show, Generic)++-- | Revision and closed-world physical target binding selected while the+-- corresponding group row remains locked. Legacy groups have no revision but+-- still carry their catalog-declared physical targets.+data ProjectionWriteBinding = ProjectionWriteBinding+  { writeGroupId :: !RebuildGroupId,+    writeRevisionId :: !(Maybe ProjectionRevisionId),+    writePhysicalTargets :: !PhysicalTargets+  }+  deriving stock (Eq, Show, Generic)++data ProjectionWriteFence+  = ProjectionWritesAllowed ![ProjectionWriteBinding]+  | ProjectionWriteFenced !RebuildGroupId !RebuildRunId+  | ProjectionWriteGroupUnregistered !RebuildGroupId+  | ProjectionServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId+  | ProjectionServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text+  deriving stock (Eq, Show, Generic)++-- | Exclusive group lock and exact serving binding used by one targeted+-- stream repair. Unlike the ordinary writer fence this requires both reads+-- and writes to be available and refuses every active rebuild.+data ProjectionRepairFence+  = ProjectionRepairAllowed !ProjectionWriteBinding+  | ProjectionRepairGroupUnregistered !RebuildGroupId+  | ProjectionRepairActiveRebuild !RebuildGroupId !RebuildRunId+  | ProjectionRepairGroupUnavailable !RebuildGroupId !Text !Bool !Bool+  | ProjectionRepairSliceDrift !RebuildGroupId !Text !Text+  | ProjectionRepairServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId+  | ProjectionRepairServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text+  deriving stock (Eq, Show, Generic)++data GroupPreparation = GroupPreparation+  { clearTargets :: ![QualifiedTable],+    preservedTargets :: ![QualifiedTable],+    resetDedupNames :: ![Text],+    resetSubscriptionNames :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++-- | Resolve the closed-world target, deduplication, and subscription preparation+-- declared for one group. Versioned promotion reuses this catalog authority+-- without constructing an offline rebuild handle.+groupPreparationFor :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe GroupPreparation+groupPreparationFor = preparationFor++data GroupRebuildHandle = GroupRebuildHandle+  { handleGroup :: !RebuildGroupId,+    handleRun :: !RebuildRunId,+    handleSliceFingerprint :: !GroupSliceFingerprint,+    handlePreparation :: !GroupPreparation,+    handleResetCheckpointKeys :: ![SubscriptionCheckpointKey]+  }+  deriving stock (Eq, Show, Generic)++groupRebuildHandleGroup :: GroupRebuildHandle -> RebuildGroupId+groupRebuildHandleGroup = handleGroup++groupRebuildHandleRun :: GroupRebuildHandle -> RebuildRunId+groupRebuildHandleRun = handleRun++groupRebuildHandleSliceFingerprint :: GroupRebuildHandle -> GroupSliceFingerprint+groupRebuildHandleSliceFingerprint = handleSliceFingerprint++groupRebuildHandlePreparation :: GroupRebuildHandle -> GroupPreparation+groupRebuildHandlePreparation = handlePreparation++-- | Exact persisted subscription-member rows reset during preparation.+groupRebuildHandleResetCheckpointKeys :: GroupRebuildHandle -> [SubscriptionCheckpointKey]+groupRebuildHandleResetCheckpointKeys = handleResetCheckpointKeys++-- | Opaque proof that completion verification was recorded for this exact+-- group, run, and catalog. Plan 211 constructs it only after durable completion+-- accounting; ordinary callers cannot fabricate one through the public facade.+data GroupCompletionToken = GroupCompletionToken+  { completionGroup :: !RebuildGroupId,+    completionRun :: !RebuildRunId,+    completionSliceFingerprint :: !GroupSliceFingerprint+  }++completionTokenForHandle :: GroupRebuildHandle -> GroupCompletionToken+completionTokenForHandle handle =+  GroupCompletionToken+    { completionGroup = handleGroup handle,+      completionRun = handleRun handle,+      completionSliceFingerprint = handleSliceFingerprint handle+    }++-- | Reconstruct the opaque authorization for a persisted run. The replay+-- runner must still prove the active group/run/fingerprint in the same+-- transaction as every use of this value.+groupRebuildHandleFor ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildRunId ->+  Maybe GroupRebuildHandle+groupRebuildHandleFor catalog groupId runId = do+  preparation <- preparationFor catalog groupId+  slice <- groupSliceFingerprint catalog groupId+  pure+    GroupRebuildHandle+      { handleGroup = groupId,+        handleRun = runId,+        handleSliceFingerprint = slice,+        handlePreparation = preparation,+        handleResetCheckpointKeys = []+      }++registerProjectionCatalog ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  Eff es (Either CatalogRegistrationError [GroupRebuildMetadata])+registerProjectionCatalog catalog =+  runTransaction $ registerProjectionCatalogTx catalog++registerProjectionCatalogTx ::+  ValidatedProjectionCatalog ->+  Tx.Transaction (Either CatalogRegistrationError [GroupRebuildMetadata])+registerProjectionCatalogTx catalog = do+  groupsResult <- registerGroups [] groupIds+  case groupsResult of+    Left err -> Tx.condemn $> Left err+    Right groups -> do+      revisionsResult <- registerRevisions revisionRegistrations+      case revisionsResult of+        Left err -> Tx.condemn $> Left err+        Right () -> do+          reconcileCursorAuthorities groupIds+          queriesResult <- registerQueries queryRegistrations+          case queriesResult of+            Left err -> Tx.condemn $> Left err+            Right () -> do+              externalReads <- External.reconcileExternalReadContractsTx catalog+              case externalReads of+                Left err -> Tx.condemn $> Left (RegisteredExternalReadContract err)+                Right () -> do+                  Tx.statement () deleteOrphanLegacyGroupsStmt+                  pure (Right (List.sortOn (rebuildGroupIdText . (^. #rebuildGroupId)) groups))+  where+    groupIds = (^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups)+    revisionRegistrations =+      [ ( revision ^. #rebuildGroupId,+          revision ^. #revisionId,+          sliceTextFor catalog (revision ^. #rebuildGroupId)+        )+      | revision <- catalogInventory catalog ^. #inventoryProjectionRevisions+      ]+    queryRegistrations = catalogRegistrations catalog++    reconcileCursorAuthorities =+      traverse_ $ \groupId ->+        Tx.statement+          (cursorAuthorityParams catalog groupId)+          upsertGroupCursorAuthorityStmt++    registerGroups accumulated = \case+      [] -> pure (Right (Prelude.reverse accumulated))+      groupId : rest -> do+        let currentSlice = sliceFor groupId+            currentText = groupSliceFingerprintText currentSlice+        metadata <-+          Tx.statement+            (rebuildGroupIdText groupId, currentText)+            registerGroupStmt+        let stored = metadata ^. #sliceFingerprint+        if stored == currentText+          then registerGroups (metadata : accumulated) rest+          else+            if canonicalSlicePrefix `Text.isPrefixOf` stored+              then pure (Left (RegisteredGroupSliceDrift groupId stored currentText))+              else pure (Left (RegisteredGroupStaleFingerprint groupId stored))++    sliceFor groupId =+      fromMaybe+        (error "registerProjectionCatalogTx: inventory group has no slice")+        (groupSliceFingerprint catalog groupId)++    registerRevisions = \case+      [] -> pure (Right ())+      (groupId, revisionId, slice) : rest -> do+        stored <-+          Tx.statement+            (rebuildGroupIdText groupId, projectionRevisionIdText revisionId, slice)+            registerProjectionRevisionStmt+        if stored == slice+          then registerRevisions rest+          else pure (Left (RegisteredGroupSliceDrift groupId stored slice))++    registerQueries = \case+      [] -> pure (Right ())+      registration : rest -> do+        existing <- Tx.statement (registration ^. #registryName) lookupQueryRegistrationStmt+        result <-+          case existing of+            Nothing -> do+              Tx.statement (queryRegistrationParams registration) insertQueryRegistrationStmt+              pure (Right ())+            Just row -> reconcileQueryRegistration registration row+        case result of+          Left err -> pure (Left err)+          Right () -> registerQueries rest++    reconcileQueryRegistration registration row+      | rowVersion row /= registration ^. #version =+          pure (Left (queryDrift registration row "version differs"))+      | rowShapeHash row /= registration ^. #shapeHash =+          pure (Left (queryDrift registration row "shape hash differs"))+      | rowGroupId row == rebuildGroupIdText (registration ^. #rebuildGroupId) =+          pure (Right ())+      | rowStatus row == "live" && "$legacy-read-model:" `Text.isPrefixOf` rowGroupId row = do+          Tx.statement+            (registration ^. #registryName, rebuildGroupIdText (registration ^. #rebuildGroupId))+            adoptLegacyQueryRegistrationStmt+          pure (Right ())+      | rowStatus row /= "live" =+          pure (Left (RegisteredQueryModelNotLive (registration ^. #registryName)))+      | otherwise = pure (Left (queryDrift registration row "rebuild group differs"))++    queryDrift registration row reason =+      RegisteredQueryModelDrift+        (registration ^. #registryName)+        ( reason+            <> "; registered group="+            <> rowGroupId row+            <> ", catalog group="+            <> rebuildGroupIdText (registration ^. #rebuildGroupId)+        )++previewCatalogAdoption ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  Eff es CatalogAdoptionPlan+previewCatalogAdoption catalog =+  runTransaction $ do+    registered <- Tx.statement () listGroupsStmt+    registryRows <- Tx.statement () listQueryRegistrationBindingsStmt+    let registeredById = Map.fromList [(metadata ^. #rebuildGroupId, metadata) | metadata <- registered]+        catalogGroups =+          List.sortOn+            rebuildGroupIdText+            ((^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups))+        catalogGroupSet = Set.fromList catalogGroups+        classify groupId =+          ( groupId,+            case Map.lookup groupId registeredById of+              Nothing -> AdoptionNew+              Just metadata -> adoptionClass groupId (metadata ^. #sliceFingerprint)+          )+        removed =+          List.sortOn+            rebuildGroupIdText+            [ metadata ^. #rebuildGroupId+            | metadata <- registered,+              metadata ^. #sliceFingerprint /= "$legacy-unmanaged",+              (metadata ^. #rebuildGroupId) `Set.notMember` catalogGroupSet+            ]+        catalogRegistrationRows = List.sortOn (^. #registryName) (catalogRegistrations catalog)+        catalogRegistrationNames = Set.fromList ((^. #registryName) <$> catalogRegistrationRows)+        registeredNames = Set.fromList (Prelude.fst <$> registryRows)+        registrationPlans =+          [ RegistrationAdoption+              { registryName = registration ^. #registryName,+                rebuildGroupId = registration ^. #rebuildGroupId,+                action =+                  if (registration ^. #registryName) `Set.member` registeredNames+                    then RegistrationUpdate+                    else RegistrationInsert+              }+          | registration <- catalogRegistrationRows+          ]+        orphaned =+          List.sortOn+            (^. #registryName)+            [ OrphanedRegistration name groupId+            | (name, storedGroupId) <- registryRows,+              name `Set.notMember` catalogRegistrationNames,+              groupId <- maybeToList (either (Prelude.const Nothing) Just (mkRebuildGroupId storedGroupId)),+              groupId `Set.member` catalogGroupSet+            ]+    pure+      CatalogAdoptionPlan+        { groupStates = classify <$> catalogGroups,+          removedGroups = removed,+          registrations = registrationPlans,+          orphanedRegistrations = orphaned+        }+  where+    adoptionClass groupId stored+      | stored == current = AdoptionUnchanged+      | canonicalSlicePrefix `Text.isPrefixOf` stored = AdoptionSliceChanged stored current+      | otherwise = AdoptionStaleFormat stored+      where+        current = sliceTextFor catalog groupId++adoptCatalogGroups ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  NonEmpty.NonEmpty RebuildGroupId ->+  Eff es (Either CatalogAdoptionError CatalogAdoptionResult)+adoptCatalogGroups catalog requested =+  case List.find (`Set.notMember` catalogGroupSet) groupIds of+    Just groupId -> pure (Left (AdoptGroupNotInCatalog groupId))+    Nothing -> runTransaction adoptTx+  where+    groupIds = List.sort . Set.toList . Set.fromList $ NonEmpty.toList requested+    catalogGroupSet =+      Set.fromList ((^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups))+    namedGroupSet = Set.fromList groupIds+    registrations =+      List.sortOn+        (^. #registryName)+        [ registration+        | registration <- catalogRegistrations catalog,+          (registration ^. #rebuildGroupId) `Set.member` namedGroupSet+        ]+    allRegistrationNames = Set.fromList ((^. #registryName) <$> catalogRegistrations catalog)+    revisionRegistrations =+      [ ( revision ^. #rebuildGroupId,+          revision ^. #revisionId,+          sliceTextFor catalog (revision ^. #rebuildGroupId)+        )+      | revision <- catalogInventory catalog ^. #inventoryProjectionRevisions,+        (revision ^. #rebuildGroupId) `Set.member` namedGroupSet+      ]++    adoptTx = do+      locked <- lockAll [] groupIds+      case locked of+        Left err -> Tx.condemn $> Left err+        Right lockedGroups -> do+          for_ groupIds $ \groupId ->+            Tx.statement+              (rebuildGroupIdText groupId, sliceTextFor catalog groupId)+              adoptGroupSliceStmt+          for_ revisionRegistrations $ \(groupId, revisionId, slice) ->+            void+              ( Tx.statement+                  (rebuildGroupIdText groupId, projectionRevisionIdText revisionId, slice)+                  adoptProjectionRevisionStmt+              )+          for_ groupIds $ \groupId ->+            Tx.statement+              (cursorAuthorityParams catalog groupId)+              upsertGroupCursorAuthorityStmt+          registrationResults <- traverse (reconcileRegistration lockedGroups) registrations+          boundRows <- Tx.statement (rebuildGroupIdText <$> groupIds) lockGroupRegistrationsStmt+          let groupByText = Map.fromList [(rebuildGroupIdText groupId, groupId) | groupId <- groupIds]+              orphaned =+                List.sortOn+                  (^. #registryName)+                  [ OrphanedRegistration name groupId+                  | (name, storedGroupId) <- boundRows,+                    name `Set.notMember` allRegistrationNames,+                    groupId <- maybeToList (Map.lookup storedGroupId groupByText)+                  ]+          unless (null orphaned)+            $ Tx.statement ((^. #registryName) <$> orphaned) deleteQueryRegistrationsStmt+          externalReads <-+            External.reconcileExternalReadContractsForGroupsTx+              catalog+              (Just namedGroupSet)+          case externalReads of+            Left err -> Tx.condemn $> Left (AdoptExternalReadContract err)+            Right () -> do+              updated <- traverse (\groupId -> Tx.statement (rebuildGroupIdText groupId) lookupGroupStmt) groupIds+              pure+                ( Right+                    CatalogAdoptionResult+                      { adoptedGroups = [metadata | Just metadata <- updated],+                        registrationOutcomes = registrationResults,+                        removedOrphans = orphaned+                      }+                )++    reconcileRegistration lockedGroups registration = do+      existing <- Tx.statement (registration ^. #registryName) lookupQueryRegistrationStmt+      action <-+        case existing of+          Just _ -> do+            affected <- Tx.statement (queryRegistrationParams registration) adoptQueryRegistrationStmt+            when (affected /= 1)+              $ error "adoptTx: locked registration row vanished"+            pure RegistrationUpdate+          Nothing -> do+            Tx.statement+              (adoptionQueryRegistrationParams lockedGroups registration)+              insertAdoptedQueryRegistrationStmt+            pure RegistrationInsert+      pure+        RegistrationAdoption+          { registryName = registration ^. #registryName,+            rebuildGroupId = registration ^. #rebuildGroupId,+            action+          }++    adoptionQueryRegistrationParams lockedGroups registration =+      let (name, version, shape, groupId) = queryRegistrationParams registration+          status =+            case List.find ((== registration ^. #rebuildGroupId) . (^. #rebuildGroupId)) lockedGroups of+              Just metadata -> case metadata ^. #status of+                GroupLive -> "live"+                GroupFailed -> "abandoned"+                _ -> error "adoptTx: non-adoptable group reached registration insert"+              Nothing -> error "adoptTx: registration group was not locked"+       in (name, version, shape, groupId, status)++    lockAll accumulated = \case+      [] -> pure (Right (Prelude.reverse accumulated))+      groupId : rest -> do+        row <- Tx.statement (rebuildGroupIdText groupId) lockGroupForUpdateStmt+        case row of+          Nothing -> pure (Left (AdoptGroupUnregistered groupId))+          Just metadata+            | not (adoptable metadata) ->+                pure+                  ( Left+                      ( AdoptGroupNotLive+                          groupId+                          (metadata ^. #status)+                          (metadata ^. #activeRunId)+                      )+                  )+            | otherwise -> lockAll (metadata : accumulated) rest++    adoptable metadata =+      metadata+        ^. #status+        == GroupLive+        || ( metadata ^. #status == GroupFailed+               && not+                 ( canonicalSlicePrefix+                     `Text.isPrefixOf` (metadata ^. #sliceFingerprint)+                 )+           )++sliceTextFor :: ValidatedProjectionCatalog -> RebuildGroupId -> Text+sliceTextFor catalog groupId =+  maybe+    (error "sliceTextFor: catalog inventory group has no slice")+    groupSliceFingerprintText+    (groupSliceFingerprint catalog groupId)++cursorAuthorityParams ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  (Text, Text, [Text])+cursorAuthorityParams catalog groupId =+  ( rebuildGroupIdText groupId,+    if null subscriptionNames then "append" else "checkpoint",+    subscriptionNames+  )+  where+    inventory = catalogInventory catalog+    subscriptionNamesById =+      Map.fromList+        [ (subscription ^. #subscriptionId, subscription ^. #subscriptionName)+        | subscription <- inventory ^. #inventorySubscriptions+        ]+    subscriptionIds =+      Set.fromList+        [ subscriptionId+        | projection <- inventory ^. #inventoryProjections,+          projection ^. #rebuildGroupId == groupId,+          Catalog.InventoryAsyncHandler _ subscriptionId _ <- projection ^. #handlers+        ]+    subscriptionNames =+      List.sort+        [ fromMaybe+            (error "cursorAuthorityParams: validated handler has no subscription")+            (Map.lookup subscriptionId subscriptionNamesById)+        | subscriptionId <- Set.toList subscriptionIds+        ]++lookupProjectionRebuildGroup ::+  (Store :> es) =>+  RebuildGroupId ->+  Eff es (Maybe GroupRebuildMetadata)+lookupProjectionRebuildGroup groupId =+  runTransaction+    $ Tx.statement+      (rebuildGroupIdText groupId)+      lookupGroupStmt++beginGroupRebuild ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildRequest ->+  Eff es (Either RebuildStartError GroupRebuildHandle)+beginGroupRebuild catalog groupId request =+  case preparationFor catalog groupId of+    Nothing -> pure (Left (RebuildGroupNotInCatalog groupId))+    Just preparation ->+      runTransaction $ do+        registered <-+          Tx.statement+            (rebuildGroupIdText groupId)+            lockGroupForUpdateStmt+        case registered of+          Nothing -> Tx.condemn $> Left (RebuildGroupUnregistered groupId)+          Just metadata+            | metadata ^. #sliceFingerprint /= expectedSliceText ->+                Tx.condemn+                  $> Left+                    ( RebuildGroupSliceDrift+                        groupId+                        (metadata ^. #sliceFingerprint)+                        expectedSliceText+                    )+            | metadata ^. #status /= GroupLive+                && metadata ^. #status /= GroupFailed ->+                Tx.condemn+                  $> Left+                    ( RebuildGroupNotLive+                        groupId+                        (metadata ^. #status)+                        (metadata ^. #activeRunId)+                    )+            | otherwise -> do+                Tx.statement+                  ( rebuildGroupIdText groupId,+                    rebuildRunIdText (request ^. #rebuildRunId),+                    request ^. #requestedBy,+                    request ^. #requestReason+                  )+                  beginGroupStmt+                Tx.statement (rebuildGroupIdText groupId) markGroupQueriesRebuildingStmt+                truncateTargets (preparation ^. #clearTargets)+                unless (null (preparation ^. #resetDedupNames))+                  $ Tx.statement (preparation ^. #resetDedupNames) deleteProjectionDedupStmt+                resetReport <- resetDeclaredSubscriptions preparation (request ^. #replayFrom)+                let missingNames = Vector.toList (resetReport ^. #missingSubscriptionNames)+                if null missingNames+                  then+                    pure+                      ( Right+                          GroupRebuildHandle+                            { handleGroup = groupId,+                              handleRun = request ^. #rebuildRunId,+                              handleSliceFingerprint = expectedSlice,+                              handlePreparation = preparation,+                              handleResetCheckpointKeys = Vector.toList (resetReport ^. #resetCheckpointKeys)+                            }+                      )+                  else+                    Tx.condemn+                      $> Left (RebuildSubscriptionCheckpointsMissing groupId missingNames)+  where+    expectedSlice =+      fromMaybe+        (error "beginGroupRebuild: prepared group has no catalog slice")+        (groupSliceFingerprint catalog groupId)+    expectedSliceText = groupSliceFingerprintText expectedSlice++-- | Reset every declared subscription member to one exact position.+-- Preparation uses this to rewind to @replayFrom@; promotion uses it to+-- advance to the captured head. Both transitions remain inside the group+-- lifecycle transaction.+resetDeclaredSubscriptions :: GroupPreparation -> GlobalPosition -> Tx.Transaction SubscriptionCheckpointResetReport+resetDeclaredSubscriptions preparation replayFrom =+  case NonEmpty.nonEmpty (SubscriptionName <$> preparation ^. #resetSubscriptionNames) of+    Nothing ->+      pure+        SubscriptionCheckpointResetReport+          { resetCheckpointKeys = Vector.empty,+            missingSubscriptionNames = Vector.empty+          }+    Just subscriptionNames -> resetSubscriptionCheckpointsTx subscriptionNames replayFrom++finishGroupRebuild ::+  (Store :> es) =>+  GroupRebuildHandle ->+  GroupCompletionToken ->+  Eff es (Either GroupTransitionError GroupRebuildMetadata)+finishGroupRebuild handle token+  | not (tokenMatchesHandle handle token) =+      pure (Left (RebuildCompletionTokenMismatch (handleGroup handle) (handleRun handle)))+  | otherwise = runTransaction (finishGroupRebuildTx handle token)++finishGroupRebuildTx ::+  GroupRebuildHandle ->+  GroupCompletionToken ->+  Tx.Transaction (Either GroupTransitionError GroupRebuildMetadata)+finishGroupRebuildTx handle token+  | not (tokenMatchesHandle handle token) =+      pure (Left (RebuildCompletionTokenMismatch (handleGroup handle) (handleRun handle)))+  | otherwise = do+      promoted <-+        Tx.statement+          ( rebuildGroupIdText (handleGroup handle),+            rebuildRunIdText (handleRun handle),+            groupSliceFingerprintText (handleSliceFingerprint handle)+          )+          finishGroupStmt+      case promoted of+        Nothing ->+          Tx.condemn+            $> Left (RebuildHandleNoLongerActive (handleGroup handle) (handleRun handle))+        Just metadata -> do+          Tx.statement (rebuildGroupIdText (handleGroup handle)) markGroupQueriesLiveStmt+          pure (Right metadata)++abandonGroupRebuild ::+  (Store :> es) =>+  GroupRebuildHandle ->+  RebuildFailure ->+  Eff es (Either GroupTransitionError GroupRebuildMetadata)+abandonGroupRebuild handle failure =+  runTransaction $ do+    abandoned <-+      Tx.statement+        ( rebuildGroupIdText (handleGroup handle),+          rebuildRunIdText (handleRun handle),+          groupSliceFingerprintText (handleSliceFingerprint handle),+          failure ^. #failureCode,+          failure ^. #failureDetail+        )+        abandonGroupStmt+    case abandoned of+      Nothing ->+        Tx.condemn+          $> Left (RebuildHandleNoLongerActive (handleGroup handle) (handleRun handle))+      Just metadata -> do+        Tx.statement (rebuildGroupIdText (handleGroup handle)) markGroupQueriesAbandonedStmt+        pure (Right metadata)++-- | Abandon a run stamped by migration 0024 before canonical slice identity+-- existed. This recovery transition deliberately does not compare a catalog+-- slice: the sentinel is evidence that no meaningful slice was persisted.+-- Failed groups are returned unchanged so retries preserve the first failure+-- evidence.+abandonPreCanonicalGroupRebuild ::+  (Store :> es) =>+  RebuildGroupId ->+  RebuildRunId ->+  RebuildFailure ->+  Eff es (Either GroupTransitionError GroupRebuildMetadata)+abandonPreCanonicalGroupRebuild groupId runId failure =+  runTransaction $ do+    locked <- Tx.statement (rebuildGroupIdText groupId) lockGroupForUpdateStmt+    case locked of+      Just metadata+        | metadata ^. #activeRunId == Just runId ->+            case metadata ^. #status of+              GroupFailed -> pure (Right metadata)+              GroupRebuilding -> do+                abandoned <-+                  Tx.statement+                    ( rebuildGroupIdText groupId,+                      rebuildRunIdText runId,+                      failure ^. #failureCode,+                      failure ^. #failureDetail+                    )+                    abandonPreCanonicalGroupStmt+                case abandoned of+                  Nothing -> inactive+                  Just updated -> do+                    Tx.statement (rebuildGroupIdText groupId) markGroupQueriesAbandonedStmt+                    pure (Right updated)+              _ -> inactive+      _ -> inactive+  where+    inactive =+      Tx.condemn+        $> Left (RebuildHandleNoLongerActive groupId runId)++lockProjectionGroupsTx ::+  ValidatedProjectionCatalog ->+  [RebuildGroupId] ->+  Tx.Transaction ProjectionWriteFence+lockProjectionGroupsTx catalog = go [] . List.sort . Set.toList . Set.fromList+  where+    go bindings = \case+      [] -> pure (ProjectionWritesAllowed (Prelude.reverse bindings))+      groupId : rest -> do+        row <-+          Tx.statement+            (rebuildGroupIdText groupId)+            lockGroupWithServingBindingsStmt+        case row of+          Nothing -> pure (ProjectionWriteGroupUnregistered groupId)+          Just (_, activeRunId, _, False, _) ->+            case activeRunId of+              Just runId -> pure (ProjectionWriteFenced groupId (RebuildRunId runId))+              Nothing -> pure (ProjectionWriteGroupUnregistered groupId)+          Just ("live", Nothing, Nothing, True, _) ->+            case declaredPhysicalTargets catalog groupId of+              Nothing -> pure (ProjectionWriteGroupUnregistered groupId)+              Just targets ->+                go+                  (ProjectionWriteBinding groupId Nothing targets : bindings)+                  rest+          Just (status, _, Just revisionIdText, True, rows)+            | status `Prelude.elem` ["serving-versioned", "rebuilding-versioned"] ->+                case findRevision revisionIdText of+                  Nothing ->+                    case Catalog.mkProjectionRevisionId revisionIdText of+                      Left _ -> pure (ProjectionWriteGroupUnregistered groupId)+                      Right revisionId ->+                        pure (ProjectionServingRevisionUnavailable groupId revisionId)+                  Just revision ->+                    case servingPhysicalTargets revision rows of+                      Left detail ->+                        pure+                          ( ProjectionServingBindingInvalid+                              groupId+                              (revision ^. #revisionId)+                              detail+                          )+                      Right targets ->+                        go+                          ( ProjectionWriteBinding+                              groupId+                              (Just (revision ^. #revisionId))+                              targets+                              : bindings+                          )+                          rest+          _ -> pure (ProjectionWriteGroupUnregistered groupId)++    findRevision revisionIdText =+      case Catalog.mkProjectionRevisionId revisionIdText of+        Left _ -> Nothing+        Right revisionId -> Catalog.catalogProjectionRevision catalog revisionId++-- | Take the group's writer-conflicting lock and resolve the exact persisted+-- serving revision/physical generations without changing lifecycle state.+-- Sanctioned external readers take the compatible shared lock and therefore+-- wait for this transaction to finish.+lockProjectionGroupForRepairTx ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  Tx.Transaction ProjectionRepairFence+lockProjectionGroupForRepairTx catalog groupId = do+  row <-+    Tx.statement+      (rebuildGroupIdText groupId)+      lockGroupForRepairStmt+  case row of+    Nothing -> pure (ProjectionRepairGroupUnregistered groupId)+    Just (_, Just activeRunId, _, _, _, _) ->+      pure (ProjectionRepairActiveRebuild groupId (RebuildRunId activeRunId))+    Just (status, Nothing, maybeRevisionId, readsAllowed, writesAllowed, storedSlice) ->+      case groupSliceFingerprint catalog groupId of+        Nothing -> pure (ProjectionRepairGroupUnregistered groupId)+        Just currentSlice+          | storedSlice /= groupSliceFingerprintText currentSlice ->+              pure+                ( ProjectionRepairSliceDrift+                    groupId+                    (groupSliceFingerprintText currentSlice)+                    storedSlice+                )+          | not readsAllowed || not writesAllowed || status /= "serving-versioned" ->+              pure (ProjectionRepairGroupUnavailable groupId status readsAllowed writesAllowed)+          | otherwise ->+              case maybeRevisionId >>= findRevision of+                Nothing ->+                  case maybeRevisionId >>= eitherToMaybe . Catalog.mkProjectionRevisionId of+                    Just revisionId -> pure (ProjectionRepairServingRevisionUnavailable groupId revisionId)+                    Nothing -> pure (ProjectionRepairGroupUnavailable groupId status readsAllowed writesAllowed)+                Just revision -> do+                  rows <-+                    Tx.statement+                      (rebuildGroupIdText groupId)+                      servingTargetBindingsStmt+                  case servingPhysicalTargets revision rows of+                    Left detail ->+                      pure+                        ( ProjectionRepairServingBindingInvalid+                            groupId+                            (revision ^. #revisionId)+                            detail+                        )+                    Right targets ->+                      pure+                        ( ProjectionRepairAllowed+                            ( ProjectionWriteBinding+                                groupId+                                (Just (revision ^. #revisionId))+                                targets+                            )+                        )+  where+    findRevision revisionIdText =+      case Catalog.mkProjectionRevisionId revisionIdText of+        Left _ -> Nothing+        Right revisionId -> Catalog.catalogProjectionRevision catalog revisionId++    eitherToMaybe = \case+      Left _ -> Nothing+      Right value -> Just value++declaredPhysicalTargets ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  Maybe PhysicalTargets+declaredPhysicalTargets catalog groupId = do+  group <-+    List.find+      ((== groupId) . (^. #rebuildGroupId))+      (inventory ^. #inventoryGroups)+  targetRows <- traverse targetFor (group ^. #orderedTargets)+  either (Prelude.const Nothing) Just+    $ mkPhysicalTargets+      (group ^. #orderedTargets)+      ( Map.fromList+          [ (target ^. #targetId, target ^. #qualifiedTable)+          | target <- targetRows+          ]+      )+  where+    inventory = catalogInventory catalog+    targetFor targetId =+      List.find ((== targetId) . (^. #targetId)) (inventory ^. #inventoryTargets)++servingPhysicalTargets ::+  Catalog.ProjectionRevision ->+  [(Text, Text, Text, Text)] ->+  Either Text PhysicalTargets+servingPhysicalTargets revision rows = do+  parsedRows <- traverse parseRow rows+  let expected = Map.keys (revision ^. #targetProvisioners)+      supplied = Map.fromList [(targetId, table) | (targetId, table) <- parsedRows]+  case mkPhysicalTargets expected supplied of+    Left errors -> Left (Text.pack (show errors))+    Right targets -> Right targets+  where+    parseRow (targetIdTextValue, revisionIdTextValue, schemaName, tableName)+      | revisionIdTextValue /= projectionRevisionIdText (revision ^. #revisionId) =+          Left+            ( "serving target "+                <> targetIdTextValue+                <> " belongs to revision "+                <> revisionIdTextValue+            )+      | otherwise = do+          targetId <-+            case mkTargetId targetIdTextValue of+              Left err -> Left (Text.pack (show err))+              Right value -> Right value+          pure (targetId, QualifiedTable schemaName tableName)++data RegisteredQueryRow = RegisteredQueryRow+  { rowVersion :: !Int,+    rowShapeHash :: !Text,+    rowGroupId :: !Text,+    rowStatus :: !Text+  }++queryRegistrationParams :: CatalogRegistration -> (Text, Int64, Text, Text)+queryRegistrationParams registration =+  ( registration ^. #registryName,+    Prelude.fromIntegral (registration ^. #version),+    registration ^. #shapeHash,+    rebuildGroupIdText (registration ^. #rebuildGroupId)+  )++preparationFor :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe GroupPreparation+preparationFor catalog groupId = do+  group <- List.find ((== groupId) . (^. #rebuildGroupId)) (inventory ^. #inventoryGroups)+  let orderedTargetRows = mapMaybe targetFor (group ^. #orderedTargets)+      replayableProjectionIds =+        Set.fromList+          [ entry ^. #projectionId+          | entry <- replayAdapterMetadata catalog,+            entry ^. #rebuildGroupId == groupId,+            entry ^. #replayable+          ]+      asyncRows =+        [ entry+        | entry <- asyncProjectionRegistrations catalog,+          (entry ^. #projectionId) `Set.member` replayableProjectionIds+        ]+  pure+    GroupPreparation+      { clearTargets =+          [ target ^. #qualifiedTable+          | target <- orderedTargetRows,+            target ^. #resetPolicy == ClearBeforeReplay+          ],+        preservedTargets =+          [ target ^. #qualifiedTable+          | target <- orderedTargetRows,+            target ^. #resetPolicy == PreserveAndReconcile+          ],+        resetDedupNames = List.nub (List.sort ((^. #dedupName) <$> asyncRows)),+        resetSubscriptionNames = List.nub (List.sort ((^. #subscriptionName) <$> asyncRows))+      }+  where+    inventory = catalogInventory catalog+    targetFor targetId =+      List.find ((== targetId) . (^. #targetId)) (inventory ^. #inventoryTargets)++truncateTargets :: [QualifiedTable] -> Tx.Transaction ()+truncateTargets = \case+  [] -> pure ()+  targets ->+    Tx.sql+      ( TE.encodeUtf8+          ( "TRUNCATE TABLE "+              <> Text.intercalate+                ", "+                [ qualifyTable (target ^. #schemaName) (target ^. #tableName)+                | target <- targets+                ]+          )+      )++tokenMatchesHandle :: GroupRebuildHandle -> GroupCompletionToken -> Bool+tokenMatchesHandle handle token =+  completionGroup token == handleGroup handle+    && completionRun token == handleRun handle+    && completionSliceFingerprint token == handleSliceFingerprint handle++registerGroupStmt :: Statement (Text, Text) GroupRebuildMetadata+registerGroupStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_groups+      (group_id, slice_fingerprint, status)+    VALUES ($1, $2, 'live')+    ON CONFLICT (group_id) DO UPDATE+      SET group_id = EXCLUDED.group_id+    RETURNING group_id, slice_fingerprint, status, active_run_id,+              requested_by, request_reason, started_at, completed_at, failed_at,+              failure_code, failure_detail+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    groupMetadataSingle++registerProjectionRevisionStmt :: Statement (Text, Text, Text) Text+registerProjectionRevisionStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_revisions+      (group_id, revision_id, group_slice_fingerprint)+    VALUES ($1, $2, $3)+    ON CONFLICT (group_id, revision_id) DO UPDATE+      SET revision_id = EXCLUDED.revision_id+    RETURNING group_slice_fingerprint+    """+    ( 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.text)))++upsertGroupCursorAuthorityStmt :: Statement (Text, Text, [Text]) ()+upsertGroupCursorAuthorityStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_group_cursors+      (group_id, position_basis, subscription_names)+    VALUES ($1, $2, $3)+    ON CONFLICT (group_id) DO UPDATE+      SET position_basis = EXCLUDED.position_basis,+          subscription_names = EXCLUDED.subscription_names,+          updated_at = now()+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    )+    D.noResult++adoptProjectionRevisionStmt :: Statement (Text, Text, Text) ()+adoptProjectionRevisionStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_revisions+      (group_id, revision_id, group_slice_fingerprint)+    VALUES ($1, $2, $3)+    ON CONFLICT (group_id, revision_id) DO UPDATE+      SET group_slice_fingerprint = EXCLUDED.group_slice_fingerprint,+          updated_at = now()+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++lookupGroupStmt :: Statement Text (Maybe GroupRebuildMetadata)+lookupGroupStmt =+  preparable+    """+    SELECT group_id, slice_fingerprint, status, active_run_id,+           requested_by, request_reason, started_at, completed_at, failed_at,+           failure_code, failure_detail+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe groupMetadataDecoder)++listGroupsStmt :: Statement () [GroupRebuildMetadata]+listGroupsStmt =+  preparable+    """+    SELECT group_id, slice_fingerprint, status, active_run_id,+           requested_by, request_reason, started_at, completed_at, failed_at,+           failure_code, failure_detail+    FROM keiro.keiro_projection_rebuild_groups+    ORDER BY group_id+    """+    E.noParams+    (D.rowList groupMetadataDecoder)++lockGroupForUpdateStmt :: Statement Text (Maybe GroupRebuildMetadata)+lockGroupForUpdateStmt =+  preparable+    """+    SELECT group_id, slice_fingerprint, status, active_run_id,+           requested_by, request_reason, started_at, completed_at, failed_at,+           failure_code, failure_detail+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id = $1+    FOR UPDATE+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe groupMetadataDecoder)++beginGroupStmt :: Statement (Text, Text, Text, Text) ()+beginGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'rebuilding',+        reads_allowed = FALSE,+        writes_allowed = FALSE,+        active_run_id = $2,+        requested_by = $3,+        request_reason = $4,+        started_at = now(),+        completed_at = NULL,+        failed_at = NULL,+        failure_code = NULL,+        failure_detail = NULL,+        updated_at = now()+    WHERE group_id = $1+    """+    ( 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++finishGroupStmt :: Statement (Text, Text, Text) (Maybe GroupRebuildMetadata)+finishGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'live',+        reads_allowed = TRUE,+        writes_allowed = TRUE,+        active_run_id = NULL,+        completed_at = now(),+        failed_at = NULL,+        failure_code = NULL,+        failure_detail = NULL,+        updated_at = now()+    WHERE group_id = $1+      AND active_run_id = $2+      AND slice_fingerprint = $3+      AND status = 'rebuilding'+    RETURNING group_id, slice_fingerprint, status, active_run_id,+              requested_by, request_reason, started_at, completed_at, failed_at,+              failure_code, failure_detail+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe groupMetadataDecoder)++abandonGroupStmt :: Statement (Text, Text, Text, Text, Text) (Maybe GroupRebuildMetadata)+abandonGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'failed',+        reads_allowed = FALSE,+        writes_allowed = FALSE,+        failed_at = now(),+        failure_code = $4,+        failure_detail = $5,+        updated_at = now()+    WHERE group_id = $1+      AND active_run_id = $2+      AND slice_fingerprint = $3+      AND status = 'rebuilding'+    RETURNING group_id, slice_fingerprint, status, active_run_id,+              requested_by, request_reason, started_at, completed_at, failed_at,+              failure_code, failure_detail+    """+    ( 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.rowMaybe groupMetadataDecoder)++abandonPreCanonicalGroupStmt :: Statement (Text, Text, Text, Text) (Maybe GroupRebuildMetadata)+abandonPreCanonicalGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'failed',+        reads_allowed = FALSE,+        writes_allowed = FALSE,+        failed_at = now(),+        failure_code = $3,+        failure_detail = $4,+        updated_at = now()+    WHERE group_id = $1+      AND active_run_id = $2+      AND status = 'rebuilding'+    RETURNING group_id, slice_fingerprint, status, active_run_id,+              requested_by, request_reason, started_at, completed_at, failed_at,+              failure_code, failure_detail+    """+    ( 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.rowMaybe groupMetadataDecoder)++lockGroupWithServingBindingsStmt :: Statement Text (Maybe (Text, Maybe Text, Maybe Text, Bool, [(Text, Text, Text, Text)]))+lockGroupWithServingBindingsStmt =+  preparable+    """+    SELECT locked_group.status,+           locked_group.active_run_id,+           locked_group.serving_revision_id,+           locked_group.writes_allowed,+           COALESCE(array_agg(generations.target_id ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.revision_id ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.schema_name ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),+           COALESCE(array_agg(generations.relation_name ORDER BY generations.target_id)+             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[])+    FROM (+      SELECT status, active_run_id, serving_revision_id, writes_allowed+      FROM keiro.keiro_projection_rebuild_groups+      WHERE group_id = $1+      FOR SHARE+    ) AS locked_group+    LEFT JOIN LATERAL (+      SELECT target_id, revision_id, schema_name, relation_name+      FROM keiro.keiro_projection_target_generations+      WHERE group_id = $1 AND lifecycle = 'serving'+    ) AS generations ON TRUE+    GROUP BY locked_group.status,+             locked_group.active_run_id,+             locked_group.serving_revision_id,+             locked_group.writes_allowed+    """+    (E.param (E.nonNullable E.text))+    ( D.rowMaybe+        ( (,,,,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nonNullable D.bool)+            <*> ( List.zip4+                    <$> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+                )+        )+    )++lockGroupForRepairStmt :: Statement Text (Maybe (Text, Maybe Text, Maybe Text, Bool, Bool, Text))+lockGroupForRepairStmt =+  preparable+    """+    SELECT status, active_run_id, serving_revision_id,+           reads_allowed, writes_allowed, slice_fingerprint+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id = $1+    FOR UPDATE+    """+    (E.param (E.nonNullable E.text))+    ( D.rowMaybe+        ( (,,,,,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nullable D.text)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.text)+        )+    )++servingTargetBindingsStmt :: Statement Text [(Text, Text, Text, Text)]+servingTargetBindingsStmt =+  preparable+    """+    SELECT target_id, revision_id, schema_name, relation_name+    FROM keiro.keiro_projection_target_generations+    WHERE group_id = $1 AND lifecycle = 'serving'+    ORDER BY target_id+    """+    (E.param (E.nonNullable E.text))+    ( D.rowList+        ( (,,,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+        )+    )++insertQueryRegistrationStmt :: Statement (Text, Int64, Text, Text) ()+insertQueryRegistrationStmt =+  preparable+    """+    INSERT INTO keiro.keiro_read_models+      (name, version, shape_hash, rebuild_group_id, status, last_built_at)+    VALUES ($1, $2, $3, $4, 'live', now())+    """+    ( 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))+    )+    D.noResult++insertAdoptedQueryRegistrationStmt :: Statement (Text, Int64, Text, Text, Text) ()+insertAdoptedQueryRegistrationStmt =+  preparable+    """+    INSERT INTO keiro.keiro_read_models+      (name, version, shape_hash, rebuild_group_id, status, last_built_at)+    VALUES ($1, $2, $3, $4, $5,+            CASE WHEN $5 = 'live' THEN now() ELSE NULL END)+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++lookupQueryRegistrationStmt :: Statement Text (Maybe RegisteredQueryRow)+lookupQueryRegistrationStmt =+  preparable+    """+    SELECT version, shape_hash, rebuild_group_id, status+    FROM keiro.keiro_read_models+    WHERE name = $1+    FOR UPDATE+    """+    (E.param (E.nonNullable E.text))+    ( D.rowMaybe+        ( RegisteredQueryRow+            <$> (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.text)+        )+    )++adoptLegacyQueryRegistrationStmt :: Statement (Text, Text) ()+adoptLegacyQueryRegistrationStmt =+  preparable+    """+    UPDATE keiro.keiro_read_models+    SET rebuild_group_id = $2,+        updated_at = now()+    WHERE name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++adoptGroupSliceStmt :: Statement (Text, Text) ()+adoptGroupSliceStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET slice_fingerprint = $2,+        updated_at = now()+    WHERE group_id = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++adoptQueryRegistrationStmt :: Statement (Text, Int64, Text, Text) Int64+adoptQueryRegistrationStmt =+  preparable+    """+    UPDATE keiro.keiro_read_models+    SET version = $2,+        shape_hash = $3,+        rebuild_group_id = $4,+        updated_at = now()+    WHERE name = $1+    """+    ( 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))+    )+    D.rowsAffected++listQueryRegistrationBindingsStmt :: Statement () [(Text, Text)]+listQueryRegistrationBindingsStmt =+  preparable+    """+    SELECT name, rebuild_group_id+    FROM keiro.keiro_read_models+    ORDER BY name+    """+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))++lockGroupRegistrationsStmt :: Statement [Text] [(Text, Text)]+lockGroupRegistrationsStmt =+  preparable+    """+    SELECT name, rebuild_group_id+    FROM keiro.keiro_read_models+    WHERE rebuild_group_id = ANY($1)+    ORDER BY name+    FOR UPDATE+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))++deleteQueryRegistrationsStmt :: Statement [Text] ()+deleteQueryRegistrationsStmt =+  preparable+    """+    DELETE FROM keiro.keiro_read_models+    WHERE name = ANY($1)+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    D.noResult++deleteOrphanLegacyGroupsStmt :: Statement () ()+deleteOrphanLegacyGroupsStmt =+  preparable+    """+    DELETE FROM keiro.keiro_projection_rebuild_groups AS groups+    WHERE groups.slice_fingerprint = '$legacy-unmanaged'+      AND groups.status = 'live'+      AND NOT EXISTS (+        SELECT 1+        FROM keiro.keiro_read_models AS models+        WHERE models.rebuild_group_id = groups.group_id+      )+    """+    E.noParams+    D.noResult++markGroupQueriesRebuildingStmt :: Statement Text ()+markGroupQueriesRebuildingStmt =+  queryStatusStatement "rebuilding" False++markGroupQueriesLiveStmt :: Statement Text ()+markGroupQueriesLiveStmt =+  queryStatusStatement "live" True++markGroupQueriesAbandonedStmt :: Statement Text ()+markGroupQueriesAbandonedStmt =+  queryStatusStatement "abandoned" False++queryStatusStatement :: Text -> Bool -> Statement Text ()+queryStatusStatement newStatus stampBuilt =+  preparable+    ( "UPDATE keiro.keiro_read_models SET status = '"+        <> newStatus+        <> "', last_built_at = "+        <> (if stampBuilt then "now()" else "last_built_at")+        <> ", updated_at = now() WHERE rebuild_group_id = $1"+    )+    (E.param (E.nonNullable E.text))+    D.noResult++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++-- | Insert a bounded batch of async-projection dedup identities. Existing+-- identities are expected during promotion retries and are left unchanged.+insertProjectionDedupBatchStmt :: Statement ([Text], [UUID]) Int64+insertProjectionDedupBatchStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)+    SELECT pair.name, pair.event+    FROM unnest($1::text[], $2::uuid[]) AS pair (name, event)+    ON CONFLICT (projection_name, event_id) DO NOTHING+    """+    ( contrazip2+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    )+    D.rowsAffected++groupMetadataSingle :: D.Result GroupRebuildMetadata+groupMetadataSingle = D.singleRow groupMetadataDecoder++groupMetadataDecoder :: D.Row GroupRebuildMetadata+groupMetadataDecoder =+  GroupRebuildMetadata+    <$> (decodeGroupId <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.text)+    <*> (groupStatusFromText <$> D.column (D.nonNullable D.text))+    <*> (fmap RebuildRunId <$> D.column (D.nullable D.text))+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+  where+    decodeGroupId raw =+      case mkRebuildGroupId raw of+        Right value -> value+        Left _ -> error "stored rebuild group id violates catalog identity invariants"++groupStatusFromText :: Text -> GroupLifecycleStatus+groupStatusFromText = \case+  "live" -> GroupLive+  "rebuilding" -> GroupRebuilding+  "failed" -> GroupFailed+  raw -> UnknownGroupStatus raw
+ src/Keiro/ReadModel/Rebuild/Runner.hs view
@@ -0,0 +1,1614 @@+{-# OPTIONS_HADDOCK hide #-}++-- | Catalog-driven, fixed-head projection replay.+module Keiro.ReadModel.Rebuild.Runner+  ( RebuildOptions (..),+    defaultRebuildOptions,+    CatalogRebuildError (..),+    RebuildRunStatus (..),+    RebuildFailureEvidence (..),+    RebuildSourceProgress (..),+    RebuildAdapterProgress (..),+    RebuildVerificationProgress (..),+    RebuildRunReport (..),+    AsyncDedupBackfill (..),+    collectAsyncDedupFloors,+    collectAsyncDedupBackfill,+    startCatalogRebuild,+    resumeCatalogRebuild,+    inspectCatalogRebuild,+    abandonCatalogRebuild,+  )+where++import Contravariant.Extras+  ( contrazip2,+    contrazip3,+    contrazip4,+    contrazip5,+    contrazip6,+    contrazip8,+  )+import Data.Int (Int32)+import Data.List qualified as List+import Data.Map.Strict qualified as Map+import Data.Maybe (listToMaybe, mapMaybe)+import Data.Text qualified as Text+import Data.Time (diffUTCTime)+import Data.UUID (UUID)+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import Keiro.Projection.Catalog+  ( CatalogReplayAdapter,+    RebuildGroupId,+    RebuildVerification (..),+    ReplayDecodeError (..),+    SourceId,+    SourceScope (..),+    ValidatedProjectionCatalog,+    catalogFingerprintText,+    catalogInventory,+    catalogRebuildVerifications,+    catalogReplayAdapterOrder,+    catalogReplayAdapterProjectionId,+    catalogReplayAdapterSourceId,+    catalogReplayAdapters,+    groupSliceFingerprintText,+    projectionIdText,+    rebuildGroupIdText,+    runCatalogReplayAdapter,+    sourceIdText,+  )+import Keiro.Projection.Catalog qualified as Catalog+import Keiro.Projection.Catalog.Preimage (Preimage (..), hashPreimage)+import Keiro.ReadModel (subscriptionPositionFromInventory)+import Keiro.ReadModel.Rebuild.Group+  ( GroupTransitionError,+    RebuildFailure (..),+    RebuildRequest (..),+    RebuildRunId,+    RebuildStartError,+    abandonGroupRebuild,+    abandonPreCanonicalGroupRebuild,+    beginGroupRebuild,+    completionTokenForHandle,+    finishGroupRebuildTx,+    groupRebuildHandleFor,+    groupRebuildHandlePreparation,+    insertProjectionDedupBatchStmt,+    mkRebuildRunId,+    preCanonicalRunSliceSentinel,+    rebuildRunIdText,+    resetDeclaredSubscriptions,+  )+import Keiro.Telemetry (KeiroMetrics)+import Keiro.Telemetry qualified as Telemetry+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Read qualified as Store+import Kiroku.Store.Subscription (subscriptionCheckpointInventory)+import Kiroku.Store.Subscription.Types (SubscriptionName (..))+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent)+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude (all, any, concatMap, const, filter, id, not, null, (&&), (*), (+), (||))+import Prelude qualified++runnerFormat :: Text+runnerFormat = "keiro/projection-replay/v4"++data RebuildOptions = RebuildOptions+  { rebuildRequest :: !RebuildRequest,+    replayPageSize :: !Int32,+    rebuildMetrics :: !(Maybe KeiroMetrics)+  }+  deriving stock (Generic)++defaultRebuildOptions :: RebuildRequest -> RebuildOptions+defaultRebuildOptions request =+  RebuildOptions+    { rebuildRequest = request,+      replayPageSize = 500,+      rebuildMetrics = Nothing+    }++data CatalogRebuildError+  = CatalogRebuildInvalidPageSize !Int32+  | CatalogRebuildRunAlreadyExists !RebuildRunId+  | CatalogRebuildRunNotFound !RebuildRunId+  | CatalogRebuildStartFailed !RebuildStartError+  | CatalogRebuildStartAfterCapturedHead !GlobalPosition !GlobalPosition+  | CatalogRebuildContractMismatch !RebuildRunId !Text !Text+  | CatalogRebuildSliceMismatch !RebuildRunId !Text !Text+  | -- | The run predates canonical slice identity and must be abandoned,+    -- adopted, and started fresh rather than resumed.+    CatalogRebuildRunPreCanonical !RebuildRunId !RebuildGroupId+  | CatalogRebuildGroupMissing !RebuildGroupId+  | CatalogRebuildRunNotActive !RebuildRunId+  | CatalogRebuildDecodeFailed !RebuildRunId !SourceId !Text !GlobalPosition !ReplayDecodeError+  | CatalogRebuildVerificationFailed !RebuildRunId !Text !Text+  | CatalogRebuildPromotionCheckpointsMissing !RebuildRunId ![SubscriptionName]+  | CatalogRebuildInvariantFailed !RebuildRunId !Text+  | CatalogRebuildPromotionFailed !GroupTransitionError+  | CatalogRebuildAbandonFailed !GroupTransitionError+  deriving stock (Eq, Show, Generic)++data RebuildRunStatus+  = RebuildRunRunning+  | RebuildRunFailed+  | RebuildRunVerified+  | RebuildRunPromoted+  | UnknownRebuildRunStatus !Text+  deriving stock (Eq, Ord, Show, Generic)++data RebuildFailureEvidence = RebuildFailureEvidence+  { failureCode :: !Text,+    failureDetail :: !Text,+    failureSourceId :: !(Maybe SourceId),+    failureProjectionId :: !(Maybe Text),+    failurePosition :: !(Maybe GlobalPosition)+  }+  deriving stock (Eq, Show, Generic)++data RebuildSourceProgress = RebuildSourceProgress+  { sourceId :: !SourceId,+    sourceScope :: !SourceScope,+    cursorPosition :: !GlobalPosition,+    targetPosition :: !GlobalPosition,+    exhaustedThrough :: !(Maybe GlobalPosition),+    eventCount :: !Int64+  }+  deriving stock (Eq, Show, Generic)++data RebuildAdapterProgress = RebuildAdapterProgress+  { sourceId :: !SourceId,+    projectionId :: !Text,+    adapterOrder :: !Int,+    evaluationCount :: !Int64,+    applyCount :: !Int64,+    completedThrough :: !(Maybe GlobalPosition)+  }+  deriving stock (Eq, Show, Generic)++data RebuildVerificationProgress = RebuildVerificationProgress+  { verificationId :: !Text,+    verificationVersion :: !Text,+    verificationStatus :: !Text,+    verificationDetail :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)++data RebuildRunReport = RebuildRunReport+  { rebuildRunId :: !RebuildRunId,+    rebuildGroupId :: !RebuildGroupId,+    catalogFingerprint :: !Text,+    groupSliceFingerprint :: !Text,+    contractFingerprint :: !Text,+    runnerFormatVersion :: !Text,+    capturedHead :: !GlobalPosition,+    configuredPageSize :: !Int32,+    runStatus :: !RebuildRunStatus,+    failureEvidence :: !(Maybe RebuildFailureEvidence),+    sources :: ![RebuildSourceProgress],+    adapters :: ![RebuildAdapterProgress],+    verifications :: ![RebuildVerificationProgress]+  }+  deriving stock (Eq, Show, Generic)++data SourceSpec = SourceSpec+  { specSourceId :: !SourceId,+    specScope :: !SourceScope+  }+  deriving stock (Generic)++data SourcePage = SourcePage+  { pageSource :: !RebuildSourceProgress,+    pageEvents :: ![RecordedEvent],+    pageProvesExhaustion :: !Bool+  }+  deriving stock (Generic)++data RoutedEvent = RoutedEvent+  { routedSourceId :: !SourceId,+    routedEvent :: !RecordedEvent+  }+  deriving stock (Generic)++data AdapterCounts = AdapterCounts+  { evaluations :: !Int64,+    applications :: !Int64+  }+  deriving stock (Generic)++-- | Promotion-time redelivery-safety input for one rebuild group. Each pair+-- identifies a replayed async application that must remain deduplicated after+-- promotion; each floor is the slowest durable member of that subscription.+data AsyncDedupBackfill = AsyncDedupBackfill+  { backfillPairs :: ![(Text, UUID)],+    backfillFloors :: ![(Text, GlobalPosition)]+  }+  deriving stock (Eq, Show, Generic)++-- | Resolve the authoritative durable floor for every async subscription in a+-- rebuild group without scanning event history. Missing rows are explicit so+-- callers can refuse before fencing writers.+collectAsyncDedupFloors ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  Eff es (Either [SubscriptionName] [(Text, GlobalPosition)])+collectAsyncDedupFloors catalog groupId =+  case Catalog.catalogAsyncIdempotencyKeys catalog groupId of+    [] -> pure (Right [])+    specs -> do+      inventory <- subscriptionCheckpointInventory+      let resolved =+            [ ( SubscriptionName (Catalog.specSubscriptionName spec),+                subscriptionPositionFromInventory+                  (SubscriptionName (Catalog.specSubscriptionName spec))+                  inventory+              )+            | spec <- specs+            ]+          missing =+            List.sort+              . List.nub+              $ [subscriptionName | (subscriptionName, Nothing) <- resolved]+      pure+        $ if not (null missing)+          then Left missing+          else+            Right+              ( Map.toAscList+                  ( Map.fromListWith+                      Prelude.min+                      [ (name, floor)+                      | (SubscriptionName name, Just floor) <- resolved+                      ]+                  )+              )++-- | Collect the async dedup identities between each subscription's durable+-- floor and the captured rebuild head. Missing checkpoint rows are returned+-- explicitly and are never synthesized.+collectAsyncDedupBackfill ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  Int32 ->+  GlobalPosition ->+  Eff es (Either [SubscriptionName] AsyncDedupBackfill)+collectAsyncDedupBackfill catalog groupId pageSize capturedHead =+  case Catalog.catalogAsyncIdempotencyKeys catalog groupId of+    [] -> pure (Right (AsyncDedupBackfill [] []))+    specs -> do+      inventory <- subscriptionCheckpointInventory+      let resolved =+            [ ( spec,+                SubscriptionName (Catalog.specSubscriptionName spec),+                subscriptionPositionFromInventory+                  (SubscriptionName (Catalog.specSubscriptionName spec))+                  inventory+              )+            | spec <- specs+            ]+          missing =+            List.sort+              . List.nub+              $ [subscriptionName | (_, subscriptionName, Nothing) <- resolved]+      if not (null missing)+        then pure (Left missing)+        else do+          let specFloors =+                [ (spec, floor)+                | (spec, _, Just floor) <- resolved+                ]+              sourceKeys =+                List.nub+                  [ (Catalog.specSourceId spec, Catalog.specSourceScope spec)+                  | (spec, _) <- specFloors+                  ]+              floors =+                Map.toAscList+                  ( Map.fromListWith+                      Prelude.min+                      [ (Catalog.specSubscriptionName spec, floor)+                      | (spec, floor) <- specFloors+                      ]+                  )+          pairs <-+            concatMap id+              <$> traverse+                (collectSourcePairs specFloors)+                sourceKeys+          pure+            ( Right+                AsyncDedupBackfill+                  { backfillPairs = pairs,+                    backfillFloors = floors+                  }+            )+  where+    collectSourcePairs specFloors (sourceId, scope) =+      let members =+            [ (spec, floor)+            | (spec, floor) <- specFloors,+              Catalog.specSourceId spec == sourceId,+              Catalog.specSourceScope spec == scope+            ]+          cursor = Prelude.minimum (Prelude.snd <$> members)+       in collectPages scope members cursor++    collectPages scope members cursor+      | cursor >= capturedHead = pure []+      | otherwise = do+          raw <-+            case scope of+              AllStreams -> Store.readAllForward cursor pageSize+              CategorySource category -> Store.readCategory category cursor pageSize+          let rawEvents = Vector.toList raw+              eligible = Prelude.takeWhile ((<= capturedHead) . (^. #globalPosition)) rawEvents+              pairs =+                [ (Catalog.specDedupName spec, eventId)+                | event <- eligible,+                  (spec, floor) <- members,+                  event ^. #globalPosition > floor,+                  let EventId eventId = Catalog.specIdempotencyKey spec event+                ]+              beyondHead = Prelude.any ((> capturedHead) . (^. #globalPosition)) rawEvents+              shortPage = Vector.length raw < Prelude.fromIntegral pageSize+              reachedHead =+                not (null eligible)+                  && Prelude.last eligible ^. #globalPosition == capturedHead+          if beyondHead || shortPage || reachedHead || null eligible+            then pure pairs+            else+              (pairs <>)+                <$> collectPages+                  scope+                  members+                  (Prelude.last eligible ^. #globalPosition)++startCatalogRebuild ::+  (IOE :> es, Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildOptions ->+  Eff es (Either CatalogRebuildError RebuildRunReport)+startCatalogRebuild catalog groupId options+  | options ^. #replayPageSize <= 0 =+      pure (Left (CatalogRebuildInvalidPageSize (options ^. #replayPageSize)))+  | otherwise = do+      let request = options ^. #rebuildRequest+          runId = request ^. #rebuildRunId+      existing <- inspectCatalogRebuildMaybe runId+      case existing of+        Just _ -> pure (Left (CatalogRebuildRunAlreadyExists runId))+        Nothing -> case rebuildContract catalog groupId of+          Nothing -> pure (Left (CatalogRebuildGroupMissing groupId))+          Just contract -> do+            started <- beginGroupRebuild catalog groupId request+            case started of+              Left err -> pure (Left (CatalogRebuildStartFailed err))+              Right handle -> do+                headPosition <- captureHead+                if request ^. #replayFrom > headPosition+                  then do+                    _ <-+                      abandonGroupRebuild+                        handle+                        RebuildFailure+                          { failureCode = "replay.start-after-head",+                            failureDetail =+                              "requested replay cursor is beyond the captured store head"+                          }+                    pure+                      ( Left+                          ( CatalogRebuildStartAfterCapturedHead+                              (request ^. #replayFrom)+                              headPosition+                          )+                      )+                  else do+                    runTransaction+                      ( initializeRunTx+                          catalog+                          groupId+                          options+                          headPosition+                          contract+                      )+                    Telemetry.recordProjectionRebuildStarts (options ^. #rebuildMetrics) 1+                    driveCatalogRebuild catalog groupId runId (options ^. #replayPageSize) contract (options ^. #rebuildMetrics)++resumeCatalogRebuild ::+  (IOE :> es, Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  RebuildOptions ->+  Eff es (Either CatalogRebuildError RebuildRunReport)+resumeCatalogRebuild catalog runId options+  | options ^. #replayPageSize <= 0 =+      pure (Left (CatalogRebuildInvalidPageSize (options ^. #replayPageSize)))+  | otherwise = do+      inspectCatalogRebuildMaybe runId >>= \case+        Nothing -> pure (Left (CatalogRebuildRunNotFound runId))+        Just report+          | report ^. #groupSliceFingerprint == preCanonicalRunSliceSentinel ->+              pure+                ( Left+                    ( CatalogRebuildRunPreCanonical+                        runId+                        (report ^. #rebuildGroupId)+                    )+                )+          | otherwise -> do+              let groupId = report ^. #rebuildGroupId+                  expected = report ^. #contractFingerprint+              case rebuildContract catalog groupId of+                Nothing -> pure (Left (CatalogRebuildGroupMissing groupId))+                Just actual ->+                  if expected /= actual+                    then pure (Left (CatalogRebuildContractMismatch runId expected actual))+                    else do+                      resumed <- runTransaction (resumeRunTx runId (options ^. #replayPageSize) actual)+                      if resumed+                        then do+                          Telemetry.recordProjectionRebuildResumes (options ^. #rebuildMetrics) 1+                          driveCatalogRebuild catalog groupId runId (options ^. #replayPageSize) actual (options ^. #rebuildMetrics)+                        else pure (Left (CatalogRebuildRunNotActive runId))++inspectCatalogRebuild ::+  (Store :> es) =>+  RebuildRunId ->+  Eff es (Either CatalogRebuildError RebuildRunReport)+inspectCatalogRebuild runId =+  maybe (Left (CatalogRebuildRunNotFound runId)) Right+    <$> inspectCatalogRebuildMaybe runId++inspectCatalogRebuildMaybe ::+  (Store :> es) =>+  RebuildRunId ->+  Eff es (Maybe RebuildRunReport)+inspectCatalogRebuildMaybe runId =+  runTransaction $ do+    maybeRun <- Tx.statement (rebuildRunIdText runId) inspectRunStmt+    traverse+      ( \report -> do+          sourceRows <- Tx.statement (rebuildRunIdText runId) inspectSourcesStmt+          adapterRows <- Tx.statement (rebuildRunIdText runId) inspectAdaptersStmt+          verificationRows <- Tx.statement (rebuildRunIdText runId) inspectVerificationsStmt+          pure+            report+              { sources = sourceRows,+                adapters = adapterRows,+                verifications = verificationRows+              }+      )+      maybeRun++abandonCatalogRebuild ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  RebuildFailure ->+  Eff es (Either CatalogRebuildError RebuildRunReport)+abandonCatalogRebuild catalog runId failure =+  inspectCatalogRebuildMaybe runId >>= \case+    Nothing -> pure (Left (CatalogRebuildRunNotFound runId))+    Just report+      | report ^. #groupSliceFingerprint == preCanonicalRunSliceSentinel ->+          case report ^. #runStatus of+            RebuildRunRunning -> abandonPreCanonical report+            RebuildRunFailed -> abandonPreCanonical report+            _ -> pure (Left (CatalogRebuildRunNotActive runId))+      | otherwise -> do+          let groupId = report ^. #rebuildGroupId+              stored = report ^. #groupSliceFingerprint+          case groupSliceFingerprintText <$> Catalog.groupSliceFingerprint catalog groupId of+            Nothing -> pure (Left (CatalogRebuildGroupMissing groupId))+            Just current ->+              if stored /= current+                then pure (Left (CatalogRebuildSliceMismatch runId stored current))+                else case groupRebuildHandleFor catalog groupId runId of+                  Nothing -> pure (Left (CatalogRebuildRunNotActive runId))+                  Just handle -> do+                    abandoned <- abandonGroupRebuild handle failure+                    recordAbandonment abandoned+  where+    abandonPreCanonical report = do+      abandoned <-+        abandonPreCanonicalGroupRebuild+          (report ^. #rebuildGroupId)+          runId+          failure+      recordAbandonment abandoned++    recordAbandonment = \case+      Left err -> pure (Left (CatalogRebuildAbandonFailed err))+      Right _ -> do+        recordFailure+          runId+          (failure ^. #failureCode)+          (failure ^. #failureDetail)+          Nothing+          Nothing+          Nothing+        inspectCatalogRebuild runId++captureHead :: (Store :> es) => Eff es GlobalPosition+captureHead = do+  events <- Store.readAllBackward (GlobalPosition 0) 1+  pure $ maybe (GlobalPosition 0) (^. #globalPosition) (events Vector.!? 0)++driveCatalogRebuild ::+  (IOE :> es, Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildRunId ->+  Int32 ->+  Text ->+  Maybe KeiroMetrics ->+  Eff es (Either CatalogRebuildError RebuildRunReport)+driveCatalogRebuild catalog groupId runId pageSize contract metrics =+  inspectCatalogRebuildMaybe runId >>= continueFromReport+  where+    fleet = catalogReplayAdapters catalog groupId++    continueFromReport = \case+      Nothing -> pure (Left (CatalogRebuildRunNotFound runId))+      Just report+        | report ^. #runStatus == RebuildRunPromoted -> pure (Right report)+        | report ^. #runStatus /= RebuildRunRunning ->+            pure (Left (CatalogRebuildRunNotActive runId))+        | all sourceComplete (report ^. #sources) ->+            verifyAndPromote catalog groupId runId contract metrics+        | otherwise ->+            go+              (appliedFloor report)+              [ emptySourcePage source+              | source <- report ^. #sources,+                not (sourceComplete source)+              ]++    appliedFloor report =+      List.foldl'+        Prelude.max+        (GlobalPosition 0)+        [source ^. #cursorPosition | source <- report ^. #sources]++    go appliedThrough buffers = do+      pages <- traverse refillSourcePage buffers+      let ordered = orderedCandidates pages+          horizon = mergeHorizon pages+          eligible =+            Prelude.takeWhile+              ((<= horizon) . (^. #globalPosition) . (^. #routedEvent))+              ordered+          chunk = Prelude.take (Prelude.fromIntegral pageSize) eligible+      case duplicatePosition ordered of+        Just duplicate -> do+          let detail = "duplicate global position in merged category history: " <> renderPosition duplicate+          recordFailure runId "replay.global-position-duplicate" detail Nothing Nothing (Just duplicate)+          Telemetry.recordProjectionRebuildFailures metrics 1+          pure (Left (CatalogRebuildInvariantFailed runId detail))+        Nothing+          | Just regressed <- chunkRegression appliedThrough chunk -> do+              let detail =+                    "merged chunk regressed to global position "+                      <> renderPosition regressed+                      <> " at or below applied floor "+                      <> renderPosition appliedThrough+              recordFailure runId "replay.global-position-regression" detail Nothing Nothing (Just regressed)+              Telemetry.recordProjectionRebuildFailures metrics 1+              pure (Left (CatalogRebuildInvariantFailed runId detail))+          | null chunk,+            not (null ordered) -> do+              let detail = "buffered merge stalled: candidates exist above the merge horizon " <> renderPosition horizon+              recordFailure runId "replay.buffer-horizon-stalled" detail Nothing Nothing Nothing+              Telemetry.recordProjectionRebuildFailures metrics 1+              pure (Left (CatalogRebuildInvariantFailed runId detail))+          | otherwise -> do+              startedAt <- liftIO getCurrentTime+              applied <- runTransaction (applyChunkTx runId contract fleet pages chunk)+              case applied of+                Left ChunkInactive ->+                  pure (Left (CatalogRebuildRunNotActive runId))+                Left ChunkInterfered ->+                  inspectCatalogRebuildMaybe runId >>= continueFromReport+                Left (ChunkDecode failure) -> do+                  recordFailure+                    runId+                    "replay.decode-failure"+                    (failure ^. #decodeDetail)+                    (Just (failure ^. #decodeSource))+                    (Just (failure ^. #decodeProjection))+                    (Just (failure ^. #decodePosition))+                  Telemetry.recordProjectionRebuildFailures metrics 1+                  pure+                    ( Left+                        ( CatalogRebuildDecodeFailed+                            runId+                            (failure ^. #decodeSource)+                            (failure ^. #decodeProjection)+                            (failure ^. #decodePosition)+                            (failure ^. #decodeError)+                        )+                    )+                Right () -> do+                  finishedAt <- liftIO getCurrentTime+                  Telemetry.recordProjectionRebuildPages metrics 1+                  Telemetry.recordProjectionRebuildEvents metrics (Prelude.fromIntegral (Prelude.length chunk))+                  Telemetry.recordProjectionRebuildPageDuration metrics (Prelude.realToFrac (diffUTCTime finishedAt startedAt) * 1000)+                  let advanced = advanceSourcePages pages chunk+                      incomplete = filter (not . sourceComplete . (^. #pageSource)) advanced+                  if null incomplete+                    then verifyAndPromote catalog groupId runId contract metrics+                    else go (chunkCeiling appliedThrough chunk) incomplete++    refillSourcePage page+      | null (page ^. #pageEvents) = readSourcePage pageSize (page ^. #pageSource)+      | otherwise = pure page++emptySourcePage :: RebuildSourceProgress -> SourcePage+emptySourcePage source =+  SourcePage+    { pageSource = source,+      pageEvents = [],+      pageProvesExhaustion = False+    }++advanceSourcePages :: [SourcePage] -> [RoutedEvent] -> [SourcePage]+advanceSourcePages pages chunk = Prelude.map advance pages+  where+    advances =+      Map.fromListWith+        combine+        [ ( routed ^. #routedSourceId,+            (routed ^. #routedEvent . #globalPosition, 1 :: Int)+          )+        | routed <- chunk+        ]++    combine (leftPosition, leftCount) (rightPosition, rightCount) =+      (Prelude.max leftPosition rightPosition, leftCount + rightCount)++    advance page =+      let source = page ^. #pageSource+          (cursor, consumed) =+            Map.findWithDefault+              (source ^. #cursorPosition, 0)+              (source ^. #sourceId)+              advances+          remaining = Prelude.drop consumed (page ^. #pageEvents)+          advancedSource =+            source+              { cursorPosition = cursor,+                eventCount = source ^. #eventCount + Prelude.fromIntegral consumed,+                exhaustedThrough =+                  if null remaining && page ^. #pageProvesExhaustion+                    then Just (source ^. #targetPosition)+                    else source ^. #exhaustedThrough+              }+       in page+            { pageSource = advancedSource,+              pageEvents = remaining+            }++sourceComplete :: RebuildSourceProgress -> Bool+sourceComplete source = source ^. #exhaustedThrough == Just (source ^. #targetPosition)++readSourcePage ::+  (Store :> es) =>+  Int32 ->+  RebuildSourceProgress ->+  Eff es SourcePage+readSourcePage pageSize source = do+  raw <-+    case source ^. #sourceScope of+      AllStreams -> Store.readAllForward cursor pageSize+      CategorySource category -> Store.readCategory category cursor pageSize+  let rawEvents = Vector.toList raw+      eligible = Prelude.takeWhile ((<= target) . (^. #globalPosition)) rawEvents+      beyondTarget = Prelude.any ((> target) . (^. #globalPosition)) rawEvents+      shortPage = Vector.length raw < Prelude.fromIntegral pageSize+      reachedTarget = not (null eligible) && (Prelude.last eligible ^. #globalPosition == target)+  pure+    SourcePage+      { pageSource = source,+        pageEvents = eligible,+        pageProvesExhaustion = beyondTarget || shortPage || reachedTarget+      }+  where+    cursor = source ^. #cursorPosition+    target = source ^. #targetPosition++orderedCandidates :: [SourcePage] -> [RoutedEvent]+orderedCandidates =+  List.sortOn ((^. #globalPosition) . (^. #routedEvent))+    . concatMap+      ( \page ->+          [ RoutedEvent (page ^. #pageSource . #sourceId) event+          | event <- page ^. #pageEvents+          ]+      )++pageHorizon :: SourcePage -> GlobalPosition+pageHorizon page+  | page ^. #pageProvesExhaustion = page ^. #pageSource . #targetPosition+  | otherwise =+      case page ^. #pageEvents of+        [] -> page ^. #pageSource . #cursorPosition+        events -> Prelude.last events ^. #globalPosition++mergeHorizon :: [SourcePage] -> GlobalPosition+mergeHorizon = Prelude.minimum . Prelude.map pageHorizon++duplicatePosition :: [RoutedEvent] -> Maybe GlobalPosition+duplicatePosition candidates =+  listToMaybe+    [ left ^. #routedEvent . #globalPosition+    | (left, right) <- List.zip candidates (Prelude.drop 1 candidates),+      left ^. #routedEvent . #globalPosition == right ^. #routedEvent . #globalPosition+    ]++chunkRegression :: GlobalPosition -> [RoutedEvent] -> Maybe GlobalPosition+chunkRegression appliedThrough = \case+  routed : _+    | routed ^. #routedEvent . #globalPosition <= appliedThrough ->+        Just (routed ^. #routedEvent . #globalPosition)+  _ -> Nothing++chunkCeiling :: GlobalPosition -> [RoutedEvent] -> GlobalPosition+chunkCeiling appliedThrough = \case+  [] -> appliedThrough+  chunk -> Prelude.last chunk ^. #routedEvent . #globalPosition++data DecodeFailure = DecodeFailure+  { decodeSource :: !SourceId,+    decodeProjection :: !Text,+    decodePosition :: !GlobalPosition,+    decodeError :: !ReplayDecodeError,+    decodeDetail :: !Text+  }+  deriving stock (Generic)++data ChunkFailure+  = ChunkInactive+  | ChunkInterfered+  | ChunkDecode !DecodeFailure++applyChunkTx ::+  RebuildRunId ->+  Text ->+  [CatalogReplayAdapter] ->+  [SourcePage] ->+  [RoutedEvent] ->+  Tx.Transaction (Either ChunkFailure ())+applyChunkTx runId contract fleet pages chunk = do+  active <- Tx.statement (rebuildRunIdText runId, contract) lockActiveRunStmt+  if not active+    then Tx.condemn >> pure (Left ChunkInactive)+    else+      applyEvents Map.empty chunk >>= \case+        Left failure -> Tx.condemn >> pure (Left (ChunkDecode failure))+        Right counts -> do+          advanced <- traverse updateSource (Map.toList sourceAdvances)+          if not (all id advanced)+            then Tx.condemn >> pure (Left ChunkInterfered)+            else do+              traverse_ updateAdapter (Map.toList counts)+              traverse_ completeSource completedSources+              pure (Right ())+  where+    applyEvents counts = \case+      [] -> pure (Right counts)+      routed : rest ->+        applyAdapters counts routed (adaptersFor routed) >>= \case+          Left failure -> pure (Left failure)+          Right updated -> applyEvents updated rest++    adaptersFor routed =+      [ adapter+      | adapter <- fleet,+        catalogReplayAdapterSourceId adapter == routed ^. #routedSourceId+      ]++    applyAdapters counts _ [] = pure (Right counts)+    applyAdapters counts routed (adapter : rest) = do+      result <- runCatalogReplayAdapter adapter (routed ^. #routedEvent)+      let key =+            ( sourceIdText (catalogReplayAdapterSourceId adapter),+              projectionIdText (catalogReplayAdapterProjectionId adapter)+            )+          previous = Map.findWithDefault (AdapterCounts 0 0) key counts+          evaluated = previous {evaluations = previous ^. #evaluations + 1}+      case result of+        Left decodeError@(ReplayDecodeError detail) ->+          pure+            ( Left+                DecodeFailure+                  { decodeSource = routed ^. #routedSourceId,+                    decodeProjection = projectionIdText (catalogReplayAdapterProjectionId adapter),+                    decodePosition = routed ^. #routedEvent . #globalPosition,+                    decodeError,+                    decodeDetail = detail+                  }+            )+        Right applied ->+          let counted =+                if applied+                  then evaluated {applications = evaluated ^. #applications + 1}+                  else evaluated+           in applyAdapters (Map.insert key counted counts) routed rest++    sourceAdvances =+      Map.fromListWith+        combineSourceAdvance+        [ ( sourceIdText (routed ^. #routedSourceId),+            (routed ^. #routedEvent . #globalPosition, 1 :: Int64)+          )+        | routed <- chunk+        ]++    combineSourceAdvance (leftPosition, leftCount) (rightPosition, rightCount) =+      (Prelude.max leftPosition rightPosition, leftCount + rightCount)++    updateSource (sourceId, (GlobalPosition cursor, count)) =+      case Map.lookup sourceId expectedSourceCursors of+        Nothing -> pure False+        Just (GlobalPosition expected) ->+          Tx.statement+            (rebuildRunIdText runId, sourceId, expected, cursor, count)+            advanceSourceStmt++    updateAdapter ((sourceId, projectionId), AdapterCounts evaluationDelta applyDelta) =+      Tx.statement+        (rebuildRunIdText runId, sourceId, projectionId, evaluationDelta, applyDelta)+        advanceAdapterStmt++    expectedSourceCursors =+      Map.fromList+        [ (sourceIdText (page ^. #pageSource . #sourceId), page ^. #pageSource . #cursorPosition)+        | page <- pages+        ]++    consumedCounts = fmap Prelude.snd sourceAdvances++    completedSources =+      [ page ^. #pageSource+      | page <- pages,+        page ^. #pageProvesExhaustion,+        Map.findWithDefault 0 (sourceIdText (page ^. #pageSource . #sourceId)) consumedCounts+          == Prelude.fromIntegral (Prelude.length (page ^. #pageEvents))+      ]++    completeSource source =+      let GlobalPosition target = source ^. #targetPosition+       in Tx.statement+            (rebuildRunIdText runId, sourceIdText (source ^. #sourceId), target)+            completeSourceStmt++verifyAndPromote ::+  (IOE :> es, Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildRunId ->+  Text ->+  Maybe KeiroMetrics ->+  Eff es (Either CatalogRebuildError RebuildRunReport)+verifyAndPromote catalog groupId runId contract metrics = do+  verification <- runTransaction (runVerificationsTx runId contract hooks)+  case verification of+    Left (verificationId, detail) -> do+      recordFailure runId "replay.verification-failure" detail Nothing (Just verificationId) Nothing+      Telemetry.recordProjectionRebuildFailures metrics 1+      pure (Left (CatalogRebuildVerificationFailed runId verificationId detail))+    Right () -> do+      maybeReport <- inspectCatalogRebuildMaybe runId+      case maybeReport of+        Nothing -> pure (Left (CatalogRebuildRunNotFound runId))+        Just report -> do+          backfill <-+            collectAsyncDedupBackfill+              catalog+              groupId+              (report ^. #configuredPageSize)+              (report ^. #capturedHead)+          case backfill of+            Left missing -> promotionCheckpointsMissing missing+            Right redeliverySafety ->+              case groupRebuildHandleFor catalog groupId runId of+                Nothing -> pure (Left (CatalogRebuildGroupMissing groupId))+                Just handle -> do+                  promoted <-+                    runTransaction $ do+                      complete <-+                        Tx.statement+                          ( rebuildRunIdText runId,+                            contract,+                            Prelude.fromIntegral sourceCount,+                            Prelude.fromIntegral adapterCount,+                            Prelude.fromIntegral (Prelude.length hooks)+                          )+                          completionProofStmt+                      if not complete+                        then Tx.condemn >> pure (Left PromotionProofIncomplete)+                        else do+                          Tx.statement (rebuildRunIdText runId) markVerifiedStmt+                          traverse_+                            ( \batch ->+                                Tx.statement+                                  (Prelude.unzip batch)+                                  insertProjectionDedupBatchStmt+                            )+                            (dedupBatches (redeliverySafety ^. #backfillPairs))+                          checkpointResult <-+                            if null asyncSpecs+                              then pure (Right ())+                              else do+                                resetReport <-+                                  resetDeclaredSubscriptions+                                    (groupRebuildHandlePreparation handle)+                                    (report ^. #capturedHead)+                                let missing =+                                      Vector.toList+                                        (resetReport ^. #missingSubscriptionNames)+                                if null missing+                                  then pure (Right ())+                                  else+                                    Tx.condemn+                                      >> pure (Left (PromotionCheckpointsMissing missing))+                          case checkpointResult of+                            Left failure -> pure (Left failure)+                            Right () -> do+                              transition <- finishGroupRebuildTx handle (completionTokenForHandle handle)+                              case transition of+                                Left err -> pure (Left (PromotionTransitionFailed err))+                                Right _ -> do+                                  Tx.statement (rebuildRunIdText runId) markPromotedStmt+                                  pure (Right ())+                  case promoted of+                    Left PromotionProofIncomplete -> do+                      let detail = "source, adapter, or verification completion proof is incomplete"+                      Telemetry.recordProjectionRebuildFailures metrics 1+                      pure (Left (CatalogRebuildInvariantFailed runId detail))+                    Left (PromotionTransitionFailed err) ->+                      pure (Left (CatalogRebuildPromotionFailed err))+                    Left (PromotionCheckpointsMissing missing) ->+                      promotionCheckpointsMissing missing+                    Right () -> do+                      Telemetry.recordProjectionRebuildPromotions metrics 1+                      inspectCatalogRebuild runId+  where+    hooks = catalogRebuildVerifications catalog groupId+    sourceCount = Prelude.length (sourceSpecs catalog groupId)+    adapterCount = Prelude.length (catalogReplayAdapters catalog groupId)+    asyncSpecs = Catalog.catalogAsyncIdempotencyKeys catalog groupId++    promotionCheckpointsMissing missing = do+      let detail =+            "declared subscription checkpoints are missing at promotion: "+              <> Text.intercalate ", " [name | SubscriptionName name <- missing]+      recordFailure+        runId+        "promotion.checkpoints-missing"+        detail+        Nothing+        Nothing+        Nothing+      Telemetry.recordProjectionRebuildFailures metrics 1+      pure (Left (CatalogRebuildPromotionCheckpointsMissing runId missing))++data PromotionFailure+  = PromotionProofIncomplete+  | PromotionTransitionFailed !GroupTransitionError+  | PromotionCheckpointsMissing ![SubscriptionName]++dedupBatches :: [(Text, UUID)] -> [[(Text, UUID)]]+dedupBatches = \case+  [] -> []+  pairs ->+    let (batch, rest) = Prelude.splitAt 10000 pairs+     in batch : dedupBatches rest++runVerificationsTx ::+  RebuildRunId ->+  Text ->+  [RebuildVerification] ->+  Tx.Transaction (Either (Text, Text) ())+runVerificationsTx runId contract hooks = do+  active <- Tx.statement (rebuildRunIdText runId, contract) lockActiveRunStmt+  if not active+    then pure (Left ("$runner", "rebuild run is no longer active"))+    else go hooks+  where+    go = \case+      [] -> pure (Right ())+      hook : rest -> do+        outcome <- hook ^. #verifyRebuild+        case outcome of+          Left detail -> do+            Tx.statement+              (rebuildRunIdText runId, hook ^. #verificationId, detail)+              failVerificationStmt+            pure (Left (hook ^. #verificationId, detail))+          Right () -> do+            Tx.statement+              (rebuildRunIdText runId, hook ^. #verificationId)+              passVerificationStmt+            go rest++recordFailure ::+  (Store :> es) =>+  RebuildRunId ->+  Text ->+  Text ->+  Maybe SourceId ->+  Maybe Text ->+  Maybe GlobalPosition ->+  Eff es ()+recordFailure runId code detail source projection position =+  runTransaction+    $ Tx.statement+      ( rebuildRunIdText runId,+        code,+        detail,+        sourceIdText <$> source,+        projection,+        globalPositionToInt <$> position+      )+      recordFailureStmt++initializeRunTx ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildOptions ->+  GlobalPosition ->+  Text ->+  Tx.Transaction ()+initializeRunTx catalog groupId options headPosition contract = do+  Tx.statement+    ( rebuildRunIdText runId,+      rebuildGroupIdText groupId,+      catalogFingerprintText (Catalog.catalogFingerprint catalog),+      groupSliceFingerprintText currentSlice,+      contract,+      runnerFormat,+      globalPositionToInt headPosition,+      options ^. #replayPageSize+    )+    insertRunStmt+  traverse_ insertSource (sourceSpecs catalog groupId)+  traverse_ insertAdapter (catalogReplayAdapters catalog groupId)+  traverse_ insertVerification (catalogRebuildVerifications catalog groupId)+  where+    request = options ^. #rebuildRequest+    runId = request ^. #rebuildRunId+    startCursor = globalPositionToInt (request ^. #replayFrom)+    target = globalPositionToInt headPosition+    currentSlice =+      fromMaybe+        (error "initializeRunTx: rebuild contract exists without a group slice")+        (Catalog.groupSliceFingerprint catalog groupId)++    insertSource source =+      let (scope, category) = encodeScope (source ^. #specScope)+       in Tx.statement+            (rebuildRunIdText runId, sourceIdText (source ^. #specSourceId), scope, category, startCursor, target)+            insertSourceStmt++    insertAdapter adapter =+      Tx.statement+        ( rebuildRunIdText runId,+          sourceIdText (catalogReplayAdapterSourceId adapter),+          projectionIdText (catalogReplayAdapterProjectionId adapter),+          Prelude.fromIntegral (catalogReplayAdapterOrder adapter)+        )+        insertAdapterStmt++    insertVerification hook =+      Tx.statement+        (rebuildRunIdText runId, hook ^. #verificationId, hook ^. #verificationVersion)+        insertVerificationStmt++sourceSpecs :: ValidatedProjectionCatalog -> RebuildGroupId -> [SourceSpec]+sourceSpecs catalog groupId =+  mapMaybe sourceFor orderedSourceIds+  where+    orderedSourceIds =+      List.nub+        [ catalogReplayAdapterSourceId adapter+        | adapter <- catalogReplayAdapters catalog groupId+        ]+    sourceFor wanted =+      listToMaybe+        [ SourceSpec+            { specSourceId = source ^. #sourceId,+              specScope = source ^. #sourceScope+            }+        | source <- catalogInventory catalog ^. #inventorySources,+          source ^. #sourceId == wanted+        ]++rebuildContract :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe Text+rebuildContract catalog groupId = do+  slice <- Catalog.groupSliceFingerprint catalog groupId+  pure+    ( hashPreimage+        "contract-v4"+        ( PRecord+            runnerFormat+            [ PText (groupSliceFingerprintText slice),+              PList+                [ PRecord+                    "adapter"+                    [ PText (sourceIdText (catalogReplayAdapterSourceId adapter)),+                      PText (projectionIdText (catalogReplayAdapterProjectionId adapter))+                    ]+                | adapter <- catalogReplayAdapters catalog groupId+                ]+            ]+        )+    )++encodeScope :: SourceScope -> (Text, Maybe Text)+encodeScope AllStreams = ("all", Nothing)+encodeScope (CategorySource (CategoryName category)) = ("category", Just category)++decodeScope :: Text -> Maybe Text -> SourceScope+decodeScope "all" _ = AllStreams+decodeScope "category" (Just category) = CategorySource (CategoryName category)+decodeScope raw _ = error ("invalid persisted rebuild source scope: " <> Text.unpack raw)++globalPositionToInt :: GlobalPosition -> Int64+globalPositionToInt (GlobalPosition position) = position++renderPosition :: GlobalPosition -> Text+renderPosition (GlobalPosition position) = Text.pack (show position)++runStatusFromText :: Text -> RebuildRunStatus+runStatusFromText = \case+  "running" -> RebuildRunRunning+  "failed" -> RebuildRunFailed+  "verified" -> RebuildRunVerified+  "promoted" -> RebuildRunPromoted+  raw -> UnknownRebuildRunStatus raw++decodeSourceId :: Text -> SourceId+decodeSourceId raw =+  either+    (const (error ("invalid persisted source id: " <> Text.unpack raw)))+    id+    (Catalog.mkSourceId raw)++decodeGroupId :: Text -> RebuildGroupId+decodeGroupId raw =+  either+    (const (error ("invalid persisted rebuild group id: " <> Text.unpack raw)))+    id+    (Catalog.mkRebuildGroupId raw)++decodeRunId :: Text -> RebuildRunId+decodeRunId raw =+  either+    (const (error ("invalid persisted rebuild run id: " <> Text.unpack raw)))+    id+    (mkRebuildRunId raw)++inspectRunStmt :: Statement Text (Maybe RebuildRunReport)+inspectRunStmt =+  preparable+    """+    SELECT run_id, group_id, catalog_fingerprint, group_slice_fingerprint,+           contract_fingerprint, runner_format, captured_head, page_size, status,+           failure_code, failure_detail, failure_source_id,+           failure_projection_id, failure_position+    FROM keiro.keiro_projection_rebuild_runs+    WHERE run_id = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe runReportDecoder)++runReportDecoder :: D.Row RebuildRunReport+runReportDecoder =+  makeReport+    <$> (decodeRunId <$> D.column (D.nonNullable D.text))+    <*> (decodeGroupId <$> 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)+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.int4)+    <*> (runStatusFromText <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> (fmap decodeSourceId <$> D.column (D.nullable D.text))+    <*> D.column (D.nullable D.text)+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+  where+    makeReport runId groupId fingerprint sliceFingerprint contract format headPosition pageSize status failureCode failureDetail failureSource failureProjection failurePosition =+      RebuildRunReport+        { rebuildRunId = runId,+          rebuildGroupId = groupId,+          catalogFingerprint = fingerprint,+          groupSliceFingerprint = sliceFingerprint,+          contractFingerprint = contract,+          runnerFormatVersion = format,+          capturedHead = headPosition,+          configuredPageSize = pageSize,+          runStatus = status,+          failureEvidence =+            RebuildFailureEvidence+              <$> failureCode+              <*> failureDetail+              <*> pure failureSource+              <*> pure failureProjection+              <*> pure failurePosition,+          sources = [],+          adapters = [],+          verifications = []+        }++inspectSourcesStmt :: Statement Text [RebuildSourceProgress]+inspectSourcesStmt =+  preparable+    """+    SELECT source_id, source_scope, category, cursor_position, target_position,+           exhausted_through, event_count+    FROM keiro.keiro_projection_rebuild_sources+    WHERE run_id = $1+    ORDER BY source_id+    """+    (E.param (E.nonNullable E.text))+    (D.rowList sourceProgressDecoder)++sourceProgressDecoder :: D.Row RebuildSourceProgress+sourceProgressDecoder =+  RebuildSourceProgress+    <$> (decodeSourceId <$> D.column (D.nonNullable D.text))+    <*> (decodeScope <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text))+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> D.column (D.nonNullable D.int8)++inspectAdaptersStmt :: Statement Text [RebuildAdapterProgress]+inspectAdaptersStmt =+  preparable+    """+    SELECT source_id, projection_id, adapter_order, evaluation_count,+           apply_count, completed_through+    FROM keiro.keiro_projection_rebuild_adapters+    WHERE run_id = $1+    ORDER BY adapter_order+    """+    (E.param (E.nonNullable E.text))+    ( D.rowList+        ( RebuildAdapterProgress+            <$> (decodeSourceId <$> D.column (D.nonNullable D.text))+            <*> D.column (D.nonNullable D.text)+            <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int4))+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+            <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+        )+    )++inspectVerificationsStmt :: Statement Text [RebuildVerificationProgress]+inspectVerificationsStmt =+  preparable+    """+    SELECT verification_id, verification_version, status, detail+    FROM keiro.keiro_projection_rebuild_verifications+    WHERE run_id = $1+    ORDER BY verification_id+    """+    (E.param (E.nonNullable E.text))+    ( D.rowList+        ( RebuildVerificationProgress+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nullable D.text)+        )+    )++insertRunStmt :: Statement (Text, Text, Text, Text, Text, Text, Int64, Int32) ()+insertRunStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_runs+      (run_id, group_id, catalog_fingerprint, group_slice_fingerprint,+       contract_fingerprint, runner_format, captured_head, page_size, status)+    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'running')+    """+    ( contrazip8+        (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))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int4))+    )+    D.noResult++insertSourceStmt :: Statement (Text, Text, Text, Maybe Text, Int64, Int64) ()+insertSourceStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_sources+      (run_id, source_id, source_scope, category, cursor_position, target_position)+    VALUES ($1, $2, $3, $4, $5, $6)+    """+    ( contrazip6+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++insertAdapterStmt :: Statement (Text, Text, Text, Int32) ()+insertAdapterStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_adapters+      (run_id, source_id, projection_id, adapter_order)+    VALUES ($1, $2, $3, $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.int4))+    )+    D.noResult++insertVerificationStmt :: Statement (Text, Text, Text) ()+insertVerificationStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_verifications+      (run_id, verification_id, verification_version)+    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++resumeRunTx :: RebuildRunId -> Int32 -> Text -> Tx.Transaction Bool+resumeRunTx runId pageSize contract = do+  updated <- Tx.statement (rebuildRunIdText runId, contract, pageSize) resumeRunStmt+  pure (updated == Just contract)++resumeRunStmt :: Statement (Text, Text, Int32) (Maybe Text)+resumeRunStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs AS runs+    SET status = 'running', page_size = $3, failed_at = NULL,+        failure_code = NULL, failure_detail = NULL, failure_source_id = NULL,+        failure_projection_id = NULL, failure_position = NULL, updated_at = now()+    FROM keiro.keiro_projection_rebuild_groups AS groups+    WHERE runs.run_id = $1+      AND runs.contract_fingerprint = $2+      AND runs.status IN ('running', 'failed')+      AND groups.group_id = runs.group_id+      AND groups.status = 'rebuilding'+      AND groups.active_run_id = runs.run_id+      AND groups.slice_fingerprint = runs.group_slice_fingerprint+    RETURNING runs.contract_fingerprint+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowMaybe (D.column (D.nonNullable D.text)))++lockActiveRunStmt :: Statement (Text, Text) Bool+lockActiveRunStmt =+  preparable+    """+    SELECT runs.run_id+    FROM keiro.keiro_projection_rebuild_runs AS runs+    JOIN keiro.keiro_projection_rebuild_groups AS groups+      ON groups.group_id = runs.group_id+    WHERE runs.run_id = $1+      AND runs.contract_fingerprint = $2+      AND runs.status = 'running'+      AND groups.status = 'rebuilding'+      AND groups.active_run_id = runs.run_id+      AND groups.slice_fingerprint = runs.group_slice_fingerprint+    FOR UPDATE OF runs, groups+    """+    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)))+    (isJust <$> D.rowMaybe (D.column (D.nonNullable D.text)))++advanceSourceStmt :: Statement (Text, Text, Int64, Int64, Int64) Bool+advanceSourceStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_sources+    SET cursor_position = $4,+        event_count = event_count + $5,+        updated_at = now()+    WHERE run_id = $1 AND source_id = $2 AND cursor_position = $3+    RETURNING source_id+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+    )+    (isJust <$> D.rowMaybe (D.column (D.nonNullable D.text)))++advanceAdapterStmt :: Statement (Text, Text, Text, Int64, Int64) ()+advanceAdapterStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_adapters+    SET evaluation_count = evaluation_count + $4,+        apply_count = apply_count + $5,+        updated_at = now()+    WHERE run_id = $1 AND source_id = $2 AND projection_id = $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.int8))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++completeSourceStmt :: Statement (Text, Text, Int64) ()+completeSourceStmt =+  preparable+    """+    WITH completed_source AS (+      UPDATE keiro.keiro_projection_rebuild_sources+      SET exhausted_through = target_position, updated_at = now()+      WHERE run_id = $1 AND source_id = $2 AND target_position = $3+      RETURNING run_id, source_id, target_position+    )+    UPDATE keiro.keiro_projection_rebuild_adapters AS adapters+    SET completed_through = completed_source.target_position, updated_at = now()+    FROM completed_source+    WHERE adapters.run_id = completed_source.run_id+      AND adapters.source_id = completed_source.source_id+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++passVerificationStmt :: Statement (Text, Text) ()+passVerificationStmt =+  verificationResultStmt "passed"++failVerificationStmt :: Statement (Text, Text, Text) ()+failVerificationStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_verifications+    SET status = 'failed', detail = $3, completed_at = now()+    WHERE run_id = $1 AND verification_id = $2+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++verificationResultStmt :: Text -> Statement (Text, Text) ()+verificationResultStmt status =+  preparable+    ( "UPDATE keiro.keiro_projection_rebuild_verifications "+        <> "SET status = '"+        <> status+        <> "', detail = NULL, completed_at = now() "+        <> "WHERE run_id = $1 AND verification_id = $2"+    )+    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)))+    D.noResult++recordFailureStmt :: Statement (Text, Text, Text, Maybe Text, Maybe Text, Maybe Int64) ()+recordFailureStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET status = 'failed', failed_at = now(), failure_code = $2,+        failure_detail = $3, failure_source_id = $4,+        failure_projection_id = $5, failure_position = $6, updated_at = now()+    WHERE run_id = $1 AND status IN ('running', 'failed')+    """+    ( contrazip6+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.int8))+    )+    D.noResult++completionProofStmt :: Statement (Text, Text, Int64, Int64, Int64) Bool+completionProofStmt =+  preparable+    """+    SELECT+      runs.status = 'running'+      AND groups.status = 'rebuilding'+      AND groups.active_run_id = runs.run_id+      AND groups.slice_fingerprint = runs.group_slice_fingerprint+      AND (SELECT count(*) FROM keiro.keiro_projection_rebuild_sources sources+           WHERE sources.run_id = runs.run_id) = $3+      AND NOT EXISTS (+        SELECT 1 FROM keiro.keiro_projection_rebuild_sources sources+        WHERE sources.run_id = runs.run_id+          AND sources.exhausted_through IS DISTINCT FROM runs.captured_head+      )+      AND (SELECT count(*) FROM keiro.keiro_projection_rebuild_adapters adapters+           WHERE adapters.run_id = runs.run_id) = $4+      AND NOT EXISTS (+        SELECT 1 FROM keiro.keiro_projection_rebuild_adapters adapters+        WHERE adapters.run_id = runs.run_id+          AND adapters.completed_through IS DISTINCT FROM runs.captured_head+      )+      AND (SELECT count(*) FROM keiro.keiro_projection_rebuild_verifications verifications+           WHERE verifications.run_id = runs.run_id) = $5+      AND NOT EXISTS (+        SELECT 1 FROM keiro.keiro_projection_rebuild_verifications verifications+        WHERE verifications.run_id = runs.run_id AND verifications.status <> 'passed'+      )+    FROM keiro.keiro_projection_rebuild_runs runs+    JOIN keiro.keiro_projection_rebuild_groups groups ON groups.group_id = runs.group_id+    WHERE runs.run_id = $1 AND runs.contract_fingerprint = $2+    FOR UPDATE OF runs, groups+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+    )+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++markVerifiedStmt :: Statement Text ()+markVerifiedStmt =+  statusUpdateStmt "verified" "verified_at"++markPromotedStmt :: Statement Text ()+markPromotedStmt =+  statusUpdateStmt "promoted" "promoted_at"++statusUpdateStmt :: Text -> Text -> Statement Text ()+statusUpdateStmt status timestampColumn =+  preparable+    ( "UPDATE keiro.keiro_projection_rebuild_runs SET status = '"+        <> status+        <> "', "+        <> timestampColumn+        <> " = now(), updated_at = now() WHERE run_id = $1"+    )+    (E.param (E.nonNullable E.text))+    D.noResult
+ src/Keiro/ReadModel/Rebuild/Status.hs view
@@ -0,0 +1,168 @@+{-# OPTIONS_HADDOCK hide #-}++-- | Typed decoding for the frozen public projection-group status relation.+-- The public facade is "Keiro.ReadModel.Rebuild".+module Keiro.ReadModel.Rebuild.Status+  ( ServingPositionBasis (..),+    ProjectionGroupStatusV1 (..),+    listProjectionGroupStatuses,+    lookupProjectionGroupStatus,+  )+where++import Data.Text qualified as Text+import Effectful (Eff, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import Keiro.Projection.Catalog+  ( ProjectionRevisionId,+    RebuildGroupId,+    mkProjectionRevisionId,+    mkRebuildGroupId,+    rebuildGroupIdText,+  )+import Keiro.ReadModel.Rebuild.Group+  ( RebuildRunId,+    mkRebuildRunId,+  )+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (GlobalPosition (..))+import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | The v1 interpretation of 'servingAppliedPosition'. Unknown future values+-- require a new SQL contract and deliberately fail this decoder.+data ServingPositionBasis+  = ServingPositionAppend+  | ServingPositionCheckpoint+  | ServingPositionUnmanaged+  deriving stock (Eq, Ord, Show, Generic)++-- | One row from @keiro_read.projection_group_status_v1@. Lifecycle is+-- diagnostic; 'readsAllowed' is the authoritative read-availability fact.+data ProjectionGroupStatusV1 = ProjectionGroupStatusV1+  { groupId :: !RebuildGroupId,+    lifecyclePhase :: !Text,+    readsAllowed :: !Bool,+    writesAllowed :: !Bool,+    servingRevisionId :: !(Maybe ProjectionRevisionId),+    servingEpoch :: !Int64,+    servingPositionBasis :: !ServingPositionBasis,+    servingAppliedPosition :: !(Maybe GlobalPosition),+    activeRunId :: !(Maybe RebuildRunId),+    candidateRevisionId :: !(Maybe ProjectionRevisionId),+    candidateRebuildPosition :: !(Maybe GlobalPosition),+    candidateRebuildHead :: !(Maybe GlobalPosition),+    queryModels :: ![Text],+    rebuildStartedAt :: !(Maybe UTCTime),+    lastPromotedAt :: !(Maybe UTCTime),+    failedAt :: !(Maybe UTCTime),+    failureCode :: !(Maybe Text),+    failureDetail :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)++-- | List the complete public status relation in stable group-id order.+listProjectionGroupStatuses ::+  (Store :> es) =>+  Eff es [ProjectionGroupStatusV1]+listProjectionGroupStatuses =+  runTransaction $ Tx.statement () listProjectionGroupStatusesStmt++-- | Look up one status row by stable rebuild-group identity.+lookupProjectionGroupStatus ::+  (Store :> es) =>+  RebuildGroupId ->+  Eff es (Maybe ProjectionGroupStatusV1)+lookupProjectionGroupStatus groupId =+  runTransaction $+    Tx.statement+      (rebuildGroupIdText groupId)+      lookupProjectionGroupStatusStmt++listProjectionGroupStatusesStmt :: Statement () [ProjectionGroupStatusV1]+listProjectionGroupStatusesStmt =+  preparable+    (statusSelect <> " ORDER BY group_id")+    E.noParams+    (D.rowList projectionGroupStatusDecoder)++lookupProjectionGroupStatusStmt :: Statement Text (Maybe ProjectionGroupStatusV1)+lookupProjectionGroupStatusStmt =+  preparable+    (statusSelect <> " WHERE group_id = $1")+    (E.param (E.nonNullable E.text))+    (D.rowMaybe projectionGroupStatusDecoder)++statusSelect :: Text+statusSelect =+  """+  SELECT group_id,+         lifecycle_phase,+         reads_allowed,+         writes_allowed,+         serving_revision_id,+         serving_epoch,+         serving_position_basis,+         serving_applied_position,+         active_run_id,+         candidate_revision_id,+         candidate_rebuild_position,+         candidate_rebuild_head,+         query_models,+         rebuild_started_at,+         last_promoted_at,+         failed_at,+         failure_code,+         failure_detail+  FROM keiro_read.projection_group_status_v1+  """++projectionGroupStatusDecoder :: D.Row ProjectionGroupStatusV1+projectionGroupStatusDecoder =+  ProjectionGroupStatusV1+    <$> D.column (D.nonNullable (D.refine decodeGroupId D.text))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.bool)+    <*> D.column (D.nonNullable D.bool)+    <*> D.column (D.nullable (D.refine decodeRevisionId D.text))+    <*> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable (D.refine decodeServingPositionBasis D.text))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> D.column (D.nullable (D.refine decodeRunId D.text))+    <*> D.column (D.nullable (D.refine decodeRevisionId D.text))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)++decodeGroupId :: Text -> Either Text RebuildGroupId+decodeGroupId raw =+  case mkRebuildGroupId raw of+    Right value -> Right value+    Left err -> Left ("invalid projection status group id: " <> Text.pack (show err))++decodeRevisionId :: Text -> Either Text ProjectionRevisionId+decodeRevisionId raw =+  case mkProjectionRevisionId raw of+    Right value -> Right value+    Left err -> Left ("invalid projection status revision id: " <> Text.pack (show err))++decodeRunId :: Text -> Either Text RebuildRunId+decodeRunId raw =+  case mkRebuildRunId raw of+    Right value -> Right value+    Left err -> Left ("invalid projection status run id: " <> err)++decodeServingPositionBasis :: Text -> Either Text ServingPositionBasis+decodeServingPositionBasis = \case+  "append" -> Right ServingPositionAppend+  "checkpoint" -> Right ServingPositionCheckpoint+  "unmanaged" -> Right ServingPositionUnmanaged+  raw -> Left ("unknown projection status serving position basis: " <> raw)
+ src/Keiro/ReadModel/Rebuild/Stream.hs view
@@ -0,0 +1,431 @@+{-# OPTIONS_HADDOCK hide #-}++-- | Transactional targeted repair for explicitly stream-scoped projections.+-- Applications own row selection and event semantics; Keiro owns admission,+-- locking, history completeness, ordering, rollback, and redelivery evidence.+module Keiro.ReadModel.Rebuild.Stream+  ( StreamReprojectionRequest (..),+    StreamReprojectionError (..),+    StreamReprojectionReport (..),+    validateStreamReprojectionAdmission,+    reprojectStream,+    reprojectStreamTx,+  )+where++import Data.Functor (($>))+import Data.Int (Int32)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Maybe (listToMaybe)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.UUID (UUID)+import Data.Vector qualified as Vector+import Effectful (Eff, (:>))+import Keiro.Prelude+import Keiro.Projection.Catalog+  ( CatalogAsyncDedupSpec (..),+    DedupKeyId,+    PhysicalTargets,+    ProjectionId,+    ProjectionRevisionId,+    RebuildGroupId,+    ReplayDecodeError,+    SourceId,+    SourceScope (..),+    StreamClearCount (..),+    StreamScopedReplay,+    TargetId,+    ValidatedProjectionCatalog,+    catalogAsyncIdempotencyKeys,+    catalogInventory,+    catalogStreamScopedReplay,+    resolvePhysicalTarget,+  )+import Keiro.ReadModel.Rebuild.Group+  ( ProjectionRepairFence (..),+    RebuildRunId,+    insertProjectionDedupBatchStmt,+    lockProjectionGroupForRepairTx,+  )+import Kiroku.Store.Effect (Store)+import Kiroku.Store.HistoryRetention+  ( StreamHistoryUnavailable,+    lockStreamHistoryForReplayTx,+    readStreamForwardTx,+  )+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types+  ( CategoryName (..),+    EventId (..),+    RecordedEvent,+    StreamName (..),+    StreamVersion (..),+  )+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude (any, filter, fromIntegral, last, length, map, not, null, zip, (+), (-), (||))+import Prelude qualified++data StreamReprojectionRequest = StreamReprojectionRequest+  { rebuildGroupId :: !RebuildGroupId,+    projectionId :: !ProjectionId,+    streamName :: !StreamName,+    pageSize :: !Int32,+    maxEvents :: !Int64+  }+  deriving stock (Eq, Show, Generic)++data StreamReprojectionError+  = StreamReprojectionInvalidPageSize !Int32+  | StreamReprojectionInvalidMaxEvents !Int64+  | StreamReprojectionEventLimitExceeded !StreamName !Int64 !Int64+  | StreamReprojectionGroupUnregistered !RebuildGroupId+  | StreamReprojectionActiveRebuild !RebuildGroupId !RebuildRunId+  | StreamReprojectionGroupUnavailable !RebuildGroupId !Text !Bool !Bool+  | StreamReprojectionSliceDrift !RebuildGroupId !Text !Text+  | StreamReprojectionServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId+  | StreamReprojectionServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text+  | StreamReprojectionUnknownProjection !ProjectionId+  | StreamReprojectionProjectionGroupMismatch !ProjectionId !RebuildGroupId !RebuildGroupId+  | StreamReprojectionPolicyUnavailable !ProjectionRevisionId !ProjectionId+  | StreamReprojectionSourceMismatch !SourceId !StreamName+  | StreamReprojectionHistoryUnavailable !StreamHistoryUnavailable+  | StreamReprojectionSoftDeleted !StreamName+  | StreamReprojectionTruncated !StreamName !StreamVersion+  | StreamReprojectionForeignEvent !StreamName !StreamVersion+  | StreamReprojectionClearFailed !Text+  | StreamReprojectionClearEvidenceInvalid ![TargetId] ![TargetId]+  | StreamReprojectionDecodeFailed !StreamVersion !ReplayDecodeError+  | StreamReprojectionVerificationFailed !Text+  | StreamReprojectionDedupIdentityUnavailable !DedupKeyId+  | StreamReprojectionHistoryIncomplete !StreamVersion !StreamVersion+  deriving stock (Eq, Show, Generic)++data StreamReprojectionReport = StreamReprojectionReport+  { rebuildGroupId :: !RebuildGroupId,+    projectionId :: !ProjectionId,+    streamName :: !StreamName,+    servingRevisionId :: !ProjectionRevisionId,+    streamVersion :: !StreamVersion,+    maxEvents :: !Int64,+    clearedRows :: ![StreamClearCount],+    replayedEvents :: !Int64,+    appliedEvents :: !Int64,+    dedupInserted :: !Int64,+    dedupExisting :: !Int64,+    verified :: !Bool+  }+  deriving stock (Eq, Show, Generic)++reprojectStream ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  StreamReprojectionRequest ->+  Eff es (Either StreamReprojectionError StreamReprojectionReport)+reprojectStream catalog request =+  runTransaction (reprojectStreamTx catalog request)++reprojectStreamTx ::+  ValidatedProjectionCatalog ->+  StreamReprojectionRequest ->+  Tx.Transaction (Either StreamReprojectionError StreamReprojectionReport)+reprojectStreamTx catalog request+  | request ^. #pageSize <= 0 =+      pure (Left (StreamReprojectionInvalidPageSize (request ^. #pageSize)))+  | request ^. #maxEvents <= 0 =+      pure (Left (StreamReprojectionInvalidMaxEvents (request ^. #maxEvents)))+  | otherwise =+      case validateStreamReprojectionAdmission catalog request of+        Left err -> pure (Left err)+        Right () -> lockHistoryAndRepair+  where+    lockHistoryAndRepair = do+      lockedHistory <- lockStreamHistoryForReplayTx (request ^. #streamName)+      case lockedHistory of+        Left unavailable ->+          pure (Left (StreamReprojectionHistoryUnavailable unavailable))+        Right streamInfo+          | isJust (streamInfo ^. #deletedAt) ->+              pure (Left (StreamReprojectionSoftDeleted (request ^. #streamName)))+          | streamInfo ^. #truncateBefore > StreamVersion 0 ->+              pure+                ( Left+                    ( StreamReprojectionTruncated+                        (request ^. #streamName)+                        (streamInfo ^. #truncateBefore)+                    )+                )+          | streamEventCount streamInfo > request ^. #maxEvents ->+              pure+                ( Left+                    ( StreamReprojectionEventLimitExceeded+                        (request ^. #streamName)+                        (streamEventCount streamInfo)+                        (request ^. #maxEvents)+                    )+                )+          | otherwise -> lockGroupAndRepair streamInfo++    streamEventCount streamInfo =+      case streamInfo ^. #version of+        StreamVersion value -> value++    -- Catalog command writers append first and acquire the group shared lock in+    -- their continuation. Taking the stream guard before the exclusive group+    -- fence preserves that global order and lets an already-appended writer+    -- finish instead of forming a row-lock cycle.+    lockGroupAndRepair streamInfo = do+      locked <- lockProjectionGroupForRepairTx catalog (request ^. #rebuildGroupId)+      case locked of+        ProjectionRepairGroupUnregistered groupId ->+          pure (Left (StreamReprojectionGroupUnregistered groupId))+        ProjectionRepairActiveRebuild groupId runId ->+          pure (Left (StreamReprojectionActiveRebuild groupId runId))+        ProjectionRepairGroupUnavailable groupId status readsAllowed writesAllowed ->+          pure (Left (StreamReprojectionGroupUnavailable groupId status readsAllowed writesAllowed))+        ProjectionRepairSliceDrift groupId expected actual ->+          pure (Left (StreamReprojectionSliceDrift groupId expected actual))+        ProjectionRepairServingRevisionUnavailable groupId revisionId ->+          pure (Left (StreamReprojectionServingRevisionUnavailable groupId revisionId))+        ProjectionRepairServingBindingInvalid groupId revisionId detail ->+          pure (Left (StreamReprojectionServingBindingInvalid groupId revisionId detail))+        ProjectionRepairAllowed binding ->+          case binding ^. #writeRevisionId of+            Nothing ->+              pure+                ( Left+                    ( StreamReprojectionGroupUnavailable+                        (request ^. #rebuildGroupId)+                        "unversioned"+                        True+                        True+                    )+                )+            Just revisionId ->+              case catalogStreamScopedReplay catalog revisionId (request ^. #projectionId) of+                Nothing ->+                  pure (Left (StreamReprojectionPolicyUnavailable revisionId (request ^. #projectionId)))+                Just policy -> runPolicy streamInfo binding revisionId policy++    runPolicy streamInfo binding revisionId policy = do+      cleared <-+        (policy ^. #clearStreamRows)+          (binding ^. #writePhysicalTargets)+          (request ^. #streamName)+      case cleared of+        Left detail -> condemned (StreamReprojectionClearFailed detail)+        Right clearCounts ->+          case validateClearEvidence policy (binding ^. #writePhysicalTargets) clearCounts of+            Left err -> condemned err+            Right normalizedCounts ->+              case policyDedupSpecs catalog (request ^. #rebuildGroupId) policy of+                Left err -> condemned err+                Right dedupSpecs -> do+                  replayed <-+                    replayPages+                      policy+                      (binding ^. #writePhysicalTargets)+                      streamInfo+                      dedupSpecs+                      (StreamVersion 0)+                      0+                      0+                      0+                      0+                  case replayed of+                    Left err -> pure (Left err)+                    Right (replayedEvents, appliedEvents, inserted, existing) -> do+                      verification <-+                        (policy ^. #verifyStreamRows)+                          (binding ^. #writePhysicalTargets)+                          (request ^. #streamName)+                      case verification of+                        Left detail -> condemned (StreamReprojectionVerificationFailed detail)+                        Right () ->+                          pure+                            ( Right+                                StreamReprojectionReport+                                  { rebuildGroupId = request ^. #rebuildGroupId,+                                    projectionId = request ^. #projectionId,+                                    streamName = request ^. #streamName,+                                    servingRevisionId = revisionId,+                                    streamVersion = streamInfo ^. #version,+                                    maxEvents = request ^. #maxEvents,+                                    clearedRows = normalizedCounts,+                                    replayedEvents,+                                    appliedEvents,+                                    dedupInserted = inserted,+                                    dedupExisting = existing,+                                    verified = True+                                  }+                            )++    replayPages policy targets streamInfo dedupSpecs cursor replayed applied inserted existing = do+      page <-+        readStreamForwardTx+          (request ^. #streamName)+          cursor+          (request ^. #pageSize)+      if Vector.null page+        then+          if cursor == streamInfo ^. #version+            then pure (Right (replayed, applied, inserted, existing))+            else condemned (StreamReprojectionHistoryIncomplete (streamInfo ^. #version) cursor)+        else do+          let events = Vector.toList page+          case List.find (foreignEvent streamInfo) events of+            Just recorded ->+              condemned+                ( StreamReprojectionForeignEvent+                    (request ^. #streamName)+                    (recorded ^. #streamVersion)+                )+            Nothing -> do+              outcomes <- traverse ((policy ^. #replayStreamEvent) targets) events+              case firstDecodeFailure events outcomes of+                Just err -> condemned err+                Nothing -> do+                  let pairs = dedupPairs dedupSpecs events+                  insertedNow <-+                    if null pairs+                      then pure 0+                      else Tx.statement (Prelude.unzip pairs) insertProjectionDedupBatchStmt+                  let attempted = fromIntegral (length pairs)+                      appliedNow = fromIntegral (length (filter (== Right True) outcomes))+                      nextCursor = last events ^. #streamVersion+                  replayPages+                    policy+                    targets+                    streamInfo+                    dedupSpecs+                    nextCursor+                    (replayed + fromIntegral (length events))+                    (applied + appliedNow)+                    (inserted + insertedNow)+                    (existing + attempted - insertedNow)++    foreignEvent streamInfo recorded =+      recorded ^. #originalStreamId /= streamInfo ^. #id+        || recorded ^. #originalVersion /= recorded ^. #streamVersion++    firstDecodeFailure events outcomes =+      listToMaybe+        [ StreamReprojectionDecodeFailed (recorded ^. #streamVersion) decodeError+        | (recorded, Left decodeError) <- zip events outcomes+        ]++policySource ::+  ValidatedProjectionCatalog ->+  ProjectionId ->+  Maybe (SourceId, SourceScope)+policySource catalog wantedProjection = do+  projection <-+    List.find+      ((== wantedProjection) . (^. #projectionId))+      (catalogInventory catalog ^. #inventoryProjections)+  source <-+    List.find+      ((== projection ^. #sourceId) . (^. #sourceId))+      (catalogInventory catalog ^. #inventorySources)+  pure (source ^. #sourceId, source ^. #sourceScope)++streamMatchesSource :: SourceScope -> StreamName -> Bool+streamMatchesSource AllStreams _ = True+streamMatchesSource (CategorySource category) (StreamName name) =+  categoryText category == Text.takeWhile (/= '-') name+  where+    categoryText = \case+      CategoryName value -> value++validateClearEvidence ::+  StreamScopedReplay ->+  PhysicalTargets ->+  [StreamClearCount] ->+  Either StreamReprojectionError [StreamClearCount]+validateClearEvidence policy targets counts+  | expected /= actual+      || length actualList /= Set.size actual+      || any ((< 0) . (^. #clearedRows)) counts =+      Left+        ( StreamReprojectionClearEvidenceInvalid+            (Set.toAscList expected)+            actualList+        )+  | any (isNothing . (`resolvePhysicalTarget` targets)) actualList =+      Left+        ( StreamReprojectionClearEvidenceInvalid+            (Set.toAscList expected)+            actualList+        )+  | otherwise = Right (List.sortOn (^. #targetId) counts)+  where+    expected = Set.fromList (NonEmpty.toList (policy ^. #streamOwnedTargets))+    actualList = map (^. #targetId) counts+    actual = Set.fromList actualList++policyDedupSpecs ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  StreamScopedReplay ->+  Either StreamReprojectionError [CatalogAsyncDedupSpec]+policyDedupSpecs catalog groupId policy =+  traverse resolve (policy ^. #affectedAsyncDedup)+  where+    available = catalogAsyncIdempotencyKeys catalog groupId+    resolve dedupId =+      maybe+        (Left (StreamReprojectionDedupIdentityUnavailable dedupId))+        Right+        (List.find ((== dedupId) . (^. #specDedupKeyId)) available)++dedupPairs :: [CatalogAsyncDedupSpec] -> [RecordedEvent] -> [(Text, UUID)]+dedupPairs specs events =+  [ (spec ^. #specDedupName, eventIdToUuid ((spec ^. #specIdempotencyKey) recorded))+  | recorded <- events,+    spec <- specs+  ]++eventIdToUuid :: EventId -> UUID+eventIdToUuid (EventId value) = value++condemned :: StreamReprojectionError -> Tx.Transaction (Either StreamReprojectionError value)+condemned err = Tx.condemn $> Left err++-- | Validate the pure catalog and request facts shared by preview and force.+-- The mutation still revalidates all persisted lifecycle and history facts+-- under its transaction locks.+validateStreamReprojectionAdmission ::+  ValidatedProjectionCatalog ->+  StreamReprojectionRequest ->+  Either StreamReprojectionError ()+validateStreamReprojectionAdmission catalog request+  | request ^. #pageSize <= 0 =+      Left (StreamReprojectionInvalidPageSize (request ^. #pageSize))+  | request ^. #maxEvents <= 0 =+      Left (StreamReprojectionInvalidMaxEvents (request ^. #maxEvents))+  | otherwise = do+      projection <-+        maybe+          (Left (StreamReprojectionUnknownProjection (request ^. #projectionId)))+          Right+          ( List.find+              ((== request ^. #projectionId) . (^. #projectionId))+              (catalogInventory catalog ^. #inventoryProjections)+          )+      let declaredGroup = projection ^. #rebuildGroupId+          requestedGroup = request ^. #rebuildGroupId+      if declaredGroup /= requestedGroup+        then+          Left+            ( StreamReprojectionProjectionGroupMismatch+                (request ^. #projectionId)+                declaredGroup+                requestedGroup+            )+        else case policySource catalog (request ^. #projectionId) of+          Nothing -> Left (StreamReprojectionUnknownProjection (request ^. #projectionId))+          Just (sourceId, sourceScope)+            | not (streamMatchesSource sourceScope (request ^. #streamName)) ->+                Left (StreamReprojectionSourceMismatch sourceId (request ^. #streamName))+            | otherwise -> Right ()
+ src/Keiro/ReadModel/Rebuild/Versioned.hs view
@@ -0,0 +1,3756 @@+{-# OPTIONS_HADDOCK hide #-}++-- | Persisted schema-versioned projection target lifecycle.+module Keiro.ReadModel.Rebuild.Versioned+  ( VersionedTargetMode (..),+    VersionedRebuildRequest (..),+    VersionedRebuildError (..),+    VersionedGenerationLifecycle (..),+    VersionedTargetGeneration (..),+    VersionedLeaseEvidence (..),+    VersionedRebuildHandle (..),+    VersionedRebuildPhase (..),+    VersionedSourceProgress (..),+    VersionedRebuildReport (..),+    VersionedAbandonResult (..),+    VersionedRetiredGenerationPreview (..),+    VersionedRetiredDropResult (..),+    beginVersionedRebuild,+    beginVersionedRebuildTx,+    applyVersionedReplayEvent,+    applyVersionedReplayEventTx,+    verifyVersionedCandidate,+    verifyVersionedCandidateTx,+    resumeVersionedRebuild,+    inspectVersionedRebuild,+    abandonVersionedRebuild,+    abandonVersionedRebuildTx,+    listVersionedRetiredGenerations,+    previewVersionedRetiredDrop,+    dropVersionedRetiredGeneration,+  )+where++import Contravariant.Extras+  ( contrazip2,+    contrazip3,+    contrazip4,+    contrazip5,+    contrazip6,+    contrazip7,+    contrazip8,+  )+import Control.Monad (foldM, join)+import Data.ByteString qualified as ByteString+import Data.Functor (($>))+import Data.Int (Int32)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (listToMaybe)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.UUID (UUID)+import Data.UUID qualified as UUID+import Data.UUID.V5 qualified as UUID.V5+import Data.Vector qualified as Vector+import Effectful (Eff, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Connection (qualifyTable, quoteIdentifier)+import Keiro.Prelude+import Keiro.Projection.Catalog+  ( PhysicalTargets,+    ProjectionRevision (..),+    ProjectionRevisionId,+    PromotionObjectKind (..),+    PromotionObjectName (..),+    QualifiedTable (..),+    RebuildGroupId,+    ReplayDecodeError,+    SourceId,+    SourceScope (..),+    TargetGenerationId (..),+    TargetId,+    TargetProvisioner (..),+    TargetProvisioningContext (..),+    TargetSchemaEvidence (..),+    TargetSchemaVersion (..),+    TargetSchemaViolation,+    ValidatedProjectionCatalog,+    catalogFingerprint,+    catalogFingerprintText,+    catalogInventory,+    catalogProjectionRevision,+    externalReadContractIdText,+    externalReadContractVersionValue,+    groupSliceFingerprint,+    groupSliceFingerprintText,+    mkPhysicalTargets,+    physicalTargetMap,+    projectionRevisionIdText,+    rebuildGroupIdText,+    sourceIdText,+    targetIdText,+  )+import Keiro.Projection.Catalog qualified as Catalog+import Keiro.ReadModel.External qualified as External+import Keiro.ReadModel.Rebuild.Group+  ( RebuildRunId,+    groupPreparationFor,+    mkRebuildRunId,+    rebuildRunIdText,+    resetDeclaredSubscriptions,+  )+import Keiro.ReadModel.Rebuild.Runner+  ( collectAsyncDedupFloors,+  )+import Kiroku.Store.Effect (Store)+import Kiroku.Store.HistoryRetention+  ( HistoryRetentionLease (..),+    HistoryRetentionLeaseDuration,+    HistoryRetentionLeaseHandle (..),+    HistoryRetentionLeaseId (..),+    HistoryRetentionLeaseRequest,+    HistoryRetentionReleaseResult (..),+    HistoryRetentionRenewalError,+    acquireHistoryRetentionLeaseTx,+    historyRetentionLeaseOwnerText,+    maxHistoryRetentionLeaseDuration,+    mkHistoryRetentionLeaseDuration,+    mkHistoryRetentionLeaseOwner,+    releaseHistoryRetentionLeaseTx,+    renewHistoryRetentionLeaseTx,+  )+import Kiroku.Store.Read qualified as Store+import Kiroku.Store.Subscription.Types (SubscriptionName)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent)+import "hasql-transaction" Hasql.Transaction qualified as Tx++data VersionedTargetMode+  = ApplicationProvisioned+  | RestrictedClone+  deriving stock (Eq, Ord, Show, Generic)++versionedRunnerFormat :: Text+versionedRunnerFormat = "keiro/versioned-rebuild/v3"++versionedContract :: Text -> ProjectionRevisionId -> Text+versionedContract slice revisionId =+  Text.intercalate+    ":"+    [ "versioned-contract-v3",+      slice,+      projectionRevisionIdText revisionId+    ]++data VersionedRebuildRequest = VersionedRebuildRequest+  { rebuildRunId :: !RebuildRunId,+    rebuildGroupId :: !RebuildGroupId,+    servingRevisionId :: !ProjectionRevisionId,+    candidateRevisionId :: !ProjectionRevisionId,+    servingTargets :: !PhysicalTargets,+    targetMode :: !VersionedTargetMode,+    replayPageSize :: !Int32,+    cutoverThreshold :: !Int64,+    cutoverLockTimeoutMs :: !Int64,+    promotionDedupLimit :: !Int64,+    retentionLeaseRequest :: !HistoryRetentionLeaseRequest,+    requestedBy :: !Text,+    requestReason :: !Text+  }+  deriving stock (Generic)++data VersionedRebuildError+  = VersionedGroupNotInCatalog !RebuildGroupId+  | VersionedRevisionNotInCatalog !ProjectionRevisionId+  | VersionedRevisionGroupMismatch !ProjectionRevisionId !RebuildGroupId+  | VersionedGroupUnregistered !RebuildGroupId+  | VersionedGroupSliceDrift !RebuildGroupId !Text !Text+  | VersionedGroupNotReady !RebuildGroupId !Text !(Maybe Text)+  | VersionedServingRevisionMismatch !RebuildGroupId !ProjectionRevisionId !ProjectionRevisionId+  | VersionedServingTargetSetMismatch !ProjectionRevisionId+  | VersionedServingTargetBindingMismatch !TargetId !QualifiedTable !QualifiedTable+  | VersionedInvalidCutoverThreshold !Int64+  | VersionedInvalidCutoverLockTimeout !Int64+  | VersionedInvalidPromotionDedupLimit !Int64+  | VersionedInvalidReplayPageSize !Int32+  | VersionedCloneContractMismatch !TargetId !Text+  | VersionedCloneRefused !TargetId !QualifiedTable ![Text]+  | VersionedClonePromotionObjectMissing !TargetId !PromotionObjectName+  | VersionedRunIdentityConflict !RebuildRunId !Text+  | VersionedStagingNameCollision !TargetId !QualifiedTable !Int64+  | VersionedPhysicalRelationMissing !TargetId !QualifiedTable+  | VersionedRelationIdentityMismatch !TargetId !Int64 !Int64+  | VersionedSchemaValidationFailed !TargetId ![TargetSchemaViolation]+  | VersionedSchemaValidatorMissing !TargetId+  | VersionedPromotionEvidenceMismatch !TargetId ![PromotionObjectName] ![PromotionObjectName]+  | VersionedPersistedLifecycleInvalid !RebuildRunId !Text+  | VersionedRetentionOwnerInvalid !RebuildRunId+  | VersionedRetentionRenewalFailed !RebuildRunId !HistoryRetentionRenewalError+  | VersionedRetentionReleaseFailed !RebuildRunId !Text+  | VersionedReplayDecodeFailed !RebuildRunId !Text !ReplayDecodeError+  | VersionedCandidateVerificationFailed !RebuildRunId !Text !Text+  | VersionedReplayContractMismatch !RebuildRunId !Text !Text+  | VersionedReplayInvariantFailed !RebuildRunId !Text+  | VersionedPromotionCheckpointsMissing !RebuildRunId ![SubscriptionName]+  | VersionedPromotionDedupLimitExceeded !RebuildRunId !Int64 !Int64+  | VersionedCutoverDeadlineExceeded !RebuildRunId !Text+  | VersionedObservedShapeMismatch !TargetId !Text !Text+  | VersionedRetiredNameCollision !TargetId !QualifiedTable !Int64+  | VersionedGenerationNotFound !TargetGenerationId+  | VersionedGenerationNotRetired !TargetGenerationId !VersionedGenerationLifecycle+  | VersionedRetiredDropBlocked !TargetGenerationId ![Text]+  | VersionedExternalReadReconciliationFailed !RebuildRunId !External.ExternalReadReconciliationError+  deriving stock (Eq, Show, Generic)++data VersionedGenerationLifecycle+  = GenerationStaging+  | GenerationServing+  | GenerationRetired+  | GenerationDropped+  | UnknownGenerationLifecycle !Text+  deriving stock (Eq, Ord, Show, Generic)++data VersionedTargetGeneration = VersionedTargetGeneration+  { generationId :: !TargetGenerationId,+    rebuildGroupId :: !RebuildGroupId,+    targetId :: !TargetId,+    revisionId :: !ProjectionRevisionId,+    physicalTable :: !QualifiedTable,+    relationOid :: !Int64,+    schemaVersion :: !TargetSchemaVersion,+    expectedShapeId :: !Text,+    observedShapeFingerprint :: !Text,+    lifecycle :: !VersionedGenerationLifecycle+  }+  deriving stock (Eq, Show, Generic)++data VersionedLeaseEvidence = VersionedLeaseEvidence+  { leaseId :: !UUID,+    owner :: !Text,+    protectedThrough :: !GlobalPosition,+    expiresAt :: !UTCTime,+    renewedAt :: !UTCTime,+    releasedAt :: !(Maybe UTCTime)+  }+  deriving stock (Eq, Show, Generic)++data VersionedRebuildHandle = VersionedRebuildHandle+  { rebuildRunId :: !RebuildRunId,+    rebuildGroupId :: !RebuildGroupId,+    servingRevisionId :: !ProjectionRevisionId,+    candidateRevisionId :: !ProjectionRevisionId,+    servingEpoch :: !Int64,+    cutoverThreshold :: !Int64,+    cutoverLockTimeoutMs :: !Int64,+    promotionDedupLimit :: !Int64,+    lease :: !VersionedLeaseEvidence,+    candidateGenerations :: ![VersionedTargetGeneration]+  }+  deriving stock (Eq, Show, Generic)++data VersionedRebuildPhase+  = VersionedReplayRunning+  | VersionedCutoverPendingHead+  | VersionedCutoverReplaying+  | VersionedPromoted+  | VersionedFailed+  | VersionedAbandoned+  | UnknownVersionedRebuildPhase !Text !Text+  deriving stock (Eq, Ord, Show, Generic)++data VersionedSourceProgress = VersionedSourceProgress+  { sourceId :: !SourceId,+    sourceScope :: !SourceScope,+    cursorPosition :: !GlobalPosition,+    targetPosition :: !GlobalPosition,+    exhaustedThrough :: !(Maybe GlobalPosition),+    eventCount :: !Int64+  }+  deriving stock (Eq, Show, Generic)++data VersionedRebuildReport = VersionedRebuildReport+  { rebuildRunId :: !RebuildRunId,+    rebuildGroupId :: !RebuildGroupId,+    phase :: !VersionedRebuildPhase,+    servingRevisionId :: !ProjectionRevisionId,+    candidateRevisionId :: !ProjectionRevisionId,+    servingEpoch :: !Int64,+    capturedHead :: !GlobalPosition,+    replayPageSize :: !Int32,+    cutoverThreshold :: !Int64,+    cutoverLockTimeoutMs :: !Int64,+    promotionDedupLimit :: !Int64,+    stagedDedupCount :: !Int64,+    dedupProvisionalHead :: !(Maybe GlobalPosition),+    promotionPrepared :: !Bool,+    lease :: !VersionedLeaseEvidence,+    sources :: ![VersionedSourceProgress],+    servingGenerations :: ![VersionedTargetGeneration],+    candidateGenerations :: ![VersionedTargetGeneration]+  }+  deriving stock (Eq, Show, Generic)++data VersionedAbandonResult = VersionedAbandonResult+  { rebuildRunId :: !RebuildRunId,+    rebuildGroupId :: !RebuildGroupId,+    alreadyAbandoned :: !Bool,+    droppedGenerations :: ![VersionedTargetGeneration]+  }+  deriving stock (Eq, Show, Generic)++data VersionedRetiredGenerationPreview = VersionedRetiredGenerationPreview+  { generation :: !VersionedTargetGeneration,+    activeRunId :: !(Maybe RebuildRunId),+    supportedReadContracts :: ![Text],+    externalDependencies :: ![Text],+    droppable :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data VersionedRetiredDropResult = VersionedRetiredDropResult+  { generation :: !VersionedTargetGeneration,+    alreadyDropped :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data PersistedGroup = PersistedGroup+  { persistedSlice :: !Text,+    persistedStatus :: !Text,+    persistedActiveRun :: !(Maybe Text),+    persistedServingRevision :: !(Maybe Text),+    persistedServingEpoch :: !Int64+  }+  deriving stock (Eq, Show, Generic)++data PersistedRun = PersistedRun+  { persistedRunId :: !Text,+    persistedGroupId :: !Text,+    persistedCatalogFingerprint :: !Text,+    persistedGroupSliceFingerprint :: !Text,+    persistedContractFingerprint :: !Text,+    persistedRunnerFormat :: !Text,+    persistedCapturedHead :: !Int64,+    persistedPageSize :: !Int32,+    persistedRunStatus :: !Text,+    persistedCandidateRevision :: !Text,+    persistedCutoverThreshold :: !Int64,+    persistedCutoverLockTimeoutMs :: !Int64,+    persistedPromotionDedupLimit :: !Int64,+    persistedDedupProvisionalHead :: !(Maybe Int64),+    persistedPromotionPreparedAt :: !(Maybe UTCTime),+    persistedLeaseId :: !UUID,+    persistedLeaseOwner :: !Text,+    persistedProtectedThrough :: !Int64,+    persistedLeaseExpiresAt :: !UTCTime,+    persistedLeaseRenewedAt :: !UTCTime,+    persistedLeaseReleasedAt :: !(Maybe UTCTime)+  }+  deriving stock (Eq, Show, Generic)++data InsertRun = InsertRun+  { runText :: !Text,+    groupText :: !Text,+    catalogText :: !Text,+    sliceText :: !Text,+    contractText :: !Text,+    candidateText :: !Text,+    pageSizeValue :: !Int32,+    thresholdValue :: !Int64,+    timeoutValue :: !Int64,+    dedupLimitValue :: !Int64,+    leaseUuid :: !UUID,+    leaseOwnerText :: !Text,+    protectedPosition :: !Int64,+    leaseExpiry :: !UTCTime,+    leaseRenewal :: !UTCTime+  }+  deriving stock (Generic)++data InsertGeneration = InsertGeneration+  { generationUuid :: !UUID,+    generationGroup :: !Text,+    generationTarget :: !Text,+    generationRevision :: !Text,+    generationSchema :: !Text,+    generationRelation :: !Text,+    generationOid :: !Int64,+    generationSchemaVersion :: !Text,+    generationExpectedShape :: !Text,+    generationObservedShape :: !Text,+    generationSnapshot :: !Text,+    generationRun :: !(Maybe Text),+    generationLifecycle :: !Text+  }+  deriving stock (Generic)++data VersionedSourcePage = VersionedSourcePage+  { pageSource :: !VersionedSourceProgress,+    pageEvents :: ![RecordedEvent],+    pageProvesExhaustion :: !Bool+  }+  deriving stock (Generic)++data VersionedRoutedEvent = VersionedRoutedEvent+  { routedSourceId :: !SourceId,+    routedEvent :: !RecordedEvent+  }+  deriving stock (Generic)++beginVersionedRebuild ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  VersionedRebuildRequest ->+  Eff es (Either VersionedRebuildError VersionedRebuildHandle)+beginVersionedRebuild catalog request =+  runTransaction (beginVersionedRebuildTx catalog request)++beginVersionedRebuildTx ::+  ValidatedProjectionCatalog ->+  VersionedRebuildRequest ->+  Tx.Transaction (Either VersionedRebuildError VersionedRebuildHandle)+beginVersionedRebuildTx catalog request =+  case validateRequest catalog request of+    Left err -> pure (Left err)+    Right (servingRevision, candidateRevision, expectedServingTargets, slice) -> do+      groupRow <-+        Tx.statement+          (rebuildGroupIdText (request ^. #rebuildGroupId))+          lockVersionedGroupStmt+      case groupRow of+        Nothing -> condemned (VersionedGroupUnregistered (request ^. #rebuildGroupId))+        Just group+          | group ^. #persistedSlice /= slice ->+              condemned+                ( VersionedGroupSliceDrift+                    (request ^. #rebuildGroupId)+                    (group ^. #persistedSlice)+                    slice+                )+          | otherwise -> do+              existing <-+                Tx.statement+                  (rebuildRunIdText (request ^. #rebuildRunId))+                  lookupVersionedRunStmt+              case existing of+                Just run -> resumeExisting request slice group run+                Nothing ->+                  beginFresh+                    catalog+                    request+                    group+                    servingRevision+                    candidateRevision+                    expectedServingTargets+                    slice++-- | Apply one durable event through every adapter of the persisted candidate+-- revision, with the run's staging generations as its closed-world physical+-- target map. A stale or absent compiled revision fails before application SQL.+applyVersionedReplayEvent ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  RecordedEvent ->+  Eff es (Either VersionedRebuildError Int)+applyVersionedReplayEvent catalog runId recorded =+  runTransaction (applyVersionedReplayEventTx catalog runId recorded)++applyVersionedReplayEventTx ::+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  RecordedEvent ->+  Tx.Transaction (Either VersionedRebuildError Int)+applyVersionedReplayEventTx catalog runId recorded = do+  execution <- candidateExecutionContext catalog runId+  case execution of+    Left err -> condemned err+    Right (revision, targets) -> go 0 (revision ^. #replayAdapters)+      where+        go applied = \case+          [] -> pure (Right applied)+          adapter : rest -> do+            outcome <- (adapter ^. #runRevisionReplay) targets recorded+            case outcome of+              Left decodeError ->+                condemned+                  ( VersionedReplayDecodeFailed+                      runId+                      (adapter ^. #adapterId)+                      decodeError+                  )+              Right didApply -> go (if didApply then applied + 1 else applied) rest++-- | Run every application verification for the persisted candidate revision+-- against the staging generation map. This is independently callable so the+-- converging runner can execute it before the M5 cutover transaction.+verifyVersionedCandidate ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Eff es (Either VersionedRebuildError ())+verifyVersionedCandidate catalog runId =+  runTransaction (verifyVersionedCandidateTx catalog runId)++verifyVersionedCandidateTx ::+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Tx.Transaction (Either VersionedRebuildError ())+verifyVersionedCandidateTx catalog runId = do+  execution <- candidateExecutionContext catalog runId+  case execution of+    Left err -> condemned err+    Right (revision, targets) -> go (revision ^. #revisionVerifications)+      where+        go = \case+          [] -> pure (Right ())+          verification : rest -> do+            outcome <- (verification ^. #runRevisionVerification) targets+            case outcome of+              Left detail ->+                condemned+                  ( VersionedCandidateVerificationFailed+                      runId+                      (verification ^. #revisionVerificationId)+                      detail+                  )+              Right () -> go rest++inspectVersionedRebuild ::+  (Store :> es) =>+  RebuildRunId ->+  Eff es (Either VersionedRebuildError VersionedRebuildReport)+inspectVersionedRebuild runId = runTransaction $ do+  maybeRun <- Tx.statement (rebuildRunIdText runId) lookupVersionedRunStmt+  case maybeRun of+    Nothing -> pure (Left (VersionedRunIdentityConflict runId "versioned run does not exist"))+    Just run -> do+      maybeGroup <- Tx.statement (run ^. #persistedGroupId) readVersionedGroupStmt+      case maybeGroup of+        Nothing -> pure (Left (VersionedGroupUnregistered (parseGroupId (run ^. #persistedGroupId))))+        Just group -> do+          sourceRows <- Tx.statement (rebuildRunIdText runId) loadVersionedSourcesStmt+          servingRows <- Tx.statement (run ^. #persistedGroupId) loadServingGenerationsStmt+          candidateRows <- loadCandidateGenerations runId+          stagedCount <- Tx.statement (rebuildRunIdText runId) countStagedDedupStmt+          pure $ do+            servingText <-+              maybe+                (Left (VersionedPersistedLifecycleInvalid runId "serving revision is absent"))+                Right+                (group ^. #persistedServingRevision)+            pure+              VersionedRebuildReport+                { rebuildRunId = runId,+                  rebuildGroupId = parseGroupId (run ^. #persistedGroupId),+                  phase = parseVersionedPhase (run ^. #persistedRunStatus) (group ^. #persistedStatus),+                  servingRevisionId = parseRevisionId servingText,+                  candidateRevisionId = parseRevisionId (run ^. #persistedCandidateRevision),+                  servingEpoch = group ^. #persistedServingEpoch,+                  capturedHead = GlobalPosition (run ^. #persistedCapturedHead),+                  replayPageSize = run ^. #persistedPageSize,+                  cutoverThreshold = run ^. #persistedCutoverThreshold,+                  cutoverLockTimeoutMs = run ^. #persistedCutoverLockTimeoutMs,+                  promotionDedupLimit = run ^. #persistedPromotionDedupLimit,+                  stagedDedupCount = stagedCount,+                  dedupProvisionalHead = GlobalPosition <$> run ^. #persistedDedupProvisionalHead,+                  promotionPrepared = isJust (run ^. #persistedPromotionPreparedAt),+                  lease = leaseEvidenceFor run,+                  sources = sourceRows,+                  servingGenerations = servingRows,+                  candidateGenerations = candidateRows+                }++leaseEvidenceFor :: PersistedRun -> VersionedLeaseEvidence+leaseEvidenceFor run =+  VersionedLeaseEvidence+    { leaseId = run ^. #persistedLeaseId,+      owner = run ^. #persistedLeaseOwner,+      protectedThrough = GlobalPosition (run ^. #persistedProtectedThrough),+      expiresAt = run ^. #persistedLeaseExpiresAt,+      renewedAt = run ^. #persistedLeaseRenewedAt,+      releasedAt = run ^. #persistedLeaseReleasedAt+    }++parseVersionedPhase :: Text -> Text -> VersionedRebuildPhase+parseVersionedPhase "running" "rebuilding-versioned" = VersionedReplayRunning+parseVersionedPhase "running" "cutover-versioned" = VersionedCutoverPendingHead+parseVersionedPhase "cutover" "cutover-versioned" = VersionedCutoverReplaying+parseVersionedPhase "promoted" "serving-versioned" = VersionedPromoted+parseVersionedPhase "failed" "failed-versioned" = VersionedFailed+parseVersionedPhase "abandoned" "serving-versioned" = VersionedAbandoned+parseVersionedPhase runStatus groupStatus = UnknownVersionedRebuildPhase runStatus groupStatus++-- | Advance one durable unit of versioned replay or cutover work. Every call+-- renews the original retention lease before mutation. The cutover fence and+-- final-head capture are deliberately separate durable phases, so a crash+-- between them resumes by recapturing the head while writers remain fenced.+resumeVersionedRebuild ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Eff es (Either VersionedRebuildError VersionedRebuildReport)+resumeVersionedRebuild catalog runId =+  inspectVersionedRebuild runId >>= \case+    Left err -> pure (Left err)+    Right report ->+      case validateVersionedReportContract catalog report of+        Left err -> pure (Left err)+        Right () -> advance report+  where+    advance report =+      case report ^. #phase of+        VersionedReplayRunning+          | allVersionedSourcesComplete (report ^. #sources) -> do+              visibleHead <- captureVersionedHead+              let GlobalPosition visible = visibleHead+                  GlobalPosition captured = report ^. #capturedHead+              if visible - captured > report ^. #cutoverThreshold+                then do+                  extended <-+                    runTransaction+                      (extendVersionedReplayHeadTx runId (expectedContract report) visibleHead)+                  case extended of+                    Left err -> pure (Left err)+                    Right () -> inspectVersionedRebuild runId+                else do+                  floors <- collectAsyncDedupFloors catalog (report ^. #rebuildGroupId)+                  case floors of+                    Left missing -> pure (Left (VersionedPromotionCheckpointsMissing runId missing))+                    Right resolvedFloors -> do+                      admitted <-+                        runTransaction+                          ( admitVersionedCutoverTx+                              runId+                              (expectedContract report)+                              resolvedFloors+                              (Prelude.fromIntegral (Prelude.length (Catalog.catalogAsyncIdempotencyKeys catalog (report ^. #rebuildGroupId))))+                          )+                      case admitted of+                        Left err -> pure (Left err)+                        Right () -> do+                          fenced <-+                            runTransaction+                              ( enterVersionedCutoverTx+                                  runId+                                  (expectedContract report)+                                  (report ^. #cutoverLockTimeoutMs)+                              )+                          case fenced of+                            Left err -> pure (Left err)+                            Right () -> inspectVersionedRebuild runId+          | otherwise -> do+              applied <- applyNextVersionedChunk catalog report+              case applied of+                Left err -> pure (Left err)+                Right () -> inspectVersionedRebuild runId+        VersionedCutoverPendingHead -> do+          finalHead <- captureVersionedHead+          captured <-+            runTransaction+              (captureVersionedCutoverHeadTx runId (expectedContract report) finalHead)+          case captured of+            Left err -> pure (Left err)+            Right () -> inspectVersionedRebuild runId+        VersionedCutoverReplaying+          | allVersionedSourcesComplete (report ^. #sources) -> do+              floors <- collectAsyncDedupFloors catalog (report ^. #rebuildGroupId)+              case floors of+                Left missing -> pure (Left (VersionedPromotionCheckpointsMissing runId missing))+                Right resolvedFloors+                  | not (report ^. #promotionPrepared) -> do+                      prepared <-+                        runTransaction+                          ( prepareVersionedPromotionTx+                              catalog+                              runId+                              (expectedContract report)+                              resolvedFloors+                          )+                      case prepared of+                        Left err -> pure (Left err)+                        Right () -> inspectVersionedRebuild runId+                  | otherwise -> do+                      promoted <-+                        runTransaction+                          ( promoteVersionedRebuildTx+                              catalog+                              runId+                              (expectedContract report)+                              (report ^. #cutoverLockTimeoutMs)+                          )+                      case promoted of+                        Left err -> pure (Left err)+                        Right () -> inspectVersionedRebuild runId+          | otherwise -> do+              applied <- applyNextVersionedChunk catalog report+              case applied of+                Left err -> pure (Left err)+                Right () -> inspectVersionedRebuild runId+        VersionedPromoted -> pure (Right report)+        VersionedFailed ->+          pure (Left (VersionedPersistedLifecycleInvalid runId "versioned run is failed and must be abandoned"))+        VersionedAbandoned ->+          pure (Left (VersionedPersistedLifecycleInvalid runId "versioned run was abandoned"))+        UnknownVersionedRebuildPhase runStatus groupStatus ->+          pure+            ( Left+                ( VersionedPersistedLifecycleInvalid+                    runId+                    ("run=" <> runStatus <> ", group=" <> groupStatus)+                )+            )++    expectedContract report =+      versionedContract+        (groupSliceText catalog (report ^. #rebuildGroupId))+        (report ^. #candidateRevisionId)++validateVersionedReportContract ::+  ValidatedProjectionCatalog ->+  VersionedRebuildReport ->+  Either VersionedRebuildError ()+validateVersionedReportContract catalog report = do+  revision <-+    maybe+      (Left (VersionedRevisionNotInCatalog (report ^. #candidateRevisionId)))+      Right+      (catalogProjectionRevision catalog (report ^. #candidateRevisionId))+  unless+    (revision ^. #rebuildGroup == report ^. #rebuildGroupId)+    (Left (VersionedRevisionGroupMismatch (report ^. #candidateRevisionId) (report ^. #rebuildGroupId)))+  void+    ( maybe+        (Left (VersionedGroupNotInCatalog (report ^. #rebuildGroupId)))+        Right+        (groupSliceFingerprint catalog (report ^. #rebuildGroupId))+    )++groupSliceText :: ValidatedProjectionCatalog -> RebuildGroupId -> Text+groupSliceText catalog groupId =+  maybe+    (error "validated versioned report group has no slice")+    groupSliceFingerprintText+    (groupSliceFingerprint catalog groupId)++allVersionedSourcesComplete :: [VersionedSourceProgress] -> Bool+allVersionedSourcesComplete =+  all (\source -> source ^. #exhaustedThrough == Just (source ^. #targetPosition))++captureVersionedHead :: (Store :> es) => Eff es GlobalPosition+captureVersionedHead = do+  events <- Store.readAllBackward (GlobalPosition 0) 1+  pure $ maybe (GlobalPosition 0) (^. #globalPosition) (events Vector.!? 0)++applyNextVersionedChunk ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  VersionedRebuildReport ->+  Eff es (Either VersionedRebuildError ())+applyNextVersionedChunk catalog report = do+  pages <- traverse (readVersionedSourcePage (report ^. #replayPageSize)) incomplete+  let ordered = versionedOrderedCandidates pages+      duplicates = duplicateVersionedPosition ordered+      horizon = versionedMergeHorizon pages+      chunk =+        Prelude.take+          (Prelude.fromIntegral (report ^. #replayPageSize))+          (Prelude.takeWhile ((<= horizon) . (^. #routedEvent . #globalPosition)) ordered)+  case duplicates of+    Just duplicate ->+      pure+        ( Left+            ( VersionedReplayInvariantFailed+                (report ^. #rebuildRunId)+                ("duplicate global position " <> renderGlobalPosition duplicate)+            )+        )+    Nothing ->+      runTransaction+        ( applyVersionedChunkTx+            catalog+            (report ^. #rebuildGroupId)+            (report ^. #rebuildRunId)+            (versionedContract (groupSliceText catalog (report ^. #rebuildGroupId)) (report ^. #candidateRevisionId))+            pages+            chunk+        )+  where+    incomplete =+      [ source+      | source <- report ^. #sources,+        source ^. #exhaustedThrough /= Just (source ^. #targetPosition)+      ]++readVersionedSourcePage ::+  (Store :> es) =>+  Int32 ->+  VersionedSourceProgress ->+  Eff es VersionedSourcePage+readVersionedSourcePage pageSize source = do+  raw <-+    case source ^. #sourceScope of+      AllStreams -> Store.readAllForward (source ^. #cursorPosition) pageSize+      CategorySource category -> Store.readCategory category (source ^. #cursorPosition) pageSize+  let events = Vector.toList raw+      eligible = Prelude.takeWhile ((<= source ^. #targetPosition) . (^. #globalPosition)) events+      beyondTarget = Prelude.any ((> source ^. #targetPosition) . (^. #globalPosition)) events+      shortPage = Vector.length raw < Prelude.fromIntegral pageSize+      reachedTarget =+        not (null eligible)+          && Prelude.last eligible ^. #globalPosition == source ^. #targetPosition+  pure+    VersionedSourcePage+      { pageSource = source,+        pageEvents = eligible,+        pageProvesExhaustion = beyondTarget || shortPage || reachedTarget+      }++versionedOrderedCandidates :: [VersionedSourcePage] -> [VersionedRoutedEvent]+versionedOrderedCandidates =+  List.sortOn ((^. #globalPosition) . (^. #routedEvent))+    . concatMap+      ( \page ->+          [ VersionedRoutedEvent (page ^. #pageSource . #sourceId) event+          | event <- page ^. #pageEvents+          ]+      )++versionedMergeHorizon :: [VersionedSourcePage] -> GlobalPosition+versionedMergeHorizon pages = Prelude.minimum (versionedPageHorizon <$> pages)++versionedPageHorizon :: VersionedSourcePage -> GlobalPosition+versionedPageHorizon page+  | page ^. #pageProvesExhaustion = page ^. #pageSource . #targetPosition+  | otherwise =+      case page ^. #pageEvents of+        [] -> page ^. #pageSource . #cursorPosition+        events -> Prelude.last events ^. #globalPosition++duplicateVersionedPosition :: [VersionedRoutedEvent] -> Maybe GlobalPosition+duplicateVersionedPosition events =+  listToMaybe+    [ left ^. #routedEvent . #globalPosition+    | (left, right) <- List.zip events (Prelude.drop 1 events),+      left ^. #routedEvent . #globalPosition == right ^. #routedEvent . #globalPosition+    ]++renderGlobalPosition :: GlobalPosition -> Text+renderGlobalPosition (GlobalPosition position) = Text.pack (show position)++applyVersionedChunkTx ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildRunId ->+  Text ->+  [VersionedSourcePage] ->+  [VersionedRoutedEvent] ->+  Tx.Transaction (Either VersionedRebuildError ())+applyVersionedChunkTx catalog groupId runId contract pages chunk = do+  renewed <- renewVersionedLeaseTx runId contract+  case renewed of+    Left err -> pure (Left err)+    Right () -> do+      active <- Tx.statement (rebuildRunIdText runId, contract) lockActiveVersionedReplayStmt+      if not active+        then condemned (VersionedPersistedLifecycleInvalid runId "run is not active for versioned replay")+        else do+          execution <- candidateExecutionContext catalog runId+          case execution of+            Left err -> condemned err+            Right (revision, targets) -> do+              applied <- applyEvents revision targets Map.empty chunk+              case applied of+                Left err -> condemned err+                Right adapterCounts -> do+                  stageVersionedDedupEvidence catalog groupId runId chunk+                  advanced <- traverse advanceSource (Map.toList sourceAdvances)+                  if not (all id advanced)+                    then condemned (VersionedReplayInvariantFailed runId "source cursor changed concurrently")+                    else do+                      traverse_ advanceAdapter (Map.toList adapterCounts)+                      traverse_ completeSource completedSources+                      pure (Right ())+  where+    applyEvents _ _ counts [] = pure (Right counts)+    applyEvents revision targets counts (routed : rest) =+      applyAdapters targets counts routed (revision ^. #replayAdapters) >>= \case+        Left err -> pure (Left err)+        Right updated -> applyEvents revision targets updated rest++    applyAdapters _ counts _ [] = pure (Right counts)+    applyAdapters targets counts routed (adapter : rest) = do+      outcome <- (adapter ^. #runRevisionReplay) targets (routed ^. #routedEvent)+      let key = (sourceIdText (routed ^. #routedSourceId), adapter ^. #adapterId)+          (evaluations, applications) = Map.findWithDefault (0, 0) key counts+          evaluated = (evaluations + 1, applications)+      case outcome of+        Left decodeError ->+          pure (Left (VersionedReplayDecodeFailed runId (adapter ^. #adapterId) decodeError))+        Right didApply ->+          applyAdapters+            targets+            (Map.insert key (if didApply then (evaluations + 1, applications + 1) else evaluated) counts)+            routed+            rest++    sourceAdvances =+      Map.fromListWith+        combineAdvance+        [ ( sourceIdText (routed ^. #routedSourceId),+            (routed ^. #routedEvent . #globalPosition, 1 :: Int64)+          )+        | routed <- chunk+        ]++    combineAdvance (leftPosition, leftCount) (rightPosition, rightCount) =+      (Prelude.max leftPosition rightPosition, leftCount + rightCount)++    expectedCursors =+      Map.fromList+        [ (sourceIdText (page ^. #pageSource . #sourceId), page ^. #pageSource . #cursorPosition)+        | page <- pages+        ]++    advanceSource (sourceText, (GlobalPosition cursor, eventDelta)) =+      case Map.lookup sourceText expectedCursors of+        Nothing -> pure False+        Just (GlobalPosition expected) ->+          Tx.statement+            (rebuildRunIdText runId, sourceText, expected, cursor, eventDelta)+            advanceVersionedSourceStmt++    advanceAdapter ((sourceText, adapterId), (evaluations, applications)) =+      Tx.statement+        (rebuildRunIdText runId, sourceText, adapterId, evaluations, applications)+        advanceVersionedAdapterStmt++    consumedBySource =+      Map.fromListWith+        (+)+        [ (sourceIdText (routed ^. #routedSourceId), 1 :: Int)+        | routed <- chunk+        ]++    completedSources =+      [ page ^. #pageSource+      | page <- pages,+        page ^. #pageProvesExhaustion,+        Map.findWithDefault 0 (sourceIdText (page ^. #pageSource . #sourceId)) consumedBySource+          == Prelude.length (page ^. #pageEvents)+      ]++    completeSource source =+      let GlobalPosition target = source ^. #targetPosition+       in Tx.statement+            (rebuildRunIdText runId, sourceIdText (source ^. #sourceId), target)+            completeVersionedSourceStmt++stageVersionedDedupEvidence ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  RebuildRunId ->+  [VersionedRoutedEvent] ->+  Tx.Transaction ()+stageVersionedDedupEvidence catalog groupId runId routedEvents =+  unless (null rows) $+    Tx.statement+      ( [rebuildRunIdText runId | _ <- rows],+        [subscription | (subscription, _, _, _) <- rows],+        [projection | (_, projection, _, _) <- rows],+        [eventId | (_, _, eventId, _) <- rows],+        [position | (_, _, _, position) <- rows]+      )+      insertStagedDedupBatchStmt+  where+    specs = Catalog.catalogAsyncIdempotencyKeys catalog groupId+    rows =+      [ ( Catalog.specSubscriptionName spec,+          Catalog.specDedupName spec,+          eventId,+          position+        )+      | routed <- routedEvents,+        spec <- specs,+        Catalog.specSourceId spec == routed ^. #routedSourceId,+        let EventId eventId = Catalog.specIdempotencyKey spec (routed ^. #routedEvent),+        let GlobalPosition position = routed ^. #routedEvent . #globalPosition+      ]++renewVersionedLeaseTx ::+  RebuildRunId ->+  Text ->+  Tx.Transaction (Either VersionedRebuildError ())+renewVersionedLeaseTx runId contract = do+  maybeRun <- Tx.statement (rebuildRunIdText runId) lookupVersionedRunStmt+  case maybeRun of+    Nothing -> pure (Left (VersionedRunIdentityConflict runId "versioned run does not exist"))+    Just run+      | run ^. #persistedContractFingerprint /= contract ->+          pure (Left (VersionedReplayContractMismatch runId (run ^. #persistedContractFingerprint) contract))+      | run ^. #persistedRunnerFormat /= versionedRunnerFormat ->+          pure (Left (VersionedReplayContractMismatch runId (run ^. #persistedRunnerFormat) versionedRunnerFormat))+      | otherwise ->+          case mkHistoryRetentionLeaseOwner (run ^. #persistedLeaseOwner) of+            Left _ -> pure (Left (VersionedRetentionOwnerInvalid runId))+            Right owner -> do+              renewed <-+                renewHistoryRetentionLeaseTx+                  (HistoryRetentionLeaseHandle (HistoryRetentionLeaseId (run ^. #persistedLeaseId)) owner)+                  versionedRenewalDuration+              case renewed of+                Left renewalError -> do+                  Tx.statement+                    ( rebuildRunIdText runId,+                      "retention.renewal-failed",+                      Text.pack (show renewalError)+                    )+                    markVersionedRetentionFailureStmt+                  pure (Left (VersionedRetentionRenewalFailed runId renewalError))+                Right lease -> do+                  updated <-+                    Tx.statement+                      (rebuildRunIdText runId, lease ^. #expiresAt, lease ^. #renewedAt)+                      updateVersionedLeaseEvidenceStmt+                  if updated+                    then pure (Right ())+                    else condemned (VersionedPersistedLifecycleInvalid runId "lease renewal lost the active run")++versionedRenewalDuration :: HistoryRetentionLeaseDuration+versionedRenewalDuration =+  either+    (error . show)+    id+    (mkHistoryRetentionLeaseDuration maxHistoryRetentionLeaseDuration)++extendVersionedReplayHeadTx ::+  RebuildRunId ->+  Text ->+  GlobalPosition ->+  Tx.Transaction (Either VersionedRebuildError ())+extendVersionedReplayHeadTx runId contract (GlobalPosition newHead) = do+  renewed <- renewVersionedLeaseTx runId contract+  case renewed of+    Left err -> pure (Left err)+    Right () -> do+      extended <- Tx.statement (rebuildRunIdText runId, contract, newHead) extendVersionedReplayHeadStmt+      if extended+        then pure (Right ())+        else condemned (VersionedPersistedLifecycleInvalid runId "replay head could not be extended")++enterVersionedCutoverTx ::+  RebuildRunId ->+  Text ->+  Int64 ->+  Tx.Transaction (Either VersionedRebuildError ())+enterVersionedCutoverTx runId contract timeoutMs = do+  renewed <- renewVersionedLeaseTx runId contract+  case renewed of+    Left err -> pure (Left err)+    Right () -> do+      deadline <- Tx.statement timeoutMs capturePromotionDeadlineStmt+      outcome <-+        Tx.statement+          (rebuildRunIdText runId, contract, deadline)+          tryEnterVersionedCutoverStmt+      case outcome of+        "fenced" -> pure (Right ())+        "deadline-exceeded" -> condemned (VersionedCutoverDeadlineExceeded runId "writer-fence")+        _ -> condemned (VersionedPersistedLifecycleInvalid runId "cutover fence prerequisites are incomplete")++admitVersionedCutoverTx ::+  RebuildRunId ->+  Text ->+  [(Text, GlobalPosition)] ->+  Int64 ->+  Tx.Transaction (Either VersionedRebuildError ())+admitVersionedCutoverTx runId contract floors asyncSpecCount = do+  renewed <- renewVersionedLeaseTx runId contract+  case renewed of+    Left err -> pure (Left err)+    Right () -> do+      let (subscriptions, positions) =+            Prelude.unzip+              [ (subscription, position)+              | (subscription, GlobalPosition position) <- floors+              ]+      unless (null floors) $+        Tx.statement+          (rebuildRunIdText runId, subscriptions, positions)+          pruneStagedDedupStmt+      admission <-+        Tx.statement+          ( rebuildRunIdText runId,+            contract,+            asyncSpecCount+          )+          admitVersionedCutoverStmt+      case admission of+        Nothing -> condemned (VersionedPersistedLifecycleInvalid runId "dedup admission prerequisites are incomplete")+        Just (stagedCount, requiredCount, dedupLimit, admitted)+          | admitted -> pure (Right ())+          | otherwise ->+              condemned+                ( VersionedPromotionDedupLimitExceeded+                    runId+                    dedupLimit+                    (Prelude.max stagedCount requiredCount)+                )++captureVersionedCutoverHeadTx ::+  RebuildRunId ->+  Text ->+  GlobalPosition ->+  Tx.Transaction (Either VersionedRebuildError ())+captureVersionedCutoverHeadTx runId contract (GlobalPosition finalHead) = do+  renewed <- renewVersionedLeaseTx runId contract+  case renewed of+    Left err -> pure (Left err)+    Right () -> do+      captured <- Tx.statement (rebuildRunIdText runId, contract, finalHead) captureVersionedCutoverHeadStmt+      if captured+        then pure (Right ())+        else condemned (VersionedPersistedLifecycleInvalid runId "final cutover head could not be captured")++prepareVersionedPromotionTx ::+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Text ->+  [(Text, GlobalPosition)] ->+  Tx.Transaction (Either VersionedRebuildError ())+prepareVersionedPromotionTx catalog runId contract floors = do+  renewed <- renewVersionedLeaseTx runId contract+  case renewed of+    Left err -> pure (Left err)+    Right () -> do+      locked <- Tx.statement (rebuildRunIdText runId, contract) lockActiveVersionedPreparationStmt+      if not locked+        then condemned (VersionedPersistedLifecycleInvalid runId "promotion preparation prerequisites are incomplete")+        else do+          maybeRun <- Tx.statement (rebuildRunIdText runId) lookupVersionedRunStmt+          case maybeRun of+            Nothing -> condemned (VersionedRunIdentityConflict runId "versioned run vanished under preparation lock")+            Just run -> do+              let (subscriptions, positions) =+                    Prelude.unzip+                      [ (subscription, position)+                      | (subscription, GlobalPosition position) <- floors+                      ]+              unless (null floors) $+                Tx.statement+                  (rebuildRunIdText runId, subscriptions, positions)+                  pruneStagedDedupStmt+              stagedCount <- Tx.statement (rebuildRunIdText runId) countStagedDedupStmt+              if stagedCount > run ^. #persistedPromotionDedupLimit+                then+                  condemned+                    ( VersionedPromotionDedupLimitExceeded+                        runId+                        (run ^. #persistedPromotionDedupLimit)+                        stagedCount+                    )+                else prepareRun run+  where+    prepareRun run = do+      maybeGroup <- Tx.statement (run ^. #persistedGroupId) readVersionedGroupStmt+      case maybeGroup >>= (^. #persistedServingRevision) of+        Nothing -> condemned (VersionedPersistedLifecycleInvalid runId "serving revision vanished under preparation lock")+        Just servingRevisionText ->+          case ( catalogRevisionByText catalog servingRevisionText,+                 catalogRevisionByText catalog (run ^. #persistedCandidateRevision)+               ) of+            (Nothing, _) -> condemned (VersionedRevisionNotInCatalog (parseRevisionId servingRevisionText))+            (_, Nothing) -> condemned (VersionedRevisionNotInCatalog (parseRevisionId (run ^. #persistedCandidateRevision)))+            (Just servingRevision, Just candidateRevision) -> do+              serving <- Tx.statement (run ^. #persistedGroupId) loadServingGenerationsStmt+              candidate <- loadCandidateGenerations runId+              case pairPromotionGenerations runId servingRevision candidateRevision serving candidate of+                Left err -> condemned err+                Right pairs -> do+                  identities <- verifyGenerationIdentities (serving <> candidate)+                  case identities of+                    Left err -> condemned err+                    Right () -> do+                      revalidated <- revalidatePromotionPairs servingRevision candidateRevision pairs+                      case revalidated of+                        Left err -> condemned err+                        Right () -> do+                          objectsValid <- promotionObjectsMatch runId candidateRevision+                          if not objectsValid+                            then condemned (VersionedPersistedLifecycleInvalid runId "persisted promotion object map differs from the candidate revision")+                            else do+                              verified <- runPromotionVerifications runId candidateRevision (candidateTargets pairs)+                              case verified of+                                Left err -> condemned err+                                Right () -> do+                                  void (Tx.statement (rebuildRunIdText runId) installStagedDedupStmt)+                                  checkpoints <- reconcilePromotionCheckpoints catalog runId run floors+                                  case checkpoints of+                                    Left err -> condemned err+                                    Right () -> releaseAndMarkPrepared run++    releaseAndMarkPrepared run =+      case mkHistoryRetentionLeaseOwner (run ^. #persistedLeaseOwner) of+        Left _ -> condemned (VersionedRetentionOwnerInvalid runId)+        Right validatedOwner -> do+          releaseResult <-+            releaseHistoryRetentionLeaseTx+              ( HistoryRetentionLeaseHandle+                  (HistoryRetentionLeaseId (run ^. #persistedLeaseId))+                  validatedOwner+              )+          case releaseEvidence releaseResult of+            Left detail -> condemned (VersionedRetentionReleaseFailed runId detail)+            Right releasedAt -> do+              prepared <-+                Tx.statement+                  (rebuildRunIdText runId, releasedAt)+                  markVersionedPromotionPreparedStmt+              if prepared+                then pure (Right ())+                else condemned (VersionedPersistedLifecycleInvalid runId "promotion preparation lost its locked run")++promoteVersionedRebuildTx ::+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Text ->+  Int64 ->+  Tx.Transaction (Either VersionedRebuildError ())+promoteVersionedRebuildTx catalog runId contract timeoutMs = do+  deadline <- Tx.statement timeoutMs capturePromotionDeadlineStmt+  lockOutcome <-+    Tx.statement+      (rebuildRunIdText runId, contract, deadline)+      tryLockActiveVersionedPromotionStmt+  case lockOutcome of+    "deadline-exceeded" -> condemned (VersionedCutoverDeadlineExceeded runId "promotion-group")+    "locked" -> promoteLocked deadline+    _ -> condemned (VersionedPersistedLifecycleInvalid runId "promotion prerequisites are incomplete")+  where+    promoteLocked deadline = do+      maybeRun <- Tx.statement (rebuildRunIdText runId) lookupVersionedRunStmt+      case maybeRun of+        Nothing -> condemned (VersionedRunIdentityConflict runId "versioned run vanished under promotion lock")+        Just run -> do+          maybeGroup <- Tx.statement (run ^. #persistedGroupId) readVersionedGroupStmt+          case maybeGroup >>= (^. #persistedServingRevision) of+            Nothing -> condemned (VersionedPersistedLifecycleInvalid runId "serving revision vanished under promotion lock")+            Just servingRevisionText ->+              case ( catalogRevisionByText catalog servingRevisionText,+                     catalogRevisionByText catalog (run ^. #persistedCandidateRevision)+                   ) of+                (Nothing, _) -> condemned (VersionedRevisionNotInCatalog (parseRevisionId servingRevisionText))+                (_, Nothing) -> condemned (VersionedRevisionNotInCatalog (parseRevisionId (run ^. #persistedCandidateRevision)))+                (Just servingRevision, Just candidateRevision) -> do+                  serving <- Tx.statement (run ^. #persistedGroupId) loadServingGenerationsStmt+                  candidate <- loadCandidateGenerations runId+                  case pairPromotionGenerations runId servingRevision candidateRevision serving candidate of+                    Left err -> condemned err+                    Right pairs -> do+                      collisions <- traverse retiredRelationCollision pairs+                      case [err | Left err <- collisions] of+                        err : _ -> condemned err+                        [] -> do+                          relationsLocked <- lockPromotionRelations deadline pairs+                          if not relationsLocked+                            then condemned (VersionedCutoverDeadlineExceeded runId "target-relations")+                            else do+                              identities <- verifyGenerationIdentities (serving <> candidate)+                              case identities of+                                Left err -> condemned err+                                Right () -> do+                                  objectsValid <- promotionObjectsMatch runId candidateRevision+                                  if not objectsValid+                                    then condemned (VersionedPersistedLifecycleInvalid runId "persisted promotion object map differs from the candidate revision")+                                    else do+                                      traverse_ (renamePromotionPair servingRevision candidateRevision) pairs+                                      promotedRun <-+                                        Tx.statement+                                          (rebuildRunIdText runId, run ^. #persistedLeaseReleasedAt)+                                          markVersionedRunPromotedStmt+                                      promotedGroup <-+                                        Tx.statement+                                          ( run ^. #persistedGroupId,+                                            rebuildRunIdText runId,+                                            run ^. #persistedCandidateRevision+                                          )+                                          finishVersionedPromotionGroupStmt+                                      if promotedRun && promotedGroup+                                        then do+                                          externalReads <-+                                            External.reconcileExternalReadContractsForGroupsTx+                                              catalog+                                              (Just (Set.singleton (parseGroupId (run ^. #persistedGroupId))))+                                          case externalReads of+                                            Left err -> condemned (VersionedExternalReadReconciliationFailed runId err)+                                            Right () -> pure (Right ())+                                        else condemned (VersionedPersistedLifecycleInvalid runId "promotion metadata transition lost its locked row")++    retiredRelationCollision (_, serving, _) = do+      let retired = retiredTableFor serving+      resolveRelationOid retired <&> \case+        Nothing -> Right ()+        Just oid -> Left (VersionedRetiredNameCollision (serving ^. #targetId) retired oid)++promotionObjectsMatch :: RebuildRunId -> ProjectionRevision -> Tx.Transaction Bool+promotionObjectsMatch runId candidateRevision = do+  persistedObjectRows <- Tx.statement (rebuildRunIdText runId) loadPromotionObjectsStmt+  let persistedObjects = groupPromotionObjectRows persistedObjectRows+      declaredObjects =+        [ ( targetIdText targetId,+            provisioner ^. #promotionObjectNames+          )+        | (targetId, provisioner) <- Map.toAscList (candidateRevision ^. #targetProvisioners),+          not (null (provisioner ^. #promotionObjectNames))+        ]+  pure (persistedObjects == declaredObjects)++pairPromotionGenerations ::+  RebuildRunId ->+  ProjectionRevision ->+  ProjectionRevision ->+  [VersionedTargetGeneration] ->+  [VersionedTargetGeneration] ->+  Either VersionedRebuildError [(TargetId, VersionedTargetGeneration, VersionedTargetGeneration)]+pairPromotionGenerations runId servingRevision candidateRevision serving candidate = do+  let servingById = Map.fromList [(generation ^. #targetId, generation) | generation <- serving]+      candidateById = Map.fromList [(generation ^. #targetId, generation) | generation <- candidate]+      expectedServing = Map.keysSet (servingRevision ^. #targetProvisioners)+      expectedCandidate = Map.keysSet (candidateRevision ^. #targetProvisioners)+  unless (expectedServing == expectedCandidate) (Left (VersionedServingTargetSetMismatch (servingRevision ^. #revisionId)))+  unless (Map.keysSet servingById == expectedServing) (Left (VersionedServingTargetSetMismatch (servingRevision ^. #revisionId)))+  unless (Map.keysSet candidateById == expectedCandidate) (Left (VersionedServingTargetSetMismatch (candidateRevision ^. #revisionId)))+  traverse+    ( \targetId -> do+        servingGeneration <- maybe (Left (VersionedServingTargetSetMismatch (servingRevision ^. #revisionId))) Right (Map.lookup targetId servingById)+        candidateGeneration <- maybe (Left (VersionedServingTargetSetMismatch (candidateRevision ^. #revisionId))) Right (Map.lookup targetId candidateById)+        unless (servingGeneration ^. #lifecycle == GenerationServing) (Left (VersionedPersistedLifecycleInvalid runId "serving generation is not serving"))+        unless (candidateGeneration ^. #lifecycle == GenerationStaging) (Left (VersionedPersistedLifecycleInvalid runId "candidate generation is not staging"))+        pure (targetId, servingGeneration, candidateGeneration)+    )+    (Map.keys servingById)++lockPromotionRelations ::+  UTCTime ->+  [(TargetId, VersionedTargetGeneration, VersionedTargetGeneration)] ->+  Tx.Transaction Bool+lockPromotionRelations deadline pairs =+  Tx.statement+    ([relationOid | (_, relationOid) <- ordered], deadline)+    tryLockPromotionRelationsStmt+  where+    ordered =+      List.sortOn Prelude.fst+        . List.nub+        $ concatMap+          ( \(_, serving, candidate) ->+              [ (serving ^. #physicalTable, serving ^. #relationOid),+                (candidate ^. #physicalTable, candidate ^. #relationOid)+              ]+          )+          pairs++revalidatePromotionPairs ::+  ProjectionRevision ->+  ProjectionRevision ->+  [(TargetId, VersionedTargetGeneration, VersionedTargetGeneration)] ->+  Tx.Transaction (Either VersionedRebuildError ())+revalidatePromotionPairs servingRevision candidateRevision = foldM step (Right ())+  where+    step (Left err) _ = pure (Left err)+    step (Right ()) (targetId, serving, candidate) = do+      servingResult <- revalidate servingRevision serving (serving ^. #physicalTable) (serving ^. #physicalTable)+      case servingResult of+        Left err -> pure (Left err)+        Right () -> revalidate candidateRevision candidate (serving ^. #physicalTable) (candidate ^. #physicalTable)+      where+        revalidate revision generation servingTable stagingTable =+          case Map.lookup targetId (revision ^. #targetProvisioners) of+            Nothing -> pure (Left (VersionedServingTargetSetMismatch (revision ^. #revisionId)))+            Just provisioner -> do+              let context =+                    TargetProvisioningContext+                      targetId+                      (generation ^. #generationId)+                      servingTable+                      stagingTable+              validated <- validateProvisionedTarget targetId provisioner context (generation ^. #relationOid)+              pure $ case validated of+                Left err -> Left err+                Right evidence+                  | provisioner ^. #schemaVersion /= generation ^. #schemaVersion ->+                      Left (VersionedObservedShapeMismatch targetId (schemaVersionText (generation ^. #schemaVersion)) (schemaVersionText (provisioner ^. #schemaVersion)))+                  | provisioner ^. #expectedShapeId /= generation ^. #expectedShapeId ->+                      Left (VersionedObservedShapeMismatch targetId (generation ^. #expectedShapeId) (provisioner ^. #expectedShapeId))+                  | evidence ^. #observedShapeFingerprint /= generation ^. #observedShapeFingerprint ->+                      Left (VersionedObservedShapeMismatch targetId (generation ^. #observedShapeFingerprint) (evidence ^. #observedShapeFingerprint))+                  | otherwise -> Right ()++candidateTargets :: [(TargetId, VersionedTargetGeneration, VersionedTargetGeneration)] -> PhysicalTargets+candidateTargets pairs =+  either+    (error . show)+    id+    ( mkPhysicalTargets+        [targetId | (targetId, _, _) <- pairs]+        (Map.fromList [(targetId, candidate ^. #physicalTable) | (targetId, _, candidate) <- pairs])+    )++runPromotionVerifications ::+  RebuildRunId ->+  ProjectionRevision ->+  PhysicalTargets ->+  Tx.Transaction (Either VersionedRebuildError ())+runPromotionVerifications runId revision targets = go (revision ^. #revisionVerifications)+  where+    go [] = pure (Right ())+    go (verification : rest) = do+      result <- (verification ^. #runRevisionVerification) targets+      case result of+        Left detail ->+          pure+            ( Left+                ( VersionedCandidateVerificationFailed+                    runId+                    (verification ^. #revisionVerificationId)+                    detail+                )+            )+        Right () -> do+          Tx.statement+            (rebuildRunIdText runId, verification ^. #revisionVerificationId)+            markVersionedVerificationPassedStmt+          go rest++reconcilePromotionCheckpoints ::+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  PersistedRun ->+  [(Text, GlobalPosition)] ->+  Tx.Transaction (Either VersionedRebuildError ())+reconcilePromotionCheckpoints catalog runId run floors =+  case groupPreparationFor catalog (parseGroupId (run ^. #persistedGroupId)) of+    Nothing -> pure (Left (VersionedGroupNotInCatalog (parseGroupId (run ^. #persistedGroupId))))+    Just preparation+      | null floors -> pure (Right ())+      | otherwise -> do+          report <- resetDeclaredSubscriptions preparation (GlobalPosition (run ^. #persistedCapturedHead))+          let missing = Vector.toList (report ^. #missingSubscriptionNames)+          pure $+            if null missing+              then Right ()+              else Left (VersionedPromotionCheckpointsMissing runId missing)++renamePromotionPair ::+  ProjectionRevision ->+  ProjectionRevision ->+  (TargetId, VersionedTargetGeneration, VersionedTargetGeneration) ->+  Tx.Transaction ()+renamePromotionPair servingRevision candidateRevision (targetId, serving, candidate) = do+  let servingProvisioner = (servingRevision ^. #targetProvisioners) Map.! targetId+      candidateProvisioner = (candidateRevision ^. #targetProvisioners) Map.! targetId+      servingTable = serving ^. #physicalTable+      candidateTable = candidate ^. #physicalTable+      retiredTable = retiredTableFor serving+  for_ (List.zip [0 :: Int ..] (servingProvisioner ^. #promotionObjectNames)) $ \(objectOrder, object) ->+    renameServingObject servingTable object (retiredObjectName serving objectOrder)+  renameTable servingTable (retiredTable ^. #tableName)+  renameTable candidateTable (servingTable ^. #tableName)+  let promotedTable = QualifiedTable (candidateTable ^. #schemaName) (servingTable ^. #tableName)+  for_ (candidateProvisioner ^. #promotionObjectNames) $ \object ->+    renameCandidateObject promotedTable object+  Tx.statement+    ( generationUuidValue (serving ^. #generationId),+      retiredTable ^. #schemaName,+      retiredTable ^. #tableName+    )+    retireServingGenerationStmt+  Tx.statement+    ( generationUuidValue (candidate ^. #generationId),+      promotedTable ^. #schemaName,+      promotedTable ^. #tableName+    )+    promoteCandidateGenerationStmt++renameTable :: QualifiedTable -> Text -> Tx.Transaction ()+renameTable table newName =+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "ALTER TABLE "+            <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+            <> " RENAME TO "+            <> quoteIdentifier newName+        )+    )++renameServingObject :: QualifiedTable -> PromotionObjectName -> Text -> Tx.Transaction ()+renameServingObject table object retiredName =+  case object ^. #objectKind of+    PromotionIndex -> renameSchemaObject "INDEX" (table ^. #schemaName) (object ^. #canonicalName) retiredName+    PromotionOwnedSequence -> renameSchemaObject "SEQUENCE" (table ^. #schemaName) (object ^. #canonicalName) retiredName+    PromotionConstraint -> renameConstraint table (object ^. #canonicalName) retiredName++renameCandidateObject :: QualifiedTable -> PromotionObjectName -> Tx.Transaction ()+renameCandidateObject table object =+  case object ^. #objectKind of+    PromotionIndex -> renameSchemaObject "INDEX" (table ^. #schemaName) (object ^. #generationName) (object ^. #canonicalName)+    PromotionOwnedSequence -> renameSchemaObject "SEQUENCE" (table ^. #schemaName) (object ^. #generationName) (object ^. #canonicalName)+    PromotionConstraint -> renameConstraint table (object ^. #generationName) (object ^. #canonicalName)++renameSchemaObject :: Text -> Text -> Text -> Text -> Tx.Transaction ()+renameSchemaObject kind schema oldName newName =+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "ALTER "+            <> kind+            <> " "+            <> qualifyTable schema oldName+            <> " RENAME TO "+            <> quoteIdentifier newName+        )+    )++renameConstraint :: QualifiedTable -> Text -> Text -> Tx.Transaction ()+renameConstraint table oldName newName =+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "ALTER TABLE "+            <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+            <> " RENAME CONSTRAINT "+            <> quoteIdentifier oldName+            <> " TO "+            <> quoteIdentifier newName+        )+    )++retiredTableFor :: VersionedTargetGeneration -> QualifiedTable+retiredTableFor generation =+  QualifiedTable+    (generation ^. #physicalTable . #schemaName)+    ("keiro_r_" <> compactGenerationId (generation ^. #generationId))++retiredObjectName :: VersionedTargetGeneration -> Int -> Text+retiredObjectName generation objectOrder =+  "keiro_ro_"+    <> Text.take 40 (compactGenerationId (generation ^. #generationId))+    <> "_"+    <> Text.pack (show objectOrder)++compactGenerationId :: TargetGenerationId -> Text+compactGenerationId = Text.filter (/= '-') . UUID.toText . generationUuidValue++candidateExecutionContext ::+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Tx.Transaction (Either VersionedRebuildError (ProjectionRevision, PhysicalTargets))+candidateExecutionContext catalog runId = do+  persisted <- Tx.statement (rebuildRunIdText runId) lookupVersionedRunStmt+  case persisted of+    Nothing -> pure (Left (VersionedRunIdentityConflict runId "versioned run does not exist"))+    Just run+      | run ^. #persistedRunStatus `notElem` ["running", "cutover"] ->+          pure+            ( Left+                ( VersionedPersistedLifecycleInvalid+                    runId+                    ("candidate execution requires running or cutover, found " <> run ^. #persistedRunStatus)+                )+            )+      | otherwise ->+          case catalogRevisionByText catalog (run ^. #persistedCandidateRevision) of+            Nothing ->+              pure+                ( Left+                    ( VersionedRevisionNotInCatalog+                        (parseRevisionId (run ^. #persistedCandidateRevision))+                    )+                )+            Just revision -> do+              generations <- loadCandidateGenerations runId+              if any ((/= GenerationStaging) . (^. #lifecycle)) generations+                then+                  pure+                    ( Left+                        ( VersionedPersistedLifecycleInvalid+                            runId+                            "candidate execution requires only staging generations"+                        )+                    )+                else do+                  identities <- verifyGenerationIdentities generations+                  pure $ case identities of+                    Left err -> Left err+                    Right () ->+                      case mkPhysicalTargets+                        (Map.keys (revision ^. #targetProvisioners))+                        ( Map.fromList+                            [ (generation ^. #targetId, generation ^. #physicalTable)+                            | generation <- generations+                            ]+                        ) of+                        Left errors ->+                          Left (VersionedPersistedLifecycleInvalid runId (Text.pack (show errors)))+                        Right targets -> Right (revision, targets)++catalogRevisionByText :: ValidatedProjectionCatalog -> Text -> Maybe ProjectionRevision+catalogRevisionByText catalog wanted =+  List.find+    (\revision -> projectionRevisionIdText (revision ^. #revisionId) == wanted)+    (Catalog.catalogProjectionRevisions catalog)++abandonVersionedRebuild ::+  (Store :> es) =>+  RebuildRunId ->+  Eff es (Either VersionedRebuildError VersionedAbandonResult)+abandonVersionedRebuild runId =+  runTransaction (abandonVersionedRebuildTx runId)++abandonVersionedRebuildTx ::+  RebuildRunId ->+  Tx.Transaction (Either VersionedRebuildError VersionedAbandonResult)+abandonVersionedRebuildTx runId = do+  locked <- Tx.statement (rebuildRunIdText runId) lockVersionedRunForAbandonStmt+  case locked of+    Nothing -> condemned (VersionedRunIdentityConflict runId "versioned run does not exist")+    Just (run, groupStatus, activeRun)+      | run ^. #persistedRunStatus == "abandoned" -> do+          generations <- loadCandidateGenerations runId+          pure+            ( Right+                VersionedAbandonResult+                  { rebuildRunId = runId,+                    rebuildGroupId = parseGroupId (run ^. #persistedGroupId),+                    alreadyAbandoned = True,+                    droppedGenerations = generations+                  }+            )+      | run ^. #persistedRunStatus `notElem` ["running", "cutover", "failed"]+          || groupStatus `notElem` ["rebuilding-versioned", "cutover-versioned", "failed-versioned"]+          || activeRun /= Just (rebuildRunIdText runId) ->+          condemned+            ( VersionedPersistedLifecycleInvalid+                runId+                ("run=" <> run ^. #persistedRunStatus <> ", group=" <> groupStatus)+            )+      | otherwise -> do+          generations <- loadCandidateGenerations runId+          identityResult <- verifyGenerationIdentities generations+          case identityResult of+            Left err -> condemned err+            Right () -> do+              for_ generations $ \generation ->+                Tx.sql+                  ( Text.Encoding.encodeUtf8+                      ( "DROP TABLE "+                          <> qualifyTable+                            (generation ^. #physicalTable . #schemaName)+                            (generation ^. #physicalTable . #tableName)+                      )+                  )+              Tx.statement (rebuildRunIdText runId) markGenerationsDroppedStmt+              owner <-+                case mkHistoryRetentionLeaseOwner (run ^. #persistedLeaseOwner) of+                  Left _ -> condemned (VersionedRetentionOwnerInvalid runId)+                  Right value -> pure (Right value)+              case owner of+                Left err -> pure (Left err)+                Right validatedOwner -> do+                  releaseResult <-+                    releaseHistoryRetentionLeaseTx+                      ( HistoryRetentionLeaseHandle+                          (HistoryRetentionLeaseId (run ^. #persistedLeaseId))+                          validatedOwner+                      )+                  case releaseEvidence releaseResult of+                    Left detail -> condemned (VersionedRetentionReleaseFailed runId detail)+                    Right released -> do+                      updatedRun <-+                        Tx.statement+                          (rebuildRunIdText runId, released)+                          markVersionedRunAbandonedStmt+                      updatedGroup <-+                        Tx.statement+                          (run ^. #persistedGroupId, rebuildRunIdText runId)+                          restoreVersionedServingGroupStmt+                      if not updatedRun || not updatedGroup+                        then condemned (VersionedPersistedLifecycleInvalid runId "abandon transition lost its locked row")+                        else do+                          dropped <- loadCandidateGenerations runId+                          pure+                            ( Right+                                VersionedAbandonResult+                                  { rebuildRunId = runId,+                                    rebuildGroupId = parseGroupId (run ^. #persistedGroupId),+                                    alreadyAbandoned = False,+                                    droppedGenerations = dropped+                                  }+                            )++listVersionedRetiredGenerations ::+  (Store :> es) =>+  Eff es [VersionedTargetGeneration]+listVersionedRetiredGenerations =+  runTransaction (Tx.statement () loadRetiredGenerationsStmt)++previewVersionedRetiredDrop ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  TargetGenerationId ->+  Eff es (Either VersionedRebuildError VersionedRetiredGenerationPreview)+previewVersionedRetiredDrop catalog generationId =+  runTransaction $ do+    loadGeneration generationId >>= \case+      Nothing -> pure (Left (VersionedGenerationNotFound generationId))+      Just generation -> previewRetiredGenerationTx catalog generation++dropVersionedRetiredGeneration ::+  (Store :> es) =>+  ValidatedProjectionCatalog ->+  TargetGenerationId ->+  Eff es (Either VersionedRebuildError VersionedRetiredDropResult)+dropVersionedRetiredGeneration catalog generationId = runTransaction $ do+  loadGeneration generationId >>= \case+    Nothing -> pure (Left (VersionedGenerationNotFound generationId))+    Just generation+      | generation ^. #lifecycle == GenerationDropped ->+          pure (Right (VersionedRetiredDropResult generation True))+      | generation ^. #lifecycle /= GenerationRetired ->+          pure (Left (VersionedGenerationNotRetired generationId (generation ^. #lifecycle)))+      | otherwise -> do+          Tx.sql+            ( Text.Encoding.encodeUtf8+                ( "LOCK TABLE "+                    <> qualifyTable+                      (generation ^. #physicalTable . #schemaName)+                      (generation ^. #physicalTable . #tableName)+                    <> " IN ACCESS EXCLUSIVE MODE"+                )+            )+          verifyGenerationIdentities [generation] >>= \case+            Left err -> pure (Left err)+            Right () ->+              previewRetiredGenerationTx catalog generation >>= \case+                Left err -> pure (Left err)+                Right dropPreview+                  | not (dropPreview ^. #droppable) ->+                      pure+                        ( Left+                            ( VersionedRetiredDropBlocked+                                generationId+                                (retiredDropBlockers dropPreview)+                            )+                        )+                  | otherwise -> do+                      Tx.sql+                        ( Text.Encoding.encodeUtf8+                            ( "DROP TABLE "+                                <> qualifyTable+                                  (generation ^. #physicalTable . #schemaName)+                                  (generation ^. #physicalTable . #tableName)+                            )+                        )+                      dropped <- Tx.statement (generationUuidValue generationId) markRetiredGenerationDroppedStmt+                      if dropped+                        then+                          pure+                            ( Right+                                ( VersionedRetiredDropResult+                                    (generation & #lifecycle .~ GenerationDropped)+                                    False+                                )+                            )+                        else pure (Left (VersionedGenerationNotRetired generationId GenerationRetired))++previewRetiredGenerationTx ::+  ValidatedProjectionCatalog ->+  VersionedTargetGeneration ->+  Tx.Transaction (Either VersionedRebuildError VersionedRetiredGenerationPreview)+previewRetiredGenerationTx catalog generation+  | generation ^. #lifecycle /= GenerationRetired =+      pure+        ( Left+            ( VersionedGenerationNotRetired+                (generation ^. #generationId)+                (generation ^. #lifecycle)+            )+        )+  | otherwise = do+      activeRunText <-+        Tx.statement+          (rebuildGroupIdText (generation ^. #rebuildGroupId))+          activeVersionedGroupRunStmt+      dependencies <-+        Tx.statement+          (generation ^. #relationOid)+          retiredGenerationDependenciesStmt+      let activeRun = activeRunText >>= either (const Nothing) Just . mkRebuildRunId+          readContracts = generationReadContracts catalog (generation ^. #revisionId)+      pure+        ( Right+            VersionedRetiredGenerationPreview+              { generation,+                activeRunId = activeRun,+                supportedReadContracts = readContracts,+                externalDependencies = dependencies,+                droppable = isNothing activeRun && null readContracts && null dependencies+              }+        )++generationReadContracts :: ValidatedProjectionCatalog -> ProjectionRevisionId -> [Text]+generationReadContracts catalog revisionId =+  [ externalReadContractIdText (contract ^. #readContractId)+      <> "/v"+      <> Text.pack (show (externalReadContractVersionValue (contract ^. #contractVersion)))+  | contract <- catalogInventory catalog ^. #inventoryExternalReadContracts,+    revisionId `elem` NonEmpty.toList (contract ^. #compatibleRevisions)+  ]++retiredDropBlockers :: VersionedRetiredGenerationPreview -> [Text]+retiredDropBlockers dropPreview =+  maybe [] (\runId -> ["active-run:" <> rebuildRunIdText runId]) (dropPreview ^. #activeRunId)+    <> ["read-contract:" <> contract | contract <- dropPreview ^. #supportedReadContracts]+    <> ["postgres-dependency:" <> dependency | dependency <- dropPreview ^. #externalDependencies]++loadGeneration ::+  TargetGenerationId ->+  Tx.Transaction (Maybe VersionedTargetGeneration)+loadGeneration generationId =+  Tx.statement (generationUuidValue generationId) loadGenerationStmt++validateRequest ::+  ValidatedProjectionCatalog ->+  VersionedRebuildRequest ->+  Either+    VersionedRebuildError+    (ProjectionRevision, ProjectionRevision, Map TargetId QualifiedTable, Text)+validateRequest catalog request+  | request ^. #replayPageSize <= 0 =+      Left (VersionedInvalidReplayPageSize (request ^. #replayPageSize))+  | request ^. #cutoverThreshold < 0 =+      Left (VersionedInvalidCutoverThreshold (request ^. #cutoverThreshold))+  | request ^. #cutoverLockTimeoutMs <= 0 =+      Left (VersionedInvalidCutoverLockTimeout (request ^. #cutoverLockTimeoutMs))+  | request ^. #promotionDedupLimit <= 0 =+      Left (VersionedInvalidPromotionDedupLimit (request ^. #promotionDedupLimit))+  | otherwise = do+      serving <- findRevision (request ^. #servingRevisionId)+      candidate <- findRevision (request ^. #candidateRevisionId)+      ensureGroup serving+      ensureGroup candidate+      when (request ^. #targetMode == RestrictedClone) $ validateCloneContracts serving candidate+      expected <-+        maybe+          (Left (VersionedGroupNotInCatalog (request ^. #rebuildGroupId)))+          Right+          (catalogServingTargets catalog (request ^. #rebuildGroupId))+      let supplied = physicalTargetMap (request ^. #servingTargets)+      unless+        (Map.keysSet supplied == Map.keysSet (serving ^. #targetProvisioners))+        (Left (VersionedServingTargetSetMismatch (request ^. #servingRevisionId)))+      for_ (Map.toAscList expected) $ \(targetId, table) ->+        case Map.lookup targetId supplied of+          Just suppliedTable+            | suppliedTable == table -> Right ()+            | otherwise -> Left (VersionedServingTargetBindingMismatch targetId table suppliedTable)+          Nothing -> Left (VersionedServingTargetSetMismatch (request ^. #servingRevisionId))+      slice <-+        maybe+          (Left (VersionedGroupNotInCatalog (request ^. #rebuildGroupId)))+          (Right . groupSliceFingerprintText)+          (groupSliceFingerprint catalog (request ^. #rebuildGroupId))+      pure (serving, candidate, expected, slice)+  where+    findRevision revisionId =+      maybe+        (Left (VersionedRevisionNotInCatalog revisionId))+        Right+        (catalogProjectionRevision catalog revisionId)+    ensureGroup revision =+      unless+        (revision ^. #rebuildGroup == request ^. #rebuildGroupId)+        ( Left+            ( VersionedRevisionGroupMismatch+                (revision ^. #revisionId)+                (request ^. #rebuildGroupId)+            )+        )+    validateCloneContracts serving candidate =+      for_ (Map.toAscList (serving ^. #targetProvisioners)) $ \(targetId, servingProvisioner) ->+        case Map.lookup targetId (candidate ^. #targetProvisioners) of+          Nothing -> Left (VersionedCloneContractMismatch targetId "candidate target is absent")+          Just candidateProvisioner ->+            unless+              ( servingProvisioner ^. #schemaVersion == candidateProvisioner ^. #schemaVersion+                  && servingProvisioner ^. #expectedShapeId == candidateProvisioner ^. #expectedShapeId+              )+              (Left (VersionedCloneContractMismatch targetId "schema version or expected shape differs"))++beginFresh ::+  ValidatedProjectionCatalog ->+  VersionedRebuildRequest ->+  PersistedGroup ->+  ProjectionRevision ->+  ProjectionRevision ->+  Map TargetId QualifiedTable ->+  Text ->+  Tx.Transaction (Either VersionedRebuildError VersionedRebuildHandle)+beginFresh catalog request group servingRevision candidateRevision expectedServingTargets slice = do+  readiness <- ensureFreshGroupReady request group+  case readiness of+    Left err -> condemned err+    Right legacyAdoption -> do+      servingRegistered <- registerRevision request slice (servingRevision ^. #revisionId)+      candidateRegistered <- registerRevision request slice (candidateRevision ^. #revisionId)+      if not servingRegistered || not candidateRegistered+        then condemned (VersionedGroupSliceDrift (request ^. #rebuildGroupId) "registered revision has stale group identity" slice)+        else do+          servingEvidence <-+            if legacyAdoption+              then adoptServingGenerations request servingRevision expectedServingTargets+              else verifyPersistedServingGenerations request+          case servingEvidence of+            Left err -> condemned err+            Right () -> do+              lease <- acquireHistoryRetentionLeaseTx (request ^. #retentionLeaseRequest)+              insertVersionedRun catalog request slice lease+              initializeVersionedProgress catalog request candidateRevision (lease ^. #protectedThrough)+              groupUpdated <-+                Tx.statement+                  ( rebuildGroupIdText (request ^. #rebuildGroupId),+                    rebuildRunIdText (request ^. #rebuildRunId),+                    projectionRevisionIdText (request ^. #servingRevisionId),+                    request ^. #requestedBy,+                    request ^. #requestReason+                  )+                  beginVersionedGroupStmt+              if not groupUpdated+                then condemned (VersionedGroupNotReady (request ^. #rebuildGroupId) (group ^. #persistedStatus) (group ^. #persistedActiveRun))+                else do+                  provisioned <- provisionCandidateGenerations request candidateRevision expectedServingTargets+                  case provisioned of+                    Left err -> condemned err+                    Right () -> do+                      externalReads <-+                        External.reconcileExternalReadContractsForGroupsTx+                          catalog+                          (Just (Set.singleton (request ^. #rebuildGroupId)))+                      case externalReads of+                        Left err ->+                          condemned+                            ( VersionedExternalReadReconciliationFailed+                                (request ^. #rebuildRunId)+                                err+                            )+                        Right () -> loadHandle request++ensureFreshGroupReady ::+  VersionedRebuildRequest ->+  PersistedGroup ->+  Tx.Transaction (Either VersionedRebuildError Bool)+ensureFreshGroupReady request group =+  case group ^. #persistedStatus of+    "live"+      | isNothing (group ^. #persistedActiveRun)+          && isNothing (group ^. #persistedServingRevision) ->+          pure (Right True)+    "serving-versioned"+      | isNothing (group ^. #persistedActiveRun) ->+          case group ^. #persistedServingRevision of+            Just stored+              | stored == projectionRevisionIdText (request ^. #servingRevisionId) -> pure (Right False)+              | otherwise ->+                  pure+                    ( Left+                        ( VersionedServingRevisionMismatch+                            (request ^. #rebuildGroupId)+                            (request ^. #servingRevisionId)+                            (parseRevisionId stored)+                        )+                    )+            Nothing -> invalid+    _ -> invalid+  where+    invalid =+      pure+        ( Left+            ( VersionedGroupNotReady+                (request ^. #rebuildGroupId)+                (group ^. #persistedStatus)+                (group ^. #persistedActiveRun)+            )+        )++resumeExisting ::+  VersionedRebuildRequest ->+  Text ->+  PersistedGroup ->+  PersistedRun ->+  Tx.Transaction (Either VersionedRebuildError VersionedRebuildHandle)+resumeExisting request slice group run+  | run ^. #persistedGroupId /= rebuildGroupIdText (request ^. #rebuildGroupId) = conflict "group differs"+  | run ^. #persistedGroupSliceFingerprint /= slice = conflict "group slice differs"+  | run ^. #persistedContractFingerprint /= versionedContract slice (request ^. #candidateRevisionId) = conflict "replay contract differs"+  | run ^. #persistedRunnerFormat /= versionedRunnerFormat = conflict "runner format differs"+  | run ^. #persistedPageSize /= request ^. #replayPageSize = conflict "replay page size differs"+  | run ^. #persistedCandidateRevision /= projectionRevisionIdText (request ^. #candidateRevisionId) = conflict "candidate revision differs"+  | run ^. #persistedCutoverThreshold /= request ^. #cutoverThreshold = conflict "cutover threshold differs"+  | run ^. #persistedCutoverLockTimeoutMs /= request ^. #cutoverLockTimeoutMs = conflict "cutover lock timeout differs"+  | run ^. #persistedPromotionDedupLimit /= request ^. #promotionDedupLimit = conflict "promotion dedup limit differs"+  | run ^. #persistedLeaseOwner /= requestedOwner = conflict "retention owner differs"+  | run ^. #persistedRunStatus /= "running" = conflict ("run status is " <> run ^. #persistedRunStatus)+  | group ^. #persistedStatus /= "rebuilding-versioned"+      || group ^. #persistedActiveRun /= Just (rebuildRunIdText (request ^. #rebuildRunId)) =+      conflict "group no longer names this active versioned run"+  | group ^. #persistedServingRevision /= Just (projectionRevisionIdText (request ^. #servingRevisionId)) =+      conflict "serving revision differs"+  | otherwise = loadHandle request+  where+    requestedOwner =+      historyRetentionLeaseOwnerText (request ^. #retentionLeaseRequest . #owner)+    conflict =+      pure . Left . VersionedRunIdentityConflict (request ^. #rebuildRunId)++adoptServingGenerations ::+  VersionedRebuildRequest ->+  ProjectionRevision ->+  Map TargetId QualifiedTable ->+  Tx.Transaction (Either VersionedRebuildError ())+adoptServingGenerations request revision targets =+  foldM step (Right ()) (Map.toAscList (revision ^. #targetProvisioners))+  where+    step (Left err) _ = pure (Left err)+    step (Right ()) (targetId, provisioner) = do+      let table = targets Map.! targetId+      resolved <- resolveRelationOid table+      case resolved of+        Nothing -> pure (Left (VersionedPhysicalRelationMissing targetId table))+        Just oid -> do+          let generationId = servingGenerationId request targetId table oid+              context = TargetProvisioningContext targetId generationId table table+          validated <- validateProvisionedTarget targetId provisioner context oid+          case validated of+            Left err -> pure (Left err)+            Right evidence -> do+              insertGeneration+                request+                targetId+                (revision ^. #revisionId)+                generationId+                table+                provisioner+                evidence+                Nothing+                "serving"+              pure (Right ())++verifyPersistedServingGenerations ::+  VersionedRebuildRequest ->+  Tx.Transaction (Either VersionedRebuildError ())+verifyPersistedServingGenerations request = do+  rows <-+    Tx.statement+      (rebuildGroupIdText (request ^. #rebuildGroupId))+      loadServingGenerationsStmt+  let expected = physicalTargetMap (request ^. #servingTargets)+      actual = Map.fromList [(generation ^. #targetId, generation) | generation <- rows]+  if Map.keysSet expected /= Map.keysSet actual+    then pure (Left (VersionedServingTargetSetMismatch (request ^. #servingRevisionId)))+    else foldM (check actual) (Right ()) (Map.toAscList expected)+  where+    check _ (Left err) _ = pure (Left err)+    check actual (Right ()) (targetId, table) =+      case Map.lookup targetId actual of+        Nothing -> pure (Left (VersionedServingTargetSetMismatch (request ^. #servingRevisionId)))+        Just generation+          | generation ^. #revisionId /= request ^. #servingRevisionId ->+              pure (Left (VersionedServingTargetSetMismatch (request ^. #servingRevisionId)))+          | generation ^. #physicalTable /= table ->+              pure (Left (VersionedServingTargetBindingMismatch targetId table (generation ^. #physicalTable)))+          | otherwise -> do+              resolved <- resolveRelationOid table+              pure $ case resolved of+                Nothing -> Left (VersionedPhysicalRelationMissing targetId table)+                Just oid+                  | oid /= generation ^. #relationOid ->+                      Left (VersionedRelationIdentityMismatch targetId (generation ^. #relationOid) oid)+                  | otherwise -> Right ()++provisionCandidateGenerations ::+  VersionedRebuildRequest ->+  ProjectionRevision ->+  Map TargetId QualifiedTable ->+  Tx.Transaction (Either VersionedRebuildError ())+provisionCandidateGenerations request revision servingTables =+  foldM step (Right ()) (Map.toAscList (revision ^. #targetProvisioners))+  where+    step (Left err) _ = pure (Left err)+    step (Right ()) (targetId, provisioner) = do+      let generationId = candidateGenerationId request targetId+          serving = servingTables Map.! targetId+          staging = QualifiedTable (serving ^. #schemaName) (generationRelationName generationId)+          context = TargetProvisioningContext targetId generationId serving staging+      existing <- resolveRelationOid staging+      case existing of+        Just oid -> pure (Left (VersionedStagingNameCollision targetId staging oid))+        Nothing -> do+          provisioned <-+            case request ^. #targetMode of+              ApplicationProvisioned -> (provisioner ^. #provisionTarget $ context) $> Right ()+              RestrictedClone -> provisionRestrictedClone targetId provisioner context+          case provisioned of+            Left err -> pure (Left err)+            Right () -> finishProvision targetId provisioner generationId staging context+    finishProvision targetId provisioner generationId staging context = do+      resolved <- resolveRelationOid staging+      case resolved of+        Nothing -> pure (Left (VersionedPhysicalRelationMissing targetId staging))+        Just oid -> do+          validated <- validateProvisionedTarget targetId provisioner context oid+          case validated of+            Left err -> pure (Left err)+            Right evidence -> do+              insertGeneration+                request+                targetId+                (revision ^. #revisionId)+                generationId+                staging+                provisioner+                evidence+                (Just (request ^. #rebuildRunId))+                "staging"+              Tx.statement+                ( rebuildRunIdText (request ^. #rebuildRunId),+                  targetIdText targetId,+                  targetModeText (request ^. #targetMode),+                  generationUuidValue generationId+                )+                insertRunTargetStmt+              for_ (List.zip [0 :: Int32 ..] (provisioner ^. #promotionObjectNames)) $ \(objectOrder, object) ->+                Tx.statement+                  ( rebuildRunIdText (request ^. #rebuildRunId),+                    targetIdText targetId,+                    objectOrder,+                    promotionKindText (object ^. #objectKind),+                    object ^. #generationName,+                    object ^. #canonicalName+                  )+                  insertPromotionObjectStmt+              pure (Right ())++provisionRestrictedClone ::+  TargetId ->+  TargetProvisioner ->+  TargetProvisioningContext ->+  Tx.Transaction (Either VersionedRebuildError ())+provisionRestrictedClone targetId provisioner context = do+  findings <- Tx.statement (context ^. #servingTable) restrictedCloneFindingsStmt+  if not (null findings)+    then pure (Left (VersionedCloneRefused targetId (context ^. #servingTable) findings))+    else do+      Tx.sql+        ( Text.Encoding.encodeUtf8+            ( "CREATE TABLE "+                <> qualifyTable+                  (context ^. #stagingTable . #schemaName)+                  (context ^. #stagingTable . #tableName)+                <> " (LIKE "+                <> qualifyTable+                  (context ^. #servingTable . #schemaName)+                  (context ^. #servingTable . #tableName)+                <> " INCLUDING ALL)"+            )+        )+      renameClonePromotionObjects targetId context (provisioner ^. #promotionObjectNames)++renameClonePromotionObjects ::+  TargetId ->+  TargetProvisioningContext ->+  [PromotionObjectName] ->+  Tx.Transaction (Either VersionedRebuildError ())+renameClonePromotionObjects targetId context = go+  where+    go [] = pure (Right ())+    go (promotionObject : rest) = do+      copiedName <-+        Tx.statement+          ( context ^. #servingTable,+            context ^. #stagingTable,+            promotionKindText (promotionObject ^. #objectKind),+            promotionObject ^. #canonicalName+          )+          resolveClonedPromotionObjectStmt+      case copiedName of+        Nothing -> pure (Left (VersionedClonePromotionObjectMissing targetId promotionObject))+        Just currentName -> do+          renameClonedObject (context ^. #stagingTable) promotionObject currentName+          go rest++renameClonedObject :: QualifiedTable -> PromotionObjectName -> Text -> Tx.Transaction ()+renameClonedObject table promotionObject currentName =+  case promotionObject ^. #objectKind of+    PromotionIndex ->+      renameSchemaObject "INDEX" (table ^. #schemaName) currentName (promotionObject ^. #generationName)+    PromotionOwnedSequence ->+      renameSchemaObject "SEQUENCE" (table ^. #schemaName) currentName (promotionObject ^. #generationName)+    PromotionConstraint ->+      renameConstraint table currentName (promotionObject ^. #generationName)++validateProvisionedTarget ::+  TargetId ->+  TargetProvisioner ->+  TargetProvisioningContext ->+  Int64 ->+  Tx.Transaction (Either VersionedRebuildError TargetSchemaEvidence)+validateProvisionedTarget targetId provisioner context actualOid =+  case provisioner ^. #validateTarget of+    Nothing -> pure (Left (VersionedSchemaValidatorMissing targetId))+    Just validator ->+      validator context <&> \case+        Left violations -> Left (VersionedSchemaValidationFailed targetId violations)+        Right evidence+          | evidence ^. #relationOid /= actualOid ->+              Left (VersionedRelationIdentityMismatch targetId (evidence ^. #relationOid) actualOid)+          | evidence ^. #observedPromotionObjects /= provisioner ^. #promotionObjectNames ->+              Left+                ( VersionedPromotionEvidenceMismatch+                    targetId+                    (provisioner ^. #promotionObjectNames)+                    (evidence ^. #observedPromotionObjects)+                )+          | otherwise -> Right evidence++insertGeneration ::+  VersionedRebuildRequest ->+  TargetId ->+  ProjectionRevisionId ->+  TargetGenerationId ->+  QualifiedTable ->+  TargetProvisioner ->+  TargetSchemaEvidence ->+  Maybe RebuildRunId ->+  Text ->+  Tx.Transaction ()+insertGeneration request targetId revisionId generationId table provisioner evidence maybeRun lifecycle =+  Tx.statement+    InsertGeneration+      { generationUuid = generationUuidValue generationId,+        generationGroup = rebuildGroupIdText (request ^. #rebuildGroupId),+        generationTarget = targetIdText targetId,+        generationRevision = projectionRevisionIdText revisionId,+        generationSchema = table ^. #schemaName,+        generationRelation = table ^. #tableName,+        generationOid = evidence ^. #relationOid,+        generationSchemaVersion = schemaVersionText (provisioner ^. #schemaVersion),+        generationExpectedShape = provisioner ^. #expectedShapeId,+        generationObservedShape = evidence ^. #observedShapeFingerprint,+        generationSnapshot = evidence ^. #catalogSnapshot,+        generationRun = rebuildRunIdText <$> maybeRun,+        generationLifecycle = lifecycle+      }+    insertGenerationStmt++insertVersionedRun ::+  ValidatedProjectionCatalog ->+  VersionedRebuildRequest ->+  Text ->+  HistoryRetentionLease ->+  Tx.Transaction ()+insertVersionedRun catalog request slice lease =+  let HistoryRetentionLeaseId leaseId = lease ^. #leaseId+      GlobalPosition protected = lease ^. #protectedThrough+   in Tx.statement+        InsertRun+          { runText = rebuildRunIdText (request ^. #rebuildRunId),+            groupText = rebuildGroupIdText (request ^. #rebuildGroupId),+            catalogText = catalogFingerprintText (catalogFingerprint catalog),+            sliceText = slice,+            contractText = versionedContract slice (request ^. #candidateRevisionId),+            candidateText = projectionRevisionIdText (request ^. #candidateRevisionId),+            pageSizeValue = request ^. #replayPageSize,+            thresholdValue = request ^. #cutoverThreshold,+            timeoutValue = request ^. #cutoverLockTimeoutMs,+            dedupLimitValue = request ^. #promotionDedupLimit,+            leaseUuid = leaseId,+            leaseOwnerText = historyRetentionLeaseOwnerText (lease ^. #owner),+            protectedPosition = protected,+            leaseExpiry = lease ^. #expiresAt,+            leaseRenewal = lease ^. #renewedAt+          }+        insertVersionedRunStmt++initializeVersionedProgress ::+  ValidatedProjectionCatalog ->+  VersionedRebuildRequest ->+  ProjectionRevision ->+  GlobalPosition ->+  Tx.Transaction ()+initializeVersionedProgress catalog request revision (GlobalPosition target) = do+  traverse_ insertSource sources+  traverse_ insertAdapter indexedAdapters+  traverse_ insertVerification (revision ^. #revisionVerifications)+  where+    runText = rebuildRunIdText (request ^. #rebuildRunId)+    sources = versionedSourceSpecs catalog (request ^. #rebuildGroupId)+    indexedAdapters =+      [ (source, adapter, order)+      | (order, (source, adapter)) <-+          List.zip+            [0 :: Int32 ..]+            [ (source, adapter)+            | source <- sources,+              adapter <- revision ^. #replayAdapters+            ]+      ]++    insertSource (sourceId, scope) =+      let (scopeText, category) = encodeSourceScope scope+       in Tx.statement+            (runText, sourceIdText sourceId, scopeText, category, 0, target)+            insertVersionedSourceStmt++    insertAdapter ((sourceId, _), adapter, order) =+      Tx.statement+        (runText, sourceIdText sourceId, adapter ^. #adapterId, order)+        insertVersionedAdapterStmt++    insertVerification verification =+      Tx.statement+        ( runText,+          verification ^. #revisionVerificationId,+          Text.pack (show (verification ^. #revisionVerificationVersion))+        )+        insertVersionedVerificationStmt++versionedSourceSpecs :: ValidatedProjectionCatalog -> RebuildGroupId -> [(SourceId, SourceScope)]+versionedSourceSpecs catalog groupId =+  List.sortOn+    (sourceIdText . Prelude.fst)+    [ (source ^. #sourceId, source ^. #sourceScope)+    | source <- catalogInventory catalog ^. #inventorySources,+      source ^. #sourceId `elem` wantedSourceIds+    ]+  where+    wantedSourceIds =+      List.nub+        [ projection ^. #sourceId+        | projection <- catalogInventory catalog ^. #inventoryProjections,+          projection ^. #rebuildGroupId == groupId+        ]++encodeSourceScope :: SourceScope -> (Text, Maybe Text)+encodeSourceScope AllStreams = ("all", Nothing)+encodeSourceScope (CategorySource (CategoryName category)) = ("category", Just category)++registerRevision ::+  VersionedRebuildRequest ->+  Text ->+  ProjectionRevisionId ->+  Tx.Transaction Bool+registerRevision request slice revisionId =+  Tx.statement+    ( rebuildGroupIdText (request ^. #rebuildGroupId),+      projectionRevisionIdText revisionId,+      slice+    )+    ensureRevisionRegisteredStmt++loadHandle ::+  VersionedRebuildRequest ->+  Tx.Transaction (Either VersionedRebuildError VersionedRebuildHandle)+loadHandle request = do+  maybeRun <- Tx.statement (rebuildRunIdText (request ^. #rebuildRunId)) lookupVersionedRunStmt+  maybeGroup <- Tx.statement (rebuildGroupIdText (request ^. #rebuildGroupId)) readVersionedGroupStmt+  generations <- loadCandidateGenerations (request ^. #rebuildRunId)+  pure $ do+    run <- maybe (Left (VersionedPersistedLifecycleInvalid (request ^. #rebuildRunId) "run missing after begin")) Right maybeRun+    group <- maybe (Left (VersionedGroupUnregistered (request ^. #rebuildGroupId))) Right maybeGroup+    servingText <- maybe (Left (VersionedPersistedLifecycleInvalid (request ^. #rebuildRunId) "serving revision missing after begin")) Right (group ^. #persistedServingRevision)+    pure+      VersionedRebuildHandle+        { rebuildRunId = request ^. #rebuildRunId,+          rebuildGroupId = request ^. #rebuildGroupId,+          servingRevisionId = parseRevisionId servingText,+          candidateRevisionId = parseRevisionId (run ^. #persistedCandidateRevision),+          servingEpoch = group ^. #persistedServingEpoch,+          cutoverThreshold = run ^. #persistedCutoverThreshold,+          cutoverLockTimeoutMs = run ^. #persistedCutoverLockTimeoutMs,+          promotionDedupLimit = run ^. #persistedPromotionDedupLimit,+          lease =+            VersionedLeaseEvidence+              { leaseId = run ^. #persistedLeaseId,+                owner = run ^. #persistedLeaseOwner,+                protectedThrough = GlobalPosition (run ^. #persistedProtectedThrough),+                expiresAt = run ^. #persistedLeaseExpiresAt,+                renewedAt = run ^. #persistedLeaseRenewedAt,+                releasedAt = run ^. #persistedLeaseReleasedAt+              },+          candidateGenerations = generations+        }++loadCandidateGenerations :: RebuildRunId -> Tx.Transaction [VersionedTargetGeneration]+loadCandidateGenerations runId =+  Tx.statement (rebuildRunIdText runId) loadCandidateGenerationsStmt++verifyGenerationIdentities ::+  [VersionedTargetGeneration] ->+  Tx.Transaction (Either VersionedRebuildError ())+verifyGenerationIdentities = foldM step (Right ())+  where+    step (Left err) _ = pure (Left err)+    step (Right ()) generation+      | generation ^. #lifecycle == GenerationDropped = pure (Right ())+      | otherwise = do+          actual <- resolveRelationOid (generation ^. #physicalTable)+          pure $ case actual of+            Nothing -> Left (VersionedPhysicalRelationMissing (generation ^. #targetId) (generation ^. #physicalTable))+            Just oid+              | oid /= generation ^. #relationOid ->+                  Left (VersionedRelationIdentityMismatch (generation ^. #targetId) (generation ^. #relationOid) oid)+              | otherwise -> Right ()++resolveRelationOid :: QualifiedTable -> Tx.Transaction (Maybe Int64)+resolveRelationOid table =+  Tx.statement (table ^. #schemaName, table ^. #tableName) resolveRelationOidStmt++releaseEvidence :: HistoryRetentionReleaseResult -> Either Text (Maybe UTCTime)+releaseEvidence = \case+  HistoryRetentionReleased lease -> Right (lease ^. #releasedAt)+  HistoryRetentionAlreadyReleased lease -> Right (lease ^. #releasedAt)+  HistoryRetentionReleaseExpired lease -> Right (lease ^. #releasedAt)+  HistoryRetentionReleaseUnknown -> Left "retention lease is unknown"+  HistoryRetentionReleaseOwnerMismatch -> Left "retention lease owner differs"++catalogServingTargets ::+  ValidatedProjectionCatalog ->+  RebuildGroupId ->+  Maybe (Map TargetId QualifiedTable)+catalogServingTargets catalog wantedGroup = do+  group <- List.find ((== wantedGroup) . (^. #rebuildGroupId)) (catalogInventory catalog ^. #inventoryGroups)+  let targetsById =+        Map.fromList+          [ (target ^. #targetId, target ^. #qualifiedTable)+          | target <- catalogInventory catalog ^. #inventoryTargets+          ]+  traverse (`Map.lookup` targetsById) (group ^. #orderedTargets)+    <&> Map.fromList+    . List.zip (group ^. #orderedTargets)++servingGenerationId ::+  VersionedRebuildRequest ->+  TargetId ->+  QualifiedTable ->+  Int64 ->+  TargetGenerationId+servingGenerationId request targetId table oid =+  deterministicGenerationId+    ( Text.intercalate+        "\NUL"+        [ "keiro/versioned-serving-generation/v1",+          rebuildGroupIdText (request ^. #rebuildGroupId),+          projectionRevisionIdText (request ^. #servingRevisionId),+          targetIdText targetId,+          table ^. #schemaName,+          table ^. #tableName,+          Text.pack (show oid)+        ]+    )++candidateGenerationId :: VersionedRebuildRequest -> TargetId -> TargetGenerationId+candidateGenerationId request targetId =+  deterministicGenerationId+    ( Text.intercalate+        "\NUL"+        [ "keiro/versioned-candidate-generation/v1",+          rebuildRunIdText (request ^. #rebuildRunId),+          rebuildGroupIdText (request ^. #rebuildGroupId),+          targetIdText targetId+        ]+    )++deterministicGenerationId :: Text -> TargetGenerationId+deterministicGenerationId seed =+  TargetGenerationId+    ( UUID.V5.generateNamed+        UUID.V5.namespaceURL+        (ByteString.unpack (Text.Encoding.encodeUtf8 seed))+    )++generationRelationName :: TargetGenerationId -> Text+generationRelationName generationId =+  "keiro_g_" <> Text.filter (/= '-') (UUID.toText (generationUuidValue generationId))++generationUuidValue :: TargetGenerationId -> UUID+generationUuidValue (TargetGenerationId value) = value++targetModeText :: VersionedTargetMode -> Text+targetModeText ApplicationProvisioned = "application"+targetModeText RestrictedClone = "clone"++promotionKindText :: PromotionObjectKind -> Text+promotionKindText PromotionIndex = "index"+promotionKindText PromotionConstraint = "constraint"+promotionKindText PromotionOwnedSequence = "owned-sequence"++parsePromotionKind :: Text -> PromotionObjectKind+parsePromotionKind "index" = PromotionIndex+parsePromotionKind "constraint" = PromotionConstraint+parsePromotionKind "owned-sequence" = PromotionOwnedSequence+parsePromotionKind other = error ("invalid persisted promotion object kind: " <> Text.unpack other)++groupPromotionObjectRows :: [(Text, PromotionObjectName)] -> [(Text, [PromotionObjectName])]+groupPromotionObjectRows [] = []+groupPromotionObjectRows ((targetId, promotionObject) : rest) =+  let (sameTarget, remaining) = List.span ((== targetId) . Prelude.fst) rest+   in (targetId, promotionObject : (Prelude.snd <$> sameTarget))+        : groupPromotionObjectRows remaining++schemaVersionText :: TargetSchemaVersion -> Text+schemaVersionText (TargetSchemaVersion value) = value++parseGenerationLifecycle :: Text -> VersionedGenerationLifecycle+parseGenerationLifecycle "staging" = GenerationStaging+parseGenerationLifecycle "serving" = GenerationServing+parseGenerationLifecycle "retired" = GenerationRetired+parseGenerationLifecycle "dropped" = GenerationDropped+parseGenerationLifecycle other = UnknownGenerationLifecycle other++parseGroupId :: Text -> RebuildGroupId+parseGroupId value =+  either (error . show) id (Catalog.mkRebuildGroupId value)++parseRevisionId :: Text -> ProjectionRevisionId+parseRevisionId value =+  either (error . show) id (Catalog.mkProjectionRevisionId value)++condemned :: VersionedRebuildError -> Tx.Transaction (Either VersionedRebuildError value)+condemned err = Tx.condemn $> Left err++lockVersionedGroupStmt :: Statement Text (Maybe PersistedGroup)+lockVersionedGroupStmt =+  preparable+    """+    SELECT slice_fingerprint, status, active_run_id, serving_revision_id, serving_epoch+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id = $1+    FOR UPDATE+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe persistedGroupDecoder)++readVersionedGroupStmt :: Statement Text (Maybe PersistedGroup)+readVersionedGroupStmt =+  preparable+    """+    SELECT slice_fingerprint, status, active_run_id, serving_revision_id, serving_epoch+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe persistedGroupDecoder)++persistedGroupDecoder :: D.Row PersistedGroup+persistedGroupDecoder =+  PersistedGroup+    <$> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nonNullable D.int8)++ensureRevisionRegisteredStmt :: Statement (Text, Text, Text) Bool+ensureRevisionRegisteredStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_revisions+      (group_id, revision_id, group_slice_fingerprint)+    VALUES ($1, $2, $3)+    ON CONFLICT (group_id, revision_id) DO UPDATE+      SET updated_at = now()+    WHERE keiro.keiro_projection_revisions.group_slice_fingerprint = EXCLUDED.group_slice_fingerprint+    RETURNING TRUE+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++beginVersionedGroupStmt :: Statement (Text, Text, Text, Text, Text) Bool+beginVersionedGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'rebuilding-versioned',+        active_run_id = $2,+        serving_revision_id = $3,+        reads_allowed = TRUE,+        writes_allowed = TRUE,+        requested_by = $4,+        request_reason = $5,+        started_at = now(),+        completed_at = NULL,+        failed_at = NULL,+        failure_code = NULL,+        failure_detail = NULL,+        updated_at = now()+    WHERE group_id = $1+      AND active_run_id IS NULL+      AND status IN ('live', 'serving-versioned')+    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.text))+        (E.param (E.nonNullable E.text))+    )+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++insertVersionedRunStmt :: Statement InsertRun ()+insertVersionedRunStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_runs+      (run_id, group_id, catalog_fingerprint, group_slice_fingerprint,+       contract_fingerprint, runner_format, captured_head, page_size, status,+       rebuild_mode, candidate_revision_id, cutover_threshold,+       cutover_lock_timeout_ms, promotion_dedup_limit,+       history_retention_lease_id,+       history_retention_lease_owner, history_retention_protected_through,+       history_retention_expires_at, history_retention_renewed_at)+    VALUES+      ($1, $2, $3, $4, $5, 'keiro/versioned-rebuild/v3', $13, $7,+       'running', 'versioned', $6, $8, $9, $10, $11, $12, $13, $14, $15)+    """+    insertRunEncoder+    D.noResult++insertRunEncoder :: E.Params InsertRun+insertRunEncoder =+  contramap+    ( \value ->+        ( ( value ^. #runText,+            value ^. #groupText,+            value ^. #catalogText,+            value ^. #sliceText,+            value ^. #contractText,+            value ^. #candidateText,+            value ^. #pageSizeValue+          ),+          ( value ^. #thresholdValue,+            value ^. #timeoutValue,+            value ^. #dedupLimitValue,+            value ^. #leaseUuid,+            value ^. #leaseOwnerText,+            value ^. #protectedPosition,+            value ^. #leaseExpiry,+            value ^. #leaseRenewal+          )+        )+    )+    ( contrazip2+        ( contrazip7+            textParam+            textParam+            textParam+            textParam+            textParam+            textParam+            int4Param+        )+        (contrazip8 int8Param int8Param int8Param uuidParam textParam int8Param timestamptzParam timestamptzParam)+    )++insertVersionedSourceStmt :: Statement (Text, Text, Text, Maybe Text, Int64, Int64) ()+insertVersionedSourceStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_sources+      (run_id, source_id, source_scope, category, cursor_position, target_position)+    VALUES ($1, $2, $3, $4, $5, $6)+    """+    (contrazip6 textParam textParam textParam nullableTextParam int8Param int8Param)+    D.noResult++insertVersionedAdapterStmt :: Statement (Text, Text, Text, Int32) ()+insertVersionedAdapterStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_adapters+      (run_id, source_id, projection_id, adapter_order)+    VALUES ($1, $2, $3, $4)+    """+    (contrazip4 textParam textParam textParam int4Param)+    D.noResult++insertVersionedVerificationStmt :: Statement (Text, Text, Text) ()+insertVersionedVerificationStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_verifications+      (run_id, verification_id, verification_version)+    VALUES ($1, $2, $3)+    """+    (contrazip3 textParam textParam textParam)+    D.noResult++lookupVersionedRunStmt :: Statement Text (Maybe PersistedRun)+lookupVersionedRunStmt =+  preparable+    """+    SELECT run_id, group_id, catalog_fingerprint, group_slice_fingerprint,+           contract_fingerprint, runner_format, captured_head, page_size,+           status, candidate_revision_id,+           cutover_threshold, cutover_lock_timeout_ms, promotion_dedup_limit,+           dedup_provisional_head, promotion_prepared_at,+           history_retention_lease_id, history_retention_lease_owner,+           history_retention_protected_through, history_retention_expires_at,+           history_retention_renewed_at, history_retention_released_at+    FROM keiro.keiro_projection_rebuild_runs+    WHERE run_id = $1 AND rebuild_mode = 'versioned'+    """+    textParam+    (D.rowMaybe persistedRunDecoder)++countStagedDedupStmt :: Statement Text Int64+countStagedDedupStmt =+  preparable+    "SELECT count(*) FROM keiro.keiro_projection_rebuild_dedup_stage WHERE run_id = $1"+    textParam+    (D.singleRow (D.column (D.nonNullable D.int8)))++persistedRunDecoder :: D.Row PersistedRun+persistedRunDecoder =+  PersistedRun+    <$> 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)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable D.int4)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable D.int8)+    <*> D.column (D.nullable D.int8)+    <*> D.column (D.nullable D.timestamptz)+    <*> D.column (D.nonNullable D.uuid)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)++lockActiveVersionedReplayStmt :: Statement (Text, Text) Bool+lockActiveVersionedReplayStmt =+  preparable+    """+    SELECT runs.run_id+    FROM keiro.keiro_projection_rebuild_runs AS runs+    JOIN keiro.keiro_projection_rebuild_groups AS groups+      ON groups.group_id = runs.group_id+    WHERE runs.run_id = $1+      AND runs.contract_fingerprint = $2+      AND runs.status IN ('running', 'cutover')+      AND groups.status IN ('rebuilding-versioned', 'cutover-versioned')+      AND groups.active_run_id = runs.run_id+      AND groups.slice_fingerprint = runs.group_slice_fingerprint+    FOR UPDATE OF runs, groups+    """+    (contrazip2 textParam textParam)+    (isJust <$> D.rowMaybe (D.column (D.nonNullable D.text)))++updateVersionedLeaseEvidenceStmt :: Statement (Text, UTCTime, UTCTime) Bool+updateVersionedLeaseEvidenceStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET history_retention_expires_at = $2,+        history_retention_renewed_at = $3,+        updated_at = now()+    WHERE run_id = $1+      AND rebuild_mode = 'versioned'+      AND status IN ('running', 'cutover')+    RETURNING TRUE+    """+    (contrazip3 textParam timestamptzParam timestamptzParam)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++markVersionedRetentionFailureStmt :: Statement (Text, Text, Text) ()+markVersionedRetentionFailureStmt =+  preparable+    """+    WITH failed_run AS (+      UPDATE keiro.keiro_projection_rebuild_runs+      SET status = 'failed', failure_code = $2, failure_detail = $3,+          failed_at = now(), updated_at = now()+      WHERE run_id = $1+        AND rebuild_mode = 'versioned'+        AND status IN ('running', 'cutover')+      RETURNING group_id, run_id+    )+    UPDATE keiro.keiro_projection_rebuild_groups AS groups+    SET status = 'failed-versioned', reads_allowed = TRUE,+        writes_allowed = FALSE, failed_at = now(),+        failure_code = $2, failure_detail = $3, updated_at = now()+    FROM failed_run+    WHERE groups.group_id = failed_run.group_id+      AND groups.active_run_id = failed_run.run_id+      AND groups.status IN ('rebuilding-versioned', 'cutover-versioned')+    """+    (contrazip3 textParam textParam textParam)+    D.noResult++advanceVersionedSourceStmt :: Statement (Text, Text, Int64, Int64, Int64) Bool+advanceVersionedSourceStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_sources+    SET cursor_position = $4,+        event_count = event_count + $5,+        updated_at = now()+    WHERE run_id = $1 AND source_id = $2+      AND cursor_position = $3 AND exhausted_through IS NULL+      AND $4 >= $3 AND $4 <= target_position+    RETURNING TRUE+    """+    (contrazip5 textParam textParam int8Param int8Param int8Param)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++advanceVersionedAdapterStmt :: Statement (Text, Text, Text, Int64, Int64) ()+advanceVersionedAdapterStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_adapters+    SET evaluation_count = evaluation_count + $4,+        apply_count = apply_count + $5,+        updated_at = now()+    WHERE run_id = $1 AND source_id = $2 AND projection_id = $3+    """+    (contrazip5 textParam textParam textParam int8Param int8Param)+    D.noResult++completeVersionedSourceStmt :: Statement (Text, Text, Int64) ()+completeVersionedSourceStmt =+  preparable+    """+    WITH completed AS (+      UPDATE keiro.keiro_projection_rebuild_sources+      SET exhausted_through = $3, updated_at = now()+      WHERE run_id = $1 AND source_id = $2+        AND target_position = $3 AND exhausted_through IS NULL+      RETURNING run_id, source_id+    )+    UPDATE keiro.keiro_projection_rebuild_adapters AS adapters+    SET completed_through = $3, updated_at = now()+    FROM completed+    WHERE adapters.run_id = completed.run_id+      AND adapters.source_id = completed.source_id+    """+    (contrazip3 textParam textParam int8Param)+    D.noResult++insertStagedDedupBatchStmt :: Statement ([Text], [Text], [Text], [UUID], [Int64]) ()+insertStagedDedupBatchStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_dedup_stage+      (run_id, subscription_name, projection_name, event_id, global_position)+    SELECT staged.run_id, staged.subscription_name, staged.projection_name,+           staged.event_id, staged.global_position+    FROM unnest($1::text[], $2::text[], $3::text[], $4::uuid[], $5::bigint[])+      AS staged(run_id, subscription_name, projection_name, event_id, global_position)+    ON CONFLICT (run_id, projection_name, event_id) DO NOTHING+    """+    ( contrazip5+        textArrayParam+        textArrayParam+        textArrayParam+        uuidArrayParam+        int8ArrayParam+    )+    D.noResult++extendVersionedReplayHeadStmt :: Statement (Text, Text, Int64) Bool+extendVersionedReplayHeadStmt =+  preparable+    """+    WITH active_run AS (+      UPDATE keiro.keiro_projection_rebuild_runs AS runs+      SET captured_head = $3, updated_at = now()+      FROM keiro.keiro_projection_rebuild_groups AS groups+      WHERE runs.run_id = $1 AND runs.contract_fingerprint = $2+        AND runs.status = 'running'+        AND groups.group_id = runs.group_id+        AND groups.status = 'rebuilding-versioned'+        AND groups.active_run_id = runs.run_id+        AND $3 >= runs.captured_head+        AND NOT EXISTS (+          SELECT 1 FROM keiro.keiro_projection_rebuild_sources AS sources+          WHERE sources.run_id = runs.run_id+            AND sources.exhausted_through IS DISTINCT FROM sources.target_position+        )+      RETURNING runs.run_id+    ), extended AS (+      UPDATE keiro.keiro_projection_rebuild_sources AS sources+      SET target_position = $3,+          exhausted_through = CASE WHEN sources.cursor_position >= $3 THEN $3 ELSE NULL END,+          updated_at = now()+      FROM active_run+      WHERE sources.run_id = active_run.run_id+      RETURNING sources.run_id+    )+    SELECT EXISTS (SELECT 1 FROM active_run)+    """+    (contrazip3 textParam textParam int8Param)+    (D.singleRow (D.column (D.nonNullable D.bool)))++pruneStagedDedupStmt :: Statement (Text, [Text], [Int64]) ()+pruneStagedDedupStmt =+  preparable+    """+    DELETE FROM keiro.keiro_projection_rebuild_dedup_stage AS staged+    USING unnest($2::text[], $3::bigint[]) AS floors(subscription_name, global_position)+    WHERE staged.run_id = $1+      AND staged.subscription_name = floors.subscription_name+      AND staged.global_position <= floors.global_position+    """+    (contrazip3 textParam textArrayParam int8ArrayParam)+    D.noResult++admitVersionedCutoverStmt :: Statement (Text, Text, Int64) (Maybe (Int64, Int64, Int64, Bool))+admitVersionedCutoverStmt =+  preparable+    """+    WITH active AS (+      SELECT runs.run_id, runs.captured_head, runs.cutover_threshold,+             runs.promotion_dedup_limit+      FROM keiro.keiro_projection_rebuild_runs AS runs+      JOIN keiro.keiro_projection_rebuild_groups AS groups+        ON groups.group_id = runs.group_id+      WHERE runs.run_id = $1+        AND runs.contract_fingerprint = $2+        AND runs.status = 'running'+        AND groups.status = 'rebuilding-versioned'+        AND groups.active_run_id = runs.run_id+        AND NOT EXISTS (+          SELECT 1 FROM keiro.keiro_projection_rebuild_sources AS sources+          WHERE sources.run_id = runs.run_id+            AND sources.exhausted_through IS DISTINCT FROM sources.target_position+        )+      FOR UPDATE OF runs+    ), facts AS (+      SELECT active.*,+             count(staged.event_id)::bigint AS staged_count,+             count(staged.event_id)::numeric+               + active.cutover_threshold::numeric * $3::numeric AS required_count+      FROM active+      LEFT JOIN keiro.keiro_projection_rebuild_dedup_stage AS staged+        ON staged.run_id = active.run_id+      GROUP BY active.run_id, active.captured_head, active.cutover_threshold,+               active.promotion_dedup_limit+    ), admitted AS (+      UPDATE keiro.keiro_projection_rebuild_runs AS runs+      SET dedup_provisional_head = facts.captured_head,+          promotion_prepared_at = NULL,+          updated_at = now()+      FROM facts+      WHERE runs.run_id = facts.run_id+        AND facts.required_count <= facts.promotion_dedup_limit+      RETURNING runs.run_id+    )+    SELECT facts.staged_count,+           least(facts.required_count, 9223372036854775807)::bigint,+           facts.promotion_dedup_limit,+           EXISTS (SELECT 1 FROM admitted)+    FROM facts+    """+    (contrazip3 textParam textParam int8Param)+    ( D.rowMaybe+        ( (,,,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.bool)+        )+    )++capturePromotionDeadlineStmt :: Statement Int64 UTCTime+capturePromotionDeadlineStmt =+  preparable+    "SELECT clock_timestamp() + ($1 * interval '1 millisecond')"+    int8Param+    (D.singleRow (D.column (D.nonNullable D.timestamptz)))++tryEnterVersionedCutoverStmt :: Statement (Text, Text, UTCTime) Text+tryEnterVersionedCutoverStmt =+  preparable+    "SELECT keiro.keiro_try_projection_cutover_fence_v1($1, $2, $3)"+    (contrazip3 textParam textParam timestamptzParam)+    (D.singleRow (D.column (D.nonNullable D.text)))++captureVersionedCutoverHeadStmt :: Statement (Text, Text, Int64) Bool+captureVersionedCutoverHeadStmt =+  preparable+    """+    WITH cutover_run AS (+      UPDATE keiro.keiro_projection_rebuild_runs AS runs+      SET status = 'cutover', captured_head = $3, updated_at = now()+      FROM keiro.keiro_projection_rebuild_groups AS groups+      WHERE runs.run_id = $1 AND runs.contract_fingerprint = $2+        AND runs.status = 'running'+        AND groups.group_id = runs.group_id+        AND groups.status = 'cutover-versioned'+        AND groups.active_run_id = runs.run_id+        AND $3 >= runs.captured_head+      RETURNING runs.run_id+    ), retargeted AS (+      UPDATE keiro.keiro_projection_rebuild_sources AS sources+      SET target_position = $3,+          exhausted_through = CASE WHEN sources.cursor_position >= $3 THEN $3 ELSE NULL END,+          updated_at = now()+      FROM cutover_run+      WHERE sources.run_id = cutover_run.run_id+      RETURNING sources.run_id+    )+    SELECT EXISTS (SELECT 1 FROM cutover_run)+    """+    (contrazip3 textParam textParam int8Param)+    (D.singleRow (D.column (D.nonNullable D.bool)))++lockActiveVersionedPreparationStmt :: Statement (Text, Text) Bool+lockActiveVersionedPreparationStmt =+  preparable+    """+    SELECT runs.run_id+    FROM keiro.keiro_projection_rebuild_runs AS runs+    JOIN keiro.keiro_projection_rebuild_groups AS groups+      ON groups.group_id = runs.group_id+    WHERE runs.run_id = $1 AND runs.contract_fingerprint = $2+      AND runs.status = 'cutover'+      AND runs.dedup_provisional_head IS NOT NULL+      AND runs.promotion_prepared_at IS NULL+      AND groups.status = 'cutover-versioned'+      AND groups.active_run_id = runs.run_id+      AND NOT EXISTS (+        SELECT 1 FROM keiro.keiro_projection_rebuild_sources AS sources+        WHERE sources.run_id = runs.run_id+          AND sources.exhausted_through IS DISTINCT FROM sources.target_position+      )+    FOR UPDATE OF runs, groups+    """+    (contrazip2 textParam textParam)+    (isJust <$> D.rowMaybe (D.column (D.nonNullable D.text)))++tryLockActiveVersionedPromotionStmt :: Statement (Text, Text, UTCTime) Text+tryLockActiveVersionedPromotionStmt =+  preparable+    "SELECT keiro.keiro_try_projection_promotion_lock_v1($1, $2, $3)"+    (contrazip3 textParam textParam timestamptzParam)+    (D.singleRow (D.column (D.nonNullable D.text)))++tryLockPromotionRelationsStmt :: Statement ([Int64], UTCTime) Bool+tryLockPromotionRelationsStmt =+  preparable+    "SELECT keiro.keiro_try_projection_relation_locks_v1($1, $2)"+    (contrazip2 int8ArrayParam timestamptzParam)+    (D.singleRow (D.column (D.nonNullable D.bool)))++installStagedDedupStmt :: Statement Text Int64+installStagedDedupStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)+    SELECT projection_name, event_id+    FROM keiro.keiro_projection_rebuild_dedup_stage+    WHERE run_id = $1+    ON CONFLICT (projection_name, event_id) DO NOTHING+    """+    textParam+    D.rowsAffected++loadPromotionObjectsStmt :: Statement Text [(Text, PromotionObjectName)]+loadPromotionObjectsStmt =+  preparable+    """+    SELECT target_id, object_kind, generation_name, canonical_name+    FROM keiro.keiro_projection_rebuild_promotion_objects+    WHERE run_id = $1+    ORDER BY target_id, object_order+    """+    textParam+    ( D.rowList+        ( (,)+            <$> D.column (D.nonNullable D.text)+            <*> ( PromotionObjectName+                    <$> (parsePromotionKind <$> D.column (D.nonNullable D.text))+                    <*> D.column (D.nonNullable D.text)+                    <*> D.column (D.nonNullable D.text)+                )+        )+    )++retireServingGenerationStmt :: Statement (UUID, Text, Text) ()+retireServingGenerationStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_target_generations+    SET lifecycle = 'retired', schema_name = $2, relation_name = $3,+        retired_at = now()+    WHERE generation_id = $1 AND lifecycle = 'serving'+    """+    (contrazip3 uuidParam textParam textParam)+    D.noResult++promoteCandidateGenerationStmt :: Statement (UUID, Text, Text) ()+promoteCandidateGenerationStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_target_generations+    SET lifecycle = 'serving', schema_name = $2, relation_name = $3,+        served_at = now()+    WHERE generation_id = $1 AND lifecycle = 'staging'+    """+    (contrazip3 uuidParam textParam textParam)+    D.noResult++markVersionedVerificationPassedStmt :: Statement (Text, Text) ()+markVersionedVerificationPassedStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_verifications+    SET status = 'passed', detail = NULL, completed_at = now()+    WHERE run_id = $1 AND verification_id = $2 AND status = 'pending'+    """+    (contrazip2 textParam textParam)+    D.noResult++markVersionedPromotionPreparedStmt :: Statement (Text, Maybe UTCTime) Bool+markVersionedPromotionPreparedStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET promotion_prepared_at = now(),+        history_retention_released_at = COALESCE($2, now()),+        verified_at = now(), updated_at = now()+    WHERE run_id = $1+      AND rebuild_mode = 'versioned'+      AND status = 'cutover'+      AND dedup_provisional_head IS NOT NULL+      AND promotion_prepared_at IS NULL+    RETURNING TRUE+    """+    (contrazip2 textParam nullableTimestamptzParam)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++markVersionedRunPromotedStmt :: Statement (Text, Maybe UTCTime) Bool+markVersionedRunPromotedStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET status = 'promoted', verified_at = now(), promoted_at = now(),+        history_retention_released_at = COALESCE($2, now()), updated_at = now()+    WHERE run_id = $1 AND rebuild_mode = 'versioned' AND status = 'cutover'+    RETURNING TRUE+    """+    (contrazip2 textParam nullableTimestamptzParam)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++finishVersionedPromotionGroupStmt :: Statement (Text, Text, Text) Bool+finishVersionedPromotionGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'serving-versioned', active_run_id = NULL,+        serving_revision_id = $3, serving_epoch = serving_epoch + 1,+        reads_allowed = TRUE, writes_allowed = TRUE,+        completed_at = now(), updated_at = now()+    WHERE group_id = $1 AND active_run_id = $2+      AND status = 'cutover-versioned'+    RETURNING TRUE+    """+    (contrazip3 textParam textParam textParam)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++resolveRelationOidStmt :: Statement (Text, Text) (Maybe Int64)+resolveRelationOidStmt =+  preparable+    """+    SELECT classes.oid::bigint+    FROM pg_catalog.pg_class AS classes+    JOIN pg_catalog.pg_namespace AS namespaces+      ON namespaces.oid = classes.relnamespace+    WHERE namespaces.nspname = $1 AND classes.relname = $2+    """+    (contrazip2 textParam textParam)+    (D.rowMaybe (D.column (D.nonNullable D.int8)))++restrictedCloneFindingsStmt :: Statement QualifiedTable [Text]+restrictedCloneFindingsStmt =+  preparable+    """+    WITH target AS (+      SELECT classes.*+      FROM pg_catalog.pg_class AS classes+      JOIN pg_catalog.pg_namespace AS namespaces+        ON namespaces.oid = classes.relnamespace+      WHERE namespaces.nspname = $1 AND classes.relname = $2+    ), findings(feature) AS (+      SELECT 'not-permanent-ordinary-heap'+      FROM target+      WHERE relkind <> 'r' OR relpersistence <> 'p'+      UNION ALL+      SELECT 'non-default-access-method'+      FROM target JOIN pg_catalog.pg_am ON pg_am.oid = target.relam+      WHERE pg_am.amname <> current_setting('default_table_access_method')+      UNION ALL+      SELECT 'storage-options' FROM target WHERE reloptions IS NOT NULL+      UNION ALL+      SELECT 'external-nextval'+      FROM target+      WHERE EXISTS (+        SELECT 1+        FROM pg_catalog.pg_attribute AS attributes+        JOIN pg_catalog.pg_attrdef AS defaults+          ON defaults.adrelid = attributes.attrelid+         AND defaults.adnum = attributes.attnum+        WHERE attributes.attrelid = target.oid+          AND attributes.attidentity = ''+          AND pg_get_expr(defaults.adbin, defaults.adrelid) LIKE '%nextval(%'+      )+      UNION ALL+      SELECT 'foreign-keys'+      FROM target+      WHERE EXISTS (+        SELECT 1 FROM pg_catalog.pg_constraint+        WHERE contype = 'f'+          AND (conrelid = target.oid OR confrelid = target.oid)+      )+      UNION ALL+      SELECT 'triggers'+      FROM target+      WHERE EXISTS (+        SELECT 1 FROM pg_catalog.pg_trigger+        WHERE tgrelid = target.oid AND NOT tgisinternal+      )+      UNION ALL+      SELECT 'rules'+      FROM target+      WHERE EXISTS (+        SELECT 1 FROM pg_catalog.pg_rewrite+        WHERE ev_class = target.oid AND rulename <> '_RETURN'+      )+      UNION ALL+      SELECT 'row-level-security'+      FROM target+      WHERE relrowsecurity OR relforcerowsecurity+         OR EXISTS (SELECT 1 FROM pg_catalog.pg_policy WHERE polrelid = target.oid)+      UNION ALL+      SELECT 'partitioning'+      FROM target+      WHERE relkind = 'p'+         OR EXISTS (+              SELECT 1 FROM pg_catalog.pg_inherits+              WHERE inhrelid = target.oid OR inhparent = target.oid+            )+      UNION ALL+      SELECT 'publication'+      FROM target+      WHERE EXISTS (+        SELECT 1 FROM pg_catalog.pg_publication_rel WHERE prrelid = target.oid+      )+      UNION ALL+      SELECT 'non-default-owner-or-acl'+      FROM target+      WHERE relowner <> (SELECT usesysid FROM pg_catalog.pg_user WHERE usename = current_user)+         OR relacl IS NOT NULL+         OR EXISTS (+              SELECT 1 FROM pg_catalog.pg_attribute+              WHERE attrelid = target.oid AND attacl IS NOT NULL+            )+      UNION ALL+      SELECT 'non-default-replica-identity'+      FROM target WHERE relreplident <> 'd'+      UNION ALL+      SELECT 'dependent-view'+      FROM target+      WHERE EXISTS (+        SELECT 1+        FROM information_schema.view_table_usage+        WHERE table_schema = $1 AND table_name = $2+      )+      UNION ALL+      SELECT 'dependent-function'+      FROM target+      WHERE EXISTS (+        SELECT 1+        FROM pg_catalog.pg_depend+        WHERE refclassid = 'pg_class'::regclass+          AND refobjid = target.oid+          AND classid = 'pg_proc'::regclass+      )+    )+    SELECT DISTINCT feature FROM findings ORDER BY feature+    """+    ( contramap+        (\table -> (table ^. #schemaName, table ^. #tableName))+        (contrazip2 textParam textParam)+    )+    (D.rowList (D.column (D.nonNullable D.text)))++resolveClonedPromotionObjectStmt ::+  Statement (QualifiedTable, QualifiedTable, Text, Text) (Maybe Text)+resolveClonedPromotionObjectStmt =+  preparable+    """+    WITH source_table AS (+      SELECT to_regclass(format('%I.%I', $1, $2)) AS oid+    ), staging_table AS (+      SELECT to_regclass(format('%I.%I', $3, $4)) AS oid+    ), source_index AS (+      SELECT indexes.*+      FROM pg_catalog.pg_index AS indexes+      JOIN pg_catalog.pg_class AS index_classes ON index_classes.oid = indexes.indexrelid+      JOIN pg_catalog.pg_namespace AS index_namespaces ON index_namespaces.oid = index_classes.relnamespace+      WHERE indexes.indrelid = (SELECT oid FROM source_table)+        AND index_namespaces.nspname = $1 AND index_classes.relname = $6+    ), index_match(name) AS (+      SELECT candidate_classes.relname+      FROM source_index+      JOIN pg_catalog.pg_index AS candidate+        ON candidate.indrelid = (SELECT oid FROM staging_table)+       AND candidate.indisunique = source_index.indisunique+       AND candidate.indisprimary = source_index.indisprimary+       AND candidate.indisexclusion = source_index.indisexclusion+       AND candidate.indkey = source_index.indkey+       AND candidate.indcollation = source_index.indcollation+       AND candidate.indclass = source_index.indclass+       AND candidate.indoption = source_index.indoption+       AND pg_get_expr(candidate.indexprs, candidate.indrelid)+             IS NOT DISTINCT FROM pg_get_expr(source_index.indexprs, source_index.indrelid)+       AND pg_get_expr(candidate.indpred, candidate.indrelid)+             IS NOT DISTINCT FROM pg_get_expr(source_index.indpred, source_index.indrelid)+      JOIN pg_catalog.pg_class AS candidate_classes+        ON candidate_classes.oid = candidate.indexrelid+      WHERE $5 = 'index'+    ), source_constraint AS (+      SELECT constraints.*+      FROM pg_catalog.pg_constraint AS constraints+      WHERE constraints.conrelid = (SELECT oid FROM source_table)+        AND constraints.conname = $6+    ), constraint_match(name) AS (+      SELECT candidate.conname+      FROM source_constraint+      JOIN pg_catalog.pg_constraint AS candidate+        ON candidate.conrelid = (SELECT oid FROM staging_table)+       AND candidate.contype = source_constraint.contype+       AND candidate.conkey IS NOT DISTINCT FROM source_constraint.conkey+       AND pg_get_constraintdef(candidate.oid, FALSE)+             IS NOT DISTINCT FROM pg_get_constraintdef(source_constraint.oid, FALSE)+      WHERE $5 = 'constraint'+    ), source_sequence_column AS (+      SELECT dependencies.refobjsubid+      FROM pg_catalog.pg_class AS sequences+      JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.oid = sequences.relnamespace+      JOIN pg_catalog.pg_depend AS dependencies+        ON dependencies.classid = 'pg_class'::regclass+       AND dependencies.objid = sequences.oid+       AND dependencies.refclassid = 'pg_class'::regclass+       AND dependencies.refobjid = (SELECT oid FROM source_table)+       AND dependencies.deptype IN ('a', 'i')+      WHERE namespaces.nspname = $1 AND sequences.relname = $6+    ), sequence_match(name) AS (+      SELECT sequences.relname+      FROM source_sequence_column+      JOIN pg_catalog.pg_depend AS dependencies+        ON dependencies.refclassid = 'pg_class'::regclass+       AND dependencies.refobjid = (SELECT oid FROM staging_table)+       AND dependencies.refobjsubid = source_sequence_column.refobjsubid+       AND dependencies.classid = 'pg_class'::regclass+       AND dependencies.deptype IN ('a', 'i')+      JOIN pg_catalog.pg_class AS sequences ON sequences.oid = dependencies.objid+      WHERE $5 = 'owned-sequence' AND sequences.relkind = 'S'+    ), matches AS (+      SELECT name FROM index_match+      UNION ALL SELECT name FROM constraint_match+      UNION ALL SELECT name FROM sequence_match+    )+    SELECT CASE WHEN count(*) = 1 THEN min(name) END FROM matches+    """+    ( contramap+        ( \(serving, staging, kind, canonicalName) ->+            ( serving ^. #schemaName,+              serving ^. #tableName,+              staging ^. #schemaName,+              staging ^. #tableName,+              kind,+              canonicalName+            )+        )+        (contrazip6 textParam textParam textParam textParam textParam textParam)+    )+    (D.singleRow (D.column (D.nullable D.text)))++insertGenerationStmt :: Statement InsertGeneration ()+insertGenerationStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_target_generations+      (generation_id, group_id, target_id, revision_id, schema_name,+       relation_name, relation_oid, schema_version, expected_shape_id,+       observed_shape_fingerprint, observed_catalog_snapshot,+       created_by_run_id, lifecycle, served_at)+    VALUES+      ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,+       CASE WHEN $13 = 'serving' THEN now() ELSE NULL END)+    """+    insertGenerationEncoder+    D.noResult++insertGenerationEncoder :: E.Params InsertGeneration+insertGenerationEncoder =+  contramap+    ( \value ->+        ( ( value ^. #generationUuid,+            value ^. #generationGroup,+            value ^. #generationTarget,+            value ^. #generationRevision,+            value ^. #generationSchema,+            value ^. #generationRelation,+            value ^. #generationOid+          ),+          ( value ^. #generationSchemaVersion,+            value ^. #generationExpectedShape,+            value ^. #generationObservedShape,+            value ^. #generationSnapshot,+            value ^. #generationRun,+            value ^. #generationLifecycle+          )+        )+    )+    ( contrazip2+        (contrazip7 uuidParam textParam textParam textParam textParam textParam int8Param)+        (contrazip6 textParam textParam textParam textParam nullableTextParam textParam)+    )++insertRunTargetStmt :: Statement (Text, Text, Text, UUID) ()+insertRunTargetStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_run_targets+      (run_id, target_id, target_mode, candidate_generation_id)+    VALUES ($1, $2, $3, $4)+    """+    (contrazip4 textParam textParam textParam uuidParam)+    D.noResult++insertPromotionObjectStmt :: Statement (Text, Text, Int32, Text, Text, Text) ()+insertPromotionObjectStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_promotion_objects+      (run_id, target_id, object_order, object_kind, generation_name, canonical_name)+    VALUES ($1, $2, $3, $4, $5, $6)+    """+    (contrazip6 textParam textParam int4Param textParam textParam textParam)+    D.noResult++loadCandidateGenerationsStmt :: Statement Text [VersionedTargetGeneration]+loadCandidateGenerationsStmt =+  preparable+    """+    SELECT generations.generation_id, generations.group_id, generations.target_id,+           generations.revision_id, generations.schema_name,+           generations.relation_name, generations.relation_oid,+           generations.schema_version, generations.expected_shape_id,+           generations.observed_shape_fingerprint, generations.lifecycle+    FROM keiro.keiro_projection_rebuild_run_targets AS targets+    JOIN keiro.keiro_projection_target_generations AS generations+      ON generations.generation_id = targets.candidate_generation_id+    WHERE targets.run_id = $1+    ORDER BY targets.target_id+    """+    textParam+    (D.rowList generationDecoder)++loadServingGenerationsStmt :: Statement Text [VersionedTargetGeneration]+loadServingGenerationsStmt =+  preparable+    """+    SELECT generation_id, group_id, target_id, revision_id, schema_name, relation_name,+           relation_oid, schema_version, expected_shape_id,+           observed_shape_fingerprint, lifecycle+    FROM keiro.keiro_projection_target_generations+    WHERE group_id = $1 AND lifecycle = 'serving'+    ORDER BY target_id+    """+    textParam+    (D.rowList generationDecoder)++loadRetiredGenerationsStmt :: Statement () [VersionedTargetGeneration]+loadRetiredGenerationsStmt =+  preparable+    """+    SELECT generation_id, group_id, target_id, revision_id, schema_name,+           relation_name, relation_oid, schema_version, expected_shape_id,+           observed_shape_fingerprint, lifecycle+    FROM keiro.keiro_projection_target_generations+    WHERE lifecycle = 'retired'+    ORDER BY group_id, target_id, generation_id+    """+    E.noParams+    (D.rowList generationDecoder)++loadGenerationStmt :: Statement UUID (Maybe VersionedTargetGeneration)+loadGenerationStmt =+  preparable+    """+    SELECT generation_id, group_id, target_id, revision_id, schema_name,+           relation_name, relation_oid, schema_version, expected_shape_id,+           observed_shape_fingerprint, lifecycle+    FROM keiro.keiro_projection_target_generations+    WHERE generation_id = $1+    """+    uuidParam+    (D.rowMaybe generationDecoder)++activeVersionedGroupRunStmt :: Statement Text (Maybe Text)+activeVersionedGroupRunStmt =+  preparable+    """+    SELECT active_run_id+    FROM keiro.keiro_projection_rebuild_groups+    WHERE group_id = $1+    """+    textParam+    (join <$> D.rowMaybe (D.column (D.nullable D.text)))++retiredGenerationDependenciesStmt :: Statement Int64 [Text]+retiredGenerationDependenciesStmt =+  preparable+    """+    SELECT DISTINCT pg_describe_object(dependencies.classid,+                                       dependencies.objid,+                                       dependencies.objsubid)+    FROM pg_catalog.pg_depend AS dependencies+    WHERE dependencies.refclassid = 'pg_class'::regclass+      AND dependencies.refobjid = ($1::bigint)::oid+      AND dependencies.deptype = 'n'+    ORDER BY 1+    """+    int8Param+    (D.rowList (D.column (D.nonNullable D.text)))++markRetiredGenerationDroppedStmt :: Statement UUID Bool+markRetiredGenerationDroppedStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_target_generations+    SET lifecycle = 'dropped', dropped_at = now()+    WHERE generation_id = $1 AND lifecycle = 'retired'+    RETURNING TRUE+    """+    uuidParam+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++loadVersionedSourcesStmt :: Statement Text [VersionedSourceProgress]+loadVersionedSourcesStmt =+  preparable+    """+    SELECT source_id, source_scope, category, cursor_position,+           target_position, exhausted_through, event_count+    FROM keiro.keiro_projection_rebuild_sources+    WHERE run_id = $1+    ORDER BY source_id+    """+    textParam+    (D.rowList versionedSourceDecoder)++versionedSourceDecoder :: D.Row VersionedSourceProgress+versionedSourceDecoder =+  build+    <$> (parseSourceId <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nullable D.text)+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))+    <*> D.column (D.nonNullable D.int8)+  where+    build sourceId scope category cursorPosition targetPosition exhaustedThrough eventCount =+      VersionedSourceProgress+        { sourceId,+          sourceScope = parseSourceScope scope category,+          cursorPosition,+          targetPosition,+          exhaustedThrough,+          eventCount+        }++parseSourceId :: Text -> SourceId+parseSourceId value = either (error . show) id (Catalog.mkSourceId value)++parseSourceScope :: Text -> Maybe Text -> SourceScope+parseSourceScope "all" Nothing = AllStreams+parseSourceScope "category" (Just category) = CategorySource (CategoryName category)+parseSourceScope scope category = error ("invalid persisted versioned source scope: " <> show (scope, category))++generationDecoder :: D.Row VersionedTargetGeneration+generationDecoder =+  build+    <$> (TargetGenerationId <$> D.column (D.nonNullable D.uuid))+    <*> (parseGroupId <$> D.column (D.nonNullable D.text))+    <*> (parseTargetId <$> D.column (D.nonNullable D.text))+    <*> (parseRevisionId <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int8)+    <*> (TargetSchemaVersion <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)+    <*> (parseGenerationLifecycle <$> D.column (D.nonNullable D.text))+  where+    build generationId rebuildGroupId targetId revisionId schemaName tableName relationOid schemaVersion expectedShapeId observedShapeFingerprint lifecycle =+      VersionedTargetGeneration+        { generationId,+          rebuildGroupId,+          targetId,+          revisionId,+          physicalTable = QualifiedTable schemaName tableName,+          relationOid,+          schemaVersion,+          expectedShapeId,+          observedShapeFingerprint,+          lifecycle+        }++parseTargetId :: Text -> TargetId+parseTargetId value = either (error . show) id (Catalog.mkTargetId value)++lockVersionedRunForAbandonStmt :: Statement Text (Maybe (PersistedRun, Text, Maybe Text))+lockVersionedRunForAbandonStmt =+  preparable+    """+    SELECT runs.run_id, runs.group_id, runs.catalog_fingerprint,+           runs.group_slice_fingerprint, runs.contract_fingerprint,+           runs.runner_format, runs.captured_head, runs.page_size,+           runs.status, runs.candidate_revision_id,+           runs.cutover_threshold, runs.cutover_lock_timeout_ms,+           runs.promotion_dedup_limit, runs.dedup_provisional_head,+           runs.promotion_prepared_at,+           runs.history_retention_lease_id, runs.history_retention_lease_owner,+           runs.history_retention_protected_through,+           runs.history_retention_expires_at,+           runs.history_retention_renewed_at,+           runs.history_retention_released_at,+           groups.status, groups.active_run_id+    FROM keiro.keiro_projection_rebuild_runs AS runs+    JOIN keiro.keiro_projection_rebuild_groups AS groups+      ON groups.group_id = runs.group_id+    WHERE runs.run_id = $1 AND runs.rebuild_mode = 'versioned'+    FOR UPDATE OF runs, groups+    """+    textParam+    ( D.rowMaybe+        ( (,,)+            <$> persistedRunDecoder+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nullable D.text)+        )+    )++markGenerationsDroppedStmt :: Statement Text ()+markGenerationsDroppedStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_target_generations AS generations+    SET lifecycle = 'dropped', dropped_at = now()+    FROM keiro.keiro_projection_rebuild_run_targets AS targets+    WHERE targets.run_id = $1+      AND targets.candidate_generation_id = generations.generation_id+      AND generations.lifecycle = 'staging'+    """+    textParam+    D.noResult++markVersionedRunAbandonedStmt :: Statement (Text, Maybe UTCTime) Bool+markVersionedRunAbandonedStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET status = 'abandoned',+        abandoned_at = now(),+        failed_at = NULL,+        failure_code = NULL,+        failure_detail = NULL,+        failure_source_id = NULL,+        failure_projection_id = NULL,+        failure_position = NULL,+        history_retention_released_at = COALESCE($2, now()),+        updated_at = now()+    WHERE run_id = $1 AND status IN ('running', 'cutover', 'failed')+      AND rebuild_mode = 'versioned'+    RETURNING TRUE+    """+    (contrazip2 textParam nullableTimestamptzParam)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++restoreVersionedServingGroupStmt :: Statement (Text, Text) Bool+restoreVersionedServingGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET status = 'serving-versioned',+        active_run_id = NULL,+        reads_allowed = TRUE,+        writes_allowed = TRUE,+        completed_at = now(),+        updated_at = now()+    WHERE group_id = $1+      AND active_run_id = $2+      AND status IN ('rebuilding-versioned', 'cutover-versioned', 'failed-versioned')+    RETURNING TRUE+    """+    (contrazip2 textParam textParam)+    (fromMaybe False <$> D.rowMaybe (D.column (D.nonNullable D.bool)))++textParam :: E.Params Text+textParam = E.param (E.nonNullable E.text)++textArrayParam :: E.Params [Text]+textArrayParam = E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text)))++nullableTextParam :: E.Params (Maybe Text)+nullableTextParam = E.param (E.nullable E.text)++int4Param :: E.Params Int32+int4Param = E.param (E.nonNullable E.int4)++int8Param :: E.Params Int64+int8Param = E.param (E.nonNullable E.int8)++int8ArrayParam :: E.Params [Int64]+int8ArrayParam = E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int8)))++uuidParam :: E.Params UUID+uuidParam = E.param (E.nonNullable E.uuid)++uuidArrayParam :: E.Params [UUID]+uuidArrayParam = E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid)))++timestamptzParam :: E.Params UTCTime+timestamptzParam = E.param (E.nonNullable E.timestamptz)++nullableTimestamptzParam :: E.Params (Maybe UTCTime)+nullableTimestamptzParam = E.param (E.nullable E.timestamptz)
src/Keiro/ReadModel/Schema.hs view
@@ -28,7 +28,7 @@   ) where -import Contravariant.Extras (contrazip3, contrazip4)+import Contravariant.Extras (contrazip4, contrazip5) import Effectful (Eff, (:>)) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E@@ -61,6 +61,9 @@   { name :: !Text,     version :: !Int,     shapeHash :: !Text,+    -- | The group whose lifecycle gates this query model. Legacy registration+    -- uses a deterministic singleton group.+    rebuildGroupId :: !Text,     lastBuiltAt :: !(Maybe UTCTime),     status :: !ReadModelStatus   }@@ -75,9 +78,11 @@ -- schema drift. registerReadModel :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata registerReadModel name version shapeHash =-  runTransaction-    $ Tx.statement-      (name, Prelude.fromIntegral version, shapeHash)+  runTransaction $ do+    let groupId = legacyRebuildGroupId name+    Tx.statement groupId ensureLegacyGroupStmt+    Tx.statement+      (name, Prelude.fromIntegral version, shapeHash, groupId)       registerReadModelStmt  -- | Look up a read model's registry row by name, if it exists.@@ -109,25 +114,32 @@ -- 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 =+transitionReadModelTx name version shapeHash status = do+  let groupId = legacyRebuildGroupId name+      (groupStatus, activeRunId, failureDetail) = legacyGroupTransition groupId status   Tx.statement-    (name, Prelude.fromIntegral version, shapeHash, statusToText status)+    (groupId, groupStatus, activeRunId, failureDetail)+    transitionLegacyGroupStmt+  Tx.statement+    (name, Prelude.fromIntegral version, shapeHash, statusToText status, groupId)     transitionReadModelStmt -registerReadModelStmt :: Statement (Text, Int64, Text) ReadModelMetadata+registerReadModelStmt :: Statement (Text, Int64, Text, Text) ReadModelMetadata registerReadModelStmt =   preparable     """-    INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at)-    VALUES ($1, $2, $3, 'live', now())+    INSERT INTO keiro.keiro_read_models+      (name, version, shape_hash, rebuild_group_id, status, last_built_at)+    VALUES ($1, $2, $3, $4, 'live', now())     ON CONFLICT (name) DO UPDATE       SET name = EXCLUDED.name-    RETURNING name, version, shape_hash, last_built_at, status+    RETURNING name, version, shape_hash, rebuild_group_id, last_built_at, status     """-    ( contrazip3+    ( 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 @@ -135,19 +147,20 @@ lookupReadModelStmt =   preparable     """-    SELECT name, version, shape_hash, last_built_at, status+    SELECT name, version, shape_hash, rebuild_group_id, 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 :: Statement (Text, Int64, Text, 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())+    INSERT INTO keiro.keiro_read_models+      (name, version, shape_hash, status, rebuild_group_id, last_built_at, updated_at)+    VALUES ($1, $2, $3, $4, $5, now(), now())     ON CONFLICT (name) DO UPDATE       SET version = EXCLUDED.version,           shape_hash = EXCLUDED.shape_hash,@@ -157,13 +170,14 @@             ELSE keiro_read_models.last_built_at           END,           updated_at = now()-    RETURNING name, version, shape_hash, last_built_at, status+    RETURNING name, version, shape_hash, rebuild_group_id, last_built_at, status     """-    ( contrazip4+    ( contrazip5         (E.param (E.nonNullable E.text))         (E.param (E.nonNullable E.int8))         (E.param (E.nonNullable E.text))         (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))     )     readModelMetadataSingle @@ -177,8 +191,69 @@     <$> D.column (D.nonNullable D.text)     <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.timestamptz)     <*> (statusFromText <$> D.column (D.nonNullable D.text))++ensureLegacyGroupStmt :: Statement Text ()+ensureLegacyGroupStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_groups+      (group_id, slice_fingerprint, status)+    VALUES ($1, '$legacy-unmanaged', 'live')+    ON CONFLICT (group_id) DO NOTHING+    """+    (E.param (E.nonNullable E.text))+    D.noResult++transitionLegacyGroupStmt :: Statement (Text, Text, Maybe Text, Maybe Text) ()+transitionLegacyGroupStmt =+  preparable+    """+    INSERT INTO keiro.keiro_projection_rebuild_groups+      (group_id, slice_fingerprint, status, active_run_id, failure_code, failure_detail,+       reads_allowed, writes_allowed)+    VALUES (+      $1,+      '$legacy-unmanaged',+      $2,+      $3,+      CASE WHEN $2 = 'failed' THEN 'legacy-read-model-state' ELSE NULL END,+      $4,+      $2 = 'live',+      $2 = 'live'+    )+    ON CONFLICT (group_id) DO UPDATE+      SET status = EXCLUDED.status,+          active_run_id = EXCLUDED.active_run_id,+          reads_allowed = EXCLUDED.reads_allowed,+          writes_allowed = EXCLUDED.writes_allowed,+          started_at = CASE WHEN EXCLUDED.status = 'rebuilding' THEN now() ELSE NULL END,+          completed_at = CASE WHEN EXCLUDED.status = 'live' THEN now() ELSE NULL END,+          failed_at = CASE WHEN EXCLUDED.status = 'failed' THEN now() ELSE NULL END,+          failure_code = EXCLUDED.failure_code,+          failure_detail = EXCLUDED.failure_detail,+          updated_at = now()+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.text))+    )+    D.noResult++legacyRebuildGroupId :: Text -> Text+legacyRebuildGroupId name = "$legacy-read-model:" <> name++legacyGroupTransition :: Text -> ReadModelStatus -> (Text, Maybe Text, Maybe Text)+legacyGroupTransition groupId = \case+  Live -> ("live", Nothing, Nothing)+  Rebuilding -> ("rebuilding", Just groupId, Nothing)+  Paused -> ("failed", Just groupId, Just "paused")+  Abandoned -> ("failed", Just groupId, Just "abandoned")+  UnknownStatus raw -> ("failed", Just groupId, Just raw)  statusToText :: ReadModelStatus -> Text statusToText = \case
src/Keiro/Router.hs view
@@ -30,6 +30,11 @@   ( -- * Definition     Router (..),     RouterResult (..),+    DeclarativeRouter (..),+    DeclarativeRouterResult (..),+    DomainRouter (..),+    DomainRouterResult (..),+    module Keiro.Router.Selection,      -- * Idempotency     deterministicRouterCommandId,@@ -38,12 +43,19 @@     runRouterOnce,     runRouterWorkerWith,     runRouterWorker,+    runDeclarativeRouterOnce,+    runDeclarativeRouterWorkerWith,+    runDeclarativeRouterWorker,+    runDomainRouterOnce,+    runDomainRouterWorkerWith,+    runDomainRouterWorker,   ) where  import Data.ByteString qualified as ByteString import Data.ByteString.Char8 qualified as ByteString.Char8 import Data.Coerce (coerce)+import Data.List.NonEmpty qualified as NonEmpty import Data.Map.Strict qualified as Map import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.Encoding@@ -54,25 +66,29 @@ import Effectful.Error.Static (Error, tryError) import GHC.Stack (HasCallStack) import Keiki.Core (BoolAlg, RegFile)-import Keiro.Command (CommandError (..), RunCommandOptions)+import Keiro.Command (CommandError (..), DomainCommandHandler, RunCommandOptions) import Keiro.DeadLetter (DispatcherKind (..)) import Keiro.EventStream (EventStream) import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated) import Keiro.Prelude import Keiro.ProcessManager   ( DispatchFailure (..),+    DomainDispatchSummary (..),+    DomainPMCommandResult (..),     PMCommand (..),     PMCommandResult (..),     PoisonPolicy (..),     WorkerOptions (..),     ackForCommandError,-    confirmBenignDuplicate,+    ackForDomainSummary,     decideForFailures,     defaultWorkerOptions,-    deterministicCommandId,-    eventAlreadyIn,+    dispatchDeduplicatedCommand,+    legacyDeterministicCommandId,+    summarizeDomainCommandResult,   )-import Keiro.Projection (InlineProjection, runCommandWithProjections)+import Keiro.Projection (InlineProjection, runCommandWithProjections, runDomainCommandWithProjections)+import Keiro.Router.Selection import Keiro.Stream (Stream) import Keiro.Telemetry (recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison) import Kiroku.Store.Effect (Store)@@ -80,13 +96,13 @@ import Kiroku.Store.Error (StoreError (..)) import Kiroku.Store.Types (EventId (..), RecordedEvent, StreamName (..)) import Shibuya.Adapter (Adapter (..))-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..))+import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), renderDeadLetterReason) 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 (filter, fromIntegral, length, snd, zip, (+))+import Prelude (fromIntegral, length, reverse, seq, 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@@ -130,6 +146,28 @@   }   deriving stock (Generic) +-- | A checked, bounded router whose application-owned query seam returns+-- commands under a closed declarative selection contract.+data DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es = DeclarativeRouter+  { name :: !Text,+    key :: !(input -> Text),+    selectionContract :: !RouterSelectionContract,+    select :: !(input -> Eff es (Either RouterSelectionFailure [PMCommand targetCi])),+    targetEventStream :: !(ValidatedEventStream targetPhi targetRs targetState targetCi targetCo),+    targetProjections :: !(Stream targetCi -> [InlineProjection targetCo])+  }+  deriving stock (Generic)++-- | Stateless router whose target aggregate returns typed domain decisions.+data DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es = DomainRouter+  { name :: !Text,+    key :: !(input -> Text),+    resolve :: !(input -> Eff es [PMCommand targetCi]),+    targetHandler :: !(DomainCommandHandler targetPhi targetRs targetState targetCi targetCo rejection noOp),+    targetProjections :: !(Stream targetCi -> [InlineProjection targetCo])+  }+  deriving stock (Generic)+ -- | The outcome of a single 'runRouterOnce' invocation: one -- 'PMCommandResult' per resolved target, in resolution order. --@@ -141,6 +179,23 @@   }   deriving stock (Generic, Eq, Show) +-- | Pre-dispatch selection outcomes stay distinct from target dispatch+-- outcomes so worker policy cannot accidentally acknowledge a failed query or+-- evaluation as an empty successful query.+data DeclarativeRouterResult target+  = DeclarativeSelectionFailed !RouterSelectionFailure+  | DeclarativeSelectionEmpty+  | DeclarativeSelectionDispatched !(RouterResult target)+  deriving stock (Generic, Eq, Show)++-- | Detailed result of one domain router invocation. Accepted handled entries+-- retain their event batches. Worker entry points use a strict payload-free+-- summary instead of constructing this list.+newtype DomainRouterResult target co rejection noOp = DomainRouterResult+  { commandResults :: [DomainPMCommandResult target co rejection noOp]+  }+  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.@@ -182,6 +237,32 @@               bytes             ] +routerCommandIdProbes ::+  Text ->+  Text ->+  EventId ->+  Int ->+  StreamName ->+  Int ->+  NonEmpty EventId+routerCommandIdProbes routerName correlationId sourceEventId legacyIndex targetStreamName occurrence =+  deterministicRouterCommandId routerName correlationId sourceEventId targetStreamName occurrence+    :| [legacyDeterministicCommandId routerName correlationId sourceEventId legacyIndex]++annotateRouterOccurrences ::+  (PMCommand targetCi -> StreamName) ->+  [PMCommand targetCi] ->+  [(Int, Int, StreamName, PMCommand targetCi)]+annotateRouterOccurrences streamNameOf commands =+  snd (mapAccumL occurrenceStep Map.empty (zip [0 ..] commands))+  where+    occurrenceStep seen (legacyIndex, command) =+      let targetStreamName = streamNameOf command+          occurrence = Map.findWithDefault 0 targetStreamName seen+       in ( Map.insert targetStreamName (occurrence + 1) seen,+            (legacyIndex, occurrence, targetStreamName, command)+          )+ -- | Resolve the targets for one source event, then dispatch one command per -- target with crash-safe, target-identity idempotency. --@@ -211,75 +292,312 @@   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)-            )+  dispatchRouterCommands+    options+    (router ^. #name)+    (router ^. #targetEventStream)+    (router ^. #targetProjections)+    ((router ^. #key) input)+    (sourceEvent ^. #eventId)+    commands++dispatchRouterCommands ::+  forall targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  Text ->+  ValidatedEventStream targetPhi targetRs targetState targetCi targetCo ->+  (Stream targetCi -> [InlineProjection targetCo]) ->+  Text ->+  EventId ->+  [PMCommand targetCi] ->+  Eff es (RouterResult (EventStream targetPhi targetRs targetState targetCi targetCo))+dispatchRouterCommands options routerName targetEventStream targetProjections correlationId sourceEventId commands = do   results <-     traverse-      (dispatchCommand correlationId (sourceEvent ^. #eventId))-      annotated+      dispatchCommand+      (annotateRouterOccurrences streamNameOf commands)   pure (RouterResult results)   where     streamNameOf command =-      ((unvalidated (router ^. #targetEventStream)) ^. #resolveStreamName)+      ((unvalidated targetEventStream) ^. #resolveStreamName)         (retarget (command ^. #target)) -    dispatchCommand correlationId sourceEventId (legacyIndex, occurrence, targetStreamName, command) = do-      let commandId =-            deterministicRouterCommandId-              (router ^. #name)+    dispatchCommand (legacyIndex, occurrence, targetStreamName, command) = do+      let commandProbes =+            routerCommandIdProbes+              routerName               correlationId               sourceEventId+              legacyIndex               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+          commandId = NonEmpty.head commandProbes           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-      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+      dispatchDeduplicatedCommand+        options+        targetStreamName+        commandProbes+        PMCommandDuplicate+        (PMCommandFailed targetStreamName)+        PMCommandAppended+        ( runCommandWithProjections+            targetOptions+            targetEventStream+            targetStream+            (command ^. #command)+            (targetProjections (command ^. #target))+        )      retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)     retarget = coerce +-- | Evaluate and normalize one declarative selection before performing any+-- target write. Conflict and overflow therefore have an all-or-nothing+-- pre-dispatch boundary; target dispatch itself retains legacy per-target+-- idempotency and partial-success recovery.+runDeclarativeRouterOnce ::+  forall input targetPhi targetRs targetState targetCi targetCo es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCi,+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es ->+  RecordedEvent ->+  input ->+  Eff es (DeclarativeRouterResult (EventStream targetPhi targetRs targetState targetCi targetCo))+runDeclarativeRouterOnce options router sourceEvent input = do+  selected <- (router ^. #select) input+  case selected >>= normalizeRecipients ((router ^. #selectionContract) ^. #limit) of+    Left failure -> pure (DeclarativeSelectionFailed failure)+    Right [] -> pure DeclarativeSelectionEmpty+    Right commands ->+      DeclarativeSelectionDispatched+        <$> dispatchRouterCommands+          options+          (router ^. #name)+          (router ^. #targetEventStream)+          (router ^. #targetProjections)+          ((router ^. #key) input)+          (sourceEvent ^. #eventId)+          commands++-- | Detailed domain-aware router invocation. Typed rejection/no-op is handled,+-- accepted carries the exact event batch, and deterministic accepted+-- redelivery is a distinct duplicate because the original batch cannot be+-- reconstructed from its event id.+runDomainRouterOnce ::+  forall input targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es ->+  RecordedEvent ->+  input ->+  Eff es (DomainRouterResult (EventStream targetPhi targetRs targetState targetCi targetCo) targetCo rejection noOp)+runDomainRouterOnce options router sourceEvent input = do+  annotated <- resolveDomainRouterCommands router input+  results <-+    traverse+      (dispatchDomainRouterCommand options router ((router ^. #key) input) (sourceEvent ^. #eventId))+      annotated+  pure (DomainRouterResult results)++resolveDomainRouterCommands ::+  forall input targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es ->+  input ->+  Eff es [(Int, Int, StreamName, PMCommand targetCi)]+resolveDomainRouterCommands router input = do+  commands <- (router ^. #resolve) input+  pure (annotateRouterOccurrences streamNameOf commands)+  where+    streamNameOf command =+      let targetEventStream = (router ^. #targetHandler) ^. #eventStream+       in ((unvalidated targetEventStream) ^. #resolveStreamName)+            (retarget (command ^. #target))++    retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)+    retarget = coerce++dispatchDomainRouterCommand ::+  forall input targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es ->+  Text ->+  EventId ->+  (Int, Int, StreamName, PMCommand targetCi) ->+  Eff es (DomainPMCommandResult (EventStream targetPhi targetRs targetState targetCi targetCo) targetCo rejection noOp)+dispatchDomainRouterCommand options router correlationId sourceEventId (legacyIndex, occurrence, targetStreamName, command) = do+  let commandProbes =+        routerCommandIdProbes+          (router ^. #name)+          correlationId+          sourceEventId+          legacyIndex+          targetStreamName+          occurrence+      commandId = NonEmpty.head commandProbes+      targetOptions = options & #eventIds .~ [commandId]+      handler = router ^. #targetHandler+      targetStream = retarget (command ^. #target)+  dispatchDeduplicatedCommand+    options+    targetStreamName+    commandProbes+    DomainPMCommandDuplicate+    (DomainPMCommandFailed targetStreamName)+    DomainPMCommandHandled+    ( runDomainCommandWithProjections+        targetOptions+        handler+        targetStream+        (command ^. #command)+        ((router ^. #targetProjections) (command ^. #target))+    )+  where+    retarget :: Stream targetCi -> Stream (EventStream targetPhi targetRs targetState targetCi targetCo)+    retarget = coerce++foldDomainRouterSummary ::+  forall input targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es ->+  Text ->+  EventId ->+  [(Int, Int, StreamName, PMCommand targetCi)] ->+  Eff es DomainDispatchSummary+foldDomainRouterSummary options router correlationId sourceEventId = go (DomainDispatchSummary 0 [])+  where+    go summary = \case+      [] -> pure summary {failures = reverse (summary ^. #failures)}+      annotated@(emitIndex, _, _, _) : rest -> do+        result <- dispatchDomainRouterCommand options router correlationId sourceEventId annotated+        let next = summarizeDomainCommandResult emitIndex result summary+        next `seq` go next rest++-- | Domain-aware router worker with default policy. Handled payloads are+-- released target-by-target through a strict summary fold.+runDomainRouterWorker ::+  forall msg input targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runDomainRouterWorker = runDomainRouterWorkerWith defaultWorkerOptions++-- | Configurable domain-aware router worker. Selected rejection/no-op maps to+-- normal handling and bypasses rejection policy; only 'CommandError' failures+-- are summarized for acknowledgement policy.+runDomainRouterWorkerWith ::+  forall msg input targetPhi targetRs targetState targetCi targetCo rejection noOp es.+  ( HasCallStack,+    IOE :> es,+    Store :> es,+    Error StoreError :> es,+    KirokuStoreResource :> es,+    BoolAlg targetPhi (RegFile targetRs, targetCi),+    Eq targetCo+  ) =>+  WorkerOptions es msg ->+  RunCommandOptions ->+  DomainRouter input targetPhi targetRs targetState targetCi targetCo rejection noOp es ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runDomainRouterWorkerWith 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 "domain router worker could not decode message" env+        Just (recorded, input) -> do+          let correlationId = (router ^. #key) input+              attemptCount = envelopeAttemptCount env+          outcome <- tryError @StoreError $ do+            annotated <- resolveDomainRouterCommands router input+            foldDomainRouterSummary options router correlationId (recorded ^. #eventId) annotated+          case outcome of+            Left (_, storeError) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeError))+            Right summary ->+              ackForDomainSummary+                workerOptions+                DispatcherRouter+                (router ^. #name)+                correlationId+                recorded+                attemptCount+                0+                summary+      finalizeAck decision+      pure decision++    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))++    envelopeAttemptCount env =+      case env ^. #attempt of+        Nothing -> 1+        Just (Attempt attempt) -> fromIntegral attempt + 1+ -- | Run a 'Router' as a live subscription over a Shibuya 'Adapter'. -- -- Mirrors 'Keiro.ProcessManager.runProcessManagerWorker': it drains the adapter's@@ -368,36 +686,11 @@             Left (_, storeErr) -> do               recordDispatchFailed (workerOptions ^. #metrics) 1               pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeErr))-            Right (RouterResult results) -> ackDecisionFor recorded correlationId attemptCount results+            Right (RouterResult results) ->+              ackForRouterResults workerOptions (router ^. #name) 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--    commandDuplicateCount :: [PMCommandResult target] -> Int64-    commandDuplicateCount =-      fromIntegral . length . filter isDuplicateResult-      where-        isDuplicateResult = \case-          PMCommandDuplicate {} -> True-          _ -> False-     decideForPoison :: Text -> Envelope msg -> Eff es AckDecision     decideForPoison reason env = do       recordDispatchPoison (workerOptions ^. #metrics) 1@@ -411,6 +704,130 @@           pure (AckDeadLetter (InvalidPayload reason))      envelopeAttemptCount :: Envelope msg -> Int+    envelopeAttemptCount env =+      case env ^. #attempt of+        Nothing -> 1+        Just (Attempt attempt) -> fromIntegral attempt + 1++ackForRouterResults ::+  (IOE :> es, Store :> es) =>+  WorkerOptions es msg ->+  Text ->+  RecordedEvent ->+  Text ->+  Int ->+  [PMCommandResult target] ->+  Eff es AckDecision+ackForRouterResults workerOptions routerName sourceEvent correlationId attemptCount results = do+  let duplicateCount =+        fromIntegral+          ( length+              [ ()+              | PMCommandDuplicate {} <- 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+    routerName+    correlationId+    sourceEvent+    attemptCount+    failures++-- | Declarative router worker using 'defaultWorkerOptions'. Selection+-- empty/failure policies belong to the checked contract; target dispatch+-- failures continue through the ordinary router worker policy.+runDeclarativeRouterWorker ::+  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 targetCi,+    Eq targetCo+  ) =>+  RunCommandOptions ->+  DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runDeclarativeRouterWorker = runDeclarativeRouterWorkerWith defaultWorkerOptions++runDeclarativeRouterWorkerWith ::+  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 targetCi,+    Eq targetCo+  ) =>+  WorkerOptions es msg ->+  RunCommandOptions ->+  DeclarativeRouter input targetPhi targetRs targetState targetCi targetCo es ->+  Adapter es msg ->+  (msg -> Maybe (RecordedEvent, input)) ->+  Eff es ()+runDeclarativeRouterWorkerWith workerOptions options router Adapter {source = adapterSource} decodeMessage =+  Streamly.fold Fold.drain+    $ Streamly.mapM handleIngested adapterSource+  where+    contract = router ^. #selectionContract++    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 "declarative router worker could not decode message" env+        Just (recorded, input) -> do+          let correlationId = (router ^. #key) input+              attemptCount = envelopeAttemptCount env+          outcome <- tryError @StoreError (runDeclarativeRouterOnce options router recorded input)+          case outcome of+            Left (_, storeErr) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              pure (ackForCommandError (workerOptions ^. #transientRetryDelay) (StoreFailed storeErr))+            Right DeclarativeSelectionEmpty -> pure emptyDecision+            Right (DeclarativeSelectionFailed failure) -> do+              recordDispatchFailed (workerOptions ^. #metrics) 1+              pure (failureDecision failure)+            Right (DeclarativeSelectionDispatched (RouterResult results)) ->+              ackForRouterResults workerOptions (router ^. #name) recorded correlationId attemptCount results+      finalizeAck decision+      pure decision++    emptyDecision = case contract ^. #emptyPolicy of+      EmptyAck -> AckOk+      EmptyRetry -> AckRetry (workerOptions ^. #transientRetryDelay)+      EmptyDeadLetter -> AckDeadLetter (emptySelectionDeadLetterReason contract)+      EmptyHalt -> AckHalt (HaltFatal (renderDeadLetterReason (emptySelectionDeadLetterReason contract)))++    failureDecision failure = case contract ^. #failurePolicy of+      FailureRetry -> AckRetry (workerOptions ^. #transientRetryDelay)+      FailureDeadLetter -> AckDeadLetter (selectionFailureDeadLetterReason contract failure)+      FailureHalt -> AckHalt (HaltFatal (renderDeadLetterReason (selectionFailureDeadLetterReason contract failure)))++    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))+     envelopeAttemptCount env =       case env ^. #attempt of         Nothing -> 1
+ src/Keiro/Router/Selection.hs view
@@ -0,0 +1,184 @@+{-# LANGUAGE NoFieldSelectors #-}++-- | Closed runtime semantics for bounded declarative router selection.+module Keiro.Router.Selection+  ( RecipientLimit,+    mkRecipientLimit,+    recipientLimitValue,+    SelectionIdentity (..),+    SelectionVersion,+    mkSelectionVersion,+    selectionVersionValue,+    SelectionFingerprint (..),+    SelectionOrder (..),+    SelectionDedupe (..),+    RedeliveryPolicy (..),+    PartialDispatchPolicy (..),+    EmptySelectionPolicy (..),+    SelectionFailurePolicy (..),+    RouterSelectionContract (..),+    RouterSelectionFailure (..),+    normalizeRecipients,+    emptySelectionDeadLetterReason,+    selectionFailureDeadLetterReason,+  )+where++import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Keiro.Prelude+import Keiro.ProcessManager (PMCommand (..))+import Keiro.Stream (streamName)+import Kiroku.Store.Types (StreamName (..))+import Numeric.Natural (Natural)+import Shibuya.Core.Ack (DeadLetterCode, DeadLetterReason (ApplicationFailure), mkDeadLetterCode)+import Prelude (all, fromIntegral, length)++-- | A positive post-deduplication recipient bound.+newtype RecipientLimit = RecipientLimit Natural+  deriving stock (Generic, Eq, Ord, Show)++mkRecipientLimit :: Natural -> Either RouterSelectionFailure RecipientLimit+mkRecipientLimit 0 = Left (SelectionEvaluationFailed "recipient limit must be positive")+mkRecipientLimit value = Right (RecipientLimit value)++recipientLimitValue :: RecipientLimit -> Natural+recipientLimitValue (RecipientLimit value) = value++-- | Stable operator-facing identity for one declarative selection contract.+newtype SelectionIdentity = SelectionIdentity Text+  deriving stock (Generic, Eq, Ord, Show)++-- | Positive author-controlled semantic edition.+newtype SelectionVersion = SelectionVersion Natural+  deriving stock (Generic, Eq, Ord, Show)++mkSelectionVersion :: Natural -> Either RouterSelectionFailure SelectionVersion+mkSelectionVersion 0 = Left (SelectionEvaluationFailed "selection version must be positive")+mkSelectionVersion value = Right (SelectionVersion value)++selectionVersionValue :: SelectionVersion -> Natural+selectionVersionValue (SelectionVersion value) = value++-- | Lowercase hexadecimal digest of checked selection semantics.+newtype SelectionFingerprint = SelectionFingerprint Text+  deriving stock (Generic, Eq, Ord, Show)++data SelectionOrder = OrderByTargetStream+  deriving stock (Generic, Eq, Ord, Show)++data SelectionDedupe = DedupeByTargetStream+  deriving stock (Generic, Eq, Ord, Show)++data RedeliveryPolicy = StableUnion+  deriving stock (Generic, Eq, Ord, Show)++data PartialDispatchPolicy = RetainSuccesses+  deriving stock (Generic, Eq, Ord, Show)++data EmptySelectionPolicy+  = EmptyAck+  | EmptyRetry+  | EmptyDeadLetter+  | EmptyHalt+  deriving stock (Generic, Eq, Ord, Show)++data SelectionFailurePolicy+  = FailureRetry+  | FailureDeadLetter+  | FailureHalt+  deriving stock (Generic, Eq, Ord, Show)++data RouterSelectionContract = RouterSelectionContract+  { identity :: !SelectionIdentity,+    version :: !SelectionVersion,+    fingerprint :: !SelectionFingerprint,+    limit :: !RecipientLimit,+    order :: !SelectionOrder,+    dedupe :: !SelectionDedupe,+    emptyPolicy :: !EmptySelectionPolicy,+    failurePolicy :: !SelectionFailurePolicy,+    redeliveryPolicy :: !RedeliveryPolicy,+    partialPolicy :: !PartialDispatchPolicy+  }+  deriving stock (Generic, Eq, Show)++data RouterSelectionFailure+  = SelectionQueryFailed !Text+  | SelectionEvaluationFailed !Text+  | SelectionConflictingCommands !StreamName+  | SelectionRecipientOverflow !RecipientLimit !Natural+  deriving stock (Generic, Eq, Show)++-- | Sort by physical target stream, collapse exact duplicates, reject unequal+-- commands for one target, and apply the positive cap after deduplication.+normalizeRecipients :: (Eq targetCi) => RecipientLimit -> [PMCommand targetCi] -> Either RouterSelectionFailure [PMCommand targetCi]+normalizeRecipients recipientLimit commands = do+  normalized <- traverse normalizeTarget (Map.toAscList commandsByTarget)+  let actual = fromIntegral (length normalized)+  if actual <= recipientLimitValue recipientLimit+    then Right normalized+    else Left (SelectionRecipientOverflow recipientLimit actual)+  where+    commandsByTarget =+      Map.fromListWith+        (<>)+        [ (streamName (command ^. #target), command :| [])+        | command <- commands+        ]++    normalizeTarget (targetName, first :| rest)+      | all (== first) rest = Right first+      | otherwise = Left (SelectionConflictingCommands targetName)++emptySelectionDeadLetterReason :: RouterSelectionContract -> DeadLetterReason+emptySelectionDeadLetterReason contract =+  ApplicationFailure emptySelectionCode (contractDetail contract <> " returned no recipients")++selectionFailureDeadLetterReason :: RouterSelectionContract -> RouterSelectionFailure -> DeadLetterReason+selectionFailureDeadLetterReason contract failure =+  ApplicationFailure code detail+  where+    prefix = contractDetail contract+    (code, detail) = case failure of+      SelectionQueryFailed _ -> (queryFailedCode, prefix <> " query failed")+      SelectionEvaluationFailed _ -> (evaluationFailedCode, prefix <> " evaluation failed")+      SelectionConflictingCommands (StreamName targetName) ->+        (targetConflictCode, prefix <> " produced conflicting commands for target " <> Text.take 128 targetName)+      SelectionRecipientOverflow recipientLimit actual ->+        ( recipientOverflowCode,+          prefix+            <> " selected "+            <> Text.pack (show actual)+            <> " recipients after deduplication; limit is "+            <> Text.pack (show (recipientLimitValue recipientLimit))+        )++contractDetail :: RouterSelectionContract -> Text+contractDetail contract =+  "selection "+    <> Text.take 128 identityText+    <> " version "+    <> Text.pack (show (selectionVersionValue (contract ^. #version)))+  where+    SelectionIdentity identityText = contract ^. #identity++emptySelectionCode :: DeadLetterCode+emptySelectionCode = staticDeadLetterCode "keiro.router.selection.empty"++queryFailedCode :: DeadLetterCode+queryFailedCode = staticDeadLetterCode "keiro.router.selection.query_failed"++evaluationFailedCode :: DeadLetterCode+evaluationFailedCode = staticDeadLetterCode "keiro.router.selection.evaluation_failed"++targetConflictCode :: DeadLetterCode+targetConflictCode = staticDeadLetterCode "keiro.router.selection.target_conflict"++recipientOverflowCode :: DeadLetterCode+recipientOverflowCode = staticDeadLetterCode "keiro.router.selection.recipient_overflow"++staticDeadLetterCode :: Text -> DeadLetterCode+staticDeadLetterCode value = case mkDeadLetterCode value of+  Right code -> code+  Left err -> error ("Keiro.Router.Selection: invalid static dead-letter code: " <> Text.unpack err)
src/Keiro/Snapshot/Schema.hs view
@@ -25,6 +25,8 @@      -- * Storage     lookupSnapshot,+    lookupSnapshotRow,+    deleteSnapshotRow,     writeSnapshotRow,   ) where@@ -85,6 +87,30 @@       (streamIdToInt streamId, Prelude.fromIntegral version, shapeHash, stateShapeHash)       lookupSnapshotStmt +-- | Fetch the advisory snapshot row for a stream without applying codec+-- compatibility filtering. This is the operator-inspection surface; runtime+-- hydration must continue to use 'lookupSnapshot'.+lookupSnapshotRow ::+  (Store :> es) =>+  StreamId ->+  Eff es (Maybe SnapshotRow)+lookupSnapshotRow streamId =+  runTransaction+    $ Tx.statement+      (streamIdToInt streamId)+      lookupSnapshotRowStmt++-- | Delete a stream's advisory snapshot. Returns 'True' when a row existed.+-- Event history is untouched, so the next hydration falls back to full replay.+deleteSnapshotRow ::+  (Store :> es) =>+  StreamId ->+  Eff es Bool+deleteSnapshotRow streamId =+  (> 0)+    <$> runTransaction+      (Tx.statement (streamIdToInt streamId) deleteSnapshotRowStmt)+ -- | 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@@ -118,6 +144,28 @@         (E.param (E.nonNullable E.text))     )     (D.rowMaybe snapshotRowDecoder)++lookupSnapshotRowStmt :: Statement Int64 (Maybe SnapshotRow)+lookupSnapshotRowStmt =+  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+    LIMIT 1+    """+    (E.param (E.nonNullable E.int8))+    (D.rowMaybe snapshotRowDecoder)++deleteSnapshotRowStmt :: Statement Int64 Int64+deleteSnapshotRowStmt =+  preparable+    """+    DELETE FROM keiro.keiro_snapshots+    WHERE stream_id = $1+    """+    (E.param (E.nonNullable E.int8))+    D.rowsAffected  writeSnapshotStmt :: Statement (Int64, Int64, Value, Int64, Text, Text) () writeSnapshotStmt =
src/Keiro/Subscription/Shard.hs view
@@ -43,6 +43,8 @@     renewOwnedBuckets,     relinquish,     ownershipSnapshot,+    ownershipSnapshotFor,+    shardCountSnapshot,      -- * Fair-share helper     fairShareTarget,@@ -174,3 +176,18 @@   (Store :> es) => ShardLease -> Eff es [(Int, Maybe WorkerId, Maybe UTCTime)] ownershipSnapshot lease =   runTransaction (listShardOwnership (subscriptionName lease))++-- | Read ownership for a subscription without constructing a worker lease.+-- Intended for operator and observability surfaces.+ownershipSnapshotFor ::+  (Store :> es) =>+  SubscriptionName ->+  Eff es [(Int, Maybe WorkerId, Maybe UTCTime)]+ownershipSnapshotFor name = runTransaction (listShardOwnership name)++-- | Read the configured shard-count groups for a subscription.+shardCountSnapshot ::+  (Store :> es) =>+  SubscriptionName ->+  Eff es [(Int, Int)]+shardCountSnapshot name = runTransaction (listShardCounts name)
src/Keiro/Telemetry.hs view
@@ -56,9 +56,12 @@     keiro_retry_attempt,     keiro_events_appended,     keiro_replay_divergence,+    keiro_command_decision,     keiro_workflow_name,     keiro_workflow_id,     keiro_workflow_step,+    CommandDecisionClass (..),+    commandDecisionClassText,      -- * Metrics surface @@ -82,11 +85,20 @@     keiroTimerAttemptsName,     keiroTimerStuckName,     keiroTimerRequeuedName,+    keiroProjectionGlobalPositionDistanceName,     keiroProjectionLagName,     keiroProjectionWaitTimeoutsName,+    keiroProjectionRebuildStartsName,+    keiroProjectionRebuildResumesName,+    keiroProjectionRebuildPagesName,+    keiroProjectionRebuildEventsName,+    keiroProjectionRebuildFailuresName,+    keiroProjectionRebuildPromotionsName,+    keiroProjectionRebuildPageDurationName,     keiroCommandConflictsName,     keiroCommandRetriesName,     keiroCommandDuplicatesName,+    keiroCommandDecisionsName,     keiroSnapshotDecodeFailuresName,     keiroSnapshotEncodeFailuresName,     keiroSnapshotReadHitsName,@@ -125,11 +137,20 @@     recordTimerAttempts,     recordTimerStuck,     recordTimerRequeued,+    recordProjectionGlobalPositionDistance,     recordProjectionLag,     recordProjectionWaitTimeouts,+    recordProjectionRebuildStarts,+    recordProjectionRebuildResumes,+    recordProjectionRebuildPages,+    recordProjectionRebuildEvents,+    recordProjectionRebuildFailures,+    recordProjectionRebuildPromotions,+    recordProjectionRebuildPageDuration,     recordCommandConflicts,     recordCommandRetries,     recordCommandDuplicates,+    recordCommandDecision,     recordSnapshotDecodeFailures,     recordSnapshotEncodeFailures,     recordSnapshotReadHits,@@ -171,7 +192,12 @@ 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+  ( addAttributesFromBuilder,+    defaultAttributeLimits,+    emptyAttributes,+    (.@),+  ) import "hs-opentelemetry-api" OpenTelemetry.Attributes.Key (AttributeKey (..)) import "hs-opentelemetry-api" OpenTelemetry.Context (insertSpan, lookupSpan) import "hs-opentelemetry-api" OpenTelemetry.Context.ThreadLocal@@ -253,6 +279,24 @@ keiro_replay_divergence :: AttributeKey Text keiro_replay_divergence = AttributeKey "keiro.replay.divergence" +-- | Bounded domain-command outcome class. The complete value set is+-- @accepted@, @rejected@, and @no_op@; application payloads never belong here.+keiro_command_decision :: AttributeKey Text+keiro_command_decision = AttributeKey "keiro.command.decision"++-- | Closed, payload-free dimension for domain command telemetry.+data CommandDecisionClass+  = DecisionAccepted+  | DecisionRejected+  | DecisionNoOp+  deriving stock (Generic, Eq, Show)++commandDecisionClassText :: CommandDecisionClass -> Text+commandDecisionClassText = \case+  DecisionAccepted -> "accepted"+  DecisionRejected -> "rejected"+  DecisionNoOp -> "no_op"+ keiro_workflow_name :: AttributeKey Text keiro_workflow_name = AttributeKey "keiro.workflow.name" @@ -574,12 +618,36 @@ keiroTimerRequeuedName :: Text keiroTimerRequeuedName = "keiro.timer.requeued" +keiroProjectionGlobalPositionDistanceName :: Text+keiroProjectionGlobalPositionDistanceName = "keiro.projection.global_position_distance"+ keiroProjectionLagName :: Text keiroProjectionLagName = "keiro.projection.lag"  keiroProjectionWaitTimeoutsName :: Text keiroProjectionWaitTimeoutsName = "keiro.projection.wait.timeouts" +keiroProjectionRebuildStartsName :: Text+keiroProjectionRebuildStartsName = "keiro.projection.rebuild.starts"++keiroProjectionRebuildResumesName :: Text+keiroProjectionRebuildResumesName = "keiro.projection.rebuild.resumes"++keiroProjectionRebuildPagesName :: Text+keiroProjectionRebuildPagesName = "keiro.projection.rebuild.pages"++keiroProjectionRebuildEventsName :: Text+keiroProjectionRebuildEventsName = "keiro.projection.rebuild.events"++keiroProjectionRebuildFailuresName :: Text+keiroProjectionRebuildFailuresName = "keiro.projection.rebuild.failures"++keiroProjectionRebuildPromotionsName :: Text+keiroProjectionRebuildPromotionsName = "keiro.projection.rebuild.promotions"++keiroProjectionRebuildPageDurationName :: Text+keiroProjectionRebuildPageDurationName = "keiro.projection.rebuild.page.duration"+ keiroCommandConflictsName :: Text keiroCommandConflictsName = "keiro.command.conflicts" @@ -589,6 +657,9 @@ keiroCommandDuplicatesName :: Text keiroCommandDuplicatesName = "keiro.command.duplicates" +keiroCommandDecisionsName :: Text+keiroCommandDecisionsName = "keiro.command.decisions"+ keiroSnapshotDecodeFailuresName :: Text keiroSnapshotDecodeFailuresName = "keiro.snapshot.decode.failures" @@ -678,11 +749,20 @@     timerAttempts :: Histogram,     timerStuck :: Gauge Int64,     timerRequeued :: Counter Int64,+    projectionGlobalPositionDistance :: Gauge Int64,     projectionLag :: Gauge Int64,     projectionWaitTimeouts :: Counter Int64,+    projectionRebuildStarts :: Counter Int64,+    projectionRebuildResumes :: Counter Int64,+    projectionRebuildPages :: Counter Int64,+    projectionRebuildEvents :: Counter Int64,+    projectionRebuildFailures :: Counter Int64,+    projectionRebuildPromotions :: Counter Int64,+    projectionRebuildPageDuration :: Histogram,     commandConflicts :: Counter Int64,     commandRetries :: Counter Int64,     commandDuplicates :: Counter Int64,+    commandDecisions :: Counter Int64,     snapshotDecodeFailures :: Counter Int64,     snapshotEncodeFailures :: Counter Int64,     snapshotReadHits :: Counter Int64,@@ -729,11 +809,20 @@   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."+  projectionGlobalPositionDistance' <- gaugeI64 keiroProjectionGlobalPositionDistanceName "{position}" "Global position distance between the captured store position and a projection's slowest durable member checkpoint."+  projectionLag' <- gaugeI64 keiroProjectionLagName "{position}" "Deprecated compatibility gauge for the global position distance between the captured store position and a projection's slowest durable member checkpoint."   projectionWaitTimeouts' <- counterI64 keiroProjectionWaitTimeoutsName "{timeout}" "Position-wait calls that timed out before the projection caught up."+  projectionRebuildStarts' <- counterI64 keiroProjectionRebuildStartsName "{run}" "Catalog projection rebuild runs started."+  projectionRebuildResumes' <- counterI64 keiroProjectionRebuildResumesName "{run}" "Catalog projection rebuild invocations resumed."+  projectionRebuildPages' <- counterI64 keiroProjectionRebuildPagesName "{page}" "Catalog replay chunks committed."+  projectionRebuildEvents' <- counterI64 keiroProjectionRebuildEventsName "{event}" "History events committed through catalog replay chunks."+  projectionRebuildFailures' <- counterI64 keiroProjectionRebuildFailuresName "{failure}" "Catalog replay decode, invariant, or verification failures."+  projectionRebuildPromotions' <- counterI64 keiroProjectionRebuildPromotionsName "{run}" "Catalog rebuild groups promoted after completion proof."+  projectionRebuildPageDuration' <- histogram keiroProjectionRebuildPageDurationName "ms" "Elapsed time to read and commit one replay chunk."   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."+  commandDecisions' <- counterI64 keiroCommandDecisionsName "{decision}" "Successfully selected domain command decisions, partitioned by bounded decision class."   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."@@ -772,11 +861,20 @@         timerAttempts = timerAttempts',         timerStuck = timerStuck',         timerRequeued = timerRequeued',+        projectionGlobalPositionDistance = projectionGlobalPositionDistance',         projectionLag = projectionLag',         projectionWaitTimeouts = projectionWaitTimeouts',+        projectionRebuildStarts = projectionRebuildStarts',+        projectionRebuildResumes = projectionRebuildResumes',+        projectionRebuildPages = projectionRebuildPages',+        projectionRebuildEvents = projectionRebuildEvents',+        projectionRebuildFailures = projectionRebuildFailures',+        projectionRebuildPromotions = projectionRebuildPromotions',+        projectionRebuildPageDuration = projectionRebuildPageDuration',         commandConflicts = commandConflicts',         commandRetries = commandRetries',         commandDuplicates = commandDuplicates',+        commandDecisions = commandDecisions',         snapshotDecodeFailures = snapshotDecodeFailures',         snapshotEncodeFailures = snapshotEncodeFailures',         snapshotReadHits = snapshotReadHits',@@ -873,12 +971,36 @@ recordTimerRequeued :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordTimerRequeued = recordCounter timerRequeued +recordProjectionGlobalPositionDistance :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionGlobalPositionDistance = recordGaugeI64 projectionGlobalPositionDistance+ recordProjectionLag :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordProjectionLag = recordGaugeI64 projectionLag  recordProjectionWaitTimeouts :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordProjectionWaitTimeouts = recordCounter projectionWaitTimeouts +recordProjectionRebuildStarts :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionRebuildStarts = recordCounter projectionRebuildStarts++recordProjectionRebuildResumes :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionRebuildResumes = recordCounter projectionRebuildResumes++recordProjectionRebuildPages :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionRebuildPages = recordCounter projectionRebuildPages++recordProjectionRebuildEvents :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionRebuildEvents = recordCounter projectionRebuildEvents++recordProjectionRebuildFailures :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionRebuildFailures = recordCounter projectionRebuildFailures++recordProjectionRebuildPromotions :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordProjectionRebuildPromotions = recordCounter projectionRebuildPromotions++recordProjectionRebuildPageDuration :: (MonadIO m) => Maybe KeiroMetrics -> Double -> m ()+recordProjectionRebuildPageDuration = recordHistogram projectionRebuildPageDuration+ recordCommandConflicts :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordCommandConflicts = recordCounter commandConflicts @@ -887,6 +1009,18 @@  recordCommandDuplicates :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordCommandDuplicates = recordCounter commandDuplicates++-- | Record one successful domain-command decision using only the bounded+-- @accepted@, @rejected@, or @no_op@ class supplied by 'Keiro.Command'.+recordCommandDecision :: (MonadIO m) => Maybe KeiroMetrics -> CommandDecisionClass -> m ()+recordCommandDecision Nothing _ = pure ()+recordCommandDecision (Just ms) decisionClass =+  liftIO+    ( counterAdd+        (commandDecisions ms)+        1+        (addAttributesFromBuilder defaultAttributeLimits emptyAttributes (keiro_command_decision .@ commandDecisionClassText decisionClass))+    )  recordSnapshotDecodeFailures :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotDecodeFailures = recordCounter snapshotDecodeFailures
src/Keiro/Timer.hs view
@@ -23,6 +23,7 @@     scheduleTimerTx,     scheduleTimerOnceTx,     claimDueTimer,+    lookupTimer,     markTimerFired,     countDueTimers,     countStuckTimers,@@ -43,6 +44,8 @@     mkTimerWorkerOptions,     runTimerWorker,     runTimerWorkerWith,+    drainDueTimers,+    drainDueTimersWith,   ) where @@ -125,18 +128,42 @@   (TimerRow -> Eff es (Maybe EventId)) ->   Eff es (Maybe TimerRow) runTimerWorkerWith metrics options now fire = do+  timerPassPreamble metrics options now+  claimAndFireOne metrics options now fire++-- | The once-per-pass work: requeue stale @Firing@ rows per 'requeueStuckAfter',+-- then record the backlog and stuck gauges. Shared by 'runTimerWorkerWith' and+-- 'drainDueTimersWith' so a batched drain pays for it once rather than once per+-- claimed timer. Each gauge is a no-op under a 'Nothing' handle.+timerPassPreamble ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  TimerWorkerOptions ->+  UTCTime ->+  Eff es ()+timerPassPreamble metrics options now = 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.+  -- The backlog as the worker sees it at the start of the pass (including the+  -- rows it is about to claim), and the number of rows stranded in 'Firing' by+  -- earlier passes that never completed.   backlog <- countDueTimers now   recordTimerBacklog metrics (fromIntegral backlog)   stuck <- countStuckTimers now anyStuckTimer   recordTimerStuck metrics (fromIntegral stuck)++-- | Claim the earliest due timer and either dead-letter or fire it. Returns the+-- row as claimed, or 'Nothing' when nothing is due. The per-claim histograms are+-- recorded here, so they stay one-per-claimed-timer in a batched drain.+claimAndFireOne ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  TimerWorkerOptions ->+  UTCTime ->+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es (Maybe TimerRow)+claimAndFireOne metrics options now fire = do   due <- claimDueTimer now   case due of     Nothing -> pure Nothing@@ -159,6 +186,55 @@           fired <- fire timer           for_ fired (\eventId -> void (markTimerFired (timer ^. #timerId) eventId))           pure (Just timer)++-- | Claim and fire up to @limit@ timers due at @now@ in one pass, returning how+-- many were processed.+--+-- Every per-timer semantic is 'runTimerWorkerWith'’s, unchanged: earliest+-- @fire_at@ first, @FOR UPDATE SKIP LOCKED@ so concurrent workers never claim+-- the same row, the same attempt-ceiling dead-lettering, the same at-least-once+-- contract. What differs is the accounting: the requeue-and-gauge preamble runs+-- once for the whole batch instead of once per timer, so draining a backlog of+-- @K@ due timers costs one preamble rather than @K@ of them.+--+-- The loop stops early when nothing is due, so an idle pass costs exactly what+-- 'runTimerWorkerWith' costs. It also stops when a @fire@ action returns+-- 'Nothing' for every remaining row, because such a timer stays @Firing@ and is+-- no longer claimable — the drain cannot spin. A @limit@ of zero or less+-- processes nothing (but still runs the preamble).+--+-- @now@ is read once by the caller and used for every claim in the batch, so a+-- drain fires exactly the timers that were due at the instant the pass began.+drainDueTimersWith ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  TimerWorkerOptions ->+  UTCTime ->+  -- | Maximum timers to process in this pass.+  Int ->+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es Int+drainDueTimersWith metrics options now limit fire = do+  timerPassPreamble metrics options now+  go 0+  where+    go processed+      | processed >= limit = pure processed+      | otherwise =+          claimAndFireOne metrics options now fire >>= \case+            Nothing -> pure processed+            Just _ -> go (processed + 1)++-- | 'drainDueTimersWith' using 'defaultTimerWorkerOptions'. The batched sibling+-- of 'runTimerWorker'.+drainDueTimers ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  UTCTime ->+  Int ->+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es Int+drainDueTimers metrics = drainDueTimersWith metrics defaultTimerWorkerOptions  -- | Claim and fire at most one timer due at @now@ using -- 'defaultTimerWorkerOptions' (no attempt ceiling). Equivalent to
src/Keiro/Timer/Schema.hs view
@@ -19,6 +19,7 @@     scheduleTimerTx,     scheduleTimerOnceTx,     claimDueTimer,+    lookupTimer,     markTimerFired,      -- * Read-only counts@@ -144,6 +145,15 @@   runTransaction $     Tx.statement now claimDueTimerStmt +-- | Look up one timer by its stable identifier without claiming or mutating it.+--+-- Operational tooling uses this to render an exact preview before invoking one+-- of the guarded lifecycle transitions below.+lookupTimer :: (Store :> es) => TimerId -> Eff es (Maybe TimerRow)+lookupTimer timerId =+  runTransaction $+    Tx.statement (timerIdToUuid timerId) lookupTimerStmt+ -- | 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).@@ -288,6 +298,18 @@       kt.payload, kt.status, kt.attempts, kt.fired_event_id     """     (E.param (E.nonNullable E.timestamptz))+    (D.rowMaybe timerRowDecoder)++lookupTimerStmt :: Statement UUID (Maybe TimerRow)+lookupTimerStmt =+  preparable+    """+    SELECT timer_id, process_manager_name, correlation_id, fire_at,+      payload, status, attempts, fired_event_id+    FROM keiro.keiro_timers+    WHERE timer_id = $1+    """+    (E.param (E.nonNullable E.uuid))     (D.rowMaybe timerRowDecoder)  markTimerFiredStmt :: Statement (UUID, UUID) Bool
src/Keiro/Workflow.hs view
@@ -60,6 +60,53 @@ -- * Discovery (EP-42) is 'findUnfinishedWorkflowIds' plus 'completedStepName'; --   it needs no kiroku prefix subscription. --+-- == Writing a custom wake source+--+-- The three built-in wake sources — sleep timers ("Keiro.Workflow.Sleep"),+-- awakeables ("Keiro.Workflow.Awakeable"), and child workflows+-- ("Keiro.Workflow.Child") — are not privileged. Anything can resolve an+-- 'awaitStep' by appending a 'StepRecorded' under the awaited step name. What+-- makes the built-ins /correct/ is a property the append helper cannot give+-- you, so a source built on 'appendJournalEntry' alone is not safe. Three+-- obligations:+--+-- 1. __Keep a durable row keyed by the logical workflow, not by a generation.__+--    Each built-in has one: the @keiro_timers@ row, the @keiro_awakeables@ row,+--    the @keiro_workflow_children@ row. The row — not the journal — is the+--    authority on whether the wake is pending, resolved, or abandoned, and it+--    must outlive a 'continueAsNew' rotation. See+--    @docs\/adr\/0006-workflow-wake-source-rows-govern-exposure-and-terminal-races.md@.+--+-- 2. __Deliver by appending under the awaited step name, and expect to lose a+--    rotation race.__ 'appendJournalEntry' resolves the current generation with+--    its own query and then appends in a separate transaction. A 'continueAsNew'+--    committing between the two strands your completion on the closed+--    generation, and the step-index fallback deliberately does not let a closed+--    generation resolve a live one+--    (@docs\/adr\/0005-workflow-awaits-fall-back-to-the-step-index-on-replay-misses.md@).+--    An append may also be declined outright as 'JournalRefusedTerminal' when+--    the workflow has gone terminal; that is not an error — settle your own+--    durable row and deliver nothing.+--+-- 3. __Re-check the row from the arm, and re-deliver.__ This is what makes (2)+--    harmless. The arming action runs again on every resume until the awaited+--    result is journaled, so an arm that reads its durable row and re-appends an+--    already-resolved result repairs a stranded delivery onto whatever+--    generation is now current. An arm that only ever /schedules/ leaves the+--    rotation race unrepaired.+--+-- Since exact discovery+-- (@docs\/adr\/0023-workflow-discovery-is-exact-and-the-instance-row-is-the-complete-wake-ledger.md@)+-- there is a fourth obligation, and it is the one that strands a workflow+-- permanently if you skip it: __every lifecycle transition of your durable row+-- must leave the owning @keiro_workflows@ instance row discoverable, in the same+-- transaction that performs the transition.__ Delivering through+-- 'appendJournalEntry' satisfies this for free (the append transaction upserts+-- the row to @running@). A transition that appends nothing — abandoning a+-- pending wake, say, the way 'Keiro.Workflow.Awakeable.cancelAwakeable' does —+-- must write the instance row itself, or the workflow will never be re-examined+-- and will never observe the abandonment.+-- -- > __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@@ -94,7 +141,6 @@     deterministicJournalId,      -- * Errors thrown by the runtime-    WorkflowError (..),     WorkflowLeaseLost (..),      -- * Re-exported core contracts@@ -105,6 +151,14 @@     stepExists,     currentGeneration,     findUnfinishedWorkflowIds,+    WorkflowStatus (..),+    WorkflowInstanceRow (..),+    WorkflowInstanceFilter (..),+    defaultWorkflowInstanceFilter,+    listWorkflowInstances,+    CancelWorkflowOutcome (..),+    cancelWorkflow,+    forceReleaseInstanceLease,     setWorkflowWakeAfterTx,     clearWorkflowWakeAfterTx,   )@@ -121,16 +175,16 @@ import Data.Int (Int32) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map+import Data.Maybe (listToMaybe) 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.Codec (decodeRecorded) import Keiro.EventStream (SnapshotPolicy (..), Terminality (..)) import Keiro.Prelude import Keiro.Snapshot (SnapshotMissReason (..))@@ -149,19 +203,33 @@     withWorkflowSpan,   ) import Keiro.Workflow.Instance-  ( WorkflowStatus (..),-    markInstanceSuspended,+  ( CancelWorkflowOutcome (..),+    WorkflowInstanceFilter (..),+    WorkflowInstanceRow (..),+    WorkflowStatus (..),+    cancelWorkflow,+    defaultWorkflowInstanceFilter,+    forceReleaseInstanceLease,+    listWorkflowInstances,+    markInstanceSuspendedAwaiting,     renewInstanceLease,-    upsertInstanceTx,   )-import Keiro.Workflow.Schema (WorkflowStepRow (..), clearWorkflowWakeAfterTx, currentGeneration, findUnfinishedWorkflowIds, loadStepIndex, lockWorkflowStepTx, lookupStepResult, lookupStepResultTx, recordStepTx, setWorkflowWakeAfterTx, stepExists)+import Keiro.Workflow.Journal+  ( JournalAppendOutcome (..),+    appendJournal,+    appendJournalEntry,+    appendJournalEntryReturningId,+    deterministicJournalId,+    prepareJournalAppend,+  )+import Keiro.Workflow.Schema (WorkflowStepRow (..), clearWorkflowWakeAfterTx, currentGeneration, findUnfinishedWorkflowIds, loadStepIndex, lookupStepResult, recordStepTx, setWorkflowWakeAfterTx, stepExists, terminalMarkers) 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 Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (StreamId, StreamVersion (..)) import Streamly.Data.Fold qualified as Fold import Streamly.Data.Stream qualified as Streamly import System.IO.Unsafe (unsafePerformIO)@@ -255,6 +323,17 @@ -- 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'.+--+-- __Rotating abandons outstanding awakeables.__+-- 'Keiro.Workflow.Awakeable.awakeableNamed' journals a freshly allocated id+-- under an @awkid:\<label\>@ step and then awaits @awk:\<uuid\>@. The next+-- generation's journal has neither step, so the body re-runs the allocation and+-- hands out a __different__ id; a signal against the id you handed out before+-- rotating settles that awakeable's own row and resolves nothing the new+-- generation is waiting for. Re-notify whoever holds the promise from the+-- re-run allocation step — that step is the natural hook, and it runs exactly+-- once per generation. This mirrors 'Keiro.Workflow.Child.spawnChild', where a+-- fresh child after rotation needs a child id derived from the carried seed. continueAsNew :: (Workflow :> es, Aeson.ToJSON s) => s -> Eff es a continueAsNew seed = send (ContinueAsNew seed) @@ -328,7 +407,14 @@     -- | 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)+    leaseHeartbeat :: !(Maybe LeaseHeartbeat),+    -- | When 'Just', invoked for every fresh journal-append boundary this run+    --     commits: an executed step, a patch decision, the patch-set record, the+    --     completion marker, or a generation rotation. Replays, idempotent+    --     re-appends, refused appends, and wake-source writes that touch no+    --     journal do not fire it. The resume worker uses this witness to count+    --     durable movement per candidate. 'Nothing' observes nothing.+    onJournalAppend :: !(Maybe (IO ()))   }   deriving stock (Generic) @@ -343,30 +429,14 @@       metrics = Nothing,       tracer = Nothing,       activePatches = Set.empty,-      leaseHeartbeat = Nothing+      leaseHeartbeat = Nothing,+      onJournalAppend = 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@@ -377,18 +447,42 @@  instance Exception WorkflowLeaseLost --- | Internal sentinel thrown to unwind a suspended run up to 'runWorkflowWith'.-data WorkflowSuspend = WorkflowSuspend+-- | Internal sentinel thrown to unwind a suspended run up to 'runWorkflowWith',+-- carrying the step name the run parked on. The run entry point needs that name+-- to arbitrate its suspended-status write against a wake delivery for the same+-- step (see 'Keiro.Workflow.Instance.markInstanceSuspendedAwaiting').+newtype WorkflowSuspend = WorkflowSuspend Text   deriving stock (Show)  instance Exception WorkflowSuspend +-- | How one interpreted run finished, before its outcome is finalized.+--+-- A suspension is kept distinct from the other outcomes because it carries the+-- awaited step name the suspended-status write needs; every other unwinding+-- already resolves to a 'WorkflowOutcome'.+data RunUnwind a+  = RunOutcome !(WorkflowOutcome a)+  | RunSuspendedOn !Text+ -- | Internal sentinel thrown when a cancellation marker appears mid-run. data WorkflowCancelPending = WorkflowCancelPending   deriving stock (Show)  instance Exception WorkflowCancelPending +-- | Internal sentinel thrown when a terminal failure marker appears mid-run.+--+-- The failure counterpart of 'WorkflowCancelPending'. The resume worker writes+-- 'WorkflowFailed' when a workflow exhausts its crash attempts, and that can+-- land while another runner — typically a direct 'runWorkflow' call, which takes+-- no lease — is between steps. Without this the run would keep executing fresh+-- side effects past its own terminal failure.+data WorkflowFailPending = WorkflowFailPending+  deriving stock (Show)++instance Exception WorkflowFailPending+ -- | 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@@ -456,13 +550,13 @@   -- 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+  -- Terminal short-circuit (EP-43): a workflow whose journal carries a+  -- WorkflowCancelled or WorkflowFailed marker makes no further progress. Both+  -- markers are index rows on the current generation, so one query answers for+  -- both and we never run the user action. Cancellation takes precedence when+  -- somehow both are present.+  markers <- terminalMarkers name wid gen+  case (cancelledStepName `elem` markers, failedStepName `elem` markers) of     (True, _) -> pure Cancelled     (_, True) -> pure Failed     _ -> runActive gen@@ -472,6 +566,7 @@     -- records nothing and opens no span — the no-op idiom holds end to end.     mMetrics = options ^. #metrics     mTracer = options ^. #tracer+    fireAppendWitness = for_ (options ^. #onJournalAppend) liftIO     runActive :: Int -> Eff es (WorkflowOutcome a)     runActive gen =       -- EP-44: maintain the process-wide live-run count and sample the@@ -490,50 +585,71 @@           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)+          unwound <-+            (RunOutcome . Completed <$> runHandler)+              `catch` (\(WorkflowSuspend awaitedStep) -> pure (RunSuspendedOn awaitedStep))+              `catch` (\WorkflowCancelPending -> pure (RunOutcome Cancelled))+              `catch` (\WorkflowFailPending -> pure (RunOutcome Failed))               `catch` ( \(WorkflowRotate seedJson) ->-                          rotateGeneration-                            mMetrics-                            (options ^. #activePatches)-                            name-                            wid-                            gen-                            seedJson+                          RunOutcome+                            <$> rotateGeneration+                              options+                              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+          case unwound of+            -- The run parked on an unresolved await. The status write arbitrates+            -- against a wake delivery for that same step under the append path's+            -- advisory lock, so a wake landing in this gap cannot be masked by a+            -- 'suspended' status that exact discovery would never return.+            RunSuspendedOn awaitedStep -> do+              markInstanceSuspendedAwaiting name wid gen awaitedStep+              pure Suspended+            RunOutcome outcome -> 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 'JournalAlreadyPresent' and no terminal snapshot is+                -- taken (one was already taken on the original completing run, if the+                -- policy fired).+                appendCompletion name wid gen now >>= \case+                  JournalAppended appendResult -> do+                    fireAppendWitness+                    when+                      ( shouldSnapshot+                          (options ^. #snapshotPolicy)+                          Terminal+                          finalMap+                          (appendResult ^. #streamVersion)+                      )+                      (writeWorkflowSnapshotAdvisory mMetrics (appendResult ^. #streamId) (appendResult ^. #streamVersion) finalMap)+                    recordCompletedLength finalMap+                    pure (Completed result)+                  JournalAlreadyPresent {} -> do+                    recordCompletedLength finalMap+                    pure (Completed result)+                  JournalRefusedTerminal marker+                    | marker == cancelledStepName -> pure Cancelled+                    | marker == failedStepName -> pure Failed+                    | marker == continuedAsNewStepName -> pure ContinuedAsNew+                    | otherwise ->+                        throwIO+                          (WorkflowJournalAppendError ("completion refused by terminal marker " <> marker))+                  JournalAppendConflict err ->+                    throwIO (WorkflowJournalAppendError (Text.pack (show err)))+              -- Only 'RunSuspendedOn' produces a suspension, so this arm is+              -- unreachable; it keeps the case total without a partial match.+              Suspended -> 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@@ -546,10 +662,24 @@               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)+                JournalAppended {} -> do+                  fireAppendWitness+                  pure (Map.insert patchSetStepName encoded initial)                 JournalAlreadyPresent stored -> pure (Map.insert patchSetStepName stored initial)+                -- This runs only on a fresh journal, which by definition carries+                -- no terminal marker, so a refusal is an invariant violation+                -- rather than a state the caller should absorb.+                JournalRefusedTerminal marker ->+                  throwIO (WorkflowJournalAppendError ("patch set refused by terminal marker " <> marker))                 JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))             else pure initial+        -- EP-44: one journal-length observation per genuinely completed run,+        -- including an idempotent replay. A run that loses the lifecycle race+        -- to cancellation/failure/rotation reports that winner instead.+        recordCompletedLength finalMap =+          recordWorkflowJournalLength+            mMetrics+            (fromIntegral (Map.size finalMap + 1))     handler ::       Int ->       IORef (Map Text Aeson.Value) ->@@ -566,15 +696,17 @@             decodeStored key stored           Nothing -> do             renewLease-            checkCancellationPending name wid gen+            -- One probe before the side effect; the append transaction re-checks+            -- both markers at commit, so no separate post-action query is needed.+            checkTerminalPending 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.+                fireAppendWitness                 recordWorkflowStepExecuted mMetrics 1                 newMap <-                   liftIO@@ -598,6 +730,11 @@                       (Map.insert key stored m, ())                   )                 decodeStored key stored+              -- The workflow went terminal while @act@ was running: the append+              -- declined, so the step's result is not journaled and the run+              -- unwinds to Cancelled/Failed at this boundary. The action itself+              -- already ran — step side effects are at-least-once at boundaries.+              JournalRefusedTerminal marker -> throwForMarker marker               JournalAppendConflict err ->                 throwIO (WorkflowJournalAppendError (Text.pack (show err)))       Await (StepName key) arm -> do@@ -625,9 +762,9 @@                 decodeStored key stored               Nothing -> do                 renewLease-                checkCancellationPending name wid gen+                checkTerminalPending name wid gen                 localSeqUnlift env (\unlift -> unlift arm)-                throwIO WorkflowSuspend+                throwIO (WorkflowSuspend key)       CurrentWorkflow -> pure (name, wid)       CurrentRunGeneration -> pure gen       FreshOrdinal namespace ->@@ -649,7 +786,7 @@             -- Hit: the decision was made on an earlier run; replay it verbatim.             decodeStored key stored           Nothing -> do-            checkCancellationPending name wid gen+            checkTerminalPending name wid gen             recordedSet <- case Map.lookup patchSetStepName journal of               Nothing -> pure []               Just stored -> decodeStored patchSetStepName stored@@ -659,6 +796,7 @@             appendOutcome <- appendJournal name wid gen (StepRecorded key encoded now)             case appendOutcome of               JournalAppended {} -> do+                fireAppendWitness                 liftIO                   ( atomicModifyIORef' journalRef $ \m ->                       (Map.insert key encoded m, ())@@ -670,6 +808,7 @@                       (Map.insert key stored m, ())                   )                 decodeStored key stored+              JournalRefusedTerminal marker -> throwForMarker marker               JournalAppendConflict err ->                 throwIO (WorkflowJournalAppendError (Text.pack (show err)))       where@@ -689,11 +828,24 @@   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)+-- | Stop the run at this boundary if the workflow has already been cancelled or+-- terminally failed.+--+-- One query for both markers, run /before/ a fresh step action or an unresolved+-- await's arm. The append transaction enforces the same rule again at commit+-- time, so this probe is not what makes the boundary safe — it is what stops the+-- user's side effect from running at all in the common already-terminal case.+checkTerminalPending :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Eff es ()+checkTerminalPending name wid gen =+  terminalMarkers name wid gen >>= traverse_ throwForMarker . listToMaybe +-- | Map a stopping marker's reserved step name to the sentinel that unwinds the+-- run into the matching outcome. Never returns.+throwForMarker :: Text -> Eff es a+throwForMarker marker+  | marker == cancelledStepName = throwIO WorkflowCancelPending+  | otherwise = throwIO WorkflowFailPending+ -- | Pre-load a workflow's journal stream into a @step name -> result@ map. -- -- If a compatible snapshot exists ('loadWorkflowSnapshot'), seed the map from it@@ -745,118 +897,23 @@ -- 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)))+-- | Attempt the completion marker append. The caller maps a lifecycle refusal+-- to the winning workflow outcome instead of reporting a spurious append error.+appendCompletion :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> Int -> UTCTime -> Eff es JournalAppendOutcome+appendCompletion name wid gen now =+  appendJournal name wid gen (WorkflowCompleted now)  -- | 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 old generation's rotation marker, the next generation's seed, and its+-- non-empty patch set are appended in one transaction. The lifecycle marker is+-- attempted first under the shared generation lock, so cancellation/failure+-- either wins without exposing a new generation or loses before the seed makes+-- @currentGeneration@ advance. 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@@ -866,15 +923,16 @@ rotateGeneration ::   forall a es.   (IOE :> es, Store :> es, Error StoreError :> es) =>-  Maybe KeiroMetrics ->-  Set PatchId ->+  WorkflowRunOptions ->   WorkflowName ->   WorkflowId ->   Int ->   Aeson.Value ->   Eff es (WorkflowOutcome a)-rotateGeneration mMetrics patches name wid gen seedJson = do+rotateGeneration options name wid gen seedJson = do   let nextGen = gen + 1+      mMetrics = options ^. #metrics+      patches = options ^. #activePatches       encodedPatches = Aeson.toJSON (map unPatchId (Set.toList patches))       patchEvent =         StepRecorded patchSetStepName encodedPatches@@ -895,58 +953,96 @@             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) <-+  rotationTx <-+    prepareJournalAppend+      name+      wid+      gen+      (WorkflowContinuedAsNew nextGen now)+  -- The CURRENT generation's lifecycle winner and the NEXT generation's seed+  -- are one commit. If another lifecycle marker won, no new-generation row can+  -- leak; if rotation won, a cancellation prepared against the old generation+  -- observes the marker, retries, and follows the now-current generation.+  (rotationOutcome, maybeSeedOutcome, 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)-                ]+      rotationResult <- rotationTx+      let seedNextGeneration = do+            seedResult <- seedTx+            condemnOnConflict seedResult+            patchResult <- traverse id patchTx+            traverse_ condemnOnConflict patchResult+            pure (rotationResult, Just seedResult, patchResult)+      case rotationResult of+        JournalAppended {} -> seedNextGeneration+        JournalAlreadyPresent {} -> seedNextGeneration+        JournalRefusedTerminal {} -> pure (rotationResult, Nothing, Nothing)+        JournalAppendConflict {} -> do+          Tx.condemn+          pure (rotationResult, Nothing, Nothing)+  case rotationOutcome of+    JournalRefusedTerminal marker+      | marker == cancelledStepName -> pure Cancelled+      | marker == failedStepName -> pure Failed+      | otherwise ->+          throwIO (WorkflowJournalAppendError ("rotation refused by terminal marker " <> marker))+    JournalAppendConflict err ->+      throwIO (WorkflowJournalAppendError (Text.pack (show err)))+    _ -> case maybeSeedOutcome of+      Nothing ->+        throwIO (WorkflowJournalAppendError "rotation committed without a next-generation seed outcome")+      Just seedOutcome -> do+        throwOnConflict seedOutcome+        traverse_ throwOnConflict patchOutcome+        when+          ( journalAppended rotationOutcome+              || journalAppended seedOutcome+              || maybe False journalAppended patchOutcome           )-          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+          (for_ (options ^. #onJournalAppend) liftIO)+        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+        pure ContinuedAsNew   where+    -- The seed and patch-set appends target the NEXT generation, which is fresh+    -- and cannot carry a terminal marker, so a refusal there is an invariant+    -- violation and is treated exactly like a conflict rather than absorbed.     condemnOnConflict = \case       JournalAppendConflict {} -> Tx.condemn+      JournalRefusedTerminal {} -> Tx.condemn       _ -> pure ()     throwOnConflict = \case       JournalAppendConflict err ->         throwIO (WorkflowJournalAppendError (Text.pack (show err)))+      JournalRefusedTerminal marker ->+        throwIO (WorkflowJournalAppendError ("rotation seed refused by terminal marker " <> marker))       _ -> pure ()     recordedValue fallback = \case       JournalAlreadyPresent stored -> stored       _ -> fallback+    journalAppended = \case+      JournalAppended {} -> True+      _ -> False  -- | Snapshot a workflow state after its journal append has committed. The -- snapshot is advisory: a store failure is counted and cannot turn the@@ -965,87 +1061,3 @@   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
@@ -54,7 +54,6 @@     AwakeableId (..),     awakeableIdToUuid,     awakeableIdText,-    deterministicAwakeableId,      -- * Authoring surface (inside a workflow)     awakeableNamed,@@ -71,11 +70,11 @@ where  import Control.Exception (Exception)+import Data.List.NonEmpty qualified as NonEmpty import Data.Text qualified as Text import Data.UUID (UUID) import Data.UUID qualified as UUID import Data.UUID.V4 qualified as UUID.V4-import Data.UUID.V5 qualified as UUID.V5 import Effectful (Eff, IOE, (:>)) import Effectful.Exception (throwIO) import Keiro.Prelude@@ -98,6 +97,7 @@     prepareJournalAppend,     step,   )+import Keiro.Workflow.Awakeable.Internal.Identity (generation0AwakeableUuidProbes) import Keiro.Workflow.Awakeable.Schema   ( AwakeableRow,     AwakeableStatus (..),@@ -107,6 +107,8 @@     lookupAwakeableStatusTx,     registerAwakeableTx,   )+import Keiro.Workflow.Instance (WorkflowStatus (..), upsertInstanceTx)+import Keiro.Workflow.Schema (lockWorkflowStepTx, workflowStepLockKey) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx@@ -116,8 +118,9 @@ -- ---------------------------------------------------------------------------  -- | 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+-- 'awakeableNamed'. Frozen generation-0 probes live in the explicitly named+-- "Keiro.Workflow.Awakeable.Compatibility" module. 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@@ -133,21 +136,6 @@ 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.-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]- -- --------------------------------------------------------------------------- -- Errors -- ---------------------------------------------------------------------------@@ -176,6 +164,16 @@ -- 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.+--+-- The label is stable across code edits but __not__ across+-- 'Keiro.Workflow.continueAsNew'. The allocated id is journaled under an+-- @awkid:\<label\>@ step, and rotation opens a generation whose journal has no+-- such step, so the next run allocates a fresh id under the same label. The+-- previously handed-out id is orphaned: its row survives until workflow GC+-- collects it, 'signalAwakeable' against it still reports whether /that row/+-- transitioned, and nothing in the new generation is woken. Re-notify the holder+-- from the re-run allocation step whenever a workflow both rotates and hands+-- awakeable ids to the outside world. awakeableNamed ::   (Workflow :> es, Store :> es, IOE :> es, FromJSON a) =>   StepName ->@@ -204,13 +202,17 @@   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 = adopt (NonEmpty.toList probes)+  | otherwise = freshAwakeableId+  where+    probes = fmap AwakeableId (generation0AwakeableUuidProbes name wid label)+    freshAwakeableId = AwakeableId <$> liftIO UUID.V4.nextRandom+    adopt [] = freshAwakeableId+    adopt (candidate : rest) = do+      row <- lookupAwakeable (awakeableIdToUuid candidate)+      case row of+        Just _ -> pure candidate+        Nothing -> adopt rest  -- | 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@@ -349,14 +351,21 @@           for_ appendOutcome throwOnAppendConflict           pure transitioned +-- A refusal is deliberately not condemned. The owning workflow is terminal, so+-- the journal entry must not land — but the promise itself is still resolved+-- durably, and rolling the row transition back would leave the awakeable+-- pending forever with no one left to signal it. condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction () condemnOnAppendConflict = \case   JournalAppendConflict {} -> Tx.condemn+  JournalRefusedTerminal {} -> pure ()   _ -> pure ()  throwOnAppendConflict :: JournalAppendOutcome -> Eff es () throwOnAppendConflict = \case   JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  -- Not an error: signalling a terminal workflow's promise is a no-op delivery.+  JournalRefusedTerminal {} -> pure ()   _ -> pure ()  -- | Abandon a still-@pending@ awakeable: flips its row to @cancelled@ and@@ -364,6 +373,41 @@ -- 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'.+--+-- Cancellation and a concurrent stale suspend write are serialized under the+-- same generation-scoped per-step advisory lock that protects wake delivery.+-- If cancellation commits first, the suspend re-check observes the terminal+-- awakeable row; if suspension commits first, cancellation's instance upsert+-- restores @running@. Either order leaves the workflow discoverable.+--+-- Because there is no journal append, the same transaction flips the /owning+-- workflow's/ @keiro_workflows@ row to @running@ — the status a wake delivery+-- would have written. Cancellation is a wake-source lifecycle transition like+-- any other, and the instance row is what the resume worker discovers: without+-- the flip, a workflow parked on a cancelled promise would be invisible to+-- discovery and could never reach its @await@ arm to throw+-- 'WorkflowAwakeableCancelled'. @running@ here means "this workflow has progress+-- to make" — here, observing the cancellation. The upsert takes @GREATEST@ of the+-- stored and supplied generation, so passing 0 preserves whatever generation the+-- instance is on, and it never revives a terminal instance. cancelAwakeable :: (Store :> es) => AwakeableId -> Eff es Bool cancelAwakeable aid =-  runTransaction $ cancelAwakeableTx (awakeableIdToUuid aid)+  lookupAwakeable (awakeableIdToUuid aid) >>= \case+    Nothing -> pure False+    Just row -> do+      let ownerName = WorkflowName (row ^. #ownerWorkflowName)+          ownerId = WorkflowId (row ^. #ownerWorkflowId)+      gen <- currentGeneration ownerName ownerId+      runTransaction $ do+        lockWorkflowStepTx+          ( workflowStepLockKey+              (unWorkflowId ownerId)+              (unWorkflowName ownerName)+              gen+              (awakeableStepPrefix <> awakeableIdText aid)+          )+        cancelAwakeableTx (awakeableIdToUuid aid) >>= \case+          Nothing -> pure False+          Just (ownerNameText, ownerIdText) -> do+            upsertInstanceTx ownerIdText ownerNameText 0 WfRunning Nothing+            pure True
+ src/Keiro/Workflow/Awakeable/Compatibility.hs view
@@ -0,0 +1,30 @@+-- | Compatibility probes for awakeables created by generation-0 Keiro.+--+-- These identifiers are predictable from workflow coordinates, but fresh+-- awakeables are random and journaled. Ordinary workflow code must use+-- 'Keiro.Workflow.Awakeable.awakeableNamed' and pass along the returned id.+module Keiro.Workflow.Awakeable.Compatibility+  ( generation0AwakeableId,+    preUtf8Generation0AwakeableId,+  )+where++import Data.Text (Text)+import Keiro.Workflow.Awakeable (AwakeableId (..))+import Keiro.Workflow.Awakeable.Internal.Identity+  ( generation0AwakeableUuid,+    preUtf8Generation0AwakeableUuid,+  )+import Keiro.Workflow.Types (WorkflowId, WorkflowName)++-- | Reproduce the UTF-8 generation-0 id for compatibility inspection and+-- adoption tests. This does not predict a fresh allocation.+generation0AwakeableId :: WorkflowName -> WorkflowId -> Text -> AwakeableId+generation0AwakeableId name wid label =+  AwakeableId (generation0AwakeableUuid name wid label)++-- | Reproduce the pre-UTF-8 generation-0 id for compatibility inspection and+-- adoption tests. This does not predict a fresh allocation.+preUtf8Generation0AwakeableId :: WorkflowName -> WorkflowId -> Text -> AwakeableId+preUtf8Generation0AwakeableId name wid label =+  AwakeableId (preUtf8Generation0AwakeableUuid name wid label)
+ src/Keiro/Workflow/Awakeable/Internal/Identity.hs view
@@ -0,0 +1,44 @@+-- | Frozen generation-0 awakeable identity.+--+-- This module owns the seed shape and encoding probes used by both the public+-- compatibility surface and generation-0 row adoption. Keeping those paths on+-- one implementation prevents a compatibility probe from drifting away from+-- the identifier that the runtime can actually adopt.+module Keiro.Workflow.Awakeable.Internal.Identity+  ( generation0AwakeableUuid,+    preUtf8Generation0AwakeableUuid,+    generation0AwakeableUuidProbes,+  )+where++import Data.List.NonEmpty (NonEmpty)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.UUID (UUID)+import Data.UUID.V5 qualified as UUID.V5+import Keiro.DeterministicId (deterministicIdProbes, identitySeedBytes, legacySeedBytes)+import Keiro.Workflow.Types (WorkflowId (..), WorkflowName (..))++-- | The frozen UTF-8 generation-0 identifier.+generation0AwakeableUuid :: WorkflowName -> WorkflowId -> Text -> UUID+generation0AwakeableUuid name wid label =+  UUID.V5.generateNamed UUID.V5.namespaceURL $+    identitySeedBytes $+      awakeableSeed name wid label++-- | The frozen pre-UTF-8 generation-0 identifier.+preUtf8Generation0AwakeableUuid :: WorkflowName -> WorkflowId -> Text -> UUID+preUtf8Generation0AwakeableUuid name wid label =+  UUID.V5.generateNamed UUID.V5.namespaceURL $+    legacySeedBytes $+      awakeableSeed name wid label++-- | Ordered adoption candidates: UTF-8 first, then pre-UTF-8 only when the+-- encodings differ.+generation0AwakeableUuidProbes :: WorkflowName -> WorkflowId -> Text -> NonEmpty UUID+generation0AwakeableUuidProbes name wid label =+  deterministicIdProbes (awakeableSeed name wid label)++awakeableSeed :: WorkflowName -> WorkflowId -> Text -> Text+awakeableSeed (WorkflowName name) (WorkflowId wid) label =+  Text.intercalate ":" ["keiro", "awakeable", name, wid, label]
src/Keiro/Workflow/Awakeable/Schema.hs view
@@ -94,9 +94,18 @@   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'.-cancelAwakeableTx :: UUID -> Tx.Transaction Bool+-- transaction, returning the owning workflow's @(name, id)@ when /this/ call+-- performed the transition. Only @pending@ rows match, so an already-completed+-- (or already-cancelled) awakeable is left untouched and the call returns+-- 'Nothing'.+--+-- The owner coordinates are returned because cancellation writes no journal+-- entry — there is no result value to record — so it is the only wake-source+-- lifecycle transition that would otherwise leave the owning workflow's+-- @keiro_workflows@ row untouched. The caller flips that row in the same+-- transaction (see "Keiro.Workflow.Awakeable".@cancelAwakeable@) so discovery+-- still surfaces the workflow.+cancelAwakeableTx :: UUID -> Tx.Transaction (Maybe (Text, Text)) cancelAwakeableTx aid =   Tx.statement aid cancelAwakeableStmt @@ -153,7 +162,7 @@     )     ((> 0) <$> D.rowsAffected) -cancelAwakeableStmt :: Statement UUID Bool+cancelAwakeableStmt :: Statement UUID (Maybe (Text, Text)) cancelAwakeableStmt =   preparable     """@@ -162,9 +171,15 @@         updated_at = now()     WHERE awakeable_id = $1       AND status = 'pending'+    RETURNING owner_workflow_name, owner_workflow_id     """     (E.param (E.nonNullable E.uuid))-    ((> 0) <$> D.rowsAffected)+    ( D.rowMaybe+        ( (,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.text)+        )+    )  lookupAwakeableStatusStmt :: Statement UUID (Maybe AwakeableStatus) lookupAwakeableStatusStmt =
src/Keiro/Workflow/Child.hs view
@@ -106,11 +106,10 @@     runWorkflowWith,     step,   )+import Keiro.Workflow.Child.Cancel (ensureChildCancelled) import Keiro.Workflow.Child.Schema-  ( ChildRow,-    ChildStatus (..),+  ( ChildStatus (..),     lookupChild,-    markChildCancelledTx,     markChildResultTx,     registerChildTx,   )@@ -378,61 +377,22 @@     ChildCancelled -> pure ()     ChildFailed -> pure () +-- A refusal is deliberately not condemned: the parent is terminal, so its+-- await-step sentinel must not land, but the child row transition (completed or+-- cancelled) is the durable authority and must still commit. A parent that is+-- later resurrected re-delivers from that row through 'awaitChild''s arm. condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction () condemnOnAppendConflict = \case   JournalAppendConflict {} -> Tx.condemn+  JournalRefusedTerminal {} -> pure ()   _ -> pure ()  throwOnAppendConflict :: JournalAppendOutcome -> Eff es () throwOnAppendConflict = \case   JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  -- Not an error: a terminal parent simply receives nothing.+  JournalRefusedTerminal {} -> pure ()   _ -> pure ()--ensureChildCancelled ::-  (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)---- ------------------------------------------------------------------------------ The cancellation sentinel--- ------------------------------------------------------------------------------- | 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]  childOkEnvelope :: Aeson.Value -> Aeson.Value childOkEnvelope value = Aeson.object ["ok" Aeson..= value]
+ src/Keiro/Workflow/Child/Cancel.hs view
@@ -0,0 +1,95 @@+-- | Shared child-cancellation transaction used by parent-facing and+-- operator-facing APIs.+module Keiro.Workflow.Child.Cancel+  ( ensureChildCancelled,+  )+where++import Data.Aeson qualified as Aeson+import Effectful (Eff, IOE, (:>))+import Keiro.Prelude+import Keiro.Workflow.Child.Schema+  ( ChildRow,+    ChildStatus (..),+    markChildCancelledTx,+  )+import Keiro.Workflow.Journal+  ( JournalAppendOutcome (..),+    prepareJournalAppend,+  )+import Keiro.Workflow.Schema (currentGeneration)+import Keiro.Workflow.Types+  ( WorkflowId (..),+    WorkflowJournalEvent (..),+    WorkflowName (..),+  )+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Transaction (runTransaction)+import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | Ensure both durable consequences of cancelling a linked child: the child+-- journal's terminal marker and the parent's cancelled await sentinel.+--+-- The child-row transition and both appends share one transaction. Retrying a+-- historically row-only cancellation repairs the markers without reporting a+-- fresh lifecycle transition.+ensureChildCancelled ::+  (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 = Aeson.object ["cancelled" Aeson..= True],+          recordedAt = now+        }+  runTransaction $ do+    childOutcome <- childAppendTx+    case childOutcome of+      -- Another lifecycle marker won. Do not flip the child row or wake the+      -- parent as cancelled when the child journal says completed/failed.+      JournalRefusedTerminal {} ->+        pure+          ( False,+            childOutcome,+            JournalAlreadyPresent Aeson.Null+          )+      JournalAppendConflict {} -> do+        Tx.condemn+        pure+          ( False,+            childOutcome,+            JournalAlreadyPresent Aeson.Null+          )+      _ -> do+        transitioned <-+          if row ^. #status == Running+            then markChildCancelledTx (row ^. #childId) (row ^. #childName)+            else pure False+        parentOutcome <- parentAppendTx+        condemnOnAppendConflict parentOutcome+        pure (transitioned, childOutcome, parentOutcome)++condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction ()+condemnOnAppendConflict = \case+  JournalAppendConflict {} -> Tx.condemn+  JournalRefusedTerminal {} -> pure ()+  _ -> pure ()
src/Keiro/Workflow/Child/Schema.hs view
@@ -14,9 +14,10 @@ --   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).+-- * 'findRunningChildIds' lists the children this table still considers+--   @running@ (operator inspection; it was once the resume worker's extra+--   discovery seed for a zero-step child, which the instance row written at+--   spawn time made redundant). -- * 'countActiveChildren' counts outstanding children — the seam EP-44 may read --   for a @keiro.workflow.children.active@ gauge. --@@ -155,10 +156,17 @@ countActiveChildren =   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, in the same+-- @(id, name)@ tuple order as 'Keiro.Workflow.findUnfinishedWorkflowIds'.+--+-- The resume worker no longer unions this into discovery. It once did, so a+-- freshly spawned child with no @keiro_workflow_steps@ rows was still driven;+-- since migration 0011 that seed is redundant, because+-- 'Keiro.Workflow.Child.spawnChild' upserts the child's @keiro_workflows@ row as+-- @running@ inside the spawn step's transaction (and 0011 backfilled the running+-- children that predate the instance table). Retained for operator inspection:+-- it answers "which children does the child table think are still running?"+-- directly, without going through the instance table. findRunningChildIds :: (Store :> es) => Eff es [(Text, Text)] findRunningChildIds =   runTransaction (Tx.statement () findRunningChildIdsStmt)
src/Keiro/Workflow/Gc.hs view
@@ -7,11 +7,20 @@ -- 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.+--+-- The converse of that exception is deliberate and not a leak: collecting an+-- eligible terminal /parent/ also deletes the link rows of its children that are+-- still running. Nothing awaits those children any more — the parent that would+-- have received their results is gone — so they finish as ordinary workflows and+-- become eligible for collection on their own terms. module Keiro.Workflow.Gc   ( WorkflowGcPolicy (..),+    WorkflowGcCandidate (..),     WorkflowGcSummary (..),+    listWorkflowGcCandidates,     gcWorkflowsOnce,     runWorkflowGcWorker,+    runWorkflowGcWorkerWith,   ) where @@ -19,8 +28,12 @@ import Control.Concurrent (threadDelay) import Control.Monad (forever) import Data.Int (Int32)+import Data.Text qualified as Text import Data.Time (NominalDiffTime, addUTCTime) import Effectful (Eff, IOE, (:>))+import Effectful.Error.Static (Error)+import Effectful.Error.Static qualified as Error+import Effectful.Exception (catchSync) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable)@@ -28,10 +41,12 @@ import Keiro.Workflow.Schema (currentGeneration) import Keiro.Workflow.Types (WorkflowId (..), WorkflowName (..), workflowGenerationStreamName) import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError) import Kiroku.Store.Lifecycle (hardDeleteStream) import Kiroku.Store.Read (lookupStreamId) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (StreamId (..))+import System.IO (hPutStrLn, stderr) import "hasql-transaction" Hasql.Transaction qualified as Tx  data WorkflowGcPolicy = WorkflowGcPolicy@@ -40,26 +55,120 @@   }   deriving stock (Generic, Eq, Show) +data WorkflowGcCandidate = WorkflowGcCandidate+  { workflowId :: !Text,+    workflowName :: !Text+  }+  deriving stock (Generic, Eq, Show)+ data WorkflowGcSummary = WorkflowGcSummary-  { scanned :: !Int,+  { -- | Terminal instances eligibility returned this pass.+    scanned :: !Int,+    -- | Of those, how many were actually collected. A pass where+    --     @deleted < scanned@ hit a per-workflow error; the survivors stay+    --     eligible and are re-scanned next pass.     deleted :: !Int   }   deriving stock (Generic, Eq, Show) -gcWorkflowsOnce :: (Store :> es) => UTCTime -> WorkflowGcPolicy -> Eff es WorkflowGcSummary+-- | Run one garbage-collection pass.+--+-- Each eligible workflow is deleted in isolation: a store error or synchronous+-- exception on one workflow leaves the rest of the batch untouched and is+-- reported as the gap between 'scanned' and 'deleted'. A partially deleted+-- workflow converges by construction — its instance row is what makes it+-- eligible, and that row is deleted last, so anything left behind is selected+-- again on the following pass.+gcWorkflowsOnce ::+  (Store :> es, Error StoreError :> es) =>+  UTCTime ->+  WorkflowGcPolicy ->+  Eff es WorkflowGcSummary gcWorkflowsOnce now policy = do+  eligible <- listWorkflowGcCandidates now policy+  outcomes <- traverse (deleteWorkflowIsolated . candidateCoordinates) eligible+  pure WorkflowGcSummary {scanned = length eligible, deleted = length (filter id outcomes)}++-- | Preview the exact candidates one garbage-collection pass would attempt.+--+-- The CLI and other operator surfaces use this instead of reproducing the+-- eligibility query. A later 'gcWorkflowsOnce' call re-evaluates eligibility,+-- so the preview is informational rather than a lock or reservation.+listWorkflowGcCandidates ::+  (Store :> es) =>+  UTCTime ->+  WorkflowGcPolicy ->+  Eff es [WorkflowGcCandidate]+listWorkflowGcCandidates 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}+  map (uncurry WorkflowGcCandidate)+    <$> runTransaction+      (Tx.statement (cutoff, fromIntegral limit :: Int32) eligibleWorkflowsStmt) -runWorkflowGcWorker :: (IOE :> es, Store :> es) => WorkflowGcPolicy -> Int -> Eff es ()+candidateCoordinates :: WorkflowGcCandidate -> (Text, Text)+candidateCoordinates candidate =+  (candidate ^. #workflowId, candidate ^. #workflowName)++-- | 'runWorkflowGcWorkerWith' with a compact stderr logger.+runWorkflowGcWorker ::+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowGcPolicy ->+  Int ->+  Eff es () runWorkflowGcWorker policy pollMicros =-  forever $ do-    now <- liftIO getCurrentTime-    void (gcWorkflowsOnce now policy)-    liftIO (threadDelay pollMicros)+  runWorkflowGcWorkerWith policy pollMicros defaultGcLog++-- | Poll-and-collect loop: run 'gcWorkflowsOnce' every @pollMicros@+-- microseconds, forever.+--+-- A failed pass is logged through the supplied hook and retried on the next+-- tick, mirroring 'Keiro.Workflow.Resume.runWorkflowResumeWorkerWith'. Before+-- this loop had per-pass isolation it was a bare @forever@: the first transient+-- database error ended garbage collection until the process restarted. A pass+-- that collected fewer workflows than it scanned is logged too — the shortfall+-- is otherwise invisible, because the loop discards the summary.+runWorkflowGcWorkerWith ::+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  WorkflowGcPolicy ->+  Int ->+  -- | Logging hook for pass failures and partial passes.+  (Text -> IO ()) ->+  Eff es ()+runWorkflowGcWorkerWith policy pollMicros logPass = forever $ do+  now <- liftIO getCurrentTime+  onePass now+    `Error.catchError` (\_ (e :: StoreError) -> logFailure (Text.pack (show e)))+    `catchSync` (logFailure . Text.pack . show)+  liftIO (threadDelay pollMicros)+  where+    onePass now = do+      summary <- gcWorkflowsOnce now policy+      let skipped = (summary ^. #scanned) - (summary ^. #deleted)+      when (skipped > 0) $+        liftIO . logPass $+          "pass collected "+            <> Text.pack (show (summary ^. #deleted))+            <> " of "+            <> Text.pack (show (summary ^. #scanned))+            <> " eligible workflows; the remaining "+            <> Text.pack (show skipped)+            <> " stay eligible and are retried next pass"+    logFailure msg = liftIO (logPass ("pass failed: " <> msg))++defaultGcLog :: Text -> IO ()+defaultGcLog msg = hPutStrLn stderr ("keiro workflow gc: " <> Text.unpack msg)++-- | 'deleteWorkflow' with per-workflow error isolation. 'False' means this+-- workflow was not (fully) collected; the batch continues either way.+deleteWorkflowIsolated ::+  (Store :> es, Error StoreError :> es) =>+  (Text, Text) ->+  Eff es Bool+deleteWorkflowIsolated pair =+  (deleteWorkflow pair >> pure True)+    `Error.catchError` (\_ (_ :: StoreError) -> pure False)+    `catchSync` (\_ -> pure False)  deleteWorkflow :: (Store :> es) => (Text, Text) -> Eff es () deleteWorkflow (widText, nameText) = do
src/Keiro/Workflow/Instance.hs view
@@ -7,16 +7,23 @@ module Keiro.Workflow.Instance   ( WorkflowStatus (..),     WorkflowInstanceRow (..),+    WorkflowInstanceFilter (..),+    defaultWorkflowInstanceFilter,     ResurrectOutcome (..),+    CancelWorkflowOutcome (..),+    ClaimOutcome (..),     statusToText,     statusFromText,     upsertInstanceTx,-    markInstanceSuspended,+    markInstanceSuspendedAwaiting,     lookupInstance,+    listWorkflowInstances,+    cancelWorkflow,     claimInstance,     renewInstanceLeaseTx,     renewInstanceLease,     releaseInstance,+    forceReleaseInstanceLease,     recordCrashTx,     resetInstanceAttempts,     reviveFailedInstanceTx,@@ -26,27 +33,61 @@  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5) import Data.Int (Int32)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text import Data.Time (NominalDiffTime, addUTCTime)+import Data.UUID (UUID)+import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>))+import Effectful.Exception (throwIO) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Keiro.Prelude-import Keiro.Workflow.Child.Schema (reviveFailedChildTx)-import Keiro.Workflow.Schema (currentGeneration, deleteStepRowTx)-import Keiro.Workflow.Types (WorkflowId (..), WorkflowName (..), failedStepName)+import Keiro.Workflow.Awakeable.Schema+  ( AwakeableStatus (..),+    lookupAwakeableStatusTx,+  )+import Keiro.Workflow.Child.Cancel (ensureChildCancelled)+import Keiro.Workflow.Child.Schema+  ( ChildStatus (..),+    lookupChild,+    reviveFailedChildTx,+  )+import Keiro.Workflow.Instance.Schema+  ( WorkflowStatus (..),+    statusFromText,+    statusToText,+    upsertInstanceTx,+  )+import Keiro.Workflow.Journal+  ( JournalAppendOutcome (..),+    prepareJournalAppend,+  )+import Keiro.Workflow.Schema+  ( currentGeneration,+    deleteStepRowTx,+    loadStepIndex,+    lockWorkflowStepTx,+    lookupStepResultTx,+    workflowStepLockKey,+  )+import Keiro.Workflow.Types+  ( WorkflowError (..),+    WorkflowId (..),+    WorkflowJournalEvent (..),+    WorkflowName (..),+    awakeableStepPrefix,+    cancelledStepName,+    completedStepName,+    continuedAsNewStepName,+    failedStepName,+  ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx -data WorkflowStatus-  = WfRunning-  | WfSuspended-  | WfCompleted-  | WfCancelled-  | WfFailed-  deriving stock (Generic, Eq, Show)- data WorkflowInstanceRow = WorkflowInstanceRow   { workflowId :: !Text,     workflowName :: !Text,@@ -55,6 +96,7 @@     attempts :: !Int32,     lastError :: !(Maybe Text),     nextAttemptAt :: !(Maybe UTCTime),+    wakeAfter :: !(Maybe UTCTime),     leasedBy :: !(Maybe Text),     leaseExpiresAt :: !(Maybe UTCTime),     createdAt :: !UTCTime,@@ -63,36 +105,251 @@   }   deriving stock (Generic, Eq, Show) +-- | Filters and keyset cursor for operator-facing workflow enumeration.+--+-- Results are ordered by @(workflow_name, workflow_id)@. Pass the final row's+-- name and id as 'afterKey' to fetch the next page without the instability and+-- growing scan cost of an @OFFSET@ query. A non-positive 'pageSize' returns an+-- empty page.+data WorkflowInstanceFilter = WorkflowInstanceFilter+  { statuses :: !(Maybe (NonEmpty WorkflowStatus)),+    workflowName :: !(Maybe Text),+    afterKey :: !(Maybe (Text, Text)),+    pageSize :: !Int+  }+  deriving stock (Generic, Eq, Show)++-- | List every status and workflow name, starting at the first key, 100 rows+-- at a time.+defaultWorkflowInstanceFilter :: WorkflowInstanceFilter+defaultWorkflowInstanceFilter =+  WorkflowInstanceFilter+    { statuses = Nothing,+      workflowName = Nothing,+      afterKey = Nothing,+      pageSize = 100+    }+ data ResurrectOutcome   = 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+-- | Honest result of an operator cancellation request.+data CancelWorkflowOutcome+  = WorkflowCancelRecorded+  | WorkflowAlreadyTerminal !WorkflowStatus+  | WorkflowCancelUnknown+  deriving stock (Generic, Eq, Show) -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+-- | Why 'claimInstance' did or did not acquire the advance lease.+data ClaimOutcome+  = -- | This caller now holds the lease.+    ClaimAcquired+  | -- | Another live lease exists; the instance is being advanced elsewhere.+    ClaimLeaseHeld+  | -- | The crash-backoff gate (@next_attempt_at@) is still in the future.+    ClaimPaced+  | -- | The row is terminal or gone; it will drop out of discovery by itself.+    ClaimUnavailable+  deriving stock (Generic, Eq, Show) +-- | Record that a run parked on @awaitedStep@, arbitrating against a wake+-- delivery that may be landing at the same moment.+--+-- Discovery is exact: a @suspended@ instance with no due wake hint is never+-- returned, so a suspended status written /after/ a wake has already been+-- delivered would strand the workflow forever. The window is real — a run+-- consults the step index, finds the awaited step absent, runs its arm, and only+-- then writes its status, and a wake can commit anywhere in between.+--+-- The fix reuses the lock the append path already takes. Every wake delivery+-- goes through @prepareJournalAppend@, which holds the per-step advisory lock+-- ('lockWorkflowStepTx' on 'workflowStepLockKey') while it appends and upserts+-- the instance row. Awakeable cancellation takes that lock too, but writes no+-- step row, so this transaction additionally consults the awakeable row's+-- terminal lifecycle status. Taking the same lock here totally orders the+-- writers:+--+-- * suspend wins the lock — it writes @suspended@; the wake, queued behind it,+--   then writes @running@;+-- * wake wins the lock — this transaction sees either the committed step-index+--   row or the terminal awakeable row and writes @running@ itself.+--+-- Either way no resolved wake is left behind a @suspended@ status. The+-- re-check reads the same authoritative @keiro_workflow_steps@ index the+-- @Await@ miss path consults, which is written in the same transaction as every+-- journal append. For @awk:@ steps it also reads @keiro_awakeables@, whose row+-- is the durable lifecycle authority for cancellation and completion. This+-- covers cancellation (the only wake transition without a step row) and the+-- self-repair arms whose append lands before their stale suspend write.+markInstanceSuspendedAwaiting ::+  (Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  -- | The generation the run operated on.+  Int ->+  -- | The step name the run parked on.+  Text ->+  Eff es ()+markInstanceSuspendedAwaiting (WorkflowName nameText) (WorkflowId widText) gen awaitedStep =+  runTransaction $ do+    lockWorkflowStepTx (workflowStepLockKey widText nameText gen awaitedStep)+    resolved <- lookupStepResultTx widText nameText gen awaitedStep+    abandoned <- case resolved of+      Just _ -> pure False+      Nothing -> case awakeableUuidFromStep awaitedStep of+        Nothing -> pure False+        Just aid -> maybe False (/= Pending) <$> lookupAwakeableStatusTx aid+    let status = if isJust resolved || abandoned then WfRunning else WfSuspended+    upsertInstanceTx widText nameText (fromIntegral gen) status Nothing++awakeableUuidFromStep :: Text -> Maybe UUID+awakeableUuidFromStep stepName =+  UUID.fromText =<< Text.stripPrefix awakeableStepPrefix stepName+ lookupInstance :: (Store :> es) => WorkflowName -> WorkflowId -> Eff es (Maybe WorkflowInstanceRow) lookupInstance (WorkflowName name) (WorkflowId wid) =   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+-- | Enumerate workflow instance summaries using stable keyset pagination.+--+-- Status and name filters are exact. Rows inserted before the supplied cursor+-- are intentionally not revisited; rows deleted or updated concurrently never+-- cause later keys to be skipped as an @OFFSET@ query could.+listWorkflowInstances ::+  (Store :> es) =>+  WorkflowInstanceFilter ->+  Eff es [WorkflowInstanceRow]+listWorkflowInstances filters+  | filters ^. #pageSize <= 0 = pure []+  | otherwise =+      runTransaction $+        Tx.statement+          ( NonEmpty.toList . fmap statusToText <$> filters ^. #statuses,+            filters ^. #workflowName,+            fst <$> filters ^. #afterKey,+            snd <$> filters ^. #afterKey,+            fromIntegral+              ( min+                  (filters ^. #pageSize)+                  (fromIntegral (maxBound :: Int32))+              ) ::+              Int32+          )+          listWorkflowInstancesStmt++-- | Stop a top-level or linked-child workflow at its next durable boundary.+--+-- Cancellation is an append-only journal marker. Linked children delegate to+-- the same transaction as 'Keiro.Workflow.Child.cancelChild' so their parent is+-- woken with the typed cancellation sentinel. Children are not cascaded: an+-- operator must cancel descendants explicitly. A step action already in flight+-- may finish and journal idempotently; no later boundary may start.+cancelWorkflow ::+  (IOE :> es, Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  Eff es CancelWorkflowOutcome+cancelWorkflow name@(WorkflowName nameText) wid@(WorkflowId widText) =+  lookupInstance name wid >>= \case+    Just row+      | Just terminal <- terminalStatus (row ^. #status) ->+          pure (WorkflowAlreadyTerminal terminal)+    mrow -> do+      exists <- case mrow of+        Just _ -> pure True+        Nothing -> do+          gen <- currentGeneration name wid+          not . Map.null <$> loadStepIndex name wid gen+      if not exists+        then pure WorkflowCancelUnknown+        else+          lookupChild widText nameText >>= \case+            Just child -> cancelLinkedChild child+            Nothing -> cancelTopLevel+  where+    terminalStatus = \case+      WfCompleted -> Just WfCompleted+      WfCancelled -> Just WfCancelled+      WfFailed -> Just WfFailed+      _ -> Nothing++    cancelLinkedChild child = do+      (transitioned, childOutcome, parentOutcome) <- ensureChildCancelled child+      traverse_ throwOnJournalConflict [childOutcome, parentOutcome]+      if transitioned+        then pure WorkflowCancelRecorded+        else case child ^. #status of+          ChildCancelled -> pure (WorkflowAlreadyTerminal WfCancelled)+          ChildCompleted -> pure (WorkflowAlreadyTerminal WfCompleted)+          ChildFailed -> pure (WorkflowAlreadyTerminal WfFailed)+          -- The guarded child-row transition lost a race. Re-read both the+          -- instance and child rows before deciding which terminal state won.+          Running -> cancelWorkflow name wid++    cancelTopLevel = do+      gen <- currentGeneration name wid+      now <- liftIO getCurrentTime+      appendTx <-+        prepareJournalAppend+          name+          wid+          gen+          WorkflowCancelled {recordedAt = now}+      runTransaction appendTx >>= \case+        JournalAppended {} -> pure WorkflowCancelRecorded+        JournalAlreadyPresent {} ->+          pure (WorkflowAlreadyTerminal WfCancelled)+        JournalRefusedTerminal marker+          | marker == continuedAsNewStepName -> cancelWorkflow name wid+          | marker == completedStepName -> pure (WorkflowAlreadyTerminal WfCompleted)+          | marker == failedStepName -> pure (WorkflowAlreadyTerminal WfFailed)+          | marker == cancelledStepName -> pure (WorkflowAlreadyTerminal WfCancelled)+          | otherwise -> cancelWorkflow name wid+        conflict@JournalAppendConflict {} ->+          throwOnJournalConflict conflict *> pure WorkflowCancelUnknown++    throwOnJournalConflict = \case+      JournalAppendConflict err ->+        throwIO (WorkflowJournalAppendError (Text.pack (show err)))+      _ -> pure ()++-- | Claim one runnable workflow instance and classify any refusal.+--+-- A live lease takes precedence over crash pacing because it means another+-- worker is actively responsible for the instance. The guarded update and the+-- refusal classification share one transaction and one clock reading.+claimInstance :: (IOE :> es, Store :> es) => Text -> NominalDiffTime -> WorkflowName -> WorkflowId -> Eff es ClaimOutcome+claimInstance owner ttl (WorkflowName nameText) (WorkflowId widText) = do   now <- liftIO getCurrentTime-  gen <- currentGeneration name wid   runTransaction $ do-    Tx.statement (widText, nameText, fromIntegral gen :: Int32) ensureInstanceStmt-    fromMaybe False-      <$> Tx.statement+    -- Generation 0, not the resolved current generation: 'ensureInstanceStmt' is+    -- an ON CONFLICT DO NOTHING insert, so the value is used only when no row+    -- exists at all — and every discovered workflow has one (migration 0011+    -- backfilled the pre-existing instances, every append upserts, and spawnChild+    -- writes the child's row inside the spawn step's transaction). Where the+    -- insert does fire, 0 is a floor that the next truthful writer raises:+    -- 'upsertInstanceTx' takes GREATEST(stored, supplied) on conflict. Resolving+    -- MAX(generation) here would cost a query per claim to learn a number the+    -- insert almost never uses.+    Tx.statement (widText, nameText, 0 :: Int32) ensureInstanceStmt+    claimed <-+      Tx.statement         (widText, nameText, owner, now, addUTCTime ttl now)         claimInstanceStmt+    case claimed of+      Just True -> pure ClaimAcquired+      _ -> do+        state <- Tx.statement (widText, nameText) classifyClaimRefusalStmt+        pure $ case state of+          Nothing -> ClaimUnavailable+          Just (status, leaseExpiry, nextAttempt)+            | status `notElem` [WfRunning, WfSuspended] -> ClaimUnavailable+            | maybe False (>= now) leaseExpiry -> ClaimLeaseHeld+            | maybe False (> now) nextAttempt -> ClaimPaced+            | otherwise -> ClaimUnavailable  -- | Extend an instance lease only when @owner@ still holds it. --@@ -127,7 +384,29 @@   runTransaction $     Tx.statement (wid, name, owner, progressed) releaseInstanceStmt -recordCrashTx :: Text -> Text -> Text -> Tx.Transaction Int32+-- | Clear any current instance lease, returning 'True' only when a lease was+-- present.+--+-- A previous live owner is not interrupted inside an action already in flight.+-- Its next owner-guarded 'renewInstanceLease' matches no row and raises+-- 'Keiro.Workflow.WorkflowLeaseLost' before a later workflow boundary, while a+-- replacement owner can claim immediately instead of waiting for the old TTL.+forceReleaseInstanceLease ::+  (Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  Eff es Bool+forceReleaseInstanceLease (WorkflowName name) (WorkflowId wid) =+  runTransaction (Tx.statement (wid, name) forceReleaseInstanceLeaseStmt)++-- | Record a crashed advance against the instance row: bump @attempts@, store+-- the rendered error, and push @next_attempt_at@ out along the backoff ladder.+-- Returns the new attempt count, or 'Nothing' when the row matched nothing+-- because the workflow reached a terminal status between the crash and this+-- update (a parent's @cancelChild@, an operator cancellation, a concurrent+-- failure marker). That race is ordinary, not exceptional: there is no live+-- instance left to pace, so nothing is recorded and the caller skips it.+recordCrashTx :: Text -> Text -> Text -> Tx.Transaction (Maybe Int32) recordCrashTx wid name err =   Tx.statement (wid, name, err) recordCrashStmt @@ -169,58 +448,12 @@               then WorkflowResurrected               else WorkflowNotFailed -statusToText :: WorkflowStatus -> Text-statusToText = \case-  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--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- 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,+           last_error, next_attempt_at, wake_after, leased_by, lease_expires_at,            created_at, updated_at, completed_at     FROM keiro.keiro_workflows     WHERE workflow_id = $1 AND workflow_name = $2@@ -231,6 +464,32 @@     )     (D.rowMaybe instanceRowDecoder) +listWorkflowInstancesStmt :: Statement (Maybe [Text], Maybe Text, Maybe Text, Maybe Text, Int32) [WorkflowInstanceRow]+listWorkflowInstancesStmt =+  preparable+    """+    SELECT workflow_id, workflow_name, generation, status, attempts,+           last_error, next_attempt_at, wake_after, leased_by, lease_expires_at,+           created_at, updated_at, completed_at+    FROM keiro.keiro_workflows+    WHERE ($1::text[] IS NULL OR status = ANY($1))+      AND ($2::text IS NULL OR workflow_name = $2)+      AND (+        $3::text IS NULL+        OR (workflow_name, workflow_id) > ($3, $4)+      )+    ORDER BY workflow_name, workflow_id+    LIMIT $5+    """+    ( contrazip5+        (E.param (E.nullable (E.foldableArray (E.nonNullable E.text))))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowList instanceRowDecoder)+ ensureInstanceStmt :: Statement (Text, Text, Int32) () ensureInstanceStmt =   preparable@@ -271,6 +530,26 @@     )     (D.rowMaybe (D.column (D.nonNullable D.bool))) +classifyClaimRefusalStmt :: Statement (Text, Text) (Maybe (WorkflowStatus, Maybe UTCTime, Maybe UTCTime))+classifyClaimRefusalStmt =+  preparable+    """+    SELECT status, lease_expires_at, next_attempt_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+        ( (,,)+            <$> (statusFromText <$> D.column (D.nonNullable D.text))+            <*> D.column (D.nullable D.timestamptz)+            <*> D.column (D.nullable D.timestamptz)+        )+    )+ renewInstanceLeaseStmt :: Statement (Text, Text, Text, UTCTime, UTCTime) Bool renewInstanceLeaseStmt =   preparable@@ -314,7 +593,25 @@     )     D.noResult -recordCrashStmt :: Statement (Text, Text, Text) Int32+forceReleaseInstanceLeaseStmt :: Statement (Text, Text) Bool+forceReleaseInstanceLeaseStmt =+  preparable+    """+    UPDATE keiro.keiro_workflows+    SET leased_by = NULL,+        lease_expires_at = NULL,+        updated_at = now()+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND leased_by IS NOT NULL+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    ((> 0) <$> D.rowsAffected)++recordCrashStmt :: Statement (Text, Text, Text) (Maybe Int32) recordCrashStmt =   preparable     """@@ -333,7 +630,10 @@         (E.param (E.nonNullable E.text))         (E.param (E.nonNullable E.text))     )-    (D.singleRow (D.column (D.nonNullable D.int4)))+    -- 'rowMaybe', not 'singleRow': the WHERE clause deliberately matches+    -- nothing once the workflow is terminal, and a workflow can go terminal+    -- between its crash and this update.+    (D.rowMaybe (D.column (D.nonNullable D.int4)))  resetInstanceAttemptsStmt :: Statement (Text, Text) () resetInstanceAttemptsStmt =@@ -386,6 +686,7 @@     <*> (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.timestamptz)     <*> D.column (D.nullable D.text)     <*> D.column (D.nullable D.timestamptz)
+ src/Keiro/Workflow/Instance/Schema.hs view
@@ -0,0 +1,79 @@+-- | Shared workflow-instance row state used by both the public instance API and+-- the journal append implementation.+--+-- Keeping this storage layer below both modules avoids a module cycle: journal+-- appends maintain instance summaries, while operator cancellation in+-- "Keiro.Workflow.Instance" must itself append a journal marker.+module Keiro.Workflow.Instance.Schema+  ( WorkflowStatus (..),+    statusToText,+    statusFromText,+    upsertInstanceTx,+  )+where++import Contravariant.Extras (contrazip5)+import Data.Int (Int32)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import "hasql-transaction" Hasql.Transaction qualified as Tx++data WorkflowStatus+  = WfRunning+  | WfSuspended+  | WfCompleted+  | WfCancelled+  | WfFailed+  deriving stock (Generic, Eq, Show)++statusToText :: WorkflowStatus -> Text+statusToText = \case+  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++upsertInstanceTx :: Text -> Text -> Int32 -> WorkflowStatus -> Maybe Text -> Tx.Transaction ()+upsertInstanceTx wid name gen status mLastError =+  Tx.statement (wid, name, gen, statusToText status, mLastError) upsertInstanceStmt++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
+ src/Keiro/Workflow/Journal.hs view
@@ -0,0 +1,207 @@+-- | Transactional workflow journal appends shared by workflow execution and+-- operator control APIs.+module Keiro.Workflow.Journal+  ( JournalAppendOutcome (..),+    prepareJournalAppend,+    appendJournal,+    appendJournalEntry,+    appendJournalEntryReturningId,+    deterministicJournalId,+  )+where++import Data.Aeson qualified as Aeson+import Data.Maybe (listToMaybe)+import Data.Text qualified as Text+import Data.UUID.V5 qualified as UUID.V5+import Effectful (Eff, IOE, (:>))+import Effectful.Exception (throwIO)+import Keiro.Codec (encodeForAppendWithMetadata)+import Keiro.DeterministicId (identitySeedBytes)+import Keiro.Prelude+import Keiro.Workflow.Instance.Schema (WorkflowStatus (..), upsertInstanceTx)+import Keiro.Workflow.Schema+  ( WorkflowStepRow (..),+    currentGeneration,+    lockWorkflowStepTx,+    lookupStepResultTx,+    recordStepTx,+    terminalMarkersTx,+    workflowLifecycleMarkersTx,+    workflowStepLockKey,+  )+import Keiro.Workflow.Types+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Transaction (AppendConflict, appendToStreamTx, prepareEventsIO, runTransaction)+import Kiroku.Store.Types (AppendResult, EventData, EventId (..), ExpectedVersion (..))+import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | What a journal append did.+--+-- 'JournalAppended' means the event, step-index row, and workflow-instance row+-- committed together. 'JournalAlreadyPresent' is the idempotent retry outcome.+-- 'JournalRefusedTerminal' declines an ordinary step after cancellation or+-- failure, or a distinct lifecycle marker after another lifecycle marker won.+-- 'JournalAppendConflict' exposes an event-store append conflict.+data JournalAppendOutcome+  = JournalAppended !AppendResult+  | JournalAlreadyPresent !Aeson.Value+  | JournalRefusedTerminal !Text+  | JournalAppendConflict !AppendConflict+  deriving stock (Eq, Show)++-- | Build the transaction that journals one workflow event.+--+-- The returned transaction takes the per-step advisory lock, re-checks the+-- derived step index, appends to the kiroku stream, and updates both derived+-- tables atomically. Ordinary steps are refused after cancellation or failure.+-- Lifecycle events additionally share a generation lock: an exact retry is+-- idempotent, while a different completion/cancellation/failure/rotation marker+-- is refused.+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+        WorkflowFailed {} -> Nothing+        _ -> Just entryId+      row = journalRow name wid gen event+      (status, mLastError) = instanceStatusForEvent event+      journalName = workflowGenerationStreamName name wid gen+      lockKey = workflowStepLockKey (unWorkflowId wid) (unWorkflowName name) gen key+      lifecycleLockKey =+        workflowStepLockKey+          (unWorkflowId wid)+          (unWorkflowName name)+          gen+          "__keiro_lifecycle__"+      refusingMarker = case event of+        StepRecorded {} ->+          listToMaybe+            <$> terminalMarkersTx (unWorkflowId wid) (unWorkflowName name) gen+        _ ->+          listToMaybe+            <$> workflowLifecycleMarkersTx (unWorkflowId wid) (unWorkflowName name) gen+  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+    unless (isOrdinaryStep event) (lockWorkflowStepTx lifecycleLockKey)+    lockWorkflowStepTx lockKey+    lookupStepResultTx (unWorkflowId wid) (unWorkflowName name) gen key >>= \case+      Just stored -> pure (JournalAlreadyPresent stored)+      Nothing ->+        refusingMarker >>= \case+          Just marker -> pure (JournalRefusedTerminal marker)+          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++isOrdinaryStep :: WorkflowJournalEvent -> Bool+isOrdinaryStep = \case+  StepRecorded {} -> True+  _ -> False++appendJournal ::+  (IOE :> es, Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  Int ->+  WorkflowJournalEvent ->+  Eff es JournalAppendOutcome+appendJournal name wid gen event =+  prepareJournalAppend name wid gen event >>= runTransaction++-- | Append one event on the current generation, idempotently.+--+-- A late wake-source step aimed at a cancelled or failed workflow is a quiet+-- no-op so the wake source can still settle its own durable row.+appendJournalEntry ::+  (IOE :> es, Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  WorkflowJournalEvent ->+  Eff es ()+appendJournalEntry name wid event =+  void (appendJournalEntryReturningId name wid event)++-- | Like 'appendJournalEntry', returning the deterministic event id even when+-- a terminal workflow refused the delivery.+appendJournalEntryReturningId ::+  (IOE :> es, Store :> es) =>+  WorkflowName ->+  WorkflowId ->+  WorkflowJournalEvent ->+  Eff es EventId+appendJournalEntryReturningId name wid event = do+  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+    JournalRefusedTerminal {} -> pure entryId+    JournalAppendConflict err ->+      throwIO (WorkflowJournalAppendError (Text.pack (show err)))++instanceStatusForEvent :: WorkflowJournalEvent -> (WorkflowStatus, Maybe Text)+instanceStatusForEvent = \case+  StepRecorded {} -> (WfRunning, Nothing)+  WorkflowCompleted {} -> (WfCompleted, Nothing)+  WorkflowCancelled {} -> (WfCancelled, Nothing)+  WorkflowFailed reason _ -> (WfFailed, Just reason)+  WorkflowContinuedAsNew {} -> (WfRunning, Nothing)++journalKey :: WorkflowJournalEvent -> Text+journalKey = \case+  StepRecorded {stepName = key} -> key+  WorkflowCompleted {} -> completedStepName+  WorkflowCancelled {} -> cancelledStepName+  WorkflowFailed {} -> failedStepName+  WorkflowContinuedAsNew {} -> continuedAsNewStepName++journalRow :: WorkflowName -> WorkflowId -> Int -> WorkflowJournalEvent -> WorkflowStepRow+journalRow name wid gen = \case+  StepRecorded key value t -> mkRow key value t+  WorkflowCompleted t -> mkRow completedStepName Aeson.Null t+  WorkflowCancelled t -> mkRow cancelledStepName Aeson.Null t+  WorkflowFailed reason t -> mkRow failedStepName (Aeson.toJSON reason) t+  WorkflowContinuedAsNew nextGeneration t ->+    mkRow continuedAsNewStepName (Aeson.toJSON nextGeneration) t+  where+    mkRow key value t =+      WorkflowStepRow+        { workflowId = unWorkflowId wid,+          workflowName = unWorkflowName name,+          generation = gen,+          stepName = key,+          result = value,+          recordedAt = t+        }++-- | A stable journal-event id derived from the workflow identity, generation,+-- and reserved step key.+deterministicJournalId :: WorkflowName -> WorkflowId -> Int -> Text -> EventId+deterministicJournalId (WorkflowName name) (WorkflowId wid) gen key =+  EventId $+    UUID.V5.generateNamed UUID.V5.namespaceURL $+      identitySeedBytes $+        Text.intercalate ":" ["keiro", "workflow", name, wid, Text.pack (show gen), key]
src/Keiro/Workflow/Resume.hs view
@@ -36,8 +36,9 @@ --   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+-- * __'ResumeSummary'__ ('discovered', 'advanced', 'resumed', 'completed',+--   'stillSuspended', 'unknownName', 'failed', 'transientErrors',+--   'leaseSkipped', 'paced', 'sleepDue', 'unregisteredNames') — 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@@ -49,8 +50,12 @@ --   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+-- Discovery is the single 'findUnfinishedWorkflowIds' index query, and it is+-- exact: a workflow parked on an unresolved wake source is /not/ returned, so a+-- pass over a thousand parked approval flows costs one query. Every path that+-- resolves or abandons a wake (a journal append, an awakeable cancellation)+-- writes the instance row in the same transaction, which is what makes the+-- workflow visible again. 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@@ -74,6 +79,7 @@      -- * Running (fixed-poll baseline)     resumeWorkflowsOnce,+    resumeWorkflowsOnceUpTo,     runWorkflowResumeWorker,     runWorkflowResumeWorkerWith, @@ -85,17 +91,20 @@  import Control.Concurrent (threadDelay) import Control.Exception qualified as Exception-import Control.Monad (foldM, forever)+import Control.Monad (forever) import Data.Aeson qualified as Aeson-import Data.IORef (newIORef, readIORef, writeIORef)+import Data.IORef (IORef, newIORef, readIORef, writeIORef) 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 qualified as UUID import Data.UUID.V4 qualified as UUIDv4-import Effectful (Eff, IOE, (:>))+import Effectful (Eff, IOE, raise, (:>))+import Effectful.Concurrent (runConcurrent)+import Effectful.Concurrent.Async (pooledMapConcurrentlyN) import Effectful.Error.Static (Error) import Effectful.Error.Static qualified as Error import Effectful.Exception (catch, catchSync, finally, throwIO)@@ -128,8 +137,15 @@   ) import Keiro.Workflow.Awakeable.Schema (countPendingAwakeables) import Keiro.Workflow.Child (runChildWorkflow)-import Keiro.Workflow.Child.Schema (ChildRow, findRunningChildIds, lookupChild, markChildFailedTx)-import Keiro.Workflow.Instance (claimInstance, recordCrashTx, releaseInstance)+import Keiro.Workflow.Child.Schema (ChildRow, lookupChild, markChildFailedTx)+import Keiro.Workflow.Instance+  ( ClaimOutcome (..),+    WorkflowStatus (..),+    claimInstance,+    lookupInstance,+    recordCrashTx,+    releaseInstance,+  ) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Effect (Store, runStoreIO) import Kiroku.Store.Error (StoreError)@@ -176,7 +192,23 @@     --     another fresh workflow boundary. It bounds dead-worker recovery time and     --     must exceed the longest single step action or await arm.     leaseTtl :: !NominalDiffTime,+    -- | How many discovered workflows one pass may advance at the same time.+    --+    --     The default is 1: strictly sequential, which is what every release+    --     before this option did. Raising it is safe by construction — a pass+    --     never advances one instance twice (discovery returns one row per+    --     instance), each advance holds its own lease, and the per-step advisory+    --     lock already serializes same-step writers across processes — but it+    --     multiplies the database traffic a pass can have in flight, so it should+    --     be set against the store's connection-pool headroom rather than the+    --     candidate count. Values below 1 are treated as 1.+    maxConcurrentAdvances :: !Int,     -- | Per-worker logging hook. Defaults to a compact stderr renderer.+    --+    --     Called from every thread a pass advances on, so a hook must be+    --     thread-safe when 'maxConcurrentAdvances' exceeds 1. The default+    --     @hPutStrLn stderr@ renderer qualifies: lines may interleave with other+    --     output but are not corrupted.     logEvent :: !(ResumeLogEvent -> IO ())   }   deriving stock (Generic)@@ -185,11 +217,15 @@   = ResumeUnknownName !Text !Text   | ResumeTransientError !Text !Text !Text   | ResumeWorkflowCrashed !Text !Text !Int !Int !Text+  | -- | @name@, @id@: the workflow reached a terminal status between crashing+    -- and having that crash recorded, so no attempt was counted against it.+    ResumeCrashRecordSkipped !Text !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.+-- | Defaults: EP-41's 'defaultWorkflowRunOptions', a 1-second poll, a 60-second+-- lease, and sequential advancement. defaultWorkflowResumeOptions :: WorkflowResumeOptions defaultWorkflowResumeOptions =   WorkflowResumeOptions@@ -197,6 +233,7 @@       pollInterval = 1_000_000,       maxAttempts = 5,       leaseTtl = 60,+      maxConcurrentAdvances = 1,       logEvent = defaultResumeLogEvent     } @@ -227,6 +264,12 @@         <> show maxAttempt         <> ": "         <> Text.unpack err+    ResumeCrashRecordSkipped name wid ->+      "keiro resume worker: workflow "+        <> Text.unpack name+        <> " (id "+        <> Text.unpack wid+        <> ") went terminal while its crash was being recorded; skipping"     ResumeWorkflowMarkedFailed name wid err ->       "keiro resume worker: marked workflow "         <> Text.unpack name@@ -246,6 +289,12 @@ data ResumeSummary = ResumeSummary   { -- | Unfinished workflows 'findUnfinishedWorkflowIds' returned this pass.     discovered :: !Int,+    -- | Candidates whose durable state moved this pass: a fresh journal append+    -- committed by the re-invocation, a terminal failure recorded at the crash+    -- ceiling, or an external wake observed to have flipped the row back to+    -- running mid-pass. A replay-only re-suspension is not an advance. Use this,+    -- not 'discovered', as the bounded-drain continuation signal.+    advanced :: !Int,     -- | Workflows re-invoked (found in the registry and run).     resumed :: !Int,     -- | Re-invocations that reached 'Completed' this pass.@@ -258,23 +307,74 @@     failed :: !Int,     -- | Store errors observed while advancing individual workflows.     transientErrors :: !Int,-    -- | Candidates skipped because another worker holds a live lease.-    leaseSkipped :: !Int+    -- | Candidates skipped because another worker holds a live lease or the+    -- row became unavailable between discovery and claim.+    leaseSkipped :: !Int,+    -- | Candidates whose crash-backoff gate is still in the future.+    paced :: !Int,+    -- | Candidates that re-suspended without durable movement while their+    -- instance row still carries an already-due wake hint. Discovery returns+    -- them every pass by design, but only a timer-worker fire (or operator+    -- repair of a cancelled sleep timer) can move them, so a bounded drain must+    -- report them as blocked rather than spin. See ADR 23 and ADR 25.+    sleepDue :: !Int,+    -- | Deduplicated workflow names absent from the application registry.+    unregisteredNames :: !(Set Text)   }   deriving stock (Generic, Eq, Show)  -- | A zeroed 'ResumeSummary'. emptyResumeSummary :: ResumeSummary-emptyResumeSummary = ResumeSummary 0 0 0 0 0 0 0 0+emptyResumeSummary =+  ResumeSummary+    { discovered = 0,+      advanced = 0,+      resumed = 0,+      completed = 0,+      stillSuspended = 0,+      unknownName = 0,+      failed = 0,+      transientErrors = 0,+      leaseSkipped = 0,+      paced = 0,+      sleepDue = 0,+      unregisteredNames = Set.empty+    } +-- | Field-wise addition. A pass builds its summary by combining one+-- single-instance delta per advanced candidate with a seed carrying+-- 'discovered', so the result is the same whether the candidates were advanced+-- sequentially or concurrently.+instance Semigroup ResumeSummary where+  a <> b =+    ResumeSummary+      { discovered = discovered a + discovered b,+        advanced = advanced a + advanced b,+        resumed = resumed a + resumed b,+        completed = completed a + completed b,+        stillSuspended = stillSuspended a + stillSuspended b,+        unknownName = unknownName a + unknownName b,+        failed = failed a + failed b,+        transientErrors = transientErrors a + transientErrors b,+        leaseSkipped = leaseSkipped a + leaseSkipped b,+        paced = paced a + paced b,+        sleepDue = sleepDue a + sleepDue b,+        unregisteredNames = Set.union (unregisteredNames a) (unregisteredNames b)+      }++instance Monoid ResumeSummary where+  mempty = emptyResumeSummary+ -- --------------------------------------------------------------------------- -- Running -- ---------------------------------------------------------------------------  -- | Run one discover-and-reinvoke pass. ----- Discovers every unfinished workflow via 'findUnfinishedWorkflowIds', and for--- each looks its name up in @registry@:+-- Discovers every workflow with progress to make via+-- 'findUnfinishedWorkflowIds' (a workflow suspended on an unresolved wake+-- source is deliberately not discovered), 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);@@ -282,6 +382,19 @@ -- * __absent__ — log a warning and bump 'unknownName' (a workflow whose code was --   removed while instances were in flight must be visible, not silently lost). --+-- Candidates are advanced one at a time by default. Raising+-- 'maxConcurrentAdvances' advances that many at once, which is what a pass+-- wants when step bodies are slow; the reported summary is unchanged either+-- way, because each candidate contributes its own delta and the deltas are+-- added at the end.+--+-- A bounded drain repeats while the previous summary reports @advanced > 0@.+-- When a pass advances nothing, the remaining candidates are blocked in place+-- by pacing, unregistered definitions, leases, transient errors, or due sleeps+-- awaiting a timer worker ('sleepDue'), and the caller must stop and report+-- them rather than spin on 'discovered'. A replay-only re-suspension reports no+-- advance.+-- -- 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).@@ -291,80 +404,125 @@   WorkflowResumeOptions ->   WorkflowRegistry es ->   Eff es ResumeSummary-resumeWorkflowsOnce opts registry = do+resumeWorkflowsOnce = resumeWorkflowsOnceUpTo maxBound++-- | Run one discover-and-reinvoke pass over at most the supplied number of+-- candidates. This is the bounded operator-facing sibling of+-- 'resumeWorkflowsOnce'; a non-positive limit performs the discovery query but+-- advances no workflow. The summary's 'discovered' count is the number admitted+-- to this pass, not a drain-termination signal: paced crash retries and+-- unregistered workflow names and due sleeps awaiting their timer worker remain+-- discoverable without leaving the pool. A replay-only re-suspension reports no+-- advance.+-- Repeat bounded passes while the previous summary reports @advanced > 0@;+-- stop and report the remaining blocked candidates when @advanced == 0@,+-- including 'sleepDue'.+resumeWorkflowsOnceUpTo ::+  forall es.+  (IOE :> es, Store :> es, Error StoreError :> es) =>+  Int ->+  WorkflowResumeOptions ->+  WorkflowRegistry es ->+  Eff es ResumeSummary+resumeWorkflowsOnceUpTo limit 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.+  -- Discovery is the single 'findUnfinishedWorkflowIds' query over the instance+  -- table, and it is exact: an instance is returned only when its row says it+  -- has progress to make. A freshly-spawned child needs no separate seed —+  -- 'Keiro.Workflow.Child.spawnChild' upserts the child's instance row as+  -- 'running' inside the spawn step's transaction (and migration 0011+  -- backfilled the running children that predate the instance table), so a+  -- zero-step child is already visible here.   now <- liftIO getCurrentTime-  unfinished <- findUnfinishedWorkflowIds now-  runningChildren <- findRunningChildIds-  let pairs = dedupeFirstSeen (unfinished <> runningChildren)-      seed = emptyResumeSummary {discovered = length pairs}+  pairs <- take (max 0 limit) <$> findUnfinishedWorkflowIds now+  let seed = emptyResumeSummary {discovered = length pairs}   owner <- UUID.toText <$> liftIO UUIDv4.nextRandom-  foldM (advance owner) seed pairs+  deltas <- advanceAll owner pairs+  pure (mconcat (seed : deltas))   where     mMetrics = runOptions opts ^. #metrics-    dedupeFirstSeen :: [(Text, Text)] -> [(Text, Text)]-    dedupeFirstSeen = go Set.empty-      where-        go !_ [] = []-        go !seen (pair : 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) =+    -- Every candidate produces a summary delta for itself, and the deltas are+    -- added at the end, so the pass's summary does not depend on the order the+    -- candidates finish in. Concurrency is safe here for the same reason it is+    -- safe across processes: discovery returns one row per instance, so no two+    -- candidates address the same workflow, each advance holds its own lease,+    -- and the append path's per-step advisory lock still serializes same-step+    -- writers. It is opt-in because it multiplies in-flight database traffic.+    advanceAll :: Text -> [(Text, Text)] -> Eff es [ResumeSummary]+    advanceAll owner pairs+      | maxConcurrentAdvances opts <= 1 = traverse (advance owner) pairs+      | otherwise =+          runConcurrent $+            pooledMapConcurrentlyN+              (maxConcurrentAdvances opts)+              (raise . advance owner)+              pairs+    advance :: Text -> (Text, Text) -> Eff es ResumeSummary+    advance owner (widText, wnameText) =       case Map.lookup (WorkflowName wnameText) registry of         Nothing -> do           liftIO $ logEvent opts (ResumeUnknownName wnameText widText)-          pure acc {unknownName = unknownName acc + 1}+          pure+            emptyResumeSummary+              { unknownName = 1,+                unregisteredNames = Set.singleton wnameText+              }         Just (WorkflowDef runDef) -> do           let wid = WorkflowId widText               name = WorkflowName wnameText           claimed <- claimInstance owner (leaseTtl opts) name wid-          if not claimed-            then do+          case claimed of+            ClaimLeaseHeld -> do               recordWorkflowLeaseSkipped mMetrics 1-              pure acc {leaseSkipped = leaseSkipped acc + 1}-            else do+              pure emptyResumeSummary {leaseSkipped = 1}+            ClaimPaced -> do+              recordWorkflowLeaseSkipped mMetrics 1+              pure emptyResumeSummary {paced = 1}+            ClaimUnavailable -> do+              recordWorkflowLeaseSkipped mMetrics 1+              pure emptyResumeSummary {leaseSkipped = 1}+            ClaimAcquired -> do               progressedRef <- liftIO (newIORef False)+              appendedRef <- liftIO (newIORef False)               ( do                   attempt <-                     Error.catchError                       @StoreError-                      (AdvOk <$> driveInstance owner name wid runDef)+                      (AdvOk <$> driveInstance appendedRef 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+                  appended <- liftIO (readIORef appendedRef)+                  (delta, progressed) <- handleAttempt appended emptyResumeSummary name wid attempt                   liftIO (writeIORef progressedRef progressed)-                  pure acc'+                  pure delta                 )                 `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+    driveInstance :: (Aeson.ToJSON a) => IORef Bool -> Text -> WorkflowName -> WorkflowId -> (WorkflowId -> Eff (Workflow : es) a) -> Eff es (WorkflowOutcome a)+    driveInstance appendedRef owner name@(WorkflowName wnameText) wid@(WorkflowId widText) runDef = do       mChild <- lookupChild widText wnameText       let runOpts =             runOptions opts               & #leaseHeartbeat               .~ Just LeaseHeartbeat {owner, ttl = leaseTtl opts}+              & #onJournalAppend+              .~ Just (writeIORef appendedRef True)       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+    handleAttempt :: Bool -> ResumeSummary -> WorkflowName -> WorkflowId -> AdvanceResult a -> Eff es (ResumeSummary, Bool)+    handleAttempt appended acc name@(WorkflowName wnameText) wid@(WorkflowId widText) = \case       AdvOk outcome -> do-        pure (bumpForOutcome outcome acc, True)+        delta <- classifyOutcome appended name wid outcome acc+        pure (delta, True)       AdvTransient err -> do         let rendered = Text.pack (show err)         liftIO $ logEvent opts (ResumeTransientError wnameText widText rendered)@@ -375,21 +533,40 @@         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)+        mAttempt <- runTransaction (recordCrashTx widText wnameText rendered)+        case mAttempt of+          -- The instance went terminal between the crash and the crash record,+          -- so the update matched no row. There is nothing left to pace and+          -- nothing to fail: log it, count it with the other things that went+          -- wrong while advancing this instance, and leave the rest of the pass+          -- alone. Before this arm existed the zero-row result failed a+          -- single-row decoder and aborted every remaining candidate.+          Nothing -> do+            liftIO $ logEvent opts (ResumeCrashRecordSkipped wnameText widText)+            recordWorkflowResumeErrors mMetrics 1+            pure (acc {resumed = resumed acc + 1, transientErrors = transientErrors acc + 1}, False)+          Just attempt -> do+            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+                      { advanced = advanced acc + 1,+                        resumed = resumed acc + 1,+                        failed = failed acc + 1+                      },+                    False+                  )+              else pure (acc {resumed = resumed acc + 1}, False)  data AdvanceResult a   = AdvOk !(WorkflowOutcome a)@@ -432,33 +609,59 @@   throwOnAppendConflict childOutcome   throwOnAppendConflict parentOutcome +-- A refusal is deliberately not condemned: the child's own failure marker and+-- its child-row transition must commit even when the parent is already terminal+-- and cannot receive the failure sentinel. condemnOnAppendConflict :: JournalAppendOutcome -> Tx.Transaction () condemnOnAppendConflict = \case   JournalAppendConflict {} -> Tx.condemn+  JournalRefusedTerminal {} -> pure ()   _ -> pure ()  throwOnAppendConflict :: JournalAppendOutcome -> Eff es () throwOnAppendConflict = \case   JournalAppendConflict err -> throwIO (WorkflowJournalAppendError (Text.pack (show err)))+  -- Not an error: a terminal parent simply receives nothing.+  JournalRefusedTerminal {} -> pure ()   _ -> pure () --- | 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}+-- | Fold one re-invocation's outcome into the running summary, counting+-- 'advanced' only for durable movement. The existential result is discarded.+classifyOutcome ::+  (IOE :> es, Store :> es) =>+  Bool ->+  WorkflowName ->+  WorkflowId ->+  WorkflowOutcome a ->+  ResumeSummary ->+  Eff es ResumeSummary+classifyOutcome appended name wid outcome acc = case outcome of+  Completed _ -> pure base {completed = completed base + 1}+  ContinuedAsNew -> pure base+  Cancelled -> pure base+  Failed -> pure base+  Suspended+    | appended -> pure base {stillSuspended = stillSuspended base + 1}+    | otherwise -> do+        now <- liftIO getCurrentTime+        row <- lookupInstance name wid+        let suspendedBase = base {stillSuspended = stillSuspended base + 1}+        pure $ case row of+          Just r+            | r ^. #status == WfSuspended,+              Just wake <- r ^. #wakeAfter,+              wake <= now ->+                suspendedBase {sleepDue = sleepDue suspendedBase + 1}+          Just r+            | r ^. #status == WfRunning ->+                suspendedBase {advanced = advanced suspendedBase + 1}+          _ -> suspendedBase+  where+    base =+      acc+        { resumed = resumed acc + 1,+          advanced = advanced acc + (if appended then 1 else 0)+        }  -- | Poll-and-resume loop: run 'resumeWorkflowsOnce' on the configured -- 'pollInterval' forever. Mirrors how an application schedules
src/Keiro/Workflow/Schema.hs view
@@ -18,6 +18,9 @@     recordStepTx,     lookupStepResultTx,     lockWorkflowStepTx,+    workflowStepLockKey,+    terminalMarkersTx,+    workflowLifecycleMarkersTx,     deleteStepRowTx,     setWorkflowWakeAfterTx,     clearWorkflowWakeAfterTx,@@ -26,21 +29,30 @@     loadStepIndex,     lookupStepResult,     stepExists,+    terminalMarkers,     currentGeneration,     findUnfinishedWorkflowIds,   ) where -import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip6)+import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6) import Data.Int (Int32) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map+import Data.Text qualified as Text import Effectful (Eff, (:>)) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Keiro.Prelude-import Keiro.Workflow.Types (WorkflowId (..), WorkflowName (..))+import Keiro.Workflow.Types+  ( WorkflowId (..),+    WorkflowName (..),+    cancelledStepName,+    completedStepName,+    continuedAsNewStepName,+    failedStepName,+  ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx@@ -62,9 +74,12 @@   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.+-- @INSERT ... ON CONFLICT (workflow_id, workflow_name, generation, step_name)+-- DO NOTHING@, so a replayed or raced write is a no-op. The @generation@+-- component (migration @0008-keiro-workflow-generation.sql@) is what lets the+-- same step name exist independently on each generation a @continueAsNew@+-- rotation opens. 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@@ -85,6 +100,19 @@ lockWorkflowStepTx key =   void (Tx.statement key lockWorkflowStepStmt) +-- | The advisory-lock key that serializes every writer of one workflow step:+-- @\<workflowId\>\/\<workflowName\>\/\<generation\>\/\<stepName\>@.+--+-- Two writers must derive the identical key to be ordered against each other,+-- so both of them go through this function: the journal-append path in+-- "Keiro.Workflow" (@prepareJournalAppend@) and the suspend write in+-- "Keiro.Workflow.Instance" ('Keiro.Workflow.Instance.markInstanceSuspendedAwaiting').+-- The lock is a transaction-scoped Postgres advisory lock, so it is released at+-- commit or rollback and never leaks.+workflowStepLockKey :: Text -> Text -> Int -> Text -> Text+workflowStepLockKey wid name gen stepName =+  Text.intercalate "/" [wid, name, Text.pack (show gen), stepName]+ deleteStepRowTx :: Text -> Text -> Int -> Text -> Tx.Transaction () deleteStepRowTx wid name gen key =   Tx.statement@@ -123,6 +151,43 @@ stepExists (WorkflowName name) (WorkflowId wid) gen key =   runTransaction (Tx.statement (wid, name, fromIntegral gen :: Int32, key) stepExistsStmt) +-- | Which of the two /stopping/ terminal markers — 'cancelledStepName' and+-- 'failedStepName' — are recorded for this workflow generation, in one query.+--+-- Both are ordinary index rows written by the same transactional append path as+-- any step, so this reads the authoritative record rather than a derived status.+-- 'completedStepName' and 'continuedAsNewStepName' are deliberately excluded:+-- they mark a run that /finished/, not one that must stop mid-flight.+--+-- One query rather than two 'stepExists' calls, because every caller wants both+-- answers at the same instant: the run-entry probe and the pre-action boundary+-- check.+terminalMarkers :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Eff es [Text]+terminalMarkers (WorkflowName name) (WorkflowId wid) gen =+  runTransaction (terminalMarkersTx wid name gen)++-- | 'terminalMarkers' inside the caller's transaction, so the journal-append+-- transaction can enforce the same check under the lock it already holds.+terminalMarkersTx :: Text -> Text -> Int -> Tx.Transaction [Text]+terminalMarkersTx wid name gen =+  Tx.statement+    (wid, name, fromIntegral gen :: Int32, cancelledStepName, failedStepName)+    terminalMarkersStmt++-- | Every marker that ends one generation's active lifecycle. Operator+-- cancellation and the normal completion/failure/rotation paths take a shared+-- advisory lock before consulting this set, so only one distinct lifecycle+-- marker can win a race.+workflowLifecycleMarkersTx :: Text -> Text -> Int -> Tx.Transaction [Text]+workflowLifecycleMarkersTx wid name gen =+  Tx.statement+    ( wid,+      name,+      fromIntegral gen :: Int32,+      [completedStepName, cancelledStepName, failedStepName, continuedAsNewStepName]+    )+    workflowLifecycleMarkersStmt+ -- | 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@@ -134,11 +199,25 @@ currentGeneration (WorkflowName name) (WorkflowId wid) =   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 workflow instance that+-- has progress to make right now. Discovery is /exact/: an instance is returned+-- only when its row says so, never speculatively.+--+-- Two arms, matching 'Keiro.Workflow.Instance.WorkflowStatus':+--+-- * @running@ — a wake delivery, an awakeable cancellation, a crash, a rotation,+--   or a resurrection left work to do. (Crash retries stay visible here;+--   'Keiro.Workflow.Instance.claimInstance''s @next_attempt_at@ gate, not+--   discovery, is what paces their backoff.)+-- * @suspended@ with a due @wake_after@ — a sleep whose timer is due but whose+--   fire has not landed yet. A successful fire flips the row to @running@ and+--   clears the hint, so this arm mostly matters when the timer worker is behind.+--+-- A @suspended@ instance with no due wake hint is parked on a wake source+-- (an awakeable, a child, a future sleep) and is deliberately invisible: every+-- path that resolves or abandons a wake writes the instance row in the same+-- transaction, so there is nothing to notice by re-running it. The supplied time+-- is what @wake_after@ is compared against. findUnfinishedWorkflowIds :: (Store :> es) => UTCTime -> Eff es [(Text, Text)] findUnfinishedWorkflowIds now =   runTransaction (Tx.statement now findUnfinishedWorkflowIdsStmt)@@ -237,6 +316,47 @@     )     (D.singleRow (D.column (D.nonNullable D.bool))) +-- The two marker names are parameters rather than SQL literals so the reserved+-- names stay defined once, in "Keiro.Workflow.Types".+terminalMarkersStmt :: Statement (Text, Text, Int32, Text, Text) [Text]+terminalMarkersStmt =+  preparable+    """+    SELECT step_name+    FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND generation = $3+      AND step_name IN ($4, $5)+    """+    ( 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.nonNullable E.text))+    )+    (D.rowList (D.column (D.nonNullable D.text)))++workflowLifecycleMarkersStmt :: Statement (Text, Text, Int32, [Text]) [Text]+workflowLifecycleMarkersStmt =+  preparable+    """+    SELECT step_name+    FROM keiro.keiro_workflow_steps+    WHERE workflow_id = $1+      AND workflow_name = $2+      AND generation = $3+      AND step_name = ANY($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.foldableArray (E.nonNullable E.text))))+    )+    (D.rowList (D.column (D.nonNullable D.text)))+ -- 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@@ -253,18 +373,23 @@     )     (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--- wake_after a self-expiring skip: future sleepers disappear from discovery--- until their timer is due.+-- The status literals must match 'Keiro.Workflow.Instance.statusToText' for+-- running and suspended. Both arms are stated positively (rather than as the+-- complement of the terminal trio) because that is the only form the planner+-- can match against the partial index keiro_workflows_active_idx, whose+-- predicate is @status IN ('running','suspended')@: Postgres proves+-- partial-index applicability from the query predicate alone and never consults+-- the table's CHECK constraint. An OR of two arms that each imply the index+-- predicate still implies it, so migration 0021's (status, wake_after) index+-- serves this query; the covered test is "Keiro.Workflow discovery index". 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)+    WHERE status = 'running'+       OR (status = 'suspended' AND wake_after IS NOT NULL AND wake_after <= $1)     ORDER BY workflow_name, workflow_id     """     (E.param (E.nonNullable E.timestamptz))
src/Keiro/Workflow/Sleep.hs view
@@ -81,6 +81,7 @@     -- * Firing and worker wiring     workflowSleepFireAction,     runWorkflowTimerWorker,+    drainWorkflowSleepTimers,      -- * Timer id, payload, and step-name helpers     sleepTimerId,@@ -101,6 +102,7 @@ import Data.UUID.V5 qualified as UUID.V5 import Effectful (Eff, IOE, (:>)) import Effectful.Exception (throwIO)+import Keiro.DeterministicId (identitySeedBytes) import Keiro.Prelude import Keiro.Telemetry (KeiroMetrics) import Keiro.Timer@@ -108,6 +110,7 @@     TimerRequest (..),     TimerRow,     cancelTimer,+    drainDueTimers,     runTimerWorker,     scheduleTimerOnceTx,   )@@ -185,15 +188,19 @@ -- 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 seed is hashed as UTF-8 bytes ('identitySeedBytes'), which is+-- byte-identical to the original codepoint encoding for ASCII seeds and+-- collision-free for the rest; see+-- @docs\/adr\/0024-deterministic-ids-hash-utf-8-seed-bytes-and-are-frozen-replay-identity.md@. 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+      identitySeedBytes $+        Text.intercalate+          ":"+          components   where     components       | gen <= 0 =@@ -340,11 +347,33 @@               wid               targetGen               (StepRecorded {stepName = full, result = Null, recordedAt = now})-          runTransaction (appendTx <* clearWorkflowWakeAfterTx name wid) >>= \case+          -- Only a *fresh* append is a successful fire, and only a successful+          -- fire clears the wake hint. A re-fire (the first fire committed its+          -- append but the worker crashed before marking the timer fired) sees+          -- JournalAlreadyPresent; by then the workflow may have armed a+          -- \*different* sleep whose insert wrote the current hint, and only a+          -- first arm ever writes it, so clearing here would erase a live hint+          -- that nothing rewrites — hot-polling the workflow until the newer+          -- timer fires.+          outcome <-+            runTransaction $ do+              outcome <- appendTx+              case outcome of+                JournalAppended {} -> clearWorkflowWakeAfterTx name wid+                _ -> pure ()+              pure outcome+          case outcome of             JournalAppended {} ->               pure (Just (deterministicJournalId name wid targetGen full))             JournalAlreadyPresent {} ->               pure (Just (deterministicJournalId name wid targetGen full))+            -- The workflow went terminal between the instance lookup above and+            -- this transaction, so the sleep completion was declined. The timer+            -- is still this action's to settle: returning the id marks it fired+            -- rather than leaving it to be requeued forever against a workflow+            -- that will never accept it.+            JournalRefusedTerminal {} ->+              pure (Just (deterministicJournalId name wid targetGen full))             JournalAppendConflict err ->               throwIO (WorkflowJournalAppendError (Text.pack (show err))) @@ -366,8 +395,39 @@   (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 (workflowSleepOrPmFire pmFire)++-- | 'runWorkflowTimerWorker' over a whole batch: drain up to @limit@ due timers+-- in one pass, routing each to the workflow-sleep fire action or the supplied+-- process-manager fallback exactly as the single-claim worker does.+--+-- This is what a deployment wants when many sleeps come due together. The+-- single-claim worker drains a backlog of @K@ due sleeps at one per poll tick,+-- so the last sleeping workflow waits @K@ ticks to wake; one drain pass with a+-- large enough limit wakes them all. Per-timer semantics are unchanged+-- ('Keiro.Timer.drainDueTimersWith'), so a sleep still fires at-least-once and+-- still pins to the generation that armed it.+drainWorkflowSleepTimers ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  UTCTime ->+  -- | Maximum timers to process in this pass.+  Int ->+  -- | Fallback fire action for non-sleep (process-manager) timers.+  (TimerRow -> Eff es (Maybe EventId)) ->+  Eff es Int+drainWorkflowSleepTimers metrics now limit pmFire =+  drainDueTimers metrics now limit (workflowSleepOrPmFire pmFire)++-- | Route one claimed timer: a workflow sleep wakes its workflow, anything else+-- falls through to the caller's process-manager action.+workflowSleepOrPmFire ::+  (IOE :> es, Store :> es) =>+  (TimerRow -> Eff es (Maybe EventId)) ->+  TimerRow ->+  Eff es (Maybe EventId)+workflowSleepOrPmFire pmFire row = do+  handled <- workflowSleepFireAction row+  case handled of+    Just eid -> pure (Just eid)+    Nothing -> pmFire row
src/Keiro/Workflow/Types.hs view
@@ -34,6 +34,7 @@     -- * Journal events and codec     WorkflowJournalEvent (..),     workflowJournalCodec,+    WorkflowError (..),      -- * Accumulated state and run outcome     WorkflowState,@@ -55,6 +56,7 @@   ) where +import Control.Exception (Exception) import Data.Aeson qualified as Aeson import Data.Aeson.Types (parseEither) import Data.Bifunctor (first)@@ -63,6 +65,17 @@ import Keiro.Codec (Codec (..)) import Keiro.Prelude import Kiroku.Store.Types (EventType (..), StreamName (..))++-- | Errors raised while encoding, appending, loading, or decoding a workflow+-- journal.+data WorkflowError+  = WorkflowStepDecodeError !Text !Text+  | WorkflowJournalDecodeError !Text+  | WorkflowJournalEncodeError !Text+  | WorkflowJournalAppendError !Text+  deriving stock (Eq, Show)++instance Exception WorkflowError  -- | The stable name of a workflow /definition/ (for example -- @"orderFulfillment"@). Part of the journal stream name and of every
+ test/CatalogEvolutionSpec.hs view
@@ -0,0 +1,442 @@+{-# LANGUAGE MultilineStrings #-}++module CatalogEvolutionSpec+  ( spec,+  )+where++import CatalogSpec qualified as Catalog+import Contravariant.Extras (contrazip2)+import Data.ByteString (ByteString)+import Data.Either (isRight)+import Data.Text qualified as Text+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog qualified as CatalogApi+import Keiro.Projection.Catalog.Operations qualified as Operations+import Keiro.ReadModel (ReadModel (..), ReadModelStatus (Abandoned), lookupReadModel)+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Types (GlobalPosition (..))+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx++spec :: Fixture -> Spec+spec fixture = describe "catalog evolution adoption" $ around (withFreshStore fixture) $ do+  it "previews and transactionally adopts a changed slice and query schema" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.validCatalog)+    changed <- expectValid changedCatalog+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    let oldSlice = sliceText current Catalog.mainGroupId+        newSlice = sliceText changed Catalog.mainGroupId++    expectStore store (registerProjectionCatalog changed)+      `shouldReturn` Left (RegisteredGroupSliceDrift Catalog.mainGroupId oldSlice newSlice)+    expectStore store (beginGroupRebuild changed Catalog.mainGroupId (request "before-adoption"))+      `shouldReturn` Left (RebuildGroupSliceDrift Catalog.mainGroupId oldSlice newSlice)++    plan <- expectStore store (previewCatalogAdoption changed)+    plan ^. #groupStates+      `shouldBe` [(Catalog.mainGroupId, AdoptionSliceChanged oldSlice newSlice)]+    plan ^. #removedGroups `shouldBe` []+    report <-+      expectStore+        store+        ( Operations.previewCatalogAdoption+            (Operations.projectionCatalogOperations changed)+            (Catalog.mainGroupId :| [])+        )+        >>= shouldBeRight+    report ^. #reportSchema `shouldBe` "keiro/catalog-adoption-preview/v2"+    report ^. #requestedGroups `shouldBe` [Catalog.mainGroupId]+    map (^. #classification) (report ^. #groups)+      `shouldBe` [AdoptionSliceChanged oldSlice newSlice]+    map (^. #inScope) (report ^. #groups) `shouldBe` [True]+    map (^. #currentSlice) (report ^. #groups) `shouldBe` [newSlice]+    report ^. #removedGroups `shouldBe` []++    expectStore+      store+      ( Store.runTransaction+          ( Tx.sql+              "UPDATE keiro.keiro_projection_group_cursors SET position_basis = 'unmanaged', subscription_names = ARRAY[]::TEXT[] WHERE group_id = 'counter-group'"+          )+      )+    beforeAdoption <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    beforeAdoption ^? _Just . #servingPositionBasis+      `shouldBe` Just ServingPositionUnmanaged++    outcome <-+      expectStore+        store+        ( Operations.adoptCatalogGroups+            (Operations.projectionCatalogOperations changed)+            (Catalog.mainGroupId :| [])+        )+        >>= shouldBeRight+    outcome ^. #reportSchema `shouldBe` "keiro/catalog-adoption-outcome/v2"+    let adopted = outcome ^. #adoptedGroups+    map (^. #sliceFingerprint) adopted `shouldBe` [newSlice]+    afterAdoption <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    afterAdoption ^? _Just . #servingPositionBasis+      `shouldBe` Just ServingPositionCheckpoint+    metadata <- expectStore store (lookupReadModel "catalog-counter-query")+    metadata ^? _Just . #version `shouldBe` Just 2+    metadata ^? _Just . #shapeHash+      `shouldBe` Just "catalog-counter-query-v1-adopted"+    registered <- expectStore store (registerProjectionCatalog changed)+    registered `shouldSatisfy` isRight++    promoted <-+      expectStore+        store+        (startCatalogRebuild changed Catalog.mainGroupId (options "after-adoption"))+        >>= shouldBeRight+    promoted ^. #runStatus `shouldBe` RebuildRunPromoted++  it "refuses unregistered and non-live groups without partially adopting" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.additiveCatalog)+    changed <- expectValid (changeCatalog (rebuildableCatalog Catalog.additiveCatalog))+    base <- expectValid (rebuildableCatalog Catalog.validCatalog)+    _ <- expectStore store (registerProjectionCatalog base) >>= shouldBeRight+    expectStore store (adoptCatalogGroups current (Catalog.additiveGroupId :| []))+      `shouldReturn` Left (AdoptGroupUnregistered Catalog.additiveGroupId)++    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    _ <-+      expectStore store (beginGroupRebuild current Catalog.mainGroupId (request "non-live"))+        >>= shouldBeRight+    let mainBefore = sliceText current Catalog.mainGroupId+        additiveBefore = sliceText current Catalog.additiveGroupId+    expectStore+      store+      (adoptCatalogGroups changed (Catalog.additiveGroupId :| [Catalog.mainGroupId]))+      `shouldReturn` Left (AdoptGroupNotLive Catalog.mainGroupId GroupRebuilding (Just (runId "non-live")))+    main <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    main ^? _Just . #sliceFingerprint `shouldBe` Just mainBefore+    main ^? _Just . #status `shouldBe` Just GroupRebuilding+    additive <- expectStore store (lookupProjectionRebuildGroup Catalog.additiveGroupId)+    additive ^? _Just . #sliceFingerprint `shouldBe` Just additiveBefore++  it "classifies and adopts a live slice-v1 stored fingerprint" $ \store -> do+    current <- expectValid Catalog.validCatalog+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    let stale = "slice-v1:" <> Text.replicate 64 "a"+    expectStore+      store+      (Store.runTransaction (Tx.statement (rebuildGroupIdText Catalog.mainGroupId, stale) setStoredSliceStmt))+    expectStore store (registerProjectionCatalog current)+      `shouldReturn` Left (RegisteredGroupStaleFingerprint Catalog.mainGroupId stale)+    plan <- expectStore store (previewCatalogAdoption current)+    plan ^. #groupStates+      `shouldBe` [(Catalog.mainGroupId, AdoptionStaleFormat stale)]+    _ <-+      expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    registered <- expectStore store (registerProjectionCatalog current)+    registered `shouldSatisfy` isRight++  it "adopts only stale-format failed groups while preserving their fence" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.validCatalog)+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    handle <-+      expectStore store (beginGroupRebuild current Catalog.mainGroupId (request "failed-adoption"))+        >>= shouldBeRight+    _ <-+      expectStore+        store+        (abandonGroupRebuild handle (RebuildFailure "operator.abandoned" "prepare adoption boundary"))+        >>= shouldBeRight++    expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))+      `shouldReturn` Left+        (AdoptGroupNotLive Catalog.mainGroupId GroupFailed (Just (runId "failed-adoption")))++    let stale = "slice-v1:" <> Text.replicate 64 "a"+    expectStore+      store+      (Store.runTransaction (Tx.statement (rebuildGroupIdText Catalog.mainGroupId, stale) setStoredSliceStmt))+    adopted <-+      expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    map (^. #status) (adopted ^. #adoptedGroups) `shouldBe` [GroupFailed]+    map (^. #sliceFingerprint) (adopted ^. #adoptedGroups)+      `shouldBe` [sliceText current Catalog.mainGroupId]++  it "adopts a renamed query registration completely" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.validCatalog)+    renamed <- expectValid renamedCatalog+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    let oldSlice = sliceText current Catalog.mainGroupId+        newSlice = sliceText renamed Catalog.mainGroupId+    expectStore store (registerProjectionCatalog renamed)+      `shouldReturn` Left (RegisteredGroupSliceDrift Catalog.mainGroupId oldSlice newSlice)+    plan <- expectStore store (previewCatalogAdoption renamed)+    plan ^. #registrations+      `shouldContain` [RegistrationAdoption "catalog-counter-query-renamed" Catalog.mainGroupId RegistrationInsert]+    plan ^. #orphanedRegistrations+      `shouldContain` [OrphanedRegistration "catalog-counter-query" Catalog.mainGroupId]+    result <-+      expectStore store (adoptCatalogGroups renamed (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    result ^. #registrationOutcomes+      `shouldContain` [RegistrationAdoption "catalog-counter-query-renamed" Catalog.mainGroupId RegistrationInsert]+    result ^. #removedOrphans+      `shouldBe` [OrphanedRegistration "catalog-counter-query" Catalog.mainGroupId]+    renamedRow <- expectStore store (lookupReadModel "catalog-counter-query-renamed")+    renamedRow ^? _Just . #shapeHash+      `shouldBe` Just "catalog-counter-query-renamed-v1"+    oldRow <- expectStore store (lookupReadModel "catalog-counter-query")+    oldRow `shouldBe` Nothing+    registered <- expectStore store (registerProjectionCatalog renamed)+    registered `shouldSatisfy` isRight++  it "inserts an added query registration during adoption" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.validCatalog)+    added <- expectValid addedQueryCatalog+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    plan <- expectStore store (previewCatalogAdoption added)+    plan ^. #registrations+      `shouldContain` [RegistrationAdoption "catalog-added-query" Catalog.mainGroupId RegistrationInsert]+    plan ^. #orphanedRegistrations `shouldBe` []+    result <-+      expectStore store (adoptCatalogGroups added (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    result ^. #registrationOutcomes+      `shouldContain` [RegistrationAdoption "catalog-added-query" Catalog.mainGroupId RegistrationInsert]+    result ^. #removedOrphans `shouldBe` []+    addedRow <- expectStore store (lookupReadModel "catalog-added-query")+    addedRow ^? _Just . #rebuildGroupId+      `shouldBe` Just (rebuildGroupIdText Catalog.mainGroupId)++  it "does not orphan a query registration moved to an out-of-scope group" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.additiveCatalog)+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    expectStore+      store+      ( Store.runTransaction+          ( Tx.statement+              ("catalog-additive-query", rebuildGroupIdText Catalog.mainGroupId)+              setQueryGroupStmt+          )+      )+    plan <- expectStore store (previewCatalogAdoption current)+    plan ^. #orphanedRegistrations `shouldBe` []+    result <-+      expectStore store (adoptCatalogGroups current (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    result ^. #removedOrphans `shouldBe` []+    movedRow <- expectStore store (lookupReadModel "catalog-additive-query")+    movedRow ^? _Just . #rebuildGroupId+      `shouldBe` Just (rebuildGroupIdText Catalog.mainGroupId)++  it "keeps an inserted registration fenced when adopting a failed stale-format group" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql catalogFixtureSql))+    current <- expectValid (rebuildableCatalog Catalog.validCatalog)+    renamed <- expectValid renamedCatalog+    _ <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    handle <-+      expectStore store (beginGroupRebuild current Catalog.mainGroupId (request "failed-insert"))+        >>= shouldBeRight+    _ <-+      expectStore store (abandonGroupRebuild handle (RebuildFailure "operator.abandoned" "fence insert"))+        >>= shouldBeRight+    let stale = "slice-v1:" <> Text.replicate 64 "b"+    expectStore+      store+      (Store.runTransaction (Tx.statement (rebuildGroupIdText Catalog.mainGroupId, stale) setStoredSliceStmt))+    _ <-+      expectStore store (adoptCatalogGroups renamed (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    renamedRow <- expectStore store (lookupReadModel "catalog-counter-query-renamed")+    renamedRow ^? _Just . #status `shouldBe` Just Abandoned++changedCatalog :: ProjectionCatalog+changedCatalog = changeCatalog (rebuildableCatalog Catalog.validCatalog)++renamedCatalog :: ProjectionCatalog+renamedCatalog =+  let base = rebuildableCatalog Catalog.validCatalog+   in base {queryModels = renameCounterQuery <$> base ^. #queryModels}++renameCounterQuery :: SomeQueryModelBinding -> SomeQueryModelBinding+renameCounterQuery (SomeQueryModelBinding binding)+  | binding ^. #readModel . #name == "catalog-counter-query" =+      SomeQueryModelBinding+        binding+          { readModel =+              (binding ^. #readModel)+                { name = "catalog-counter-query-renamed",+                  shapeHash = "catalog-counter-query-renamed-v1"+                }+          }+  | otherwise = SomeQueryModelBinding binding++addedQueryCatalog :: ProjectionCatalog+addedQueryCatalog =+  let base = rebuildableCatalog Catalog.validCatalog+   in base+        { queryModels =+            base ^. #queryModels+              <> [SomeQueryModelBinding addedQueryBinding]+        }++addedQueryBinding :: QueryModelBinding Text ()+addedQueryBinding =+  Catalog.counterBinding+    { queryModelId = queryModelIdentity "added-query",+      readModel =+        (Catalog.counterBinding ^. #readModel)+          { name = "catalog-added-query",+            shapeHash = "catalog-added-query-v1"+          },+      rebuildGroup = Catalog.mainGroupId,+      observedTargets = [Catalog.counterTargetId],+      claimSite = claimSiteIdentity "catalog:added-query"+    }++rebuildableCatalog :: ProjectionCatalog -> ProjectionCatalog+rebuildableCatalog catalog =+  catalog+    { targets =+        [ target & #resetPolicy .~ ClearBeforeReplay+        | target <- catalog ^. #targets+        ]+    }++changeCatalog :: ProjectionCatalog -> ProjectionCatalog+changeCatalog catalog =+  catalog+    { sources =+        [ source & #codecFingerprint .~ (source ^. #codecFingerprint <> "-adopted")+        | source <- catalog ^. #sources+        ],+      queryModels = bumpQueryModel <$> catalog ^. #queryModels+    }++bumpQueryModel :: SomeQueryModelBinding -> SomeQueryModelBinding+bumpQueryModel (SomeQueryModelBinding binding) =+  SomeQueryModelBinding+    ( binding+        & #readModel+        . #version+        %~ (+ 1)+        & #readModel+        . #shapeHash+        %~ (<> "-adopted")+    )++sliceText :: ValidatedProjectionCatalog -> RebuildGroupId -> Text+sliceText catalog groupId =+  maybe+    (error "test catalog group has no slice")+    groupSliceFingerprintText+    (CatalogApi.groupSliceFingerprint catalog groupId)++request :: Text -> RebuildRequest+request identity =+  RebuildRequest+    { rebuildRunId = runId identity,+      requestedBy = "catalog-evolution-spec",+      requestReason = "exercise explicit catalog adoption",+      replayFrom = GlobalPosition 0+    }++options :: Text -> RebuildOptions+options = defaultRebuildOptions . request++runId :: Text -> RebuildRunId+runId identity =+  case mkRebuildRunId identity of+    Left err -> error (Text.unpack err)+    Right value -> value++queryModelIdentity :: Text -> QueryModelId+queryModelIdentity identity =+  case mkQueryModelId identity of+    Left err -> error (show err)+    Right value -> value++claimSiteIdentity :: Text -> ClaimSite+claimSiteIdentity identity =+  case mkClaimSite identity of+    Left err -> error (show err)+    Right value -> value++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics ->+      expectationFailure ("expected valid catalog, got " <> show diagnostics)+        >> error "unreachable"++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure ("store action failed: " <> show err) >> error "unreachable"+    Right value -> pure value++shouldBeRight :: (Show err) => Either err value -> IO value+shouldBeRight = \case+  Left err -> expectationFailure ("expected Right, got Left " <> show err) >> error "unreachable"+  Right value -> pure value++catalogFixtureSql :: ByteString+catalogFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY);+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    counter_id bigint REFERENCES app.counter(id)+  );+  CREATE TABLE app.catalog_additive (id bigint PRIMARY KEY);+  INSERT INTO subscriptions (subscription_name, last_seen)+  VALUES ('catalog-async-subscription', 0);+  """++setStoredSliceStmt :: Statement (Text, Text) ()+setStoredSliceStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET slice_fingerprint = $2+    WHERE group_id = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++setQueryGroupStmt :: Statement (Text, Text) ()+setQueryGroupStmt =+  preparable+    """+    UPDATE keiro.keiro_read_models+    SET rebuild_group_id = $2+    WHERE name = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult
+ test/CatalogOperationsSpec.hs view
@@ -0,0 +1,306 @@+{-# LANGUAGE MultilineStrings #-}++module CatalogOperationsSpec+  ( spec,+  )+where++import CatalogSpec qualified as Catalog+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString (ByteString)+import Data.Either (isLeft)+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Keiro.Prelude+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog.Operations qualified as Operations+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Types (GlobalPosition (..))+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx++spec :: Fixture -> Spec+spec fixture = do+  describe "projection catalog operations reports" $ do+    it "derives complete versioned inventory and destructive preview from one catalog" $ do+      validated <- expectValid (operationsCatalog passingVerification)+      let operations = Operations.projectionCatalogOperations validated+          inventory = Operations.catalogInventoryReport operations+          previewResult = Operations.previewGroupRebuild operations Catalog.mainGroupId+      inventory ^. #reportSchema `shouldBe` "keiro/catalog-inventory/v2"+      case Aeson.toJSON inventory of+        Aeson.Object fields -> do+          KeyMap.lookup "catalogFingerprint" fields `shouldSatisfy` (/= Nothing)+          KeyMap.lookup "inventory" fields+            `shouldSatisfy` \case+              Just (Aeson.Object inventoryFields) ->+                subscriptionsExposePolicy inventoryFields+                  && queriesExposeFreshnessAndCursor inventoryFields+              _ -> False+        other -> expectationFailure ("expected inventory object, got " <> show other)+      report <- case previewResult of+        Left err -> expectationFailure ("expected preview, got " <> show err) >> error "unreachable"+        Right value -> pure value+      report ^. #reportSchema `shouldBe` "keiro/catalog-rebuild-preview/v2"+      map (^. #resetPolicy) (report ^. #targets)+        `shouldBe` [PreserveAndReconcile, ClearBeforeReplay]+      map (^. #subscriptionName) (report ^. #subscriptionResets)+        `shouldBe` ["catalog-async-subscription"]+      map (^. #dedupName) (report ^. #dedupResets)+        `shouldBe` ["catalog-async"]+      report ^. #destructive `shouldBe` True+      report ^. #lockScope `shouldBe` [Catalog.mainGroupId]+      Operations.previewGroupRebuild operations (identityOrError mkRebuildGroupId "missing")+        `shouldSatisfy` isLeft++    it "renders stream-scoped repair policy in the stable catalog inventory JSON" $ do+      validated <-+        expectValid+          ( Catalog.bridgeCatalog+              { projectionRevisions =+                  [ Catalog.bridgeRevisionV1 & #streamScopedReplays .~ [Catalog.streamScopedCounterRepair],+                    Catalog.bridgeRevisionV2+                  ]+              }+          )+      case Aeson.toJSON (Operations.catalogInventoryReport (Operations.projectionCatalogOperations validated)) of+        Aeson.Object fields ->+          KeyMap.lookup "inventory" fields+            `shouldSatisfy` \case+              Just (Aeson.Object inventoryFields) -> inventoryExposesStreamPolicy inventoryFields+              _ -> False+        other -> expectationFailure ("expected inventory object, got " <> show other)++    it "does not construct operations or invoke a callback for an invalid catalog" $ do+      effects <- newIORef (0 :: Int)+      result <-+        useProjectionCatalogM+          (Catalog.validCatalog {projectionSets = []})+          ( \validated -> do+              modifyIORef' effects (+ 1)+              pure (Operations.projectionCatalogOperations validated)+          )+      case result of+        Failure _ -> pure ()+        Success _ -> expectationFailure "invalid catalog unexpectedly constructed operations"+      readIORef effects `shouldReturn` 0++  describe "projection catalog operations actions" $ around (withFreshStore fixture) $ do+    it "keeps registered preview read-only and starts a zero-event rebuild from catalog facts" $ \store -> do+      expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))+      validated <- expectValid (operationsCatalog passingVerification)+      let operations = Operations.projectionCatalogOperations validated+      beforePreview <- expectStore store (Operations.previewRegisteredGroupRebuild operations Catalog.mainGroupId) >>= shouldBeRight+      beforePreview ^. #registeredState `shouldBe` Nothing+      beforePreview ^. #registeredSliceMatches `shouldBe` Nothing+      _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+      registered <- expectStore store (Operations.previewRegisteredGroupRebuild operations Catalog.mainGroupId) >>= shouldBeRight+      registered ^? #registeredState . _Just . #status `shouldBe` Just GroupLive+      registered ^. #registeredSliceMatches `shouldBe` Just True+      different <- expectValid Catalog.validCatalog+      mismatchedPreview <-+        expectStore+          store+          (Operations.previewRegisteredGroupRebuild (Operations.projectionCatalogOperations different) Catalog.mainGroupId)+          >>= shouldBeRight+      mismatchedPreview ^. #registeredSliceMatches `shouldBe` Just False+      started <-+        expectStore+          store+          (Operations.startGroupRebuild operations Catalog.mainGroupId (options "operations-success"))+          >>= shouldBeRight+      started ^. #reportSchema `shouldBe` "keiro/catalog-rebuild-run/v1"+      started ^. #run . #runStatus `shouldBe` RebuildRunPromoted++    it "inspects and resumes a failed run without caller-supplied fleet lists" $ \store -> do+      expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))+      faulted <- expectValid (operationsCatalog failingVerification)+      _ <- expectStore store (registerProjectionCatalog faulted) >>= shouldBeRight+      let faultedOperations = Operations.projectionCatalogOperations faulted+      first <- expectStore store (Operations.startGroupRebuild faultedOperations Catalog.mainGroupId (options "operations-resume"))+      first `shouldSatisfy` \case+        Left (Operations.CatalogOpsRebuildError CatalogRebuildVerificationFailed {}) -> True+        _ -> False+      inspected <- expectStore store (Operations.inspectGroupRebuild faultedOperations (runId "operations-resume")) >>= shouldBeRight+      inspected ^. #run . #runStatus `shouldBe` RebuildRunFailed+      foreignCatalog <- expectValid Catalog.validCatalog+      foreignInspection <-+        expectStore+          store+          ( Operations.inspectGroupRebuild+              (Operations.projectionCatalogOperations foreignCatalog)+              (runId "operations-resume")+          )+      foreignInspection `shouldSatisfy` \case+        Left (Operations.CatalogOpsRunSliceMismatch mismatchedRun _ _) -> mismatchedRun == runId "operations-resume"+        _ -> False++      repaired <- expectValid (operationsCatalog passingVerification)+      resumed <-+        expectStore+          store+          (Operations.resumeGroupRebuild (Operations.projectionCatalogOperations repaired) (runId "operations-resume") (options "ignored"))+          >>= shouldBeRight+      resumed ^. #run . #runStatus `shouldBe` RebuildRunPromoted++    it "abandons a failed run with durable group and run evidence" $ \store -> do+      expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))+      faulted <- expectValid (operationsCatalog failingVerification)+      _ <- expectStore store (registerProjectionCatalog faulted) >>= shouldBeRight+      let operations = Operations.projectionCatalogOperations faulted+      _ <- expectStore store (Operations.startGroupRebuild operations Catalog.mainGroupId (options "operations-abandon"))+      abandoned <-+        expectStore+          store+          ( Operations.abandonGroupRebuild+              operations+              (runId "operations-abandon")+              RebuildFailure+                { failureCode = "operator.abandoned",+                  failureDetail = "operator chose rollback"+                }+          )+          >>= shouldBeRight+      abandoned ^. #run . #runStatus `shouldBe` RebuildRunFailed+      state <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+      state ^? _Just . #status `shouldBe` Just GroupFailed+      state ^? _Just . #failureCode `shouldBe` Just (Just "operator.abandoned")++operationsCatalog :: RebuildVerification -> ProjectionCatalog+operationsCatalog verificationHook =+  Catalog.validCatalog+    { targets =+        [ if target ^. #targetId == Catalog.counterTargetId+            then target & #resetPolicy .~ PreserveAndReconcile+            else target & #resetPolicy .~ ClearBeforeReplay+        | target <- Catalog.validCatalog ^. #targets+        ],+      rebuildGroups =+        [ group & #verificationHooks .~ [verificationHook]+        | group <- Catalog.validCatalog ^. #rebuildGroups+        ]+    }++subscriptionsExposePolicy :: Aeson.Object -> Bool+subscriptionsExposePolicy inventoryFields =+  case KeyMap.lookup "subscriptions" inventoryFields of+    Just (Aeson.Array subscriptions) ->+      case Vector.toList subscriptions of+        Aeson.Object subscriptionFields : _ ->+          KeyMap.lookup "checkpointOnMissing" subscriptionFields+            == Just (Aeson.String "FromBeginning")+        _ -> False+    _ -> False++queriesExposeFreshnessAndCursor :: Aeson.Object -> Bool+queriesExposeFreshnessAndCursor inventoryFields =+  case KeyMap.lookup "queryModels" inventoryFields of+    Just (Aeson.Array queries) ->+      case Vector.toList queries of+        Aeson.Object queryFields : _ ->+          KeyMap.member "freshness" queryFields+            && KeyMap.member "cursor" queryFields+        _ -> False+    _ -> False++inventoryExposesStreamPolicy :: Aeson.Object -> Bool+inventoryExposesStreamPolicy inventoryFields =+  case KeyMap.lookup "projectionRevisions" inventoryFields of+    Just (Aeson.Array revisions) -> any revisionHasPolicy (Vector.toList revisions)+    _ -> False+  where+    revisionHasPolicy = \case+      Aeson.Object revisionFields ->+        case KeyMap.lookup "streamScopedReplays" revisionFields of+          Just (Aeson.Array policies) -> any policyIsStable (Vector.toList policies)+          _ -> False+      _ -> False++    policyIsStable = \case+      Aeson.Object policyFields ->+        KeyMap.lookup "projectionId" policyFields == Just (Aeson.String "audit-owner")+          && KeyMap.lookup "ownedTargets" policyFields+            == Just (Aeson.Array (Vector.singleton (Aeson.String "audit-target")))+          && KeyMap.lookup "affectedAsyncDedup" policyFields+            == Just (Aeson.Array (Vector.singleton (Aeson.String "counter-dedup")))+          && all (`KeyMap.member` policyFields) ["clearer", "replay", "verification"]+      _ -> False++passingVerification :: RebuildVerification+passingVerification = verification (pure (Right ()))++failingVerification :: RebuildVerification+failingVerification = verification (pure (Left "fault injected by operations spec"))++verification :: Tx.Transaction (Either Text ()) -> RebuildVerification+verification action =+  RebuildVerification+    { verificationId = "operations-row-check",+      verificationVersion = "v1",+      verifyRebuild = action+    }++options :: Text -> RebuildOptions+options identity =+  defaultRebuildOptions+    RebuildRequest+      { rebuildRunId = runId identity,+        requestedBy = "catalog-operations-spec",+        requestReason = "operator-neutral adapter proof",+        replayFrom = GlobalPosition 0+      }++runId :: Text -> RebuildRunId+runId identity =+  case mkRebuildRunId identity of+    Left err -> error (Text.unpack err)+    Right value -> value++identityOrError :: (Text -> Either CatalogIdentityError identity) -> Text -> identity+identityOrError constructor value =+  case constructor value of+    Left err -> error (show err)+    Right identity -> identity++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics ->+      expectationFailure ("expected valid catalog, got " <> show diagnostics)+        >> error "unreachable"++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure ("store action failed: " <> show err) >> error "unreachable"+    Right value -> pure value++shouldBeRight :: (Show err) => Either err value -> IO value+shouldBeRight = \case+  Left err -> expectationFailure ("expected Right, got Left " <> show err) >> error "unreachable"+  Right value -> pure value++operationsFixtureSql :: ByteString+operationsFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY);+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    counter_id bigint REFERENCES app.counter(id)+  );+  INSERT INTO subscriptions (subscription_name, last_seen)+  VALUES ('catalog-async-subscription', 0);+  """
+ test/CatalogSpec.hs view
@@ -0,0 +1,1375 @@+module CatalogSpec+  ( spec,+    CatalogEvent (..),+    validCatalog,+    bridgeCatalog,+    bridgeRevisionV1,+    bridgeRevisionV2,+    streamScopedCounterRepair,+    additiveCatalog,+    validProjectionSet,+    catalogWithMissingSubscription,+    catalogAsyncProjection,+    inlineProjectionId,+    asyncProjectionId,+    counterTargetId,+    auditTargetId,+    mainGroupId,+    additiveGroupId,+    counterBinding,+    counterReadContract,+  )+where++import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.List qualified as List+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Keiro.Prelude+import Keiro.Projection (AsyncProjection (..), InlineProjection (..))+import Keiro.Projection.Catalog+import Keiro.ReadModel (ConsistencyMode (..), HeadScope (..), ReadModel (..), StrongScope (..))+import Kiroku.Store.Subscription.Types (MissingCheckpointPolicy (..))+import Kiroku.Store.Types (CategoryName (..))+import Test.Hspec++data CatalogEvent = CatalogEvent+  deriving stock (Eq, Show)++spec :: Spec+spec = describe "Keiro.Projection.Catalog" $ do+  it "derives typed live handlers, registrations, inventory, and one stable fingerprint" $ do+    validated <- expectValid validCatalog+    map (^. #name) (typedInlineProjections validated validProjectionSet)+      `shouldBe` ["catalog-inline"]+    map (^. #projectionId) (catalogInventory validated ^. #inventoryProjections)+      `shouldBe` [asyncProjectionId, inlineProjectionId]+    catalogRegistrations validated+      `shouldSatisfy` ((== [auditQueryId, counterQueryId]) . map (^. #queryModelId))+    asyncProjectionRegistrations validated+      `shouldSatisfy` ((== [asyncProjectionId]) . map (^. #projectionId))+    map (^. #checkpointOnMissing) (asyncProjectionRegistrations validated)+      `shouldBe` [FromBeginning]++    reordered <- expectValid (reverseCatalog validCatalog)+    catalogInventory reordered `shouldBe` catalogInventory validated+    catalogFingerprint reordered `shouldBe` catalogFingerprint validated+    renderCatalogInventory reordered `shouldBe` renderCatalogInventory validated++  it "resolves several query models to one multi-target owner without list-order dependence" $ do+    validated <- expectValid sharedOwnerCatalog+    let supplies = resolvedQuerySupplies validated+    map (^. #resolvedQueryModelId) supplies+      `shouldBe` [auditQueryId, counterQueryId]+    map (^. #resolvedProjectionId) supplies+      `shouldBe` [sharedProjectionId, sharedProjectionId]+    map (^. #resolvedRebuildGroupId) supplies+      `shouldBe` [mainGroupId, mainGroupId]+    map (NonEmpty.toList . (^. #resolvedObservedTargets)) supplies+      `shouldBe` [[auditTargetId, counterTargetId], [counterTargetId]]+    map (^. #resolvedQueryFreshness) supplies+      `shouldBe` [InventoryImmediate, InventoryImmediate]+    map (^. #resolvedQueryCursor) supplies+      `shouldBe` [ Just (InventoryQueryCursor asyncSubscriptionId "catalog-async-subscription"),+                   Just (InventoryQueryCursor asyncSubscriptionId "catalog-async-subscription")+                 ]+    for_ supplies $ \supply -> do+      supply ^. #resolvedSourceId `shouldBe` headSourceId+      case NonEmpty.toList (supply ^. #resolvedHandlerCapabilities) of+        [ InlineCapability inlineName,+          SubscriptionCapability asyncName subscriptionId subscriptionName sourceId policy dedupKeyId dedupName+          ] -> do+            inlineName `shouldBe` "catalog-inline"+            asyncName `shouldBe` "catalog-async"+            subscriptionId `shouldBe` asyncSubscriptionId+            subscriptionName `shouldBe` "catalog-async-subscription"+            sourceId `shouldBe` headSourceId+            policy `shouldBe` FromBeginning+            dedupKeyId `shouldBe` asyncDedupId+            dedupName `shouldBe` "catalog-async"+        capabilities -> expectationFailure ("unexpected capabilities: " <> show capabilities)++    outerReordered <- expectValid (reverseCatalog sharedOwnerCatalog)+    resolvedQuerySupplies outerReordered `shouldBe` supplies+    catalogInventory outerReordered `shouldBe` catalogInventory validated+    catalogFingerprint outerReordered `shouldBe` catalogFingerprint validated+    groupSliceFingerprint outerReordered mainGroupId+      `shouldBe` groupSliceFingerprint validated mainGroupId++    ownedTargetsReordered <- expectValid reorderedSharedOwnerCatalog+    resolvedQuerySupplies ownedTargetsReordered `shouldBe` supplies+    catalogFingerprint ownedTargetsReordered `shouldBe` catalogFingerprint validated+    groupSliceFingerprint ownedTargetsReordered mainGroupId+      `shouldBe` groupSliceFingerprint validated mainGroupId++  it "derives cursor authority from the owner and validates waiting scope" $ do+    immediate <- expectValid validCatalog+    let immediateQueries = catalogInventory immediate ^. #inventoryQueryModels+    map (^. #freshness) immediateQueries+      `shouldBe` [InventoryImmediate, InventoryImmediate]+    map (^. #cursor) immediateQueries+      `shouldBe` [ Just (InventoryQueryCursor asyncSubscriptionId "catalog-async-subscription"),+                   Nothing+                 ]++    waiting <- expectValid (setQueryWait auditQueryId Strong (CategoryHead "counter") validCatalog)+    map (^. #freshness) (catalogInventory waiting ^. #inventoryQueryModels)+      `shouldBe` [InventoryWaitForHead (CategoryVisibleHead "counter"), InventoryImmediate]+    map (^. #resolvedQueryCursor) (resolvedQuerySupplies waiting)+      `shouldBe` [Just (InventoryQueryCursor asyncSubscriptionId "catalog-async-subscription"), Nothing]++    diagnosticsFor (setQueryWait counterQueryId Strong EntireLog validCatalog)+      `shouldSatisfy` hasDiagnostic+        QueryWaitWithoutCompatibleCursor+        (queryModelIdText counterQueryId)+    diagnosticsFor (setQueryWait auditQueryId Strong (CategoryHead "counter") catalogWithMissingSubscription)+      `shouldSatisfy` hasDiagnostic+        QueryWaitWithAmbiguousCursor+        (queryModelIdText auditQueryId)++    let wrongCategory =+          setQueryWait auditQueryId Strong (CategoryHead "orders") $+            validCatalog+              { sources = [headSource & #sourceScope .~ CategorySource (CategoryName "payments")]+              }+    diagnosticsFor wrongCategory+      `shouldSatisfy` hasDiagnostic+        QueryWaitWithoutCompatibleCursor+        (queryModelIdText auditQueryId)++  it "fingerprints freshness and cursor policy only in the owning group slice" $ do+    immediate <- expectValid additiveCatalog+    waiting <- expectValid (setQueryWait auditQueryId Strong (CategoryHead "counter") additiveCatalog)+    catalogFingerprint waiting `shouldNotBe` catalogFingerprint immediate+    groupSliceFingerprint waiting mainGroupId+      `shouldNotBe` groupSliceFingerprint immediate mainGroupId+    groupSliceFingerprint waiting additiveGroupId+      `shouldBe` groupSliceFingerprint immediate additiveGroupId+    renderCatalogInventory waiting+      `shouldSatisfy` Text.isInfixOf "wait-for-head:category-visible-head:counter"++  it "rejects empty and split-owner query target sets with stable derived diagnostics" $ do+    let emptyObserved =+          sharedOwnerCatalog+            { queryModels =+                [ SomeQueryModelBinding (counterBinding & #observedTargets .~ []),+                  SomeQueryModelBinding (auditBinding & #observedTargets .~ [auditTargetId, counterTargetId])+                ]+            }+        splitOwners =+          validCatalog+            { queryModels =+                [ SomeQueryModelBinding counterBinding,+                  SomeQueryModelBinding (auditBinding & #observedTargets .~ [auditTargetId, counterTargetId])+                ]+            }+        emptyDiagnostics = diagnosticsFor emptyObserved+        splitDiagnostics = diagnosticsFor splitOwners+    emptyDiagnostics+      `shouldSatisfy` hasDiagnostic EmptyQueryObservedTargets (queryModelIdText counterQueryId)+    map (^. #diagnosticCode) splitDiagnostics+      `shouldSatisfy` (== [QueryModelWithMultipleSuppliers])+    map claimSiteText (splitDiagnostics ^?! ix 0 . #diagnosticSites)+      `shouldBe` ["catalog:async", "catalog:audit-query", "catalog:inline"]++    let missingOwnerDiagnostics = diagnosticsFor (catalogWithDefinitions (inlineDefinition :| []))+    missingOwnerDiagnostics+      `shouldSatisfy` hasDiagnostic TargetWithoutOwner (targetIdText auditTargetId)+    missingOwnerDiagnostics+      `shouldSatisfy` all ((/= QueryModelWithoutSupplier) . (^. #diagnosticCode))++  it "keeps supply derived from canonical owner and observed-target facts" $ do+    shared <- expectValid sharedOwnerCatalog+    split <- expectValid validCatalog+    observedSubset <-+      expectValid+        sharedOwnerCatalog+          { queryModels =+              [ SomeQueryModelBinding counterBinding,+                SomeQueryModelBinding auditBinding+              ]+          }+    catalogFingerprint shared `shouldNotBe` catalogFingerprint split+    groupSliceFingerprint shared mainGroupId+      `shouldNotBe` groupSliceFingerprint split mainGroupId+    catalogFingerprint shared `shouldNotBe` catalogFingerprint observedSubset+    groupSliceFingerprint shared mainGroupId+      `shouldNotBe` groupSliceFingerprint observedSubset mainGroupId+    let beforeAccessor = catalogFingerprint shared+    resolvedQuerySupplies shared `shouldSatisfy` (not . null)+    catalogFingerprint shared `shouldBe` beforeAccessor++  it "fingerprints and renders checkpoint policy without changing subscription identity" $ do+    fromBeginning <- expectValid validCatalog+    failIfMissing <- expectValid (catalogWithCheckpointPolicy FailIfMissing PreserveAndReconcile)+    catalogFingerprint fromBeginning `shouldNotBe` catalogFingerprint failIfMissing+    map (^. #subscriptionId) (catalogInventory fromBeginning ^. #inventorySubscriptions)+      `shouldBe` map (^. #subscriptionId) (catalogInventory failIfMissing ^. #inventorySubscriptions)+    renderCatalogInventory failIfMissing `shouldSatisfy` Text.isInfixOf "FailIfMissing"++  it "reports missing and independent duplicate owners with every claim site" $ do+    let missing = catalogWithDefinitions (inlineDefinition :| [])+        duplicateOwner = catalogWithDefinitions (inlineDefinition :| [duplicateInlineDefinition, asyncDefinition])+    diagnosticsFor missing+      `shouldSatisfy` hasDiagnostic TargetWithoutOwner (targetIdText auditTargetId)+    let duplicateDiagnostics =+          filter+            (\entry -> entry ^. #diagnosticCode == TargetWithMultipleOwners)+            (diagnosticsFor duplicateOwner)+    map (^. #diagnosticIdentity) duplicateDiagnostics+      `shouldBe` [targetIdText counterTargetId]+    map claimSiteText (duplicateDiagnostics ^?! ix 0 . #diagnosticSites)+      `shouldBe` ["catalog:inline", "catalog:inline-duplicate"]++  it "accumulates stable identity and reference diagnostics independent of input order" $ do+    let invalid =+          validCatalog+            { sources = validCatalog ^. #sources <> [headSource & #claimSite .~ site "catalog:source-duplicate"],+              targets =+                [ counterTarget & #dependsOn .~ [unknownTargetId],+                  auditTarget+                ],+              projectionSets =+                [ SomeProjectionSet+                    validProjectionSet+                      { projectionDefinitions =+                          (inlineDefinition & #rebuildGroup .~ unknownGroupId)+                            :| [ asyncDefinition+                                   & #handlers+                                   .~ ( AsyncHandler catalogAsyncProjection unknownSubscriptionId unknownDedupId (site "catalog:async")+                                          :| []+                                      )+                               ]+                      }+                ]+            }+        expectedCodes =+          [ DuplicateSourceId,+            UnknownTargetDependency,+            UnknownGroupReference,+            UnknownSubscriptionReference,+            UnknownDedupKeyReference+          ]+        diagnostics = diagnosticsFor invalid+        reordered = diagnosticsFor (reverseCatalog invalid)+    for_ expectedCodes $ \code ->+      diagnostics `shouldSatisfy` any ((== code) . (^. #diagnosticCode))+    reordered `shouldBe` diagnostics++  it "rejects duplicate physical tables and runtime registry names" $ do+    let secondSubscription =+          catalogSubscription+            & #subscriptionId+            .~ subscription "second-subscription"+            & #claimSite+            .~ site "catalog:second-subscription"+        secondDedup =+          catalogDedup+            & #dedupKeyId+            .~ dedup "second-dedup"+            & #claimSite+            .~ site "catalog:second-dedup"+        duplicateNames =+          validCatalog+            { targets =+                [ counterTarget,+                  auditTarget & #qualifiedTable .~ (counterTarget ^. #qualifiedTable)+                ],+              subscriptions = [catalogSubscription, secondSubscription],+              dedupKeys = [catalogDedup, secondDedup],+              queryModels =+                [ SomeQueryModelBinding counterBinding,+                  SomeQueryModelBinding+                    (auditBinding & #readModel . #name .~ (counterReadModel ^. #name))+                ]+            }+        codes = map (^. #diagnosticCode) (diagnosticsFor duplicateNames)+    codes `shouldSatisfy` List.elem DuplicateQualifiedTable+    codes `shouldSatisfy` List.elem DuplicateSubscriptionName+    codes `shouldSatisfy` List.elem DuplicateDedupName+    codes `shouldSatisfy` List.elem DuplicateQueryModelRegistryName++  it "rejects cross-group writes, dependency cycles, and unsafe replay combinations" $ do+    let cycleCatalog =+          validCatalog+            { targets =+                [ counterTarget & #dependsOn .~ [auditTargetId],+                  auditTarget & #dependsOn .~ [counterTargetId]+                ]+            }+        secondGroup =+          RebuildGroupDeclaration+            { rebuildGroupId = otherGroupId,+              orderedTargets = [auditTargetId],+              verificationHooks = [],+              claimSite = site "catalog:other-group"+            }+        crossGroupCatalog =+          validCatalog+            { rebuildGroups =+                [ validGroup & #orderedTargets .~ [counterTargetId],+                  secondGroup+                ]+            }+        liveOnlyClear =+          catalogWithDefinitions+            ( inlineDefinition+                { replayPolicy = LiveOnly (LiveOnlyReason "external side effect")+                }+                :| [asyncDefinition]+            )+    diagnosticsFor cycleCatalog+      `shouldSatisfy` any ((== TargetDependencyCycle) . (^. #diagnosticCode))+    diagnosticsFor crossGroupCatalog+      `shouldSatisfy` any ((== ProjectionCrossesRebuildGroups) . (^. #diagnosticCode))+    diagnosticsFor liveOnlyClear+      `shouldSatisfy` any ((== ClearTargetRequiresReplayableOwner) . (^. #diagnosticCode))++  it "accepts every explicit policy except current-head seeding after a replayable clear" $ do+    for_ [FromBeginning, FailIfMissing] $ \policy -> do+      _ <- expectValid (catalogWithCheckpointPolicy policy ClearBeforeReplay)+      pure ()+    for_ [FromBeginning, FromCurrentHead, FailIfMissing] $ \policy -> do+      _ <- expectValid (catalogWithCheckpointPolicy policy PreserveAndReconcile)+      pure ()+    let diagnostics = diagnosticsFor (catalogWithCheckpointPolicy FromCurrentHead ClearBeforeReplay)+    diagnostics+      `shouldSatisfy` hasDiagnostic+        ReplayableClearTargetStartsAtCurrentHead+        (subscriptionIdText asyncSubscriptionId <> "/" <> targetIdText auditTargetId)+    diagnostics+      `shouldSatisfy` any+        ( Text.isInfixOf "FromCurrentHead"+            . (^. #diagnosticMessage)+        )++  it "rejects all-stream/category overlap while accepting distinct category fan-in" $ do+    let secondSourceId = source "audit-source"+        categoryCatalog = twoSourceCatalog (CategorySource (CategoryName "counter")) (CategorySource (CategoryName "audit")) secondSourceId+        overlappingCatalog = twoSourceCatalog AllStreams (CategorySource (CategoryName "audit")) secondSourceId+    _ <- expectValid categoryCatalog+    diagnosticsFor overlappingCatalog+      `shouldSatisfy` any ((== AmbiguousSourceOrdering) . (^. #diagnosticCode))++  it "rejects duplicate and malformed rebuild verification identities" $ do+    let verification identity version =+          RebuildVerification+            { verificationId = identity,+              verificationVersion = version,+              verifyRebuild = pure (Right ())+            }+        withHooks hooks =+          validCatalog+            { rebuildGroups =+                [validGroup {verificationHooks = hooks}]+            }+        duplicate = diagnosticsFor (withHooks [verification "row-count" "v1", verification "row-count" "v2"])+        malformed = diagnosticsFor (withHooks [verification " row-count" ""])+    duplicate+      `shouldSatisfy` any ((== DuplicateRebuildVerificationId) . (^. #diagnosticCode))+    malformed+      `shouldSatisfy` any ((== InvalidRebuildVerificationIdentity) . (^. #diagnosticCode))++  describe "projection revisions" $ do+    it "accepts a v1/v2 bridge and inventories every durable revision fact canonically" $ do+      validated <- expectValid bridgeCatalog+      let revisions = catalogInventory validated ^. #inventoryProjectionRevisions+      map (^. #revisionId) revisions `shouldBe` [revision "counter-v1", revision "counter-v2"]+      map (^. #schemaVersion) (revisions ^.. folded . #targetProvisioners . folded)+        `shouldBe` replicate 2 (TargetSchemaVersion "v1") <> replicate 2 (TargetSchemaVersion "v2")++      reordered <- expectValid (reverseCatalog bridgeCatalog)+      catalogInventory reordered `shouldBe` catalogInventory validated+      catalogFingerprint reordered `shouldBe` catalogFingerprint validated+      groupSliceFingerprint reordered mainGroupId+        `shouldBe` groupSliceFingerprint validated mainGroupId++    it "constructs physical target mappings only when they are closed-world total" $ do+      let supplied =+            Map.fromList+              [ (counterTargetId, QualifiedTable "generation" "counter"),+                (unknownTargetId, QualifiedTable "generation" "unexpected")+              ]+      mkPhysicalTargets [counterTargetId, auditTargetId] supplied+        `shouldBe` Left (MissingPhysicalTarget auditTargetId :| [UnexpectedPhysicalTarget unknownTargetId])+      mkPhysicalTargets+        [counterTargetId, auditTargetId]+        (Map.fromList [(counterTargetId, QualifiedTable "generation" "counter"), (auditTargetId, QualifiedTable "generation" "audit")])+        `shouldSatisfy` (\case Right _ -> True; Left _ -> False)++    it "accumulates stable bridge diagnostics for incomplete revisions and stale read contracts" $ do+      let malformedV1 =+            bridgeRevisionV1+              & #liveHandlers+              .~ []+              & #replayAdapters+              .~ []+              & #targetProvisioners+              .~ Map.insert+                unknownTargetId+                (targetProvisioner "unknown" (TargetSchemaVersion "v1") [] & #validateTarget .~ Nothing)+                (Map.delete auditTargetId (bridgeRevisionV1 ^. #targetProvisioners))+          partialV2 =+            bridgeRevisionV2+              & #liveHandlers+              .~ [ RevisionLiveHandler+                     "counter-v2-live"+                     1+                     (RevisionInlineDelivery inlineProjectionId (catalogInlineProjection ^. #name))+                     [auditTargetId]+                     (\_ _ -> pure ())+                 ]+              & #replayAdapters+              %~ map (#requiredTargets .~ [counterTargetId])+          invalid =+            bridgeCatalog+              { projectionRevisions = [malformedV1, partialV2, bridgeRevisionV2],+                externalReadContracts =+                  [ counterReadContract+                      & #compatibleRevisions+                      .~ (revision "counter-v3" :| [])+                  ]+              }+          diagnostics = diagnosticsFor invalid+      for_+        [ DuplicateProjectionRevisionId,+          UnknownRevisionReference,+          UnknownTargetProvisioner,+          ProjectionRevisionWithoutLiveHandler,+          ProjectionRevisionWithoutReplayAdapter,+          ProjectionRevisionTargetSetDrift,+          ProjectionRevisionMissingSchemaValidation,+          ProjectionRevisionPhysicalTargetsNotTotal,+          ProjectionRevisionLiveCapabilityMismatch,+          ProjectionRevisionLiveTargetOwnershipMismatch+        ]+        (\code -> diagnostics `shouldSatisfy` any ((== code) . (^. #diagnosticCode)))+      diagnosticsFor (reverseCatalog invalid) `shouldBe` diagnostics++    it "fingerprints revision schema, provider, validator, handler, replay, verification, and promotion order" $ do+      baseline <- expectValid bridgeCatalog+      let mutateV2 update =+            bridgeCatalog+              { projectionRevisions = [bridgeRevisionV1, update bridgeRevisionV2]+              }+          variants =+            [ mutateV2 (adjustCounterProvisioner (#schemaVersion .~ TargetSchemaVersion "v2.1")),+              mutateV2 (adjustCounterProvisioner (#provisionerVersion .~ 2)),+              mutateV2 (adjustCounterProvisioner (#validatorVersion .~ 2)),+              mutateV2 (adjustCounterProvisioner (\p -> p & #promotionObjectNames %~ reverse)),+              mutateV2 (\value -> value & #liveHandlers %~ map (#handlerVersion .~ 3)),+              mutateV2 (\value -> value & #replayAdapters %~ map (#adapterVersion .~ 3)),+              mutateV2 (\value -> value & #revisionVerifications %~ map (#revisionVerificationVersion .~ 3))+            ]+      for_ (zip ["schema", "provisioner", "validator", "promotion-order", "live", "replay", "verification"] variants) $ \(label, variant) -> do+        changed <- expectValid variant+        when (catalogFingerprint changed == catalogFingerprint baseline) $+          expectationFailure ("catalog fingerprint ignored revision " <> label <> " identity")+        when (groupSliceFingerprint changed mainGroupId == groupSliceFingerprint baseline mainGroupId) $+          expectationFailure ("group slice fingerprint ignored revision " <> label <> " identity")++    it "validates and fingerprints stream-scoped repair policy in the owning revision" $ do+      baseline <- expectValid bridgeCatalog+      let withPolicy =+            bridgeCatalog+              { projectionRevisions =+                  [ bridgeRevisionV1 & #streamScopedReplays .~ [streamScopedCounterRepair],+                    bridgeRevisionV2+                  ]+              }+      validated <- expectValid withPolicy+      when+        ( isNothing+            ( catalogStreamScopedReplay+                validated+                (revision "counter-v1")+                asyncProjectionId+            )+        )+        (expectationFailure "validated stream-scoped policy was not resolvable")+      case catalogInventory validated ^. #inventoryProjectionRevisions of+        firstRevision : _ -> do+          map (^. #projectionId) (firstRevision ^. #streamScopedReplays)+            `shouldBe` [asyncProjectionId]+          firstRevision ^. #streamScopedReplays . folded . #affectedAsyncDedup+            `shouldBe` [asyncDedupId]+        [] -> expectationFailure "stream-scoped revision inventory was empty"+      catalogFingerprint validated `shouldNotBe` catalogFingerprint baseline+      groupSliceFingerprint validated mainGroupId+        `shouldNotBe` groupSliceFingerprint baseline mainGroupId++    it "rejects unknown, duplicate, cross-group, target, dedup, and identity stream policies" $ do+      let invalidPolicy =+            streamScopedCounterRepair+              & #clearerId+              .~ " bad-clearer"+              & #clearerVersion+              .~ 0+              & #streamOwnedTargets+              .~ (counterTargetId :| [])+              & #affectedAsyncDedup+              .~ []+          invalid =+            bridgeCatalog+              { projectionRevisions =+                  [ bridgeRevisionV1+                      & #streamScopedReplays+                      .~ [ invalidPolicy,+                           invalidPolicy,+                           streamScopedCounterRepair & #streamProjectionId .~ projection "unknown-projection"+                         ],+                    bridgeRevisionV2+                      & #rebuildGroup+                      .~ otherGroupId+                      & #streamScopedReplays+                      .~ [streamScopedCounterRepair]+                  ]+              }+          codes = map (^. #diagnosticCode) (diagnosticsFor invalid)+      for_+        [ DuplicateStreamScopedReplayProjection,+          UnknownStreamScopedReplayProjection,+          StreamScopedReplayGroupMismatch,+          StreamScopedReplayTargetSetMismatch,+          StreamScopedReplayDedupMismatch,+          InvalidStreamScopedReplayIdentity+        ]+        (\code -> codes `shouldSatisfy` List.elem code)++  describe "external read contracts" $ do+    it "validates and inventories a versioned all-row contract canonically" $ do+      validated <- expectValid bridgeCatalog+      catalogExternalReadContracts validated `shouldBe` [counterReadContract]+      case catalogInventory validated ^. #inventoryExternalReadContracts of+        [contract] -> do+          contract ^. #readContractId `shouldBe` identityOrError mkExternalReadContractId "counter_reader"+          contract ^. #contractVersion `shouldBe` ExternalReadContractVersion 1+          contract ^. #functionName `shouldBe` "counter_reader_v1"+          contract ^. #resultShapeHash `shouldBe` "catalog-counter-query-v1"+          NonEmpty.toList (contract ^. #compatibleRevisions)+            `shouldBe` [revision "counter-v1", revision "counter-v2"]+        contracts -> expectationFailure ("unexpected contracts: " <> show contracts)++      let reorderedContract = counterReadContract & #compatibleRevisions %~ NonEmpty.reverse+      reordered <- expectValid (bridgeCatalog {externalReadContracts = [reorderedContract]})+      catalogInventory reordered `shouldBe` catalogInventory validated+      catalogFingerprint reordered `shouldBe` catalogFingerprint validated+      groupSliceFingerprint reordered mainGroupId+        `shouldBe` groupSliceFingerprint validated mainGroupId++    it "accumulates query, shape, revision, SQL, collision, generation, and immutable-signature diagnostics" $ do+      let unknownQuery = counterReadContract & #queryModelId .~ queryModel "missing-query"+          wrongShape = counterReadContract & #resultShapeHash .~ "counter-v2-shape"+          unknownRevision = counterReadContract & #compatibleRevisions .~ (revision "missing" :| [])+          wrongOwnerCatalog =+            bridgeCatalog+              { projectionRevisions =+                  [ bridgeRevisionV1,+                    bridgeRevisionV2 & #rebuildGroup .~ otherGroupId+                  ],+                externalReadContracts =+                  [ counterReadContract+                      & #compatibleRevisions+                      .~ (revision "counter-v2" :| [])+                  ]+              }+          unsafeContract =+            keyedCounterContractWith+              "Unsafe-Reader"+              [SqlFunctionArgument "bad-name" (QualifiedSqlType "pg_catalog" "text[]")]+              (QualifiedSqlType "app_contract" "counter_row_v1")+              (QualifiedFunction "app_private" "lookup_counter")+              1+          duplicateAndDrift = [counterReadContract, keyedCounterContract]+          sharedImplementation = QualifiedFunction "app_private" "lookup_counter"+          implementationCollision =+            [ keyedCounterContract & #readContractId .~ identityOrError mkExternalReadContractId "counter_reader_one",+              keyedCounterContractWith+                "counter_reader_two"+                [SqlFunctionArgument "counter_id" (QualifiedSqlType "pg_catalog" "text")]+                (QualifiedSqlType "app_contract" "counter_row_v1")+                sharedImplementation+                1+            ]+          generationRegression =+            [ counterReadContract & #surfaceGeneration .~ 2,+              counterReadContract+                & #contractVersion+                .~ ExternalReadContractVersion 2+                & #surfaceGeneration+                .~ 1+            ]+          codesFor contracts = map (^. #diagnosticCode) (diagnosticsFor (bridgeCatalog {externalReadContracts = contracts}))+      codesFor [unknownQuery] `shouldSatisfy` List.elem UnknownExternalReadQueryModel+      codesFor [wrongShape] `shouldSatisfy` List.elem ExternalReadShapeMismatch+      codesFor [unknownRevision] `shouldSatisfy` List.elem UnknownRevisionReference+      map (^. #diagnosticCode) (diagnosticsFor wrongOwnerCatalog)+        `shouldSatisfy` List.elem ExternalReadRevisionOwnershipMismatch+      codesFor [unsafeContract] `shouldSatisfy` List.elem InvalidExternalReadSqlIdentifier+      codesFor [unsafeContract] `shouldSatisfy` List.elem InvalidExternalReadSqlType+      codesFor duplicateAndDrift `shouldSatisfy` List.elem DuplicateExternalReadContractVersion+      codesFor duplicateAndDrift `shouldSatisfy` List.elem DuplicateExternalReadFunctionName+      codesFor duplicateAndDrift `shouldSatisfy` List.elem ExternalReadImmutableSignatureDrift+      codesFor implementationCollision `shouldSatisfy` List.elem ExternalReadImplementationCollision+      codesFor generationRegression `shouldSatisfy` List.elem ExternalReadSurfaceGenerationRegression++    it "fingerprints every contract fact in only the owning group slice" $ do+      baseline <- expectValid bridgeCatalog+      let variants =+            [ counterReadContract & #readContractId .~ identityOrError mkExternalReadContractId "counter_reader_renamed",+              counterReadContract & #contractVersion .~ ExternalReadContractVersion 2,+              counterReadContract & #compatibleRevisions .~ (revision "counter-v2" :| []),+              counterReadContract & #surfaceGeneration .~ 2,+              keyedCounterContract,+              keyedCounterContractWith+                "counter_reader"+                [SqlFunctionArgument "counter_id" (QualifiedSqlType "pg_catalog" "text")]+                (QualifiedSqlType "app_contract" "counter_row_v1")+                (QualifiedFunction "app_private" "lookup_counter")+                2,+              keyedCounterContractWith+                "counter_reader"+                [SqlFunctionArgument "counter_id" (QualifiedSqlType "pg_catalog" "uuid")]+                (QualifiedSqlType "app_contract" "counter_row_v1")+                (QualifiedFunction "app_private" "lookup_counter")+                1,+              keyedCounterContractWith+                "counter_reader"+                [SqlFunctionArgument "counter_id" (QualifiedSqlType "pg_catalog" "text")]+                (QualifiedSqlType "app_contract" "counter_row_v2")+                (QualifiedFunction "app_private" "lookup_counter")+                1+            ]+      for_ variants $ \contract -> do+        changed <- expectValid (bridgeCatalog {externalReadContracts = [contract]})+        catalogFingerprint changed `shouldNotBe` catalogFingerprint baseline+        groupSliceFingerprint changed mainGroupId+          `shouldNotBe` groupSliceFingerprint baseline mainGroupId++  it "keeps baseline removal comparison separate from single-catalog validity" $ do+    previous <- expectValid validCatalog+    current <- expectValid smallerCatalog+    compareCatalogBaseline (catalogInventory previous) (catalogInventory current)+      `shouldSatisfy` List.elem (TargetRemoved auditTargetId)++    previousBridge <- expectValid bridgeCatalog+    currentWithoutBridge <- expectValid validCatalog+    compareCatalogBaseline (catalogInventory previousBridge) (catalogInventory currentWithoutBridge)+      `shouldSatisfy` List.elem (ProjectionRevisionRemoved (revision "counter-v1"))+    compareCatalogBaseline (catalogInventory previousBridge) (catalogInventory currentWithoutBridge)+      `shouldSatisfy` List.elem+        ( ExternalReadContractRemoved+            (identityOrError mkExternalReadContractId "counter_reader")+            (ExternalReadContractVersion 1)+        )++  it "does not invoke an effectful callback for an invalid catalog" $ do+    effects <- newIORef (0 :: Int)+    invalidResult <-+      useProjectionCatalogM+        (catalogWithDefinitions (inlineDefinition :| []))+        (\_ -> modifyIORef' effects (+ 1))+    invalidResult `shouldSatisfy` isFailure+    readIORef effects `shouldReturn` 0++    validResult <-+      useProjectionCatalogM+        validCatalog+        (\_ -> modifyIORef' effects (+ 1))+    validResult `shouldSatisfy` isSuccess+    readIORef effects `shouldReturn` 1++  it "labels legacy values as unmanaged without changing their behavior" $ do+    map (^. #name) (getUnmanagedInlineProjections (unmanagedInlineProjections [catalogInlineProjection]))+      `shouldBe` ["catalog-inline"]+    getUnmanagedAsyncProjection (unmanagedAsyncProjection catalogAsyncProjection) ^. #name+      `shouldBe` "catalog-async"+    getUnmanagedReadModel (unmanagedReadModel counterReadModel) ^. #name+      `shouldBe` "catalog-counter-query"++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics -> expectationFailure ("expected a valid catalog, got: " <> show diagnostics) >> error "unreachable"++diagnosticsFor :: ProjectionCatalog -> [CatalogDiagnostic]+diagnosticsFor catalog =+  case validateProjectionCatalog catalog of+    Failure diagnostics -> NonEmpty.toList diagnostics+    Success _ -> []++hasDiagnostic :: CatalogDiagnosticCode -> Text -> [CatalogDiagnostic] -> Bool+hasDiagnostic code identity =+  any+    ( \entry ->+        entry ^. #diagnosticCode == code+          && entry ^. #diagnosticIdentity == identity+    )++isFailure :: Validation error value -> Bool+isFailure (Failure _) = True+isFailure (Success _) = False++isSuccess :: Validation error value -> Bool+isSuccess (Success _) = True+isSuccess (Failure _) = False++validCatalog :: ProjectionCatalog+validCatalog =+  ProjectionCatalog+    { sources = [headSource],+      targets = [counterTarget, auditTarget],+      rebuildGroups = [validGroup],+      projectionRevisions = [],+      externalReadContracts = [],+      subscriptions = [catalogSubscription],+      dedupKeys = [catalogDedup],+      queryModels =+        [ SomeQueryModelBinding counterBinding,+          SomeQueryModelBinding auditBinding+        ],+      projectionSets = [SomeProjectionSet validProjectionSet]+    }++bridgeCatalog :: ProjectionCatalog+bridgeCatalog =+  validCatalog+    { projectionRevisions = [bridgeRevisionV1, bridgeRevisionV2],+      externalReadContracts = [counterReadContract]+    }++counterReadContract :: ExternalReadContract+counterReadContract =+  AllRowsExternalRead+    { readContractId = identityOrError mkExternalReadContractId "counter_reader",+      contractVersion = ExternalReadContractVersion 1,+      queryModelId = counterQueryId,+      resultContractType = QualifiedSqlType "app_contract" "counter_row_v1",+      resultShapeHash = "catalog-counter-query-v1",+      compatibleRevisions = revision "counter-v1" :| [revision "counter-v2"],+      surfaceGeneration = 1,+      claimSite = site "catalog:counter-reader"+    }++keyedCounterContract :: ExternalReadContract+keyedCounterContract =+  keyedCounterContractWith+    "counter_reader"+    [SqlFunctionArgument "counter_id" (QualifiedSqlType "pg_catalog" "text")]+    (QualifiedSqlType "app_contract" "counter_row_v1")+    (QualifiedFunction "app_private" "lookup_counter")+    1++keyedCounterContractWith :: Text -> [SqlFunctionArgument] -> QualifiedSqlType -> QualifiedFunction -> Int -> ExternalReadContract+keyedCounterContractWith identity functionArguments resultType implementation implementationVersion =+  KeyedExternalRead+    { readContractId = identityOrError mkExternalReadContractId identity,+      contractVersion = ExternalReadContractVersion 1,+      queryModelId = counterQueryId,+      arguments = functionArguments,+      resultContractType = resultType,+      privateImplementation = implementation,+      privateImplementationVersion = implementationVersion,+      resultShapeHash = "catalog-counter-query-v1",+      compatibleRevisions = revision "counter-v1" :| [revision "counter-v2"],+      surfaceGeneration = 1,+      claimSite = site "catalog:counter-reader-keyed"+    }++bridgeRevisionV1 :: ProjectionRevision+bridgeRevisionV1 = bridgeRevision "counter-v1" "v1" 1++bridgeRevisionV2 :: ProjectionRevision+bridgeRevisionV2 = bridgeRevision "counter-v2" "v2" 2++streamScopedCounterRepair :: StreamScopedReplay+streamScopedCounterRepair =+  StreamScopedReplay+    { streamProjectionId = asyncProjectionId,+      streamOwnedTargets = auditTargetId :| [],+      clearerId = "audit-owner/clear-stream",+      clearerVersion = 1,+      clearStreamRows = \_ _ -> pure (Right [StreamClearCount auditTargetId 0]),+      streamReplayId = "audit-owner/replay-stream",+      streamReplayVersion = 1,+      replayStreamEvent = \_ _ -> pure (Right False),+      streamVerificationId = "audit-owner/verify-stream",+      streamVerificationVersion = 1,+      verifyStreamRows = \_ _ -> pure (Right ()),+      affectedAsyncDedup = [asyncDedupId],+      claimSite = site "catalog:audit-owner-stream-repair"+    }++bridgeRevision :: Text -> Text -> Int -> ProjectionRevision+bridgeRevision identity schema version =+  ProjectionRevision+    { revisionId = revision identity,+      rebuildGroup = mainGroupId,+      targetProvisioners =+        Map.fromList+          [ ( counterTargetId,+              targetProvisioner+                (identity <> "-counter")+                (TargetSchemaVersion schema)+                [ PromotionObjectName PromotionIndex ("counter_idx__" <> schema) "counter_idx",+                  PromotionObjectName PromotionOwnedSequence ("counter_id_seq__" <> schema) "counter_id_seq"+                ]+            ),+            ( auditTargetId,+              targetProvisioner+                (identity <> "-audit")+                (TargetSchemaVersion schema)+                [PromotionObjectName PromotionConstraint ("counter_audit_pkey__" <> schema) "counter_audit_pkey"]+            )+          ],+      liveHandlers =+        [ RevisionLiveHandler+            (identity <> "-counter-live")+            version+            (RevisionInlineDelivery inlineProjectionId (catalogInlineProjection ^. #name))+            [counterTargetId]+            (\_ _ -> pure ()),+          RevisionLiveHandler+            (identity <> "-audit-live")+            version+            (RevisionSubscriptionDelivery asyncProjectionId asyncSubscriptionId asyncDedupId)+            [auditTargetId]+            (\_ _ -> pure ())+        ],+      replayAdapters =+        [ RevisionReplayAdapter+            (identity <> "-replay")+            version+            [counterTargetId, auditTargetId]+            (\_ _ -> pure (Right False))+        ],+      revisionVerifications =+        [ RevisionVerification+            (identity <> "-verification")+            version+            [counterTargetId, auditTargetId]+            (\_ -> pure (Right ()))+        ],+      streamScopedReplays = [],+      claimSite = site ("catalog:" <> identity)+    }++targetProvisioner :: Text -> TargetSchemaVersion -> [PromotionObjectName] -> TargetProvisioner+targetProvisioner identity schema promotionObjects =+  TargetProvisioner+    { provisionerId = identity <> "-provisioner",+      provisionerVersion = 1,+      schemaVersion = schema,+      expectedShapeId = identity <> "-shape",+      provisionTarget = \_ -> pure (),+      validatorId = identity <> "-validator",+      validatorVersion = 1,+      validateTarget =+        Just+          ( \_ ->+              pure+                ( Right+                    TargetSchemaEvidence+                      { relationOid = 1,+                        observedShapeFingerprint = identity <> "-shape",+                        observedPromotionObjects = promotionObjects,+                        catalogSnapshot = "catalog-snapshot-v1"+                      }+                )+          ),+      promotionObjectNames = promotionObjects+    }++adjustCounterProvisioner :: (TargetProvisioner -> TargetProvisioner) -> ProjectionRevision -> ProjectionRevision+adjustCounterProvisioner update value =+  value & #targetProvisioners %~ Map.adjust update counterTargetId++sharedOwnerCatalog :: ProjectionCatalog+sharedOwnerCatalog = sharedOwnerCatalogWithOrders (counterTargetId :| [auditTargetId]) [auditTargetId, counterTargetId]++reorderedSharedOwnerCatalog :: ProjectionCatalog+reorderedSharedOwnerCatalog =+  reverseCatalog+    (sharedOwnerCatalogWithOrders (auditTargetId :| [counterTargetId]) [counterTargetId, auditTargetId])++sharedOwnerCatalogWithOrders :: NonEmpty TargetId -> [TargetId] -> ProjectionCatalog+sharedOwnerCatalogWithOrders owned observed =+  validCatalog+    { queryModels =+        [ SomeQueryModelBinding counterBinding,+          SomeQueryModelBinding (auditBinding & #observedTargets .~ observed)+        ],+      projectionSets =+        [ SomeProjectionSet+            validProjectionSet+              { projectionDefinitions =+                  inlineDefinition+                    { projectionId = sharedProjectionId,+                      ownedTargets = owned,+                      handlers =+                        InlineHandler catalogInlineProjection (site "catalog:inline-handler")+                          :| [AsyncHandler catalogAsyncProjection asyncSubscriptionId asyncDedupId (site "catalog:async-handler")],+                      claimSite = site "catalog:shared-owner"+                    }+                    :| []+              }+        ]+    }++-- | A catalog extension that adds one completely independent read-side slice.+-- Existing declarations are byte-for-byte unchanged.+additiveCatalog :: ProjectionCatalog+additiveCatalog =+  validCatalog+    { sources = validCatalog ^. #sources <> [additiveSource],+      targets = validCatalog ^. #targets <> [additiveTarget],+      rebuildGroups = validCatalog ^. #rebuildGroups <> [additiveGroup],+      queryModels = validCatalog ^. #queryModels <> [SomeQueryModelBinding additiveBinding],+      projectionSets = validCatalog ^. #projectionSets <> [SomeProjectionSet additiveProjectionSet]+    }++additiveSource :: SourceDeclaration+additiveSource =+  SourceDeclaration+    { sourceId = additiveSourceId,+      sourceScope = CategorySource (CategoryName "catalog-additive"),+      codecFingerprint = "catalog-additive-codec-v1",+      claimSite = site "catalog:additive-source"+    }++additiveTarget :: TargetDeclaration+additiveTarget =+  TargetDeclaration+    { targetId = additiveTargetId,+      qualifiedTable = QualifiedTable "app" "catalog_additive",+      resetPolicy = ClearBeforeReplay,+      dependsOn = [],+      claimSite = site "catalog:additive-target"+    }++additiveGroup :: RebuildGroupDeclaration+additiveGroup =+  RebuildGroupDeclaration+    { rebuildGroupId = additiveGroupId,+      orderedTargets = [additiveTargetId],+      verificationHooks = [],+      claimSite = site "catalog:additive-group"+    }++additiveProjectionSet :: ProjectionSet CatalogEvent+additiveProjectionSet =+  ProjectionSet+    { projectionSource = additiveSourceId,+      projectionDefinitions =+        ProjectionDefinition+          { projectionId = additiveProjectionId,+            rebuildGroup = additiveGroupId,+            ownedTargets = additiveTargetId :| [],+            replayPolicy = replayablePolicy,+            handlers =+              InlineHandler+                InlineProjection+                  { name = "catalog-additive-inline",+                    apply = \_ _ -> pure ()+                  }+                (site "catalog:additive-handler")+                :| [],+            claimSite = site "catalog:additive-projection"+          }+          :| [],+      claimSite = site "catalog:additive-set"+    }++additiveBinding :: QueryModelBinding Text ()+additiveBinding =+  QueryModelBinding+    { queryModelId = additiveQueryId,+      readModel = readModelDefinition "catalog-additive-query" "catalog_additive",+      rebuildGroup = additiveGroupId,+      observedTargets = [additiveTargetId],+      claimSite = site "catalog:additive-query"+    }++validProjectionSet :: ProjectionSet CatalogEvent+validProjectionSet =+  ProjectionSet+    { projectionSource = headSourceId,+      projectionDefinitions = inlineDefinition :| [asyncDefinition],+      claimSite = site "catalog:set"+    }++catalogWithCheckpointPolicy :: MissingCheckpointPolicy -> TargetResetPolicy -> ProjectionCatalog+catalogWithCheckpointPolicy policy reset =+  validCatalog+    { subscriptions = [catalogSubscription & #checkpointOnMissing .~ policy],+      targets =+        [ if target ^. #targetId == auditTargetId+            then target & #resetPolicy .~ reset+            else target & #resetPolicy .~ PreserveAndReconcile+        | target <- validCatalog ^. #targets+        ]+    }++catalogWithMissingSubscription :: ProjectionCatalog+catalogWithMissingSubscription =+  validCatalog+    { subscriptions =+        validCatalog ^. #subscriptions+          <> [ SubscriptionDeclaration+                 { subscriptionId = missingSubscriptionId,+                   subscriptionName = "catalog-missing-subscription",+                   subscriptionSource = headSourceId,+                   checkpointOnMissing = FromBeginning,+                   claimSite = site "catalog:missing-subscription"+                 }+             ],+      dedupKeys =+        validCatalog ^. #dedupKeys+          <> [ DedupKeyDeclaration+                 { dedupKeyId = missingDedupId,+                   dedupName = "catalog-missing-async",+                   claimSite = site "catalog:missing-dedup"+                 }+             ],+      projectionSets =+        [ SomeProjectionSet+            validProjectionSet+              { projectionDefinitions =+                  inlineDefinition+                    :| [ asyncDefinition+                           & #handlers+                           .~ ( AsyncHandler catalogAsyncProjection asyncSubscriptionId asyncDedupId (site "catalog:async-handler")+                                  :| [ AsyncHandler+                                         catalogMissingAsyncProjection+                                         missingSubscriptionId+                                         missingDedupId+                                         (site "catalog:missing-async-handler")+                                     ]+                              )+                       ]+              }+        ]+    }++catalogWithDefinitions :: NonEmpty (ProjectionDefinition CatalogEvent) -> ProjectionCatalog+catalogWithDefinitions definitions =+  validCatalog+    { projectionSets =+        [ SomeProjectionSet+            validProjectionSet+              { projectionDefinitions = definitions+              }+        ]+    }++smallerCatalog :: ProjectionCatalog+smallerCatalog =+  validCatalog+    { targets = [counterTarget],+      rebuildGroups = [validGroup & #orderedTargets .~ [counterTargetId]],+      subscriptions = [],+      dedupKeys = [],+      queryModels = [SomeQueryModelBinding counterBinding],+      projectionSets =+        [ SomeProjectionSet+            validProjectionSet+              { projectionDefinitions = inlineDefinition :| []+              }+        ]+    }++twoSourceCatalog :: SourceScope -> SourceScope -> SourceId -> ProjectionCatalog+twoSourceCatalog firstScope secondScope secondSourceId =+  validCatalog+    { sources =+        [ headSource & #sourceScope .~ firstScope,+          SourceDeclaration+            { sourceId = secondSourceId,+              sourceScope = secondScope,+              codecFingerprint = "audit-codec-v1",+              claimSite = site "catalog:audit-source"+            }+        ],+      subscriptions =+        [catalogSubscription & #subscriptionSource .~ secondSourceId],+      projectionSets =+        [ SomeProjectionSet+            validProjectionSet+              { projectionDefinitions = inlineDefinition :| []+              },+          SomeProjectionSet+            ProjectionSet+              { projectionSource = secondSourceId,+                projectionDefinitions = asyncDefinition :| [],+                claimSite = site "catalog:audit-set"+              }+        ]+    }++reverseCatalog :: ProjectionCatalog -> ProjectionCatalog+reverseCatalog catalog =+  catalog+    { sources = reverse (catalog ^. #sources),+      targets = reverse (catalog ^. #targets),+      rebuildGroups = reverse (catalog ^. #rebuildGroups),+      projectionRevisions = reverse (catalog ^. #projectionRevisions),+      externalReadContracts = reverse (catalog ^. #externalReadContracts),+      subscriptions = reverse (catalog ^. #subscriptions),+      dedupKeys = reverse (catalog ^. #dedupKeys),+      queryModels = reverse (catalog ^. #queryModels),+      projectionSets = reverse (catalog ^. #projectionSets)+    }++setQueryWait :: QueryModelId -> ConsistencyMode -> StrongScope -> ProjectionCatalog -> ProjectionCatalog+setQueryWait wanted consistency scope catalog =+  catalog+    { queryModels = map updateBinding (catalog ^. #queryModels)+    }+  where+    updateBinding (SomeQueryModelBinding binding)+      | binding ^. #queryModelId == wanted =+          SomeQueryModelBinding+            ( binding+                & #readModel+                . #defaultConsistency+                .~ consistency+                & #readModel+                . #strongScope+                .~ scope+            )+      | otherwise = SomeQueryModelBinding binding++headSource :: SourceDeclaration+headSource =+  SourceDeclaration+    { sourceId = headSourceId,+      sourceScope = CategorySource (CategoryName "counter"),+      codecFingerprint = "counter-codec-v1",+      claimSite = site "catalog:source"+    }++counterTarget :: TargetDeclaration+counterTarget =+  TargetDeclaration+    { targetId = counterTargetId,+      qualifiedTable = QualifiedTable "app" "counter",+      resetPolicy = ClearBeforeReplay,+      dependsOn = [],+      claimSite = site "catalog:counter-target"+    }++auditTarget :: TargetDeclaration+auditTarget =+  TargetDeclaration+    { targetId = auditTargetId,+      qualifiedTable = QualifiedTable "app" "counter_audit",+      resetPolicy = PreserveAndReconcile,+      dependsOn = [counterTargetId],+      claimSite = site "catalog:audit-target"+    }++validGroup :: RebuildGroupDeclaration+validGroup =+  RebuildGroupDeclaration+    { rebuildGroupId = mainGroupId,+      orderedTargets = [counterTargetId, auditTargetId],+      verificationHooks = [],+      claimSite = site "catalog:group"+    }++catalogSubscription :: SubscriptionDeclaration+catalogSubscription =+  SubscriptionDeclaration+    { subscriptionId = asyncSubscriptionId,+      subscriptionName = "catalog-async-subscription",+      subscriptionSource = headSourceId,+      checkpointOnMissing = FromBeginning,+      claimSite = site "catalog:subscription"+    }++catalogDedup :: DedupKeyDeclaration+catalogDedup =+  DedupKeyDeclaration+    { dedupKeyId = asyncDedupId,+      dedupName = "catalog-async",+      claimSite = site "catalog:dedup"+    }++inlineDefinition :: ProjectionDefinition CatalogEvent+inlineDefinition =+  ProjectionDefinition+    { projectionId = inlineProjectionId,+      rebuildGroup = mainGroupId,+      ownedTargets = counterTargetId :| [],+      replayPolicy = replayablePolicy,+      handlers = InlineHandler catalogInlineProjection (site "catalog:inline-handler") :| [],+      claimSite = site "catalog:inline"+    }++duplicateInlineDefinition :: ProjectionDefinition CatalogEvent+duplicateInlineDefinition =+  inlineDefinition+    { projectionId = projection "counter-owner-duplicate",+      claimSite = site "catalog:inline-duplicate"+    }++asyncDefinition :: ProjectionDefinition CatalogEvent+asyncDefinition =+  ProjectionDefinition+    { projectionId = asyncProjectionId,+      rebuildGroup = mainGroupId,+      ownedTargets = auditTargetId :| [],+      replayPolicy = replayablePolicy,+      handlers =+        AsyncHandler catalogAsyncProjection asyncSubscriptionId asyncDedupId (site "catalog:async-handler")+          :| [],+      claimSite = site "catalog:async"+    }++replayablePolicy :: ProjectionReplayPolicy CatalogEvent+replayablePolicy =+  Replayable+    ReplayAdapter+      { decodeForReplay = const ReplayIrrelevant,+        applyForReplay = \_ _ -> pure ()+      }++catalogInlineProjection :: InlineProjection CatalogEvent+catalogInlineProjection =+  InlineProjection+    { name = "catalog-inline",+      apply = \_ _ -> pure ()+    }++catalogAsyncProjection :: AsyncProjection+catalogAsyncProjection =+  AsyncProjection+    { name = "catalog-async",+      readModelName = "catalog-audit-query",+      subscriptionName = "catalog-async-subscription",+      applyRecorded = \_ -> pure (),+      idempotencyKey = (^. #eventId)+    }++catalogMissingAsyncProjection :: AsyncProjection+catalogMissingAsyncProjection =+  catalogAsyncProjection+    { name = "catalog-missing-async",+      subscriptionName = "catalog-missing-subscription"+    }++counterBinding :: QueryModelBinding Text ()+counterBinding =+  QueryModelBinding+    { queryModelId = counterQueryId,+      readModel = counterReadModel,+      rebuildGroup = mainGroupId,+      observedTargets = [counterTargetId],+      claimSite = site "catalog:counter-query"+    }++auditBinding :: QueryModelBinding Text ()+auditBinding =+  QueryModelBinding+    { queryModelId = auditQueryId,+      readModel = auditReadModel,+      rebuildGroup = mainGroupId,+      observedTargets = [auditTargetId],+      claimSite = site "catalog:audit-query"+    }++counterReadModel :: ReadModel Text ()+counterReadModel = readModelDefinition "catalog-counter-query" "counter"++auditReadModel :: ReadModel Text ()+auditReadModel = readModelDefinition "catalog-audit-query" "counter_audit"++readModelDefinition :: Text -> Text -> ReadModel Text ()+readModelDefinition registryName tableName =+  ReadModel+    { name = registryName,+      tableName = tableName,+      schema = "app",+      subscriptionName = "catalog-async-subscription",+      version = 1,+      shapeHash = registryName <> "-v1",+      defaultConsistency = Eventual,+      strongScope = EntireLog,+      query = \_ -> pure ()+    }++inlineProjectionId, asyncProjectionId, additiveProjectionId, sharedProjectionId :: ProjectionId+inlineProjectionId = projection "counter-owner"+asyncProjectionId = projection "audit-owner"+additiveProjectionId = projection "catalog-additive-owner"+sharedProjectionId = projection "shared-owner"++counterTargetId, auditTargetId, unknownTargetId, additiveTargetId :: TargetId+counterTargetId = target "counter-target"+auditTargetId = target "audit-target"+unknownTargetId = target "unknown-target"+additiveTargetId = target "catalog-additive-target"++mainGroupId, otherGroupId, unknownGroupId, additiveGroupId :: RebuildGroupId+mainGroupId = group "counter-group"+otherGroupId = group "other-group"+unknownGroupId = group "unknown-group"+additiveGroupId = group "catalog-additive-group"++headSourceId, additiveSourceId :: SourceId+headSourceId = source "counter-source"+additiveSourceId = source "catalog-additive-source"++counterQueryId, auditQueryId, additiveQueryId :: QueryModelId+counterQueryId = queryModel "counter-query"+auditQueryId = queryModel "audit-query"+additiveQueryId = queryModel "catalog-additive-query"++asyncSubscriptionId, unknownSubscriptionId :: SubscriptionId+asyncSubscriptionId = subscription "counter-subscription"+unknownSubscriptionId = subscription "unknown-subscription"++missingSubscriptionId :: SubscriptionId+missingSubscriptionId = subscription "missing-subscription"++asyncDedupId, unknownDedupId :: DedupKeyId+asyncDedupId = dedup "counter-dedup"+unknownDedupId = dedup "unknown-dedup"++missingDedupId :: DedupKeyId+missingDedupId = dedup "missing-dedup"++projection :: Text -> ProjectionId+projection = identityOrError mkProjectionId++target :: Text -> TargetId+target = identityOrError mkTargetId++group :: Text -> RebuildGroupId+group = identityOrError mkRebuildGroupId++source :: Text -> SourceId+source = identityOrError mkSourceId++queryModel :: Text -> QueryModelId+queryModel = identityOrError mkQueryModelId++subscription :: Text -> SubscriptionId+subscription = identityOrError mkSubscriptionId++dedup :: Text -> DedupKeyId+dedup = identityOrError mkDedupKeyId++revision :: Text -> ProjectionRevisionId+revision = identityOrError mkProjectionRevisionId++site :: Text -> ClaimSite+site = identityOrError mkClaimSite++identityOrError :: (Text -> Either CatalogIdentityError identity) -> Text -> identity+identityOrError constructor value =+  case constructor value of+    Left err -> error (show err)+    Right identity -> identity
+ test/Compatibility/ReadModel011.hs view
@@ -0,0 +1,83 @@+{-# OPTIONS_GHC -Wno-deprecations #-}++-- | Compile-only coverage for the Keiro 0.11 read-model surface. This module+-- deliberately uses every misleading legacy spelling so the 0.12 migration+-- window cannot accidentally become a source break.+module Compatibility.ReadModel011+  ( legacyDirectRecord,+    legacyPositionalRecord,+    legacyPattern,+    legacyModes,+    legacyDefaultOptions,+    legacyRunQueryWith,+  )+where++import Data.Text (Text)+import Effectful (Eff, IOE, (:>))+import Keiro.ReadModel+  ( ConsistencyMode (..),+    PositionWaitOptions (..),+    ReadModel (..),+    ReadModelError,+    StrongScope (..),+    defaultStrongWaitOptions,+    runQueryWith,+  )+import Keiro.Telemetry (KeiroMetrics)+import Kiroku.Store.Effect (Store)++legacyDirectRecord :: ReadModel () ()+legacyDirectRecord =+  ReadModel+    { name = "compatibility-legacy-record",+      tableName = "legacy_record",+      schema = "public",+      subscriptionName = "legacy-subscription",+      version = 1,+      shapeHash = "legacy-v1",+      defaultConsistency = Eventual,+      strongScope = EntireLog,+      query = \() -> pure ()+    }++legacyPositionalRecord :: ReadModel () ()+legacyPositionalRecord =+  ReadModel+    "compatibility-legacy-positional"+    "legacy_positional"+    "public"+    "legacy-subscription"+    1+    "legacy-v1"+    Strong+    (CategoryHead "legacy")+    (\() -> pure ())++legacyPattern :: ReadModel q r -> (Text, Text, Text, Text, Int, Text, ConsistencyMode, StrongScope)+legacyPattern (ReadModel modelName table schemaName subscription modelVersion shape consistency scope _) =+  (modelName, table, schemaName, subscription, modelVersion, shape, consistency, scope)++legacyModes :: [ConsistencyMode]+legacyModes =+  [ Eventual,+    Strong,+    PositionWait+      PositionWaitOptions+        { target = Nothing,+          timeoutMicros = 5000000,+          pollMicros = 10000+        }+  ]++legacyDefaultOptions :: PositionWaitOptions+legacyDefaultOptions = defaultStrongWaitOptions++legacyRunQueryWith ::+  (IOE :> es, Store :> es) =>+  Maybe KeiroMetrics ->+  ConsistencyMode ->+  ReadModel q r ->+  q ->+  Eff es (Either ReadModelError r)+legacyRunQueryWith = runQueryWith
+ test/ExternalReadSpec.hs view
@@ -0,0 +1,790 @@+{-# LANGUAGE MultilineStrings #-}++module ExternalReadSpec+  ( spec,+  )+where++import CatalogSpec qualified as Catalog+import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (isEmptyMVar, newEmptyMVar, putMVar, takeMVar)+import Control.Exception (bracket)+import Data.ByteString (ByteString)+import Data.List (isInfixOf)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Connection.Settings qualified as ConnectionSettings+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSessions+import Keiro.Prelude+import Keiro.Projection.Catalog+import Keiro.ReadModel.External+import Keiro.ReadModel.Rebuild (registerProjectionCatalog)+import Keiro.Test.Postgres (Fixture, withFreshDatabase)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Test.Hspec+import Prelude (all, any, length, not, (&&), (-), (=<<))++spec :: Fixture -> Spec+spec fixture =+  describe "managed external read contracts"+    $ around (withFreshDatabase fixture)+    $ do+      it "fences lifecycle transitions, holds cutover behind readers, and enforces execute-only grants" $ \connectionString ->+        withPool connectionString $ \pool ->+          Store.withStore (Store.defaultConnectionSettings connectionString) $ \store -> do+            runScript pool allRowsFixtureSql+            catalog <- expectValid Catalog.bridgeCatalog+            register store catalog+            runScript pool activateV1Sql+            expectStore store (reconcileExternalReadContracts catalog) >>= shouldBeRight++            runStatement pool () allRowsStmt `shouldReturn` [(1, 10), (2, 20)]+            runScript pool grantReaderSql+            readerResult <- runReaderTransaction pool (Tx.statement () allRowsStmt)+            expectUsage readerResult `shouldReturn` [(1, 10), (2, 20)]+            deniedTarget <- runReaderTransaction pool (Tx.statement () rawTargetStmt)+            deniedTarget `shouldSatisfy` isSqlFailure+            deniedBinding <- runReaderTransaction pool (Tx.statement () rawBindingStmt)+            deniedBinding `shouldSatisfy` isSqlFailure+            runStatement pool () securityFactsStmt `shouldReturn` (True, True, True, True, True, True, True, True)++            runScript pool beginOfflineSql+            offline <- Pool.use pool (Session.statement () allRowsStmt)+            offline `shouldSatisfy` hasSqlState "KR001"+            runScript pool restoreV1Sql++            unknown <- Pool.use pool (Session.statement () unknownGuardStmt)+            unknown `shouldSatisfy` hasSqlState "KR002"++            runScript pool beginOnlineCandidateSql+            runStatement pool () allRowsStmt `shouldReturn` [(1, 10), (2, 20)]++            readerDone <- newEmptyMVar+            _ <-+              forkIO+                $ Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          Tx.sql "SET LOCAL application_name = 'keiro-external-lock-reader'"+                          rows <- Tx.statement () allRowsStmt+                          Tx.sql "SELECT pg_sleep(1)"+                          pure rows+                      )+                  )+                >>= putMVar readerDone+            waitForReader pool 100++            writerDone <- newEmptyMVar+            _ <-+              forkIO+                $ Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          Tx.sql compatibleCutoverSql+                          reconciled <- reconcileExternalReadContractsTx catalog+                          case reconciled of+                            Left err -> error (show err)+                            Right () -> pure ()+                      )+                  )+                >>= putMVar writerDone+            threadDelay 150_000+            isEmptyMVar writerDone `shouldReturn` True+            lockedReaderResult <- takeMVar readerDone+            expectUsage lockedReaderResult `shouldReturn` [(1, 10), (2, 20)]+            writerResult <- takeMVar writerDone+            expectUsage writerResult+            runStatement pool () allRowsStmt `shouldReturn` [(1, 30), (2, 40)]++            runScript pool corruptServingShapeSql+            incompatible <- Pool.use pool (Session.statement () allRowsStmt)+            incompatible `shouldSatisfy` hasSqlState "KR003"+            expectStore store (reconcileExternalReadContracts catalog) >>= shouldBeRight+            runScript pool unmanagedOverloadSql++            contract <- onlyContract catalog+            retirementPreview <-+              expectStore+                store+                ( previewExternalReadContractRetirement+                    (contract ^. #readContractId)+                    (contract ^. #contractVersion)+                )+                >>= shouldBeRight+            retirementPreview ^. #executeGrants+              `shouldSatisfy` any (Text.isPrefixOf "external_reader:EXECUTE")+            retirementPreview ^. #executeGrants+              `shouldSatisfy` all (not . Text.isPrefixOf "external_overload_reader:EXECUTE")+            retired <-+              expectStore+                store+                ( retireExternalReadContract+                    (contract ^. #readContractId)+                    (contract ^. #contractVersion)+                )+            retired `shouldSatisfy` \case Right value -> value ^. #currentState == "retired"; Left _ -> False+            retiredRead <- Pool.use pool (Session.statement () allRowsStmt)+            retiredRead `shouldSatisfy` hasSqlState "KR002"+            runStatement pool 7 unmanagedOverloadStmt `shouldReturn` 7++      it "enforces the all-row small-model boundary before returning partial results" $ \connectionString ->+        withPool connectionString $ \pool ->+          Store.withStore (Store.defaultConnectionSettings connectionString) $ \store -> do+            runScript pool allRowsFixtureSql+            catalog <- expectValid Catalog.bridgeCatalog+            register store catalog+            runScript pool activateV1Sql+            expectStore store (reconcileExternalReadContracts catalog) >>= shouldBeRight++            runScript pool seedAllRowsBoundarySql+            boundary <- runStatement pool () allRowsStmt+            length boundary `shouldBe` 100++            runScript pool "INSERT INTO app.counter (id, total) VALUES (101, 1010)"+            overflow <- Pool.use pool (Session.statement () allRowsStmt)+            overflow `shouldSatisfy` hasSqlState "KR004"+            selectiveOverflow <- Pool.use pool (Session.statement () selectiveAllRowsStmt)+            selectiveOverflow `shouldSatisfy` hasSqlState "KR004"++      it "revalidates retirement after a reader waits for the lifecycle lock" $ \connectionString ->+        withPool connectionString $ \pool ->+          Store.withStore (Store.defaultConnectionSettings connectionString) $ \store -> do+            runScript pool allRowsFixtureSql+            catalog <- expectValid Catalog.bridgeCatalog+            register store catalog+            runScript pool activateV1Sql+            expectStore store (reconcileExternalReadContracts catalog) >>= shouldBeRight+            contract <- onlyContract catalog++            holderDone <- newEmptyMVar+            _ <-+              forkIO+                $ Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          Tx.sql "SET LOCAL application_name = 'keiro-retirement-group-holder'"+                          Tx.sql retirementGroupHoldSql+                      )+                  )+                >>= putMVar holderDone+            waitForApplication pool "keiro-retirement-group-holder" 100++            readerDone <- newEmptyMVar+            _ <-+              forkIO+                $ Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          Tx.sql "SET LOCAL application_name = 'keiro-retirement-racing-reader'"+                          Tx.statement () allRowsStmt+                      )+                  )+                >>= putMVar readerDone+            waitForApplicationLock pool "keiro-retirement-racing-reader" 100++            retired <-+              expectStore+                store+                ( retireExternalReadContract+                    (contract ^. #readContractId)+                    (contract ^. #contractVersion)+                )+            retired `shouldSatisfy` \case Right value -> value ^. #currentState == "retired"; Left _ -> False+            isEmptyMVar readerDone `shouldReturn` True++            _ <- expectUsage =<< takeMVar holderDone+            racedRead <- takeMVar readerDone+            racedRead `shouldSatisfy` hasSqlState "KR002"++      it "refuses rolling downgrades and preserves consumer-owned dependents" $ \connectionString ->+        withPool connectionString $ \pool ->+          Store.withStore (Store.defaultConnectionSettings connectionString) $ \store -> do+            runScript pool allRowsFixtureSql+            generation1 <- expectValid Catalog.bridgeCatalog+            register store generation1+            runScript pool activateV1Sql+            expectStore store (reconcileExternalReadContracts generation1) >>= shouldBeRight+            runScript pool consumerDependentSql++            generation2 <- expectValid (catalogAtGeneration 2)+            expectStore store (reconcileExternalReadContracts generation2) >>= shouldBeRight+            downgrade <- expectStore store (reconcileExternalReadContracts generation1)+            downgrade+              `shouldSatisfy` \case+                Left ExternalReadSurfaceDowngrade {} -> True+                _ -> False+            runStatement pool () consumerDependentExistsStmt `shouldReturn` True+            runStatement pool () consumerDependentRowsStmt `shouldReturn` [(1, 10), (2, 20)]++            let unsafe = unsafeIdentifierCatalog+            case validateProjectionCatalog unsafe of+              Failure _ -> pure ()+              Success _ -> expectationFailure "injection-shaped external read identity was accepted"+            runStatement pool ("app.counter" :: Text) relationExistsStmt `shouldReturn` True++      it "wraps a selective keyed implementation without granting the implementation itself" $ \connectionString ->+        withPool connectionString $ \pool ->+          Store.withStore (Store.defaultConnectionSettings connectionString) $ \store -> do+            runScript pool keyedFixtureSql+            catalog <- expectValid keyedCatalog+            register store catalog+            runScript pool activateV1Sql+            expectStore store (reconcileExternalReadContracts catalog) >>= shouldBeRight+            runScript pool grantKeyedReaderSql++            keyedRows <- runReaderTransaction pool (Tx.statement 2 keyedRowsStmt)+            expectUsage keyedRows `shouldReturn` [(2, 20)]+            privateRows <- runReaderTransaction pool (Tx.statement 2 privateRowsStmt)+            privateRows `shouldSatisfy` isSqlFailure++            plan <-+              runTransaction pool $ do+                Tx.sql "SET LOCAL enable_seqscan = off"+                Tx.statement () keyedPlanStmt+            Text.unlines plan `shouldSatisfy` Text.isInfixOf "Index Scan"++      it "refuses a keyed implementation whose set result differs from the public row type" $ \connectionString ->+        withPool connectionString $ \pool ->+          Store.withStore (Store.defaultConnectionSettings connectionString) $ \store -> do+            runScript pool mismatchedKeyedFixtureSql+            baseCatalog <- expectValid (Catalog.bridgeCatalog {externalReadContracts = []})+            register store baseCatalog+            runScript pool activateV1Sql+            catalog <- expectValid keyedCatalog++            reconciled <- expectStore store (reconcileExternalReadContracts catalog)+            reconciled+              `shouldSatisfy` \case+                Left (ExternalReadPrivateImplementationResultMismatch implementation resultType) ->+                  implementation == QualifiedFunction "app_private" "lookup_counter"+                    && resultType == QualifiedSqlType "app_contract" "counter_row_v1"+                _ -> False+            runStatement pool () publicKeyedWrapperMissingStmt `shouldReturn` True++register :: Store.KirokuStore -> ValidatedProjectionCatalog -> IO ()+register store catalog = do+  result <- expectStore store (registerProjectionCatalog catalog)+  result `shouldSatisfy` \case Right _ -> True; Left _ -> False++onlyContract :: ValidatedProjectionCatalog -> IO ExternalReadContract+onlyContract catalog =+  case catalogExternalReadContracts catalog of+    [contract] -> pure contract+    contracts -> expectationFailure ("expected one contract, got " <> show contracts) >> error "unreachable"++catalogAtGeneration :: Int -> ProjectionCatalog+catalogAtGeneration generation =+  Catalog.bridgeCatalog+    { externalReadContracts =+        [ contract & #surfaceGeneration .~ generation+        | contract <- Catalog.bridgeCatalog ^. #externalReadContracts+        ]+    }++unsafeIdentifierCatalog :: ProjectionCatalog+unsafeIdentifierCatalog =+  Catalog.bridgeCatalog+    { externalReadContracts =+        [ contract+            & #readContractId+            .~ either (error . show) (\contractId -> contractId) (mkExternalReadContractId "reader;drop_schema_app")+        | contract <- Catalog.bridgeCatalog ^. #externalReadContracts+        ]+    }++keyedCatalog :: ProjectionCatalog+keyedCatalog =+  Catalog.bridgeCatalog+    { externalReadContracts = [keyedContract]+    }+  where+    keyedContract =+      case Catalog.bridgeCatalog ^. #externalReadContracts of+        [contract] ->+          KeyedExternalRead+            { readContractId = contract ^. #readContractId,+              contractVersion = contract ^. #contractVersion,+              queryModelId = contract ^. #queryModelId,+              arguments = [SqlFunctionArgument "counter_id" (QualifiedSqlType "pg_catalog" "int8")],+              resultContractType = contract ^. #resultContractType,+              privateImplementation = QualifiedFunction "app_private" "lookup_counter",+              privateImplementationVersion = 1,+              resultShapeHash = contract ^. #resultShapeHash,+              compatibleRevisions = contract ^. #compatibleRevisions,+              surfaceGeneration = contract ^. #surfaceGeneration,+              claimSite = contract ^. #claimSite+            }+        _ -> error "bridge catalog contract fixture drifted"++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Failure diagnostics -> expectationFailure (show diagnostics) >> error "unreachable"+    Success validated -> pure validated++withPool :: Text -> (Pool.Pool -> IO a) -> IO a+withPool connectionString =+  bracket+    ( Pool.acquire+        $ PoolConfig.settings+          [ PoolConfig.staticConnectionSettings (ConnectionSettings.connectionString connectionString),+            PoolConfig.size 6+          ]+    )+    Pool.release++runScript :: Pool.Pool -> ByteString -> IO ()+runScript pool sql = expectUsage =<< Pool.use pool (Session.script (Text.Encoding.decodeUtf8 sql))++runTransaction :: Pool.Pool -> Tx.Transaction a -> IO a+runTransaction pool transaction =+  expectUsage+    =<< Pool.use+      pool+      (TxSessions.transactionNoRetry TxSessions.ReadCommitted TxSessions.Write transaction)++runReaderTransaction :: Pool.Pool -> Tx.Transaction a -> IO (Either Pool.UsageError a)+runReaderTransaction pool transaction =+  Pool.use+    pool+    ( TxSessions.transactionNoRetry+        TxSessions.ReadCommitted+        TxSessions.Write+        (Tx.sql "SET LOCAL ROLE external_reader" >> transaction)+    )++runStatement :: Pool.Pool -> params -> Statement params result -> IO result+runStatement pool params statement = expectUsage =<< Pool.use pool (Session.statement params statement)++expectUsage :: (Show error) => Either error value -> IO value+expectUsage = \case+  Left err -> expectationFailure ("database action failed: " <> show err) >> error "unreachable"+  Right value -> pure value++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure (show err) >> error "unreachable"+    Right value -> pure value++shouldBeRight :: (Show error) => Either error value -> IO value+shouldBeRight = \case+  Left err -> expectationFailure (show err) >> error "unreachable"+  Right value -> pure value++hasSqlState :: (Show error) => String -> Either error value -> Bool+hasSqlState wanted = \case+  Left err -> wanted `isInfixOf` show err+  Right _ -> False++isSqlFailure :: Either error value -> Bool+isSqlFailure = \case Left _ -> True; Right _ -> False++waitForReader :: Pool.Pool -> Int -> IO ()+waitForReader pool = waitForApplication pool "keiro-external-lock-reader"++waitForApplication :: Pool.Pool -> Text -> Int -> IO ()+waitForApplication _ _ 0 = expectationFailure "external session did not become active"+waitForApplication pool applicationName remaining = do+  active <- runStatement pool applicationName readerActiveStmt+  if active+    then pure ()+    else threadDelay 10_000 >> waitForApplication pool applicationName (remaining - 1)++waitForApplicationLock :: Pool.Pool -> Text -> Int -> IO ()+waitForApplicationLock _ _ 0 = expectationFailure "external session did not wait for its lock"+waitForApplicationLock pool applicationName remaining = do+  waiting <- runStatement pool applicationName readerWaitingStmt+  if waiting+    then pure ()+    else threadDelay 10_000 >> waitForApplicationLock pool applicationName (remaining - 1)++allRowsFixtureSql :: ByteString+allRowsFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE SCHEMA app_contract;+  CREATE TYPE app_contract.counter_row_v1 AS (id bigint, total bigint);+  CREATE TABLE app.counter (+    id bigint PRIMARY KEY,+    total bigint NOT NULL+  );+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    detail text NOT NULL+  );+  INSERT INTO app.counter (id, total) VALUES (1, 10), (2, 20);+  """++keyedFixtureSql :: ByteString+keyedFixtureSql =+  allRowsFixtureSql+    <> """+       CREATE SCHEMA app_private;+       CREATE FUNCTION app_private.lookup_counter(counter_id bigint)+       RETURNS SETOF app_contract.counter_row_v1+       LANGUAGE sql+       STABLE+       AS $lookup$+         SELECT ROW(counter.id, counter.total)::app_contract.counter_row_v1+         FROM app.counter AS counter+         WHERE counter.id = counter_id+       $lookup$;+       """++mismatchedKeyedFixtureSql :: ByteString+mismatchedKeyedFixtureSql =+  allRowsFixtureSql+    <> """+       CREATE SCHEMA app_private;+       CREATE FUNCTION app_private.lookup_counter(counter_id bigint)+       RETURNS SETOF bigint+       LANGUAGE sql+       STABLE+       AS $lookup$+         SELECT counter.id+         FROM app.counter AS counter+         WHERE counter.id = counter_id+       $lookup$;+       """++seedAllRowsBoundarySql :: ByteString+seedAllRowsBoundarySql =+  """+  INSERT INTO app.counter (id, total)+  SELECT ordinal, ordinal * 10+  FROM generate_series(3, 100) AS ordinals(ordinal);+  """++activateV1Sql :: ByteString+activateV1Sql =+  """+  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'serving-versioned', serving_revision_id = 'counter-v1',+      serving_epoch = 0, reads_allowed = TRUE, writes_allowed = TRUE,+      active_run_id = NULL, updated_at = now()+  WHERE group_id = 'counter-group';+  """++beginOfflineSql :: ByteString+beginOfflineSql =+  """+  INSERT INTO keiro.keiro_projection_rebuild_runs+    (run_id, group_id, catalog_fingerprint, group_slice_fingerprint,+     contract_fingerprint, runner_format, captured_head, page_size)+  SELECT 'external-offline', group_id, 'catalog-external', slice_fingerprint,+         'contract-external', 'keiro/projection-replay/v2', 0, 100+  FROM keiro.keiro_projection_rebuild_groups+  WHERE group_id = 'counter-group';++  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'rebuilding', active_run_id = 'external-offline',+      serving_revision_id = NULL, serving_epoch = 0,+      reads_allowed = FALSE, writes_allowed = FALSE, updated_at = now()+  WHERE group_id = 'counter-group';+  """++restoreV1Sql :: ByteString+restoreV1Sql =+  activateV1Sql+    <> """+       DELETE FROM keiro.keiro_projection_rebuild_runs+       WHERE run_id = 'external-offline';+       """++beginOnlineCandidateSql :: ByteString+beginOnlineCandidateSql =+  """+  INSERT INTO keiro.keiro_projection_rebuild_runs+    (run_id, group_id, catalog_fingerprint, group_slice_fingerprint,+     contract_fingerprint, runner_format, captured_head, page_size,+     rebuild_mode, candidate_revision_id, cutover_threshold,+     cutover_lock_timeout_ms, promotion_dedup_limit,+     history_retention_lease_id,+     history_retention_lease_owner, history_retention_protected_through,+     history_retention_expires_at, history_retention_renewed_at)+  SELECT 'external-online', group_id, 'catalog-external', slice_fingerprint,+         'contract-external', 'keiro/versioned-rebuild/v3', 0, 100,+         'versioned', 'counter-v2', 10, 2000, 1000000,+         '00000000-0000-0000-0000-000000000027'::uuid,+         'external-read-spec', 0, now() + interval '10 minutes', now()+  FROM keiro.keiro_projection_rebuild_groups+  WHERE group_id = 'counter-group';++  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'rebuilding-versioned', active_run_id = 'external-online',+      serving_revision_id = 'counter-v1', reads_allowed = TRUE,+      writes_allowed = TRUE, updated_at = now()+  WHERE group_id = 'counter-group';+  """++compatibleCutoverSql :: ByteString+compatibleCutoverSql =+  """+  UPDATE app.counter SET total = total + 20;+  UPDATE keiro.keiro_projection_rebuild_runs+  SET status = 'promoted', history_retention_released_at = now(), updated_at = now()+  WHERE run_id = 'external-online';+  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'serving-versioned', active_run_id = NULL,+      serving_revision_id = 'counter-v2', serving_epoch = serving_epoch + 1,+      reads_allowed = TRUE, writes_allowed = TRUE, completed_at = now(),+      updated_at = now()+  WHERE group_id = 'counter-group';+  """++corruptServingShapeSql :: ByteString+corruptServingShapeSql =+  """+  UPDATE keiro.keiro_external_read_contracts+  SET serving_shape_hash = 'wrong-shape'+  WHERE contract_id = 'counter_reader' AND contract_version = 1;+  """++grantReaderSql :: ByteString+grantReaderSql =+  """+  DO $role$+  BEGIN+    IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'external_reader') THEN+      CREATE ROLE external_reader NOLOGIN;+    END IF;+  END+  $role$;+  GRANT USAGE ON SCHEMA keiro_read TO external_reader;+  GRANT EXECUTE ON FUNCTION keiro_read.counter_reader_v1() TO external_reader;+  """++grantKeyedReaderSql :: ByteString+grantKeyedReaderSql =+  """+  DO $role$+  BEGIN+    IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'external_reader') THEN+      CREATE ROLE external_reader NOLOGIN;+    END IF;+  END+  $role$;+  GRANT USAGE ON SCHEMA keiro_read TO external_reader;+  GRANT EXECUTE ON FUNCTION keiro_read.counter_reader_v1(bigint) TO external_reader;+  """++unmanagedOverloadSql :: ByteString+unmanagedOverloadSql =+  """+  DO $role$+  BEGIN+    IF NOT EXISTS (+      SELECT 1 FROM pg_catalog.pg_roles+      WHERE rolname = 'external_overload_reader'+    ) THEN+      CREATE ROLE external_overload_reader NOLOGIN;+    END IF;+  END+  $role$;+  CREATE FUNCTION keiro_read.counter_reader_v1(requested bigint)+  RETURNS bigint+  LANGUAGE sql+  AS $overload$ SELECT requested $overload$;+  REVOKE ALL ON FUNCTION keiro_read.counter_reader_v1(bigint) FROM PUBLIC;+  GRANT EXECUTE ON FUNCTION keiro_read.counter_reader_v1(bigint)+    TO external_overload_reader;+  """++retirementGroupHoldSql :: ByteString+retirementGroupHoldSql =+  """+  UPDATE keiro.keiro_projection_rebuild_groups+  SET updated_at = updated_at+  WHERE group_id = 'counter-group';+  SELECT pg_sleep(1);+  """++consumerDependentSql :: ByteString+consumerDependentSql =+  """+  CREATE FUNCTION app.consumer_counter()+  RETURNS SETOF app_contract.counter_row_v1+  LANGUAGE sql+  AS $consumer$+    SELECT * FROM keiro_read.counter_reader_v1()+  $consumer$;+  """++allRowsStmt :: Statement () [(Int64, Int64)]+allRowsStmt =+  preparable+    "SELECT id, total FROM keiro_read.counter_reader_v1() ORDER BY id"+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++selectiveAllRowsStmt :: Statement () [(Int64, Int64)]+selectiveAllRowsStmt =+  preparable+    "SELECT id, total FROM keiro_read.counter_reader_v1() WHERE id = 1"+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++unmanagedOverloadStmt :: Statement Int64 Int64+unmanagedOverloadStmt =+  preparable+    "SELECT keiro_read.counter_reader_v1($1)"+    (E.param (E.nonNullable E.int8))+    (D.singleRow (D.column (D.nonNullable D.int8)))++publicKeyedWrapperMissingStmt :: Statement () Bool+publicKeyedWrapperMissingStmt =+  preparable+    "SELECT pg_catalog.to_regprocedure('keiro_read.counter_reader_v1(bigint)') IS NULL"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++rawTargetStmt :: Statement () Int64+rawTargetStmt =+  preparable+    "SELECT count(*) FROM app.counter"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++rawBindingStmt :: Statement () Int64+rawBindingStmt =+  preparable+    "SELECT count(*) FROM keiro.external_read_counter_reader_v1_binding"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++unknownGuardStmt :: Statement () ()+unknownGuardStmt =+  preparable+    "SELECT keiro_read.guard_external_read_v1('unknown', 1)"+    E.noParams+    D.noResult++securityFactsStmt :: Statement () (Bool, Bool, Bool, Bool, Bool, Bool, Bool, Bool)+securityFactsStmt =+  preparable+    """+    SELECT wrapper.prosecdef,+           wrapper.proconfig @> ARRAY['search_path=pg_catalog']::text[],+           NOT has_function_privilege('public', wrapper.oid, 'EXECUTE'),+           pg_get_userbyid(wrapper.proowner) = current_user,+           guard.prosecdef,+           guard.proconfig @> ARRAY['search_path=pg_catalog']::text[],+           NOT has_function_privilege('public', guard.oid, 'EXECUTE'),+           pg_get_userbyid(guard.proowner) = current_user+    FROM pg_catalog.pg_proc AS wrapper+    JOIN pg_catalog.pg_namespace AS wrapper_ns ON wrapper_ns.oid = wrapper.pronamespace+    CROSS JOIN pg_catalog.pg_proc AS guard+    JOIN pg_catalog.pg_namespace AS guard_ns ON guard_ns.oid = guard.pronamespace+    WHERE wrapper_ns.nspname = 'keiro_read'+      AND wrapper.proname = 'counter_reader_v1'+      AND guard_ns.nspname = 'keiro_read'+      AND guard.proname = 'guard_external_read_v1'+    """+    E.noParams+    ( D.singleRow+        ( (,,,,,,,)+            <$> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+            <*> D.column (D.nonNullable D.bool)+        )+    )++readerActiveStmt :: Statement Text Bool+readerActiveStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1 FROM pg_catalog.pg_stat_activity+      WHERE application_name = $1+        AND state = 'active'+    )+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++readerWaitingStmt :: Statement Text Bool+readerWaitingStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1 FROM pg_catalog.pg_stat_activity+      WHERE application_name = $1+        AND wait_event_type = 'Lock'+    )+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++consumerDependentExistsStmt :: Statement () Bool+consumerDependentExistsStmt =+  preparable+    "SELECT pg_catalog.to_regprocedure('app.consumer_counter()') IS NOT NULL"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++consumerDependentRowsStmt :: Statement () [(Int64, Int64)]+consumerDependentRowsStmt =+  preparable+    "SELECT id, total FROM app.consumer_counter() ORDER BY id"+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++relationExistsStmt :: Statement Text Bool+relationExistsStmt =+  preparable+    "SELECT pg_catalog.to_regclass($1) IS NOT NULL"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++keyedRowsStmt :: Statement Int64 [(Int64, Int64)]+keyedRowsStmt =+  preparable+    "SELECT id, total FROM keiro_read.counter_reader_v1($1)"+    (E.param (E.nonNullable E.int8))+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++privateRowsStmt :: Statement Int64 [(Int64, Int64)]+privateRowsStmt =+  preparable+    "SELECT id, total FROM app_private.lookup_counter($1)"+    (E.param (E.nonNullable E.int8))+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))++keyedPlanStmt :: Statement () [Text]+keyedPlanStmt =+  preparable+    "EXPLAIN (COSTS OFF) SELECT ROW(counter.id, counter.total)::app_contract.counter_row_v1 FROM app.counter AS counter WHERE counter.id = 2"+    E.noParams+    (D.rowList (D.column (D.nonNullable D.text)))
+ test/GroupRebuildSpec.hs view
@@ -0,0 +1,595 @@+{-# LANGUAGE MultilineStrings #-}++module GroupRebuildSpec+  ( spec,+  )+where++import CatalogSpec qualified as Catalog+import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Data.Aeson qualified as Aeson+import Data.ByteString (ByteString)+import Data.Either (isLeft, isRight)+import Data.Text qualified as Text+import Data.Time (UTCTime (..), diffUTCTime, secondsToDiffTime)+import Data.Time.Calendar (Day (ModifiedJulianDay))+import Data.UUID (fromWords64)+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import Keiro.Projection+  ( CatalogAsyncApplyOutcome (..),+    applyAsyncProjectionFromCatalog,+  )+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog qualified as CatalogApi+import Keiro.ReadModel (lookupReadModel)+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Subscription.Types+  ( MissingCheckpointPolicy (FromBeginning),+    SubscriptionCheckpointKey (..),+    SubscriptionName (..),+  )+import Kiroku.Store.Types+  ( EventId (..),+    EventType (..),+    GlobalPosition (..),+    RecordedEvent (..),+    StreamId (..),+    StreamVersion (..),+  )+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx++spec :: Fixture -> Spec+spec fixture = describe "catalog rebuild groups" $ around (withFreshStore fixture) $ do+  it "registers a validated fleet atomically and refuses group-slice drift" $ \store -> do+    validated <- expectValid Catalog.validCatalog+    groups <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    map (^. #rebuildGroupId) groups `shouldBe` [Catalog.mainGroupId]+    counter <- expectStore store (lookupReadModel "catalog-counter-query")+    counter ^? _Just . #rebuildGroupId+      `shouldBe` Just (rebuildGroupIdText Catalog.mainGroupId)++    registeredAgain <- expectStore store (registerProjectionCatalog validated)+    registeredAgain `shouldSatisfy` isRight++    drifted <- expectValid (catalogWithCodecFingerprint "counter-codec-v2")+    drift <- expectStore store (registerProjectionCatalog drifted)+    drift `shouldSatisfy` \case+      Left RegisteredGroupSliceDrift {} -> True+      _ -> False+    beginDrift <-+      expectStore+        store+        (beginGroupRebuild drifted Catalog.mainGroupId (request "drifted-run" (GlobalPosition 0)))+    beginDrift `shouldSatisfy` \case+      Left RebuildGroupSliceDrift {} -> True+      _ -> False+    stored <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    stored ^? _Just . #sliceFingerprint+      `shouldBe` fmap groupSliceFingerprintText (CatalogApi.groupSliceFingerprint validated Catalog.mainGroupId)++  it "publishes conservative checkpoint status without reading private Kiroku storage" $ \store -> do+    validated <- expectValid Catalog.catalogWithMissingSubscription+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight++    initial <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    initial ^? _Just . #servingPositionBasis+      `shouldBe` Just ServingPositionCheckpoint+    initial ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing+    initial ^? _Just . #queryModels+      `shouldBe` Just ["catalog-audit-query", "catalog-counter-query"]++    _ <-+      expectStore+        store+        ( Store.initializeSubscriptionCheckpoint+            (SubscriptionName "catalog-async-subscription")+            0+            FromBeginning+        )+        >>= shouldBeRight+    incomplete <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    incomplete ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing++    _ <-+      expectStore+        store+        ( Store.initializeSubscriptionCheckpoint+            (SubscriptionName "catalog-missing-subscription")+            0+            FromBeginning+        )+        >>= shouldBeRight+    _ <-+      expectStore+        store+        ( Store.runTransaction+            ( Store.resetSubscriptionCheckpointsTx+                (SubscriptionName "catalog-async-subscription" :| [])+                (GlobalPosition 8)+            )+        )+    _ <-+      expectStore+        store+        ( Store.runTransaction+            ( Store.resetSubscriptionCheckpointsTx+                (SubscriptionName "catalog-missing-subscription" :| [])+                (GlobalPosition 5)+            )+        )++    complete <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    complete ^? _Just . #servingAppliedPosition+      `shouldBe` Just (Just (GlobalPosition 5))+    statuses <- expectStore store listProjectionGroupStatuses+    map (^. #groupId) statuses `shouldBe` [Catalog.mainGroupId]++  it "keeps adversarial checkpoint inventory and large status listings fail-safe" $ \store -> do+    validated <- expectValid Catalog.validCatalog+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    expectStore store (Store.runTransaction (Tx.sql statusAdversarialFixtureSql))++    complete <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    complete ^? _Just . #servingPositionBasis+      `shouldBe` Just ServingPositionCheckpoint+    complete ^? _Just . #servingAppliedPosition+      `shouldBe` Just (Just (GlobalPosition 7))++    duplicateMember <-+      Store.runStoreIO+        store+        ( Store.runTransaction+            ( Tx.sql+                "INSERT INTO subscriptions (subscription_name, consumer_group_member, consumer_group_size, last_seen) VALUES ('status-a', 0, 2, 100)"+            )+        )+    duplicateMember `shouldSatisfy` isLeft+    afterDuplicate <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    afterDuplicate ^? _Just . #servingAppliedPosition+      `shouldBe` Just (Just (GlobalPosition 7))++    expectStore+      store+      (Store.runTransaction (Tx.sql "DELETE FROM subscriptions WHERE subscription_name = 'status-b'"))+    missingSubscription <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    missingSubscription ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing++    expectStore+      store+      ( Store.runTransaction+          ( Tx.sql+              "UPDATE keiro.keiro_projection_group_cursors SET subscription_names = ARRAY['status-a', 'status-a']::text[] WHERE group_id = 'counter-group'"+          )+      )+    duplicateAuthority <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    duplicateAuthority ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing++    expectStore+      store+      ( Store.runTransaction+          (Tx.sql "DELETE FROM keiro.keiro_projection_group_cursors WHERE group_id = 'counter-group'")+      )+    unknownAuthority <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    unknownAuthority ^? _Just . #servingPositionBasis+      `shouldBe` Just ServingPositionUnmanaged+    unknownAuthority ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing++    statuses <- expectStore store listProjectionGroupStatuses+    length statuses `shouldBe` 251+    map (^. #groupId) statuses `shouldSatisfy` strictlyAscending++  it "registers an additive catalog without disturbing the existing group slice" $ \store -> do+    current <- expectValid Catalog.validCatalog+    additive <- expectValid Catalog.additiveCatalog+    originalGroups <- expectStore store (registerProjectionCatalog current) >>= shouldBeRight+    originalSlice <-+      maybe+        (expectationFailure "expected main-group slice" >> error "unreachable")+        pure+        (CatalogApi.groupSliceFingerprint current Catalog.mainGroupId)++    groups <- expectStore store (registerProjectionCatalog additive) >>= shouldBeRight+    map (^. #rebuildGroupId) groups+      `shouldBe` [Catalog.additiveGroupId, Catalog.mainGroupId]+    map (^. #rebuildGroupId) originalGroups `shouldBe` [Catalog.mainGroupId]+    stored <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    stored ^? _Just . #sliceFingerprint+      `shouldBe` Just (groupSliceFingerprintText originalSlice)+    stored ^? _Just . #status `shouldBe` Just GroupLive+    existingQuery <- expectStore store (lookupReadModel "catalog-counter-query")+    newQuery <- expectStore store (lookupReadModel "catalog-additive-query")+    existingQuery ^? _Just . #rebuildGroupId+      `shouldBe` Just (rebuildGroupIdText Catalog.mainGroupId)+    newQuery ^? _Just . #rebuildGroupId+      `shouldBe` Just (rebuildGroupIdText Catalog.additiveGroupId)+    additiveStatus <- expectStore store (lookupProjectionGroupStatus Catalog.additiveGroupId)+    additiveStatus ^? _Just . #servingPositionBasis+      `shouldBe` Just ServingPositionAppend+    additiveStatus ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing++  it "prepares a mixed preserve-parent/clear-child group and derives reset state" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql mixedFixtureSql))+    validated <- expectValid mixedPolicyCatalog+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    started <-+      expectStore+        store+        (beginGroupRebuild validated Catalog.mainGroupId (request "mixed-run" (GlobalPosition 3)))+        >>= shouldBeRight++    groupRebuildHandlePreparation started+      `shouldBe` GroupPreparation+        { clearTargets = [QualifiedTable "app" "counter_audit"],+          preservedTargets = [QualifiedTable "app" "counter"],+          resetDedupNames = ["catalog-async"],+          resetSubscriptionNames = ["catalog-async-subscription"]+        }+    groupRebuildHandleResetCheckpointKeys started+      `shouldBe` [ SubscriptionCheckpointKey (SubscriptionName "catalog-async-subscription") 0,+                   SubscriptionCheckpointKey (SubscriptionName "catalog-async-subscription") 1+                 ]+    rebuildingStatus <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    rebuildingStatus ^? _Just . #lifecyclePhase `shouldBe` Just "rebuilding"+    rebuildingStatus ^? _Just . #readsAllowed `shouldBe` Just False+    rebuildingStatus ^? _Just . #writesAllowed `shouldBe` Just False+    rebuildingStatus ^? _Just . #activeRunId+      `shouldBe` Just (Just (runId "mixed-run"))+    rebuildingStatus ^? _Just . #servingAppliedPosition `shouldBe` Just Nothing+    rebuildingStatus ^? _Just . #candidateRevisionId `shouldBe` Just Nothing+    rebuildingStatus ^? _Just . #candidateRebuildPosition `shouldBe` Just Nothing+    facts <- expectStore store (Store.runTransaction (Tx.statement () preparationFactsStmt))+    facts `shouldBe` (1, 0, 0, 2, 3)++    fenced <-+      expectStore+        store+        ( Store.runTransaction+            (applyAsyncProjectionFromCatalog validated Catalog.asyncProjectionId Catalog.catalogAsyncProjection sampleRecorded)+        )+    fenced+      `shouldBe` CatalogAsyncFenced Catalog.mainGroupId (runId "mixed-run")+    expectStore store (Store.runTransaction (Tx.statement () preparationFactsStmt))+      `shouldReturn` (1, 0, 0, 2, 3)++    abandoned <-+      expectStore+        store+        (abandonGroupRebuild started (RebuildFailure "verification-failed" "row count mismatch"))+        >>= shouldBeRight+    abandoned ^. #status `shouldBe` GroupFailed+    abandoned ^. #failureCode `shouldBe` Just "verification-failed"+    failedStatus <- expectStore store (lookupProjectionGroupStatus Catalog.mainGroupId)+    failedStatus ^? _Just . #lifecyclePhase `shouldBe` Just "failed"+    failedStatus ^? _Just . #readsAllowed `shouldBe` Just False+    failedStatus ^? _Just . #failureCode+      `shouldBe` Just (Just "verification-failed")+    secondAbandon <-+      expectStore+        store+        (abandonGroupRebuild started (RebuildFailure "again" "must not replace evidence"))+    secondAbandon+      `shouldBe` Left (RebuildHandleNoLongerActive Catalog.mainGroupId (runId "mixed-run"))+    reopened <-+      expectStore+        store+        (beginGroupRebuild validated Catalog.mainGroupId (request "mixed-run-retry" (GlobalPosition 3)))+        >>= shouldBeRight+    groupRebuildHandleRun reopened `shouldBe` runId "mixed-run-retry"+    afterReopen <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    afterReopen ^? _Just . #status `shouldBe` Just GroupRebuilding+    afterReopen ^? _Just . #activeRunId `shouldBe` Just (Just (runId "mixed-run-retry"))+    afterReopen ^? _Just . #failureCode `shouldBe` Just Nothing+    afterReopen ^? _Just . #failureDetail `shouldBe` Just Nothing++  it "condemns missing subscription names and rolls back targets, fences, dedup, and matched member resets" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql mixedFixtureSql))+    validated <- expectValid missingSubscriptionCatalog+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    started <-+      expectStore+        store+        (beginGroupRebuild validated Catalog.mainGroupId (request "missing-subscription-run" (GlobalPosition 3)))+    started+      `shouldBe` Left+        ( RebuildSubscriptionCheckpointsMissing+            Catalog.mainGroupId+            [SubscriptionName "catalog-missing-subscription"]+        )+    expectStore store (Store.runTransaction (Tx.statement () preparationFactsStmt))+      `shouldReturn` (1, 1, 1, 2, 50)+    stored <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    stored ^? _Just . #status `shouldBe` Just GroupLive++  it "clears a foreign-key parent and child through one multi-table truncate" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql clearFixtureSql))+    validated <- expectValid allClearCatalog+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    _ <-+      expectStore+        store+        (beginGroupRebuild validated Catalog.mainGroupId (request "clear-run" (GlobalPosition 0)))+        >>= shouldBeRight+    expectStore store (Store.runTransaction (Tx.statement () targetCountsStmt))+      `shouldReturn` (0, 0)++  it "rolls preparation back when an undeclared foreign key blocks truncate" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql blockedFixtureSql))+    validated <- expectValid allClearCatalog+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    started <-+      Store.runStoreIO+        store+        (beginGroupRebuild validated Catalog.mainGroupId (request "blocked-run" (GlobalPosition 0)))+    started `shouldSatisfy` isLeft+    expectStore store (Store.runTransaction (Tx.statement () blockedFactsStmt))+      `shouldReturn` (1, 1, 1, "live")++  it "waits for an in-flight async apply before preparing the group" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql clearFixtureSql))+    validated <- expectValid allClearCatalog+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    let slowProjection =+          Catalog.catalogAsyncProjection+            & #applyRecorded+            .~ (\_ -> Tx.sql "SELECT pg_sleep(1)")+    writerDone <- newEmptyMVar+    _ <-+      forkIO $+        Store.runStoreIO+          store+          ( Store.runTransaction+              (applyAsyncProjectionFromCatalog validated Catalog.asyncProjectionId slowProjection sampleRecorded)+          )+          >>= putMVar writerDone+    threadDelay 200_000+    startedAt <- getCurrentTime+    _ <-+      expectStore+        store+        (beginGroupRebuild validated Catalog.mainGroupId (request "async-lock-run" (GlobalPosition 0)))+        >>= shouldBeRight+    finishedAt <- getCurrentTime++    takeMVar writerDone `shouldReturn` Right CatalogAsyncApplied+    diffUTCTime finishedAt startedAt `shouldSatisfy` (> 0.5)+    expectStore store (Store.runTransaction (Tx.statement () targetCountsStmt))+      `shouldReturn` (0, 0)++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics ->+      expectationFailure ("expected valid catalog, got " <> show diagnostics)+        >> error "unreachable"++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure ("store action failed: " <> show err) >> error "unreachable"+    Right value -> pure value++shouldBeRight :: (Show err) => Either err value -> IO value+shouldBeRight = \case+  Left err -> expectationFailure ("expected Right, got Left " <> show err) >> error "unreachable"+  Right value -> pure value++catalogWithCodecFingerprint :: Text -> ProjectionCatalog+catalogWithCodecFingerprint fingerprint =+  Catalog.validCatalog+    { sources =+        [ source & #codecFingerprint .~ fingerprint+        | source <- Catalog.validCatalog ^. #sources+        ]+    }++mixedPolicyCatalog :: ProjectionCatalog+mixedPolicyCatalog = mixedPolicyCatalogFor Catalog.validCatalog++missingSubscriptionCatalog :: ProjectionCatalog+missingSubscriptionCatalog = mixedPolicyCatalogFor Catalog.catalogWithMissingSubscription++mixedPolicyCatalogFor :: ProjectionCatalog -> ProjectionCatalog+mixedPolicyCatalogFor catalog =+  catalog+    { targets =+        [ if target ^. #targetId == Catalog.counterTargetId+            then target & #resetPolicy .~ PreserveAndReconcile+            else target & #resetPolicy .~ ClearBeforeReplay+        | target <- catalog ^. #targets+        ]+    }++allClearCatalog :: ProjectionCatalog+allClearCatalog =+  Catalog.validCatalog+    { targets =+        [ target & #resetPolicy .~ ClearBeforeReplay+        | target <- Catalog.validCatalog ^. #targets+        ]+    }++request :: Text -> GlobalPosition -> RebuildRequest+request identity position =+  RebuildRequest+    { rebuildRunId = runId identity,+      requestedBy = "group-rebuild-spec",+      requestReason = "integration proof",+      replayFrom = position+    }++runId :: Text -> RebuildRunId+runId identity =+  case mkRebuildRunId identity of+    Left err -> error (Text.unpack err)+    Right value -> value++sampleRecorded :: RecordedEvent+sampleRecorded =+  RecordedEvent+    { eventId = EventId (fromWords64 1 2),+      eventType = EventType "CatalogEvent",+      streamVersion = StreamVersion 1,+      globalPosition = GlobalPosition 1,+      originalStreamId = StreamId 1,+      originalVersion = StreamVersion 1,+      payload = Aeson.Null,+      metadata = Just (Aeson.object []),+      causationId = Nothing,+      correlationId = Nothing,+      createdAt = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)+    }++mixedFixtureSql :: ByteString+mixedFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY);+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    counter_id bigint NOT NULL REFERENCES app.counter(id)+  );+  INSERT INTO app.counter VALUES (1);+  INSERT INTO app.counter_audit VALUES (1, 1);+  INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)+  VALUES ('catalog-async', '00000000-0000-0000-0000-000000000001');+  INSERT INTO subscriptions (subscription_name, consumer_group_member, consumer_group_size, last_seen)+  VALUES+    ('catalog-async-subscription', 0, 2, 50),+    ('catalog-async-subscription', 1, 2, 60);+  """++statusAdversarialFixtureSql :: ByteString+statusAdversarialFixtureSql =+  """+  UPDATE keiro.keiro_projection_group_cursors+  SET position_basis = 'checkpoint',+      subscription_names = ARRAY['status-a', 'status-b', 'status-c']::text[]+  WHERE group_id = 'counter-group';++  INSERT INTO subscriptions+    (subscription_name, consumer_group_member, consumer_group_size, last_seen)+  VALUES+    ('status-a', 0, 2, 99),+    ('status-a', 1, 2, 7),+    ('status-b', 0, 1, 12),+    ('status-c', 0, 2, 20),+    ('status-c', 1, 2, 11);++  INSERT INTO keiro.keiro_projection_rebuild_groups+    (group_id, slice_fingerprint, status)+  SELECT 'status-scale-' || ordinal::text, 'slice-v6:status-scale', 'live'+  FROM generate_series(1, 250) AS ordinals(ordinal);++  INSERT INTO keiro.keiro_projection_group_cursors+    (group_id, position_basis, subscription_names)+  SELECT group_id, 'append', ARRAY[]::text[]+  FROM keiro.keiro_projection_rebuild_groups+  WHERE group_id LIKE 'status-scale-%';+  """++clearFixtureSql :: ByteString+clearFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY);+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    counter_id bigint NOT NULL REFERENCES app.counter(id)+  );+  INSERT INTO app.counter VALUES (1);+  INSERT INTO app.counter_audit VALUES (1, 1);+  INSERT INTO subscriptions (subscription_name, last_seen)+  VALUES ('catalog-async-subscription', 50);+  """++blockedFixtureSql :: ByteString+blockedFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY);+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    counter_id bigint NOT NULL REFERENCES app.counter(id)+  );+  CREATE TABLE app.external_ref (+    id bigint PRIMARY KEY,+    counter_id bigint NOT NULL REFERENCES app.counter(id)+  );+  INSERT INTO app.counter VALUES (1);+  INSERT INTO app.counter_audit VALUES (1, 1);+  INSERT INTO app.external_ref VALUES (1, 1);+  """++preparationFactsStmt :: Statement () (Int64, Int64, Int64, Int64, Int64)+preparationFactsStmt =+  preparable+    """+    SELECT+      (SELECT count(*) FROM app.counter),+      (SELECT count(*) FROM app.counter_audit),+      (SELECT count(*) FROM keiro.keiro_projection_dedup WHERE projection_name = 'catalog-async'),+      (SELECT count(*) FROM subscriptions WHERE subscription_name = 'catalog-async-subscription'),+      (SELECT min(last_seen) FROM subscriptions WHERE subscription_name = 'catalog-async-subscription')+    """+    E.noParams+    ( D.singleRow+        ( (,,,,)+            <$> int8Column+            <*> int8Column+            <*> int8Column+            <*> int8Column+            <*> int8Column+        )+    )++targetCountsStmt :: Statement () (Int64, Int64)+targetCountsStmt =+  preparable+    """+    SELECT+      (SELECT count(*) FROM app.counter),+      (SELECT count(*) FROM app.counter_audit)+    """+    E.noParams+    (D.singleRow ((,) <$> int8Column <*> int8Column))++blockedFactsStmt :: Statement () (Int64, Int64, Int64, Text)+blockedFactsStmt =+  preparable+    """+    SELECT+      (SELECT count(*) FROM app.counter),+      (SELECT count(*) FROM app.counter_audit),+      (SELECT count(*) FROM app.external_ref),+      (SELECT status FROM keiro.keiro_projection_rebuild_groups WHERE group_id = 'counter-group')+    """+    E.noParams+    ( D.singleRow+        ( (,,,)+            <$> int8Column+            <*> int8Column+            <*> int8Column+            <*> D.column (D.nonNullable D.text)+        )+    )++int8Column :: D.Row Int64+int8Column = D.column (D.nonNullable D.int8)++strictlyAscending :: (Ord value) => [value] -> Bool+strictlyAscending values = and (zipWith (<) values (drop 1 values))
test/Main.hs view
@@ -3,12741 +3,16074 @@   ) 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 =-  Keiki.unavailableInCtor-    "SAdd"-    ( \case-        SAdd amount -> Just (RCons Proxy amount RNil)-        SSkip -> Nothing-    )-    ( \case-        RCons _ amount RNil -> SAdd amount-    )--sSkipCtor :: InCtor SkipCommand '[]-sSkipCtor =-  Keiki.unavailableInCtor-    "SSkip"-    ( \case-        SAdd {} -> Nothing-        SSkip -> Just RNil-    )-    ( \case-        RNil -> SSkip-    )--addCtor :: InCtor CounterCommand AddFields-addCtor =-  Keiki.unavailableInCtor-    "Add"-    ( \case-        Add amount -> Just (RCons Proxy amount RNil)-    )-    ( \case-        RCons _ amount RNil -> Add amount-    )--counterAddedCtor :: WireCtor CounterEvent (Int, ())-counterAddedCtor =-  Keiki.unavailableWireCtor-    "CounterAdded"-    ( \case-        CounterAdded amount -> Just (amount, ())-        CounterAudited {} -> Nothing-    )-    ( \case-        (amount, ()) -> CounterAdded amount-    )--counterAuditedCtor :: WireCtor CounterEvent (Int, ())-counterAuditedCtor =-  Keiki.unavailableWireCtor-    "CounterAudited"-    ( \case-        CounterAudited amount -> Just (amount, ())-        CounterAdded {} -> Nothing-    )-    ( \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 =-  Keiki.unavailableInCtor-    "ConfirmDivert"-    ( \case-        ConfirmDivert acuityBlack -> Just (RCons Proxy acuityBlack RNil)-    )-    ( \case-        RCons _ acuityBlack RNil -> ConfirmDivert acuityBlack-    )--divertConfirmedCtor :: WireCtor DivertEvent (Bool, ())-divertConfirmedCtor =-  Keiki.unavailableWireCtor-    "DivertConfirmed"-    ( \case-        DivertConfirmed acuityBlack -> Just (acuityBlack, ())-    )-    ( \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+import CatalogEvolutionSpec qualified+import CatalogOperationsSpec qualified+import CatalogSpec qualified+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.Char (isDigit)+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.List (isInfixOf)+import Data.List.NonEmpty qualified as NonEmpty+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.Text.IO qualified as TextIO+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.UUID.V5 qualified as UUID.V5+import Data.Vector qualified as Vector+import Data.Version (showVersion)+import Data.Word (Word64)+import Effectful (Eff, IOE, (:>))+import Effectful.Error.Static (Error, throwError)+import Effectful.Exception qualified as EffException+import ExternalReadSpec qualified+import GHC.Conc (ThreadStatus (..), threadStatus)+import GroupRebuildSpec qualified+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.DeterministicId (deterministicIdProbes, identitySeedBytes, legacySeedBytes)+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,+    cancelledStepName,+    completedStepName,+    continueAsNew,+    continueSeedStepName,+    continuedAsNewStepName,+    currentGeneration,+    defaultWorkflowRunOptions,+    deterministicJournalId,+    failedStepName,+    findUnfinishedWorkflowIds,+    loadStepIndex,+    mkWorkflowId,+    mkWorkflowName,+    patch,+    patchSetStepName,+    patchStepName,+    restoreSeed,+    runWorkflow,+    runWorkflowWith,+    step,+    stepExists,+    workflowGenerationStreamName,+    workflowJournalCodec,+  )+import Keiro.Workflow.Awakeable+  ( AwakeableId (..),+    WorkflowAwakeableCancelled (..),+    awakeableIdText,+    awakeableIdToUuid,+    awakeableNamed,+    cancelAwakeable,+    signalAwakeable,+    signalAwakeableFrom,+  )+import Keiro.Workflow.Awakeable.Compatibility+  ( generation0AwakeableId,+    preUtf8Generation0AwakeableId,+  )+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+  ( drainWorkflowSleepTimers,+    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 Numeric.Natural (Natural)+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 Paths_keiro qualified as Package+import PreCanonicalRecoverySpec qualified+import PreimageSpec qualified+import ProjectionReplaySpec qualified+import ReadModelSpec qualified+import Shibuya.Adapter (Adapter (..))+import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..), deadLetterCodeText, deadLetterReasonCode, deadLetterReasonDetail, renderDeadLetterReason)+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 VersionedRebuildSpec qualified+import VersionedTargetPostgresSpec qualified+import "hasql-transaction" Hasql.Transaction qualified as Tx++main :: IO ()+main = withMigratedSuite $ \fixture -> hspec $ do+  CatalogSpec.spec+  PreimageSpec.spec+  CatalogEvolutionSpec.spec fixture+  CatalogOperationsSpec.spec fixture+  GroupRebuildSpec.spec fixture+  ExternalReadSpec.spec fixture+  VersionedTargetPostgresSpec.spec fixture+  VersionedRebuildSpec.spec fixture+  PreCanonicalRecoverySpec.spec fixture+  ProjectionReplaySpec.spec fixture+  ReadModelSpec.spec++  describe "catalog-fenced inline projections" $ around (withFreshResourceStore fixture) $ do+    it "rolls back the event append and target write while its group rebuilds" $ \(_storeHandle, StoreRunner runStore) -> do+      validated <-+        case validateProjectionCatalog catalogInlineProjectionCatalog of+          Failure diagnostics ->+            expectationFailure ("catalog fixture failed validation: " <> show diagnostics)+              >> error "unreachable"+          Success value -> pure value+      Right () <- runStore $ Store.runTransaction (Tx.sql catalogInlineFixtureSql)+      Right (Right _) <- runStore $ Rebuild.registerProjectionCatalog validated++      let targetStream = stream "counter-catalog-fence" :: Stream CounterEventStream+      first <-+        runStore $+          runCommandWithCatalogProjections+            defaultRunCommandOptions+            counterEventStream+            targetStream+            (Add 4)+            validated+            catalogInlineProjectionSet+      first `shouldSatisfy` \case+        Right (Right (ProjectionCommandApplied result)) -> result ^. #eventsAppended == 1+        _ -> False+      Right 1 <- runStore $ Store.runTransaction (Tx.statement () catalogInlineCountStmt)++      Right (Right _) <-+        runStore $+          Rebuild.beginGroupRebuild+            validated+            catalogInlineGroupId+            Rebuild.RebuildRequest+              { rebuildRunId = catalogInlineRunId,+                requestedBy = "keiro-test",+                requestReason = "inline fence proof",+                replayFrom = GlobalPosition 0+              }+      Right 0 <- runStore $ Store.runTransaction (Tx.statement () catalogInlineCountStmt)++      second <-+        runStore $+          runCommandWithCatalogProjections+            defaultRunCommandOptions+            counterEventStream+            targetStream+            (Add 5)+            validated+            catalogInlineProjectionSet+      second+        `shouldBe` Right (Right (ProjectionCommandFenced catalogInlineGroupId catalogInlineRunId))+      Right 0 <- runStore $ Store.runTransaction (Tx.statement () catalogInlineCountStmt)+      Right recorded <-+        runStore $+          Store.readStreamForward (StreamName "counter-catalog-fence") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 1++      let foreignSource = catalogIdentity mkSourceId "catalog-inline-foreign-source"+          foreignSet = catalogInlineProjectionSet & #projectionSource .~ foreignSource+          foreignStream = stream "counter-catalog-mismatch" :: Stream CounterEventStream+      mismatch <-+        runStore $+          runCommandWithCatalogProjections+            defaultRunCommandOptions+            counterEventStream+            foreignStream+            (Add 6)+            validated+            foreignSet+      mismatch `shouldBe` Right (Right (ProjectionCommandCatalogMismatch foreignSource))+      Right absent <-+        runStore $+          Store.readStreamForward (StreamName "counter-catalog-mismatch") (StreamVersion 0) 10+      Vector.null absent `shouldBe` True++    it "waits for an in-flight writer before preparing and clearing its group" $ \(_storeHandle, StoreRunner runStore) -> do+      validated <-+        case validateProjectionCatalog catalogInlineProjectionCatalog of+          Failure diagnostics ->+            expectationFailure ("catalog fixture failed validation: " <> show diagnostics)+              >> error "unreachable"+          Success value -> pure value+      Right () <- runStore $ Store.runTransaction (Tx.sql catalogInlineFixtureSql)+      Right (Right _) <- runStore $ Rebuild.registerProjectionCatalog validated++      writerDone <- newEmptyMVar+      let targetStream = stream "counter-catalog-lock-order" :: Stream CounterEventStream+      _ <-+        forkIO $+          runStore+            ( runCommandWithCatalogProjections+                defaultRunCommandOptions+                counterEventStream+                targetStream+                (Add 9)+                validated+                catalogSlowInlineProjectionSet+            )+            >>= putMVar writerDone+      threadDelay 200_000+      startedAt <- getCurrentTime+      Right (Right _) <-+        runStore $+          Rebuild.beginGroupRebuild+            validated+            catalogInlineGroupId+            Rebuild.RebuildRequest+              { rebuildRunId = catalogInlineRunId,+                requestedBy = "keiro-test",+                requestReason = "in-flight inline lock proof",+                replayFrom = GlobalPosition 0+              }+      finishedAt <- getCurrentTime++      writer <- takeMVar writerDone+      writer `shouldSatisfy` \case+        Right (Right (ProjectionCommandApplied result)) -> result ^. #eventsAppended == 1+        _ -> False+      diffUTCTime finishedAt startedAt `shouldSatisfy` (> 0.5)+      Right 0 <- runStore $ Store.runTransaction (Tx.statement () catalogInlineCountStmt)+      pure ()++    it "dispatches inline writes through the persisted serving revision before appending" $ \(_storeHandle, StoreRunner runStore) -> do+      validated <- expectValidatedCatalog catalogInlineProjectionCatalog+      v1Only <- expectValidatedCatalog catalogInlineV1Catalog+      Right () <- runStore $ Store.runTransaction (Tx.sql catalogInlineFixtureSql)+      Right (Right _) <- runStore $ Rebuild.registerProjectionCatalog validated+      Right () <- runStore $ Store.runTransaction (Tx.sql seedCatalogInlineVersionedV1Sql)++      let targetStream = stream "counter-versioned-inline" :: Stream CounterEventStream+      first <-+        runStore $+          runCommandWithCatalogProjections+            defaultRunCommandOptions+            counterEventStream+            targetStream+            (Add 4)+            validated+            catalogInlineProjectionSet+      first `shouldSatisfy` \case+        Right (Right (ProjectionCommandApplied result)) -> result ^. #eventsAppended == 1+        _ -> False+      Right [101] <- runStore $ Store.runTransaction (Tx.statement () catalogInlineAmountsStmt)++      Right () <- runStore $ Store.runTransaction (Tx.sql promoteCatalogInlineV2Sql)+      second <-+        runStore $+          runCommandWithCatalogProjections+            defaultRunCommandOptions+            counterEventStream+            targetStream+            (Add 5)+            validated+            catalogInlineProjectionSet+      second `shouldSatisfy` \case+        Right (Right (ProjectionCommandApplied result)) -> result ^. #eventsAppended == 1+        _ -> False+      Right [202] <- runStore $ Store.runTransaction (Tx.statement () catalogInlineAmountsStmt)++      missing <-+        runStore $+          runCommandWithCatalogProjections+            defaultRunCommandOptions+            counterEventStream+            targetStream+            (Add 6)+            v1Only+            catalogInlineProjectionSet+      missing+        `shouldBe` Right+          ( Right+              ( ProjectionCommandServingRevisionUnavailable+                  catalogInlineGroupId+                  catalogInlineRevisionV2Id+              )+          )+      Right recorded <-+        runStore $+          Store.readStreamForward (StreamName "counter-versioned-inline") (StreamVersion 0) 10+      Vector.length recorded `shouldBe` 2+      Right [202] <- runStore $ Store.runTransaction (Tx.statement () catalogInlineAmountsStmt)+      pure ()++  describe "Keiro" $ do+    it "exposes the package metadata version" $+      KeiroRoot.version `shouldBe` Text.pack (showVersion Package.version)++    it "keeps package metadata as the only version authority" $ do+      source <- TextIO.readFile "src/Keiro.hs"+      source `shouldSatisfy` Text.isInfixOf "showVersion Package.version"+      let isNumericVersionAssignment sourceLine =+            "version =" `Text.isInfixOf` sourceLine+              && Text.count "." sourceLine >= 3+              && Text.any isDigit sourceLine+      Text.lines source `shouldSatisfy` all (not . isNumericVersionAssignment)++  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-" <> showVersion Package.version,+            "test/ReplaySafetyTypeProbe.hs"+          ]+          ""+      exitCode `shouldSatisfy` (/= ExitSuccess)+      stderr `shouldSatisfy` ("ValidatedEventStream" `isInfixOf`)++  describe "Keiro.Command" $ around (withFreshStore fixture) $ do+    describe "typed domain command outcomes" $ do+      it "returns the exact ordered accepted batch and compatibility result" $ \storeHandle -> do+        let target = stream "domain-command-accepted" :: Stream CounterEventStream+        commandResult <-+          Store.runStoreIO storeHandle $+            runDomainCommand defaultRunCommandOptions multiCounterDomainHandler target (Add 4)+        case commandResult of+          Right (Right outcome@DomainCommandOutcome {decision = DomainAccepted events, result}) -> do+            events `shouldBe` (CounterAdded 4 :| [CounterAudited 4])+            result ^. #streamVersion `shouldBe` StreamVersion 2+            result ^. #eventsAppended `shouldBe` 2+            forgetDomainDecision outcome `shouldBe` result+          other -> expectationFailure ("expected typed accepted command, got " <> show other)+        Right recorded <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "domain-command-accepted") (StreamVersion 0) 10+        traverse (decodeRecorded counterCodec) (Vector.toList recorded)+          `shouldBe` Right [CounterAdded 4, CounterAudited 4]++      it "attributes sibling silent edges and returns typed rejection and no-op" $ \storeHandle -> do+        let rejectionTarget = stream "domain-command-rejected" :: Stream SilentChoiceEventStream+            noOpTarget = stream "domain-command-no-op" :: Stream SilentChoiceEventStream+        rejectionResult <-+          Store.runStoreIO storeHandle $+            runDomainCommand defaultRunCommandOptions silentChoiceDomainHandler rejectionTarget RejectSilently+        case rejectionResult of+          Right (Right outcome@DomainCommandOutcome {decision = DomainRejected reason, result}) -> do+            reason `shouldBe` "edge-0: rejected"+            result ^. #eventsAppended `shouldBe` 0+            result ^. #streamVersion `shouldBe` StreamVersion 0+            result ^. #globalPosition `shouldBe` Nothing+            forgetDomainDecision outcome `shouldBe` result+          other -> expectationFailure ("expected typed domain rejection, got " <> show other)+        noOpResult <-+          Store.runStoreIO storeHandle $+            runDomainCommand defaultRunCommandOptions silentChoiceDomainHandler noOpTarget NoOpSilently+        case noOpResult of+          Right (Right outcome@DomainCommandOutcome {decision = DomainNoOp explanation, result}) -> do+            explanation `shouldBe` "edge-1: already complete"+            result ^. #eventsAppended `shouldBe` 0+            result ^. #streamVersion `shouldBe` StreamVersion 0+            result ^. #globalPosition `shouldBe` Nothing+            forgetDomainDecision outcome `shouldBe` result+          other -> expectationFailure ("expected typed domain no-op, got " <> show other)+        Right rejectedEvents <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "domain-command-rejected") (StreamVersion 0) 10+        Right noOpEvents <-+          Store.runStoreIO storeHandle $+            Store.readStreamForward (StreamName "domain-command-no-op") (StreamVersion 0) 10+        rejectedEvents `shouldBe` Vector.empty+        noOpEvents `shouldBe` Vector.empty++      it "keeps unmatched and ambiguous selection failures as CommandError" $ \storeHandle -> do+        let unmatchedTarget = stream "domain-command-unmatched" :: Stream SilentChoiceEventStream+            ambiguousTarget = stream "domain-command-ambiguous" :: Stream CounterEventStream+        unmatched <-+          Store.runStoreIO storeHandle $+            runDomainCommand defaultRunCommandOptions silentChoiceDomainHandler unmatchedTarget UnmatchedSilently+        ambiguous <-+          Store.runStoreIO storeHandle $+            runDomainCommand defaultRunCommandOptions ambiguousCounterDomainHandler ambiguousTarget (Add 1)+        unmatched `shouldBe` Right (Left CommandRejected)+        ambiguous `shouldBe` Right (Left (CommandAmbiguous [0, 1]))++      it "retains validated rejection of state-changing silent edges" $ \_ -> do+        case mkEventStream "domain-state-changing-epsilon" stateChangingEpsilonEventStreamDef of+          Left warnings ->+            map eswReason warnings+              `shouldSatisfy` any (Text.isInfixOf "state-changing-epsilon")+          Right _ -> expectationFailure "expected validation to reject a state-changing silent edge"++      it "runs SQL once with the exact accepted event pairs" $ \_ ->+        withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+          let target = stream "domain-command-sql-accepted" :: Stream CounterEventStream+          outcome <-+            runner $+              runDomainCommandWithSqlEvents+                defaultRunCommandOptions+                multiCounterDomainHandler+                target+                (Add 6)+                (\pairs _appendResult -> pure (Prelude.fst <$> pairs))+          case outcome of+            Right+              ( Right+                  ( DomainCommandOutcome {decision = DomainAccepted events, result},+                    Just callbackEvents+                    )+                ) -> do+                events `shouldBe` (CounterAdded 6 :| [CounterAudited 6])+                callbackEvents `shouldBe` NonEmpty.toList events+                result ^. #eventsAppended `shouldBe` 2+            other -> expectationFailure ("expected accepted SQL domain command, got " <> show other)++      it "skips SQL callbacks and inline projections for rejection and no-op" $ \_ ->+        withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+          let rejectionTarget = stream "domain-command-sql-rejected" :: Stream SilentChoiceEventStream+              noOpTarget = stream "domain-command-projection-no-op" :: Stream SilentChoiceEventStream+              callback _ _ = error "silent domain decision invoked SQL callback" :: Tx.Transaction Text+              projection =+                InlineProjection+                  { name = "silent-domain-bomb",+                    apply = \_ _ -> error "silent domain decision invoked projection"+                  }+          rejected <-+            runner $+              runDomainCommandWithSqlEvents+                defaultRunCommandOptions+                silentChoiceDomainHandler+                rejectionTarget+                RejectSilently+                callback+          case rejected of+            Right (Right (DomainCommandOutcome {decision = DomainRejected reason}, Nothing)) ->+              reason `shouldBe` "edge-0: rejected"+            other -> expectationFailure ("expected silent SQL rejection, got " <> show other)+          noOp <-+            runner $+              runDomainCommandWithProjections+                defaultRunCommandOptions+                silentChoiceDomainHandler+                noOpTarget+                NoOpSilently+                [projection]+          case noOp of+            Right (Right DomainCommandOutcome {decision = DomainNoOp explanation, result}) -> do+              explanation `shouldBe` "edge-1: already complete"+              result ^. #eventsAppended `shouldBe` 0+            other -> expectationFailure ("expected silent projection no-op, got " <> show other)++      it "applies inline projections atomically for accepted domain events" $ \_ ->+        withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+          Right () <-+            Store.runStoreIO storeHandle $+              initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+          let target = stream "domain-command-projection-accepted" :: Stream CounterEventStream+          outcome <-+            runner $+              runDomainCommandWithProjections+                defaultRunCommandOptions+                multiCounterDomainHandler+                target+                (Add 7)+                [counterInlineProjection]+          case outcome of+            Right (Right DomainCommandOutcome {decision = DomainAccepted events}) ->+              events `shouldBe` (CounterAdded 7 :| [CounterAudited 7])+            other -> expectationFailure ("expected accepted projected domain command, got " <> show other)+          projected <-+            Store.runStoreIO storeHandle $+              runQuery Nothing counterReadModel "inline"+          projected `shouldBe` Right (Right 7)++      it "preserves catalog outcomes while skipping catalog SQL for silent decisions" $ \_ ->+        withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+          validated <-+            case validateProjectionCatalog catalogInlineProjectionCatalog of+              Failure diagnostics ->+                expectationFailure ("catalog fixture failed validation: " <> show diagnostics)+                  >> error "unreachable"+              Success value -> pure value+          Right () <- runner $ Store.runTransaction (Tx.sql catalogInlineFixtureSql)+          Right (Right _) <- runner $ Rebuild.registerProjectionCatalog validated+          let target = stream "domain-command-catalog-rejected" :: Stream SilentChoiceEventStream+          outcome <-+            runner $+              runDomainCommandWithCatalogProjections+                defaultRunCommandOptions+                silentChoiceDomainHandler+                target+                RejectSilently+                validated+                catalogInlineProjectionSet+          case outcome of+            Right (Right (DomainProjectionCommandApplied DomainCommandOutcome {decision = DomainRejected reason})) ->+              reason `shouldBe` "edge-0: rejected"+            other -> expectationFailure ("expected applied silent catalog decision, got " <> show other)+          Right 0 <- runner $ Store.runTransaction (Tx.statement () catalogInlineCountStmt)+          let acceptedTarget = stream "domain-command-catalog-accepted" :: Stream CounterEventStream+          accepted <-+            runner $+              runDomainCommandWithCatalogProjections+                defaultRunCommandOptions+                multiCounterDomainHandler+                acceptedTarget+                (Add 5)+                validated+                catalogInlineProjectionSet+          case accepted of+            Right (Right (DomainProjectionCommandApplied DomainCommandOutcome {decision = DomainAccepted events})) ->+              events `shouldBe` (CounterAdded 5 :| [CounterAudited 5])+            other -> expectationFailure ("expected applied accepted catalog decision, got " <> show other)+          Right 2 <- runner $ Store.runTransaction (Tx.statement () catalogInlineCountStmt)+          Right (Right _) <-+            runner $+              Rebuild.beginGroupRebuild+                validated+                catalogInlineGroupId+                Rebuild.RebuildRequest+                  { rebuildRunId = catalogInlineRunId,+                    requestedBy = "keiro-test",+                    requestReason = "typed domain catalog fence proof",+                    replayFrom = GlobalPosition 0+                  }+          let fencedTarget = stream "domain-command-catalog-fenced" :: Stream CounterEventStream+          fenced <-+            runner $+              runDomainCommandWithCatalogProjections+                defaultRunCommandOptions+                multiCounterDomainHandler+                fencedTarget+                (Add 8)+                validated+                catalogInlineProjectionSet+          fenced+            `shouldBe` Right (Right (DomainProjectionCommandFenced catalogInlineGroupId catalogInlineRunId))+          Right recorded <-+            runner $+              Store.readStreamForward (StreamName "domain-command-catalog-fenced") (StreamVersion 0) 10+          recorded `shouldBe` Vector.empty+          pure ()++      it "discards an accepted conflict attempt and returns the rehydrated silent decision" $ \_ ->+        withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+          conflictInserted <- newIORef False+          let target = stream "domain-command-conflict-final-no-op" :: Stream RetryDecisionEventStream+              targetStreamName = StreamName "domain-command-conflict-final-no-op"+              insertConflict = do+                shouldInsert <- atomicModifyIORef' conflictInserted $ \inserted -> (True, not inserted)+                when shouldInsert $ do+                  encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 9))+                  appended <-+                    Store.runStoreIO storeHandle $+                      Store.appendToStream targetStreamName NoStream [encoded]+                  case appended of+                    Right _ -> pure ()+                    Left err -> expectationFailure ("failed to inject domain conflict: " <> show err)+              options =+                defaultRunCommandOptions+                  & #beforeAppend+                  .~ insertConflict+                  & #retryBackoffMicros+                  .~ 0+              callback _ _ = error "stale accepted decision invoked SQL callback" :: Tx.Transaction Text+          outcome <-+            runner $+              runDomainCommandWithSqlEvents+                options+                retryDecisionDomainHandler+                target+                (Add 1)+                callback+          case outcome of+            Right (Right (DomainCommandOutcome {decision = DomainNoOp explanation, result}, Nothing)) -> do+              explanation `shouldBe` "already drained"+              result ^. #streamVersion `shouldBe` StreamVersion 1+              result ^. #eventsAppended `shouldBe` 0+            other -> expectationFailure ("expected rehydrated no-op decision, got " <> show other)+          readIORef conflictInserted `shouldReturn` True+          Right recorded <-+            Store.runStoreIO storeHandle $+              Store.readStreamForward targetStreamName (StreamVersion 0) 10+          traverse (decodeRecorded counterCodec) (Vector.toList recorded)+            `shouldBe` Right [CounterAdded 9]++      it "records only bounded decision classes on successful spans and metrics" $ \storeHandle -> do+        (processor, spansRef) <- inMemoryListExporter+        tracerProvider <- createTracerProvider [processor] emptyTracerProviderOptions+        (metricExporter, metricsRef) <- inMemoryMetricExporter+        (meterProvider, _env) <-+          createMeterProvider+            emptyMaterializedResources+            defaultSdkMeterProviderOptions {metricExporter = Just metricExporter}+        meter <- getMeter meterProvider Telemetry.keiroInstrumentationLibrary+        keiroMetrics <- Telemetry.newKeiroMetrics meter+        let tracer = makeTracer tracerProvider "keiro-test" tracerOptions+            options =+              defaultRunCommandOptions+                & #tracer+                ?~ tracer+                & #metrics+                ?~ keiroMetrics+        Right (Right _) <-+          Store.runStoreIO storeHandle $+            runDomainCommand options multiCounterDomainHandler (stream "domain-telemetry-accepted") (Add 1)+        Right (Right _) <-+          Store.runStoreIO storeHandle $+            runDomainCommand options silentChoiceDomainHandler (stream "domain-telemetry-rejected") RejectSilently+        Right (Right _) <-+          Store.runStoreIO storeHandle $+            runDomainCommand options silentChoiceDomainHandler (stream "domain-telemetry-no-op") NoOpSilently+        _ <- shutdownTracerProvider tracerProvider Nothing+        _ <- forceFlushMeterProvider meterProvider Nothing+        spans <- traverse captureSpan =<< readIORef spansRef+        fmap (\sp -> textAttr (csAttributes sp) "keiro.command.decision") spans+          `shouldMatchList` [Just "accepted", Just "rejected", Just "no_op"]+        fmap csStatus spans `shouldSatisfy` all (== Unset)+        fmap (\sp -> textAttr (csAttributes sp) "error.type") spans+          `shouldSatisfy` all (== Nothing)+        exported <- readIORef metricsRef+        let decisionPoints =+              [ (textAttr attrs "keiro.command.decision", value)+              | (name, value, attrs) <- flattenScalarPointsWithAttributes exported,+                name == "keiro.command.decisions"+              ]+        decisionPoints+          `shouldMatchList` [ (Just "accepted", IntNumber 1),+                              (Just "rejected", IntNumber 1),+                              (Just "no_op", IntNumber 1)+                            ]++      it "keeps all five process-manager target outcomes distinguishable" $ \_ ->+        withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+          let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+              input =+                DomainDispatchInput+                  "five-outcomes"+                  [ CoordinatorAccept 3,+                    CoordinatorReject "private rejection",+                    CoordinatorNoOp "private no-op",+                    CoordinatorUnmatched+                  ]+          first <-+            runner $+              runDomainProcessManagerOnce+                defaultRunCommandOptions+                domainProcessManager+                sourceEvent+                input+          case first of+            Right (Right result) -> do+              result ^. #managerResult `shouldSatisfy` \case+                PMStateAppended {} -> True+                _ -> False+              case result ^. #commandResults of+                [ DomainPMCommandHandled DomainCommandOutcome {decision = DomainAccepted events},+                  DomainPMCommandHandled DomainCommandOutcome {decision = DomainRejected reason},+                  DomainPMCommandHandled DomainCommandOutcome {decision = DomainNoOp explanation},+                  DomainPMCommandFailed _ CommandRejected+                  ] -> do+                    events `shouldBe` (CounterAdded 3 :| [])+                    reason `shouldBe` "private rejection"+                    explanation `shouldBe` "private no-op"+                other -> expectationFailure ("expected four fresh domain PM outcomes, got " <> show other)+            other -> expectationFailure ("expected domain process-manager success, got " <> show other)+          second <-+            runner $+              runDomainProcessManagerOnce+                defaultRunCommandOptions+                domainProcessManager+                sourceEvent+                input+          case second of+            Right (Right result) -> do+              result ^. #managerResult `shouldSatisfy` \case+                PMStateDuplicate {} -> True+                _ -> False+              result ^. #commandResults `shouldSatisfy` \case+                [ DomainPMCommandDuplicate {},+                  DomainPMCommandHandled DomainCommandOutcome {decision = DomainRejected "private rejection"},+                  DomainPMCommandHandled DomainCommandOutcome {decision = DomainNoOp "private no-op"},+                  DomainPMCommandFailed _ CommandRejected+                  ] -> True+                _ -> False+            other -> expectationFailure ("expected domain process-manager redelivery, got " <> show other)++      it "keeps all five router target outcomes distinguishable" $ \_ ->+        withFreshResourceStore fixture $ \(_storeHandle, StoreRunner runner) -> do+          let sourceEvent = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)+              input =+                DomainDispatchInput+                  "five-outcomes"+                  [ CoordinatorAccept 4,+                    CoordinatorReject "router rejection",+                    CoordinatorNoOp "router no-op",+                    CoordinatorUnmatched+                  ]+          Right (DomainRouterResult first) <-+            runner $+              runDomainRouterOnce+                defaultRunCommandOptions+                domainRouter+                sourceEvent+                input+          case first of+            [ DomainPMCommandHandled DomainCommandOutcome {decision = DomainAccepted events},+              DomainPMCommandHandled DomainCommandOutcome {decision = DomainRejected reason},+              DomainPMCommandHandled DomainCommandOutcome {decision = DomainNoOp explanation},+              DomainPMCommandFailed _ CommandRejected+              ] -> do+                events `shouldBe` (CounterAdded 4 :| [])+                reason `shouldBe` "router rejection"+                explanation `shouldBe` "router no-op"+            other -> expectationFailure ("expected four fresh domain router outcomes, got " <> show other)+          Right (DomainRouterResult second) <-+            runner $+              runDomainRouterOnce+                defaultRunCommandOptions+                domainRouter+                sourceEvent+                input+          second `shouldSatisfy` \case+            [ DomainPMCommandDuplicate {},+              DomainPMCommandHandled DomainCommandOutcome {decision = DomainRejected "router rejection"},+              DomainPMCommandHandled DomainCommandOutcome {decision = DomainNoOp "router no-op"},+              DomainPMCommandFailed _ CommandRejected+              ] -> True+            _ -> False++      it "acks domain rejection and no-op in coordinator workers without leaking payloads" $ \_ ->+        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+          processManagerDecisions <- newIORef []+          routerDecisions <- newIORef []+          let rejectionPayload = "pm-private-rejection-payload"+              noOpPayload = "router-private-no-op-payload"+              processManagerSource = recordedFromEventId (EventId sampleUuid3) (CounterAdded 1)+              routerSource = recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)+              processManagerInput = DomainDispatchInput "worker-pm" [CoordinatorReject rejectionPayload, CoordinatorNoOp "pm no-op"]+              routerInput = DomainDispatchInput "worker-router" [CoordinatorReject "router rejection", CoordinatorNoOp noOpPayload]+              processManagerAdapter = inMemoryAdapter processManagerDecisions [(processManagerSource, processManagerInput)]+              routerAdapter = inMemoryAdapter routerDecisions [(routerSource, routerInput)]+              workerOptions = defaultWorkerOptions & #metrics ?~ keiroMetrics+              commandOptions = defaultRunCommandOptions & #metrics ?~ keiroMetrics+          Right () <-+            runner $+              runDomainProcessManagerWorkerWith+                workerOptions+                commandOptions+                domainProcessManager+                processManagerAdapter+                Just+          Right () <-+            runner $+              runDomainRouterWorkerWith+                workerOptions+                commandOptions+                domainRouter+                routerAdapter+                Just+          readIORef processManagerDecisions `shouldReturn` [AckOk]+          readIORef routerDecisions `shouldReturn` [AckOk]+          Right processManagerDeadLetters <- runner (listDispatchDeadLetters "domain-pm")+          Right routerDeadLetters <- runner (listDispatchDeadLetters "domain-router")+          processManagerDeadLetters `shouldBe` []+          routerDeadLetters `shouldBe` []+          _ <- forceFlushMeterProvider provider Nothing+          exported <- readIORef metricsRef+          lookup "keiro.dispatch.failed" (flattenScalarPoints exported) `shouldBe` Just (IntNumber 0)+          let rendered = Text.pack (show exported)+          Text.isInfixOf rejectionPayload rendered `shouldBe` False+          Text.isInfixOf noOpPayload rendered `shouldBe` False++    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)+        truthfulResult <-+          Store.runStoreIO storeHandle $+            runQueryWithFreshness Nothing Immediate counterImmediateReadModel "inline"+        truthfulResult `shouldBe` queryResult++    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 "returns no subscription position for an empty durable inventory" $ \_ -> do+      let inventory =+            KirokuSub.SubscriptionCheckpointInventory+              (GlobalPosition 17)+              Vector.empty+      subscriptionPositionFromInventory (SubscriptionName "missing") inventory+        `shouldBe` Nothing++    it "returns the newest visible position after a stream is hard deleted" $ \storeHandle -> do+      let target = stream "read-model-captured-head" :: Stream CounterEventStream+      Right (Right commandResult) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 1)+      capturedPosition <- case commandResult ^. #globalPosition of+        Just position -> pure position+        Nothing -> expectationFailure "expected command global position" *> error "unreachable"+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          Store.hardDeleteStream (StreamName "read-model-captured-head")+      observedHead <- Store.runStoreIO storeHandle storeHeadPosition+      observedHead `shouldBe` Right (GlobalPosition 0)+      Right (KirokuSub.SubscriptionCheckpointInventory authoritativePosition _) <-+        Store.runStoreIO storeHandle Store.subscriptionCheckpointInventory+      authoritativePosition `shouldBe` capturedPosition++    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)+      truthfulResult <-+        Store.runStoreIO storeHandle $+          runQueryWithFreshness+            Nothing+            (WaitForHead EntireVisibleLog)+            counterCursorReadModel+            "empty"+      truthfulResult `shouldBe` queryResult++    it "rejects truthful waits when an immediate inline model has no cursor" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterImmediateReadModel initializeCounterReadModelTable+      queryResult <-+        Store.runStoreIO storeHandle $+          runQueryWithFreshness+            Nothing+            (WaitForHead EntireVisibleLog)+            counterImmediateReadModel+            "inline"+      queryResult+        `shouldBe` Right+          ( Left+              ( ReadModelMissingCursor+                  "counter-read-model"+                  (WaitForHead EntireVisibleLog)+              )+          )++    it "waitFor fails fast on a cursorless model instead of burning the timeout" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      startedAt <- getCurrentTime+      waitResult <-+        Store.runStoreIO storeHandle $+          waitFor (Just keiroMetrics) defaultHeadWaitOptions counterImmediateReadModel (GlobalPosition 5)+      finishedAt <- getCurrentTime+      waitResult+        `shouldBe` Right+          ( Left+              ( ReadModelMissingCursor+                  "counter-read-model"+                  (WaitForPosition (defaultHeadWaitOptions & #target ?~ GlobalPosition 5))+              )+          )+      diffUTCTime finishedAt startedAt `shouldSatisfy` (< 2)+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.projection.wait.timeouts" (flattenScalarPoints exported) `shouldBe` Nothing++    it "deprecated Strong and PositionWait overrides fail fast on a cursorless model" $ \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 counterImmediateReadModel initializeCounterReadModelTable+      let target = stream "read-model-cursorless-strong" :: Stream CounterEventStream+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand defaultRunCommandOptions counterEventStream target (Add 5)+      startedAt <- getCurrentTime+      strongResult <-+        Store.runStoreIO storeHandle $+          runQueryWith (Just keiroMetrics) Strong counterImmediateReadModel "inline"+      finishedAt <- getCurrentTime+      strongResult+        `shouldBe` Right+          (Left (ReadModelMissingCursor "counter-read-model" (WaitForHead EntireVisibleLog)))+      diffUTCTime finishedAt startedAt `shouldSatisfy` (< 2)+      truthfulResult <-+        Store.runStoreIO storeHandle $+          runQueryWithFreshness Nothing (WaitForHead EntireVisibleLog) counterImmediateReadModel "inline"+      truthfulResult `shouldBe` strongResult+      positionResult <-+        Store.runStoreIO storeHandle $+          runQueryWith+            (Just keiroMetrics)+            (PositionWait (fastWaitOptions & #target .~ Just (GlobalPosition 5)))+            counterImmediateReadModel+            "inline"+      positionResult+        `shouldBe` Right+          ( Left+              ( ReadModelMissingCursor+                  "counter-read-model"+                  (WaitForPosition (fastWaitOptions & #target .~ Just (GlobalPosition 5)))+              )+          )+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.projection.wait.timeouts" (flattenScalarPoints exported) `shouldBe` Nothing++    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 and WaitForHead return promptly after workflow GC hard-deletes the newest events" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        Right (Right commandResult) <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              (stream "read-model-gc-strong" :: Stream CounterEventStream)+              (Add 5)+              [counterInlineProjection]+        visiblePosition <- 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 visiblePosition)+                upsertSubscriptionCursorStmt++        counter <- newIORef (0 :: Int)+        Right (Completed _) <-+          Store.runStoreIO storeHandle $+            runWorkflowWith+              (defaultWorkflowRunOptions & #snapshotPolicy .~ OnTerminal)+              (WorkflowName "gc-strong-wf")+              (WorkflowId "gsw-1")+              (demoWorkflow counter)+        now <- getCurrentTime+        Right summary <-+          Store.runStoreIO storeHandle $+            WorkflowGc.gcWorkflowsOnce+              (addUTCTime 1 now)+              WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+        summary `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 1, deleted = 1}++        observedHead <- Store.runStoreIO storeHandle storeHeadPosition+        observedHead `shouldBe` Right visiblePosition+        Right (KirokuSub.SubscriptionCheckpointInventory authoritativePosition _) <-+          Store.runStoreIO storeHandle Store.subscriptionCheckpointInventory+        authoritativePosition `shouldSatisfy` (> visiblePosition)++        startedAt <- getCurrentTime+        queryResult <-+          Store.runStoreIO storeHandle $+            runQueryWith Nothing Strong counterReadModel "inline"+        finishedAt <- getCurrentTime+        queryResult `shouldBe` Right (Right 5)+        diffUTCTime finishedAt startedAt `shouldSatisfy` (< 2)++        truthfulStartedAt <- getCurrentTime+        truthfulResult <-+          Store.runStoreIO storeHandle $+            runQueryWithFreshness+              Nothing+              (WaitForHead EntireVisibleLog)+              counterCursorReadModel+              "inline"+        truthfulFinishedAt <- getCurrentTime+        truthfulResult `shouldBe` queryResult+        diffUTCTime truthfulFinishedAt truthfulStartedAt `shouldSatisfy` (< 2)++    it "Strong and WaitForHead still time out when visible events outrun the subscription" $ \_ ->+      withFreshResourceStore fixture $ \(storeHandle, StoreRunner runner) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            initializeRegisteredReadModel counterReadModel initializeCounterReadModelTable+        Right (Right commandResult) <-+          runner $+            runCommandWithProjections+              defaultRunCommandOptions+              counterEventStream+              (stream "read-model-strong-visible-behind" :: Stream CounterEventStream)+              (Add 5)+              [counterInlineProjection]+        visiblePosition <- case commandResult ^. #globalPosition of+          Just position -> pure position+          Nothing -> expectationFailure "expected command global position" *> error "unreachable"+        queryResult <-+          Store.runStoreIO storeHandle $+            runQueryWith Nothing Strong counterReadModel "inline"+        queryResult+          `shouldBe` Right+            ( Left+                ( ReadModelWaitTimeout+                    "counter-read-model"+                    visiblePosition+                    (GlobalPosition 0)+                )+            )+        truthfulResult <-+          Store.runStoreIO storeHandle $+            runQueryWithFreshness+              Nothing+              (WaitForHead EntireVisibleLog)+              counterCursorReadModel+              "inline"+        truthfulResult `shouldBe` queryResult++    it "Strong and WaitForHead return when their 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)+        truthfulResult <-+          Store.runStoreIO storeHandle $+            runQueryWithFreshness+              Nothing+              (WaitForHead (CategoryVisibleHead "counter"))+              counterCursorReadModel+              "inline"+        truthfulResult `shouldBe` queryResult++    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)+        truthfulResult <-+          Store.runStoreIO storeHandle $+            runQueryWithFreshness+              Nothing+              (WaitForPosition (fastWaitOptions & #target .~ Just globalPosition))+              counterCursorReadModel+              "inline"+        truthfulResult `shouldBe` queryResult++    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)))+      truthfulResult <-+        Store.runStoreIO storeHandle $+          runQueryWithFreshness+            Nothing+            (WaitForPosition (fastWaitOptions & #target .~ Just (GlobalPosition 5)))+            counterCursorReadModel+            "timeout"+      truthfulResult `shouldBe` queryResult++    it "rejects a truthful position wait without a target" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel counterCursorReadModel initializeCounterReadModelTable+      queryResult <-+        Store.runStoreIO storeHandle $+          runQueryWithFreshness+            Nothing+            (WaitForPosition fastWaitOptions)+            counterCursorReadModel+            "missing-target"+      queryResult+        `shouldBe` Right (Left (ReadModelMissingPosition "counter-read-model"))++    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 "startRebuild on a cursorless model skips the checkpoint reset and completes" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          initializeRegisteredReadModel+            counterCursorlessRebuildReadModel+            initializeCounterReadModelTable+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Tx.sql "INSERT INTO counter_read_model (model_id, amount, last_seen) VALUES ('inline', 9, 1)"+            Tx.statement ("counter-read-model-sub", 7) upsertSubscriptionCursorStmt+      rebuildingResult <-+        Store.runStoreIO storeHandle $+          Rebuild.startRebuild counterCursorlessRebuildReadModel [] (GlobalPosition 0)+      rebuilding <- case rebuildingResult of+        Right metadata -> pure metadata+        Left err -> expectationFailure ("cursorless startRebuild failed: " <> show err) *> error "unreachable"+      rebuilding ^. #status `shouldBe` Rebuilding+      untouched <-+        Store.runStoreIO storeHandle $+          readSubscriptionPosition "counter-read-model-sub"+      untouched `shouldBe` Right (Just (GlobalPosition 7))+      Right (Right live) <-+        Store.runStoreIO storeHandle $+          Rebuild.finishRebuild counterCursorlessRebuildReadModel [] (GlobalPosition 0)+      live ^. #status `shouldBe` Live+      afterRebuild <-+        Store.runStoreIO storeHandle $+          runQuery Nothing counterCursorlessRebuildReadModel "inline"+      afterRebuild `shouldBe` Right (Right 0)++    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 matching global position distance and projection lag gauges" $ \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 both the preferred and+      -- compatibility gauges record the same non-negative position distance.+      Right () <-+        Store.runStoreIO storeHandle $+          recordProjectionGlobalPositionDistance (Just keiroMetrics) counterAsyncProjection+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+          preferred = lookup "keiro.projection.global_position_distance" scalars+          compatibility = lookup "keiro.projection.lag" scalars+      preferred `shouldBe` compatibility+      case preferred of+        Just (IntNumber n) -> n `shouldSatisfy` (>= 1)+        other -> expectationFailure ("expected an integer global position distance, got " <> show other)++    it "reports zero global position distance after the newest events are hard deleted" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider+          emptyMaterializedResources+          defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      Right (Right survivorResult) <-+        Store.runStoreIO storeHandle $+          runCommand+            defaultRunCommandOptions+            counterEventStream+            (stream "gauge-gc-survivor" :: Stream CounterEventStream)+            (Add 1)+      survivorPosition <- case survivorResult ^. #globalPosition of+        Just position -> pure position+        Nothing -> expectationFailure "expected survivor global position" *> error "unreachable"+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement+              ("counter-read-model-sub", globalPositionToInt survivorPosition)+              upsertSubscriptionCursorStmt+      Right (Right _) <-+        Store.runStoreIO storeHandle $+          runCommand+            defaultRunCommandOptions+            counterEventStream+            (stream "gauge-gc-victim" :: Stream CounterEventStream)+            (Add 1)+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          Store.hardDeleteStream (StreamName "gauge-gc-victim")+      Right () <-+        Store.runStoreIO storeHandle $+          recordProjectionGlobalPositionDistance (Just keiroMetrics) counterAsyncProjection+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      let scalars = flattenScalarPoints exported+      lookup "keiro.projection.global_position_distance" scalars+        `shouldBe` Just (IntNumber 0)+      lookup "keiro.projection.lag" scalars+        `shouldBe` Just (IntNumber 0)++    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 "bridges a pre-UTF-8 process-manager state and command redelivery" $ \(storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          correlationId = "\x4E2D\x6587-42"+          managerStreamName = StreamName "pm:counter-unicode"+          targetStreamName = StreamName "counter-target-unicode"+          legacyManagerId = legacyDeterministicCommandId "unicode-pm" correlationId (sourceEvent ^. #eventId) (-1)+          legacyCommandId = legacyDeterministicCommandId "unicode-pm" correlationId (sourceEvent ^. #eventId) 0+      appendCounterEventWithId storeHandle managerStreamName legacyManagerId (CounterAdded 9)+      appendCounterEventWithId storeHandle targetStreamName legacyCommandId (CounterAdded 9)+      Right (Right pmResult) <-+        _runner $+          runProcessManagerOnce defaultRunCommandOptions unicodeCounterProcessManager sourceEvent (CounterAdded 9)+      Right managerEvents <- _runner $ Store.readStreamForward managerStreamName (StreamVersion 0) 10+      Right targetEvents <- _runner $ Store.readStreamForward targetStreamName (StreamVersion 0) 10+      ( pmResult ^. #managerResult,+        pmResult ^. #commandResults,+        Vector.length managerEvents,+        Vector.length targetEvents+        )+        `shouldBe` ( PMStateDuplicate legacyManagerId,+                     [PMCommandDuplicate legacyCommandId],+                     1,+                     1+                   )++    it "bridges a pre-UTF-8 domain process-manager state and command redelivery" $ \(storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          correlationId = "\x4E2D\x6587-9"+          managerStreamName = StreamName ("domain-pm:" <> correlationId)+          targetStreamName = StreamName ("domain-pm-target:" <> correlationId <> ":0")+          legacyManagerId = legacyDeterministicCommandId "domain-pm" correlationId (sourceEvent ^. #eventId) (-1)+          legacyCommandId = legacyDeterministicCommandId "domain-pm" correlationId (sourceEvent ^. #eventId) 0+          input = DomainDispatchInput correlationId [CoordinatorAccept 9]+      appendCounterEventWithId storeHandle managerStreamName legacyManagerId (CounterAdded 1)+      appendCounterEventWithId storeHandle targetStreamName legacyCommandId (CounterAdded 9)+      Right (Right pmResult) <-+        _runner $+          runDomainProcessManagerOnce defaultRunCommandOptions domainProcessManager sourceEvent input+      Right managerEvents <- _runner $ Store.readStreamForward managerStreamName (StreamVersion 0) 10+      Right targetEvents <- _runner $ Store.readStreamForward targetStreamName (StreamVersion 0) 10+      ( pmResult ^. #managerResult,+        pmResult ^. #commandResults,+        Vector.length managerEvents,+        Vector.length targetEvents+        )+        `shouldBe` ( PMStateDuplicate legacyManagerId,+                     [DomainPMCommandDuplicate legacyCommandId],+                     1,+                     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 "RouterSelection validates positive runtime invariants" $ \(_storeHandle, StoreRunner _runner) -> do+      mkRecipientLimit 0 `shouldSatisfy` \case Left _ -> True; Right _ -> False+      mkSelectionVersion 0 `shouldSatisfy` \case Left _ -> True; Right _ -> False+      limit <- shouldBeRight (mkRecipientLimit 2)+      selectionVersion <- shouldBeRight (mkSelectionVersion 3)+      recipientLimitValue limit `shouldBe` 2+      selectionVersionValue selectionVersion `shouldBe` 3++    it "RouterSelection sorts, deduplicates, caps, and rejects conflicts before dispatch" $ \(_storeHandle, StoreRunner _runner) -> do+      limit <- shouldBeRight (mkRecipientLimit 2)+      one <- shouldBeRight (mkRecipientLimit 1)+      let targetA = PMCommand {target = stream "selection-a", command = Add 1}+          targetB = PMCommand {target = stream "selection-b", command = Add 1}+          targetBConflict = PMCommand {target = stream "selection-b", command = Add 2}+      normalizeRecipients limit [targetB, targetA, targetB]+        `shouldBe` Right [targetA, targetB]+      normalizeRecipients limit [targetB, targetBConflict, targetA]+        `shouldBe` Left (SelectionConflictingCommands (StreamName "selection-b"))+      normalizeRecipients one [targetB, targetA, targetB]+        `shouldBe` Left (SelectionRecipientOverflow one 2)+      normalizeRecipients limit [targetB, targetA]+        `shouldBe` Right [targetA, targetB]++    it "RouterSelection exposes stable public dead-letter code, detail, and rendering" $ \(_storeHandle, StoreRunner _runner) -> do+      contract <- testSelectionContract EmptyDeadLetter FailureDeadLetter 4+      recipientLimit <- shouldBeRight (mkRecipientLimit 4)+      let failures =+            [ (SelectionQueryFailed "secret backend detail", "keiro.router.selection.query_failed"),+              (SelectionEvaluationFailed "secret payload", "keiro.router.selection.evaluation_failed"),+              (SelectionConflictingCommands (StreamName "hospital-1"), "keiro.router.selection.target_conflict"),+              (SelectionRecipientOverflow recipientLimit 5, "keiro.router.selection.recipient_overflow")+            ]+          assertReason expectedCode reason = do+            deadLetterCodeText (deadLetterReasonCode reason) `shouldBe` expectedCode+            deadLetterReasonDetail reason `shouldSatisfy` maybe False (not . Text.null)+            renderDeadLetterReason reason `shouldSatisfy` Text.isPrefixOf (expectedCode <> ": ")+      assertReason "keiro.router.selection.empty" (emptySelectionDeadLetterReason contract)+      for_ failures $ \(failure, expectedCode) -> do+        let reason = selectionFailureDeadLetterReason contract failure+        assertReason expectedCode reason+        renderDeadLetterReason reason `shouldNotSatisfy` Text.isInfixOf "secret"++    it "RouterSelection performs no target callback on conflict or overflow and dispatches exactly at the cap" $ \(_storeHandle, StoreRunner _runner) -> do+      twoRecipientContract <- testSelectionContract EmptyAck FailureRetry 2+      oneRecipientContract <- testSelectionContract EmptyAck FailureRetry 1+      callbacks <- newIORef (0 :: Int)+      let options = defaultRunCommandOptions & #beforeAppend .~ modifyIORef' callbacks (+ 1)+          sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          targetA = PMCommand {target = stream "bounded-a", command = Add 1}+          targetB = PMCommand {target = stream "bounded-b", command = Add 1}+          targetBConflict = PMCommand {target = stream "bounded-b", command = Add 2}+      Right conflict <-+        _runner $+          runDeclarativeRouterOnce+            options+            (selectionRouter twoRecipientContract (pure (Right [targetB, targetBConflict, targetA])))+            sourceEvent+            (RouteGroup "g1")+      conflict `shouldBe` DeclarativeSelectionFailed (SelectionConflictingCommands (StreamName "bounded-b"))+      readIORef callbacks `shouldReturn` 0+      Right overflow <-+        _runner $+          runDeclarativeRouterOnce+            options+            (selectionRouter oneRecipientContract (pure (Right [targetB, targetA, targetB])))+            sourceEvent+            (RouteGroup "g1")+      overflow `shouldBe` DeclarativeSelectionFailed (SelectionRecipientOverflow (oneRecipientContract ^. #limit) 2)+      readIORef callbacks `shouldReturn` 0+      Right atCap <-+        _runner $+          runDeclarativeRouterOnce+            options+            (selectionRouter twoRecipientContract (pure (Right [targetB, targetA, targetB])))+            sourceEvent+            (RouteGroup "g1")+      atCap `shouldSatisfy` \case+        DeclarativeSelectionDispatched (RouterResult results) -> length results == 2 && all isAppended results+        _ -> False+      readIORef callbacks `shouldReturn` 2++    it "RouterSelection retains successful targets after a later target dispatch fails" $ \(_storeHandle, StoreRunner _runner) -> do+      contract <- testSelectionContract EmptyAck FailureRetry 2+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          router =+            selectionRouter contract (pure (Right [PMCommand {target = stream "partial-a", command = Add 1}, PMCommand {target = stream "partial-b", command = Add 9}]))+              & #targetEventStream+              .~ rejectNineEventStream+      Right result <- _runner (runDeclarativeRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1"))+      result `shouldSatisfy` \case+        DeclarativeSelectionDispatched (RouterResult [first, second]) -> isAppended first && isFailed second+        _ -> False+      Right partialA <- _runner (Store.readStreamForward (StreamName "partial-a") (StreamVersion 0) 10)+      Right partialB <- _runner (Store.readStreamForward (StreamName "partial-b") (StreamVersion 0) 10)+      Vector.length partialA `shouldBe` 1+      Vector.length partialB `shouldBe` 0++    it "RouterSelection preserves target-keyed stable union across result drift" $ \(_storeHandle, StoreRunner _runner) -> do+      contract <- testSelectionContract EmptyAck FailureRetry 2+      attempts <- newIORef (0 :: Int)+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          selectAttempt _ = do+            attempt <- liftIO (atomicModifyIORef' attempts (\value -> (value + 1, value)))+            pure $ Right $ case attempt of+              0 -> commandsFor ["union-b", "union-a"]+              _ -> commandsFor ["union-c", "union-a"]+          commandsFor targetNames = [PMCommand {target = stream targetName, command = Add 1} | targetName <- targetNames]+          router = (selectionRouter contract (pure (Right []))) {select = selectAttempt}+      Right first <- _runner (runDeclarativeRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1"))+      Right second <- _runner (runDeclarativeRouterOnce defaultRunCommandOptions router sourceEvent (RouteGroup "g1"))+      first `shouldSatisfy` \case+        DeclarativeSelectionDispatched (RouterResult results) -> all isAppended results+        _ -> False+      second `shouldSatisfy` \case+        DeclarativeSelectionDispatched (RouterResult [unionA, unionC]) -> isDuplicate unionA && isAppended unionC+        _ -> False+      for_ ["union-a", "union-b", "union-c"] $ \targetName -> do+        Right events <- _runner (Store.readStreamForward (StreamName targetName) (StreamVersion 0) 10)+        Vector.length events `shouldBe` 1++    it "RouterSelection worker lowers the complete empty and failure policy matrices" $ \(_storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          message = (sourceEvent, RouteGroup "g1")+          runCase emptySelectionPolicy failureSelectionPolicy selected = do+            contract <- testSelectionContract emptySelectionPolicy failureSelectionPolicy 2+            decisions <- newIORef []+            Right () <-+              _runner $+                runDeclarativeRouterWorker+                  defaultRunCommandOptions+                  (selectionRouter contract (pure selected))+                  (inMemoryAdapter decisions [message])+                  Just+            readIORef decisions+      runCase EmptyAck FailureRetry (Right []) `shouldReturn` [AckOk]+      runCase EmptyRetry FailureRetry (Right []) `shouldReturn` [AckRetry (RetryDelay 5)]+      emptyDeadLetter <- runCase EmptyDeadLetter FailureRetry (Right [])+      emptyDeadLetter `shouldSatisfy` \case+        [AckDeadLetter reason] -> deadLetterCodeText (deadLetterReasonCode reason) == "keiro.router.selection.empty"+        _ -> False+      emptyHalt <- runCase EmptyHalt FailureRetry (Right [])+      emptyHalt `shouldSatisfy` \case [AckHalt {}] -> True; _ -> False+      runCase EmptyAck FailureRetry (Left (SelectionQueryFailed "private")) `shouldReturn` [AckRetry (RetryDelay 5)]+      failureDeadLetter <- runCase EmptyAck FailureDeadLetter (Left (SelectionEvaluationFailed "private"))+      failureDeadLetter `shouldSatisfy` \case+        [AckDeadLetter reason] -> deadLetterCodeText (deadLetterReasonCode reason) == "keiro.router.selection.evaluation_failed"+        _ -> False+      failureHalt <- runCase EmptyAck FailureHalt (Left (SelectionQueryFailed "private"))+      failureHalt `shouldSatisfy` \case [AckHalt {}] -> True; _ -> False++    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++    it "bridges a pre-UTF-8 positional router redelivery with a non-ASCII key" $ \(storeHandle, StoreRunner _runner) -> do+      Right () <-+        _runner $+          initializeRegisteredReadModel routerTargetsReadModel initializeRouterTargetsTable+      let correlationId = "g-\x4E2D\x6587"+          targetStreamName = StreamName "transition-unicode-target"+          sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 1)+          legacyId = legacyDeterministicCommandId "demo-router" correlationId (sourceEvent ^. #eventId) 0+      Right () <-+        _runner $+          Store.runTransaction (Tx.statement (correlationId, "transition-unicode-target") insertRouterTargetStmt)+      appendCounterEventWithId storeHandle targetStreamName legacyId (CounterAdded 1)+      Right (RouterResult results) <-+        _runner $+          runRouterOnce defaultRunCommandOptions demoRouter sourceEvent (RouteGroup correlationId)+      Right targetEvents <- _runner $ Store.readStreamForward targetStreamName (StreamVersion 0) 10+      (results, Vector.length targetEvents)+        `shouldBe` ([PMCommandDuplicate legacyId], 1)++    it "bridges a pre-UTF-8 domain router redelivery with a non-ASCII key" $ \(storeHandle, StoreRunner _runner) -> do+      let sourceEvent = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+          correlationId = "\x4E2D\x6587-9"+          targetStreamName = StreamName ("domain-router-target:" <> correlationId <> ":0")+          legacyId = legacyDeterministicCommandId "domain-router" correlationId (sourceEvent ^. #eventId) 0+          input = DomainDispatchInput correlationId [CoordinatorAccept 9]+      appendCounterEventWithId storeHandle targetStreamName legacyId (CounterAdded 9)+      Right (DomainRouterResult results) <-+        _runner $+          runDomainRouterOnce defaultRunCommandOptions domainRouter sourceEvent input+      Right targetEvents <- _runner $ Store.readStreamForward targetStreamName (StreamVersion 0) 10+      (results, Vector.length targetEvents)+        `shouldBe` ([DomainPMCommandDuplicate legacyId], 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)+      Right lookedUp <-+        Store.runStoreIO storeHandle $+          lookupTimer (counterTimerRequest ^. #timerId)+      fmap (^. #status) lookedUp `shouldBe` Just Firing+      -- 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++    -- Discovery is exact. A completed workflow is finished, and a workflow+    -- parked on an unresolved await has nothing to do until its wake source+    -- resolves — the wake's own append is what makes it discoverable again.+    it "discovers a parked workflow only once its awaited step is journaled" $ \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)+      parkedAt <- getCurrentTime+      Right whileParked <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds parkedAt)+      whileParked `shouldBe` []+      Right () <- Store.runStoreIO storeHandle $ do+        now <- liftIO getCurrentTime+        appendJournalEntry+          (WorkflowName "pending")+          (WorkflowId "p-1")+          (StepRecorded "awk:wait" (toJSON (7 :: Int)) now)+      wokenAt <- getCurrentTime+      Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds wokenAt)+      unfinished `shouldBe` [("p-1", "pending")]++  describe "Keiro.Workflow instance table" $ around (withFreshStore fixture) $ do+    it "lists workflow instances with filters and stable keyset pages" $ \storeHandle -> do+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $ do+            Instance.upsertInstanceTx "b-2" "beta" 0 Instance.WfFailed (Just "boom")+            Instance.upsertInstanceTx "a-2" "alpha" 0 Instance.WfCompleted Nothing+            Instance.upsertInstanceTx "b-1" "beta" 0 Instance.WfRunning Nothing+            Instance.upsertInstanceTx "a-1" "alpha" 0 Instance.WfFailed (Just "bad")++      let firstPageFilter =+            Instance.defaultWorkflowInstanceFilter+              { Instance.pageSize = 2+              }+      Right firstPage <- Store.runStoreIO storeHandle $ Instance.listWorkflowInstances firstPageFilter+      fmap (\row -> (row ^. #workflowName, row ^. #workflowId)) firstPage+        `shouldBe` [("alpha", "a-1"), ("alpha", "a-2")]++      let secondPageFilter =+            firstPageFilter+              { Instance.afterKey = Just ("alpha", "a-2")+              }+      Right secondPage <- Store.runStoreIO storeHandle $ Instance.listWorkflowInstances secondPageFilter+      fmap (\row -> (row ^. #workflowName, row ^. #workflowId)) secondPage+        `shouldBe` [("beta", "b-1"), ("beta", "b-2")]++      let failedBetaFilter =+            Instance.defaultWorkflowInstanceFilter+              { Instance.statuses = Just (Instance.WfFailed :| []),+                Instance.workflowName = Just "beta"+              }+      Right failedBeta <- Store.runStoreIO storeHandle $ Instance.listWorkflowInstances failedBetaFilter+      fmap (\row -> (row ^. #workflowName, row ^. #workflowId, row ^. #status)) failedBeta+        `shouldBe` [("beta", "b-2", Instance.WfFailed)]++    it "cancels active workflows idempotently without minting unknown state" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "operator-cancel"+          wid = WorkflowId "operator-cancel-1"+          completedName = WorkflowName "operator-completed"+          completedId = WorkflowId "operator-completed-1"+      Left (_ :: SimulatedCrash) <-+        try $+          Store.runStoreIO storeHandle $+            runWorkflow name wid (crashAfterStep1 counter)++      Right Instance.WorkflowCancelRecorded <-+        Store.runStoreIO storeHandle $+          Instance.cancelWorkflow name wid+      Right Keiro.Workflow.Cancelled <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (threeStep counter)+      readIORef counter `shouldReturn` 1+      Right (Instance.WorkflowAlreadyTerminal Instance.WfCancelled) <-+        Store.runStoreIO storeHandle $+          Instance.cancelWorkflow name wid++      Right (Completed _) <-+        Store.runStoreIO storeHandle $+          runWorkflow completedName completedId (demoWorkflow counter)+      Right (Instance.WorkflowAlreadyTerminal Instance.WfCompleted) <-+        Store.runStoreIO storeHandle $+          Instance.cancelWorkflow completedName completedId++      Right Instance.WorkflowCancelUnknown <-+        Store.runStoreIO storeHandle $+          Instance.cancelWorkflow (WorkflowName "missing") (WorkflowId "missing-1")+      Right Nothing <-+        Store.runStoreIO storeHandle $+          Instance.lookupInstance (WorkflowName "missing") (WorkflowId "missing-1")+      pure ()++    it "cancels suspended and linked-child workflows through supported paths" $ \storeHandle -> do+      let suspendedName = WorkflowName "operator-suspended"+          suspendedId = WorkflowId "operator-suspended-1"+          parentName = WorkflowName "operator-parent"+          parentId = WorkflowId "operator-parent-1"+          childName = WorkflowName "ship"+          childId = WorkflowId "operator-child-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow suspendedName suspendedId neverArmingWorkflow+      Right Instance.WorkflowCancelRecorded <-+        Store.runStoreIO storeHandle $+          Instance.cancelWorkflow suspendedName suspendedId+      now <- getCurrentTime+      Right discovered <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      discovered `shouldNotContain` [("operator-suspended-1", "operator-suspended")]++      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow parentName parentId (parentWorkflow childId)+      Right Instance.WorkflowCancelRecorded <-+        Store.runStoreIO storeHandle $+          Instance.cancelWorkflow childName childId+      Store.runStoreIO storeHandle (runWorkflow parentName parentId (parentWorkflow childId))+        `shouldThrow` (== WorkflowChildCancelled childName childId)++    it "serializes cancellation against completion so exactly one marker wins" $ \storeHandle -> do+      let name = WorkflowName "operator-terminal-race"+          wid = WorkflowId "operator-terminal-race-1"+      seededAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "seed" (toJSON True) seededAt)+      start <- newEmptyMVar+      cancelDone <- newEmptyMVar+      completeDone <- newEmptyMVar+      _ <- forkIO $ do+        takeMVar start+        result <- Store.runStoreIO storeHandle $ Instance.cancelWorkflow name wid+        putMVar cancelDone result+      _ <- forkIO $ do+        takeMVar start+        completedAt <- getCurrentTime+        result <- Store.runStoreIO storeHandle $ appendJournalEntry name wid (WorkflowCompleted completedAt)+        putMVar completeDone result+      putMVar start ()+      putMVar start ()+      _ <- takeMVar cancelDone+      _ <- takeMVar completeDone+      Right hasCancelled <- Store.runStoreIO storeHandle $ stepExists name wid 0 cancelledStepName+      Right hasCompleted <- Store.runStoreIO storeHandle $ stepExists name wid 0 completedStepName+      (hasCancelled, hasCompleted) `shouldSatisfy` \case+        (True, False) -> True+        (False, True) -> True+        _ -> False++    it "force-releases leases and makes the old owner stop at its next boundary" $ \storeHandle -> do+      firstEffect <- newIORef (0 :: Int)+      secondEffect <- newIORef (0 :: Int)+      let name = WorkflowName "operator-force-release"+          wid = WorkflowId "operator-force-release-1"+          options owner =+            defaultWorkflowRunOptions+              & #leaseHeartbeat+              .~ Just LeaseHeartbeat {owner, ttl = 60}+          body = do+            first <-+              step (StepName "first") $ do+                value <- liftIO (incrementAndRead firstEffect)+                released <- Instance.forceReleaseInstanceLease name wid+                liftIO (released `shouldBe` True)+                pure value+            second <- step (StepName "second") (liftIO (incrementAndRead secondEffect))+            pure (first, second)+      Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 60 name wid+      claimedA `shouldBe` Instance.ClaimAcquired+      lost <-+        try+          ( Store.runStoreIO storeHandle $+              runWorkflowWith (options "owner-a") name wid body+          ) ::+          IO+            ( Either+                WorkflowLeaseLost+                (Either Store.StoreError (WorkflowOutcome (Int, Int)))+            )+      lost `shouldBe` Left WorkflowLeaseLost+      readIORef firstEffect `shouldReturn` 1+      readIORef secondEffect `shouldReturn` 0+      Right releasedAgain <- Store.runStoreIO storeHandle $ Instance.forceReleaseInstanceLease name wid+      releasedAgain `shouldBe` False++      Right claimedB <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 60 name wid+      claimedB `shouldBe` Instance.ClaimAcquired+      Right (Completed (1, 1)) <-+        Store.runStoreIO storeHandle $+          runWorkflowWith (options "owner-b") name wid body+      readIORef firstEffect `shouldReturn` 1+      readIORef secondEffect `shouldReturn` 1++    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 discovery index" $ around (withFreshStore fixture) $ do+    -- The discovery predicate must be stated as the positive active set+    -- (status IN ('running','suspended')) rather than as the complement of the+    -- terminal trio: Postgres proves partial-index applicability from the query+    -- predicate alone and never consults the table's CHECK constraint, so the+    -- complement form cannot use keiro_workflows_active_idx and seq-scans+    -- keiro_workflows on every resume pass. With seq scans discouraged, a plan+    -- that names the index is proof the planner can match it.+    it "plans the discovery predicate through keiro_workflows_active_idx" $ \storeHandle -> do+      now <- getCurrentTime+      Right () <- Store.runStoreIO storeHandle $+        Store.runTransaction $+          for_ (discoveryFixtureRows now) $ \row ->+            Tx.statement row insertWorkflowInstanceStmt+      Right planLines <- Store.runStoreIO storeHandle $+        Store.runTransaction $ do+          Tx.sql "SET LOCAL enable_seqscan = off"+          Tx.statement () explainDiscoveryStmt+      Text.unpack (Text.intercalate "\n" planLines)+        `shouldSatisfy` isInfixOf "keiro_workflows_active_idx"++    -- Exact discovery: 'running' always, 'suspended' only with a due wake hint.+    -- A suspended instance with no hint is parked on a wake source that will+    -- flip the row itself, so returning it would be pure waste.+    it "returns exactly the runnable and wake-due instances" $ \storeHandle -> do+      now <- getCurrentTime+      Right () <- Store.runStoreIO storeHandle $+        Store.runTransaction $+          for_ (discoveryFixtureRows now) $ \row ->+            Tx.statement row insertWorkflowInstanceStmt+      Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      unfinished+        `shouldBe` [ ("a-running", "discovery-index"),+                     ("c-due-sleep", "discovery-index")+                   ]++  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,+            advanced = 1,+            resumed = 1,+            completed = 1,+            stillSuspended = 0,+            unknownName = 0,+            failed = 0,+            transientErrors = 0,+            leaseSkipped = 0,+            paced = 0,+            sleepDue = 0,+            unregisteredNames = Set.empty+          }+      -- 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,+            advanced = 1,+            resumed = 1,+            completed = 1,+            stillSuspended = 0,+            unknownName = 0,+            failed = 0,+            transientErrors = 0,+            leaseSkipped = 0,+            paced = 0,+            sleepDue = 0,+            unregisteredNames = Set.empty+          }+      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,+            advanced = 0,+            resumed = 0,+            completed = 0,+            stillSuspended = 0,+            unknownName = 1,+            failed = 0,+            transientErrors = 0,+            leaseSkipped = 0,+            paced = 0,+            sleepDue = 0,+            unregisteredNames = Set.singleton "orphan"+          }+      -- 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,+            advanced = 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++    -- Concurrency is opt-in and observable. Two workflows whose step actions+    -- take ~300 ms run in overlapping windows under `maxConcurrentAdvances = 2`+    -- and in disjoint windows under the default, so one slow step body no+    -- longer delays every other workflow in the pass.+    it "advances candidates concurrently only when the option allows it" $ \storeHandle -> do+      let slowStep windows label = do+            start <- liftIO getCurrentTime+            liftIO (threadDelay 300_000)+            end <- liftIO getCurrentTime+            liftIO (modifyMVar windows (\ws -> pure ((label, start, end) : ws, ())))+            pure (1 :: Int)+          runPass concurrency prefix = do+            windows <- newMVar []+            let nameA = WorkflowName (prefix <> "-a")+                nameB = WorkflowName (prefix <> "-b")+                widA = WorkflowId (prefix <> "-1")+                widB = WorkflowId (prefix <> "-2")+                opts =+                  defaultWorkflowResumeOptions+                    & #maxConcurrentAdvances+                    .~ concurrency+                    & #logEvent+                    .~ const (pure ())+                registry =+                  Map.fromList+                    [ (nameA, WorkflowDef (\_ -> step (StepName "slow") (slowStep windows ("a" :: Text)))),+                      (nameB, WorkflowDef (\_ -> step (StepName "slow") (slowStep windows "b")))+                    ]+            now <- getCurrentTime+            Right () <-+              Store.runStoreIO storeHandle $+                appendJournalEntry nameA widA (StepRecorded "seed" (toJSON True) now)+            Right () <-+              Store.runStoreIO storeHandle $+                appendJournalEntry nameB widB (StepRecorded "seed" (toJSON True) now)+            Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+            completed summary `shouldBe` 2+            readMVar windows+      concurrentWindows <- runPass 2 "overlap"+      windowsOverlap concurrentWindows `shouldBe` True+      serialWindows <- runPass 1 "serial"+      windowsOverlap serialWindows `shouldBe` False++    -- Concurrency must not change what a pass reports or how it isolates a bad+    -- candidate: the deltas are added at the end, so the summary cannot depend+    -- on the order candidates finish in. Each phase runs against its own fresh+    -- store, because an unknown-name candidate stays discoverable and would+    -- otherwise carry into the next phase's counts.+    it "reports a mixed pass the same way when advancing sequentially" $ \storeHandle -> do+      summary <- runMixedResumePass storeHandle 1+      summary `shouldBe` expectedMixedResumeSummary++    it "reports a mixed pass the same way when advancing concurrently" $ \storeHandle -> do+      summary <- runMixedResumePass storeHandle 3+      summary `shouldBe` expectedMixedResumeSummary++    it "records no crash attempt against a workflow that already went terminal" $ \storeHandle -> do+      let name = WorkflowName "crash-race"+          wid = WorkflowId "cr-1"+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "seed" (toJSON True) now)+      -- A live instance paces normally.+      Right live <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Instance.recordCrashTx "cr-1" "crash-race" "boom"+      live `shouldBe` Just 1+      -- Once terminal, the UPDATE's status guard matches no row. That is the+      -- answer, not an error: there is no live instance left to pace.+      cancelledAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (WorkflowCancelled cancelledAt)+      Right afterTerminal <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Instance.recordCrashTx "cr-1" "crash-race" "boom"+      afterTerminal `shouldBe` Nothing++    -- The race the arm above exists for. Workflow A goes terminal inside its own+    -- run and then crashes, so the pass records its crash against a cancelled+    -- instance. The zero-row result used to fail a single-row decoder, and+    -- because the crash record sits outside the per-advance catches, the store+    -- error escaped the whole pass: `resumeWorkflowsOnce` returned Left and+    -- every remaining candidate was skipped until the next tick.+    it "survives a crash recorded against a just-cancelled workflow" $ \storeHandle -> do+      healthyCounter <- newIORef (0 :: Int)+      events <- newIORef ([] :: [ResumeLogEvent])+      let raceName = WorkflowName "crash-race-pass"+          raceId = WorkflowId "crp-1"+          healthyName = WorkflowName "crash-race-healthy"+          healthyId = WorkflowId "crh-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 1+              & #logEvent+              .~ (\event -> modifyIORef' events (event :))+          registry =+            Map.fromList+              [ ( raceName,+                  WorkflowDef+                    ( \_ -> do+                        cancelledAt <- liftIO getCurrentTime+                        appendJournalEntry raceName raceId (WorkflowCancelled cancelledAt)+                        liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)+                    )+                ),+                (healthyName, WorkflowDef (\_ -> threeStep healthyCounter))+              ]+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry raceName raceId (StepRecorded "seed" (toJSON True) now)+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry healthyName healthyId (StepRecorded "seed" (toJSON True) now)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      summary+        `shouldBe` emptyResumeSummary+          { discovered = 2,+            advanced = 1,+            resumed = 2,+            completed = 1,+            transientErrors = 1+          }+      -- The healthy workflow ran to completion regardless of which candidate+      -- discovery returned first, and nothing was marked failed: a workflow that+      -- is already cancelled must not also be condemned.+      readIORef healthyCounter `shouldReturn` 3+      logged <- readIORef events+      logged `shouldContain` [ResumeCrashRecordSkipped "crash-race-pass" "crp-1"]+      Right (Just raceRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance raceName raceId+      raceRow ^. #status `shouldBe` Instance.WfCancelled+      raceRow ^. #attempts `shouldBe` 0++    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` Instance.ClaimAcquired+      Right claimedB <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid+      claimedB `shouldBe` Instance.ClaimLeaseHeld+      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` Instance.ClaimAcquired++    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` Instance.ClaimAcquired+      Right attempt <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Instance.recordCrashTx "le-1" "lease-expire" "boom"+      attempt `shouldBe` Just 1+      Right () <- Store.runStoreIO storeHandle $ Instance.releaseInstance "owner-a" False name wid+      Right pacedClaim <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-b" 30 name wid+      pacedClaim `shouldBe` Instance.ClaimPaced+      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` Instance.ClaimAcquired+      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` Instance.ClaimAcquired+      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 == Instance.ClaimAcquired)+      Right claimedA <- Store.runStoreIO storeHandle $ Instance.claimInstance "owner-a" 0.2 name wid+      claimedA `shouldBe` Instance.ClaimAcquired+      outcome <- Store.runStoreIO storeHandle $ runWorkflowWith runOpts name wid body+      outcome `shouldBe` Right (Completed False)+      readIORef attemptedClaim `shouldReturn` Just Instance.ClaimLeaseHeld+      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` Instance.ClaimAcquired+      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+      -- Break the table discovery itself reads, so every pass fails outright.+      -- (Hiding keiro_workflow_steps no longer suffices: under exact discovery+      -- the parked workflow is not returned, so a pass never reaches it.)+      Right () <-+        Store.runStoreIO store $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_workflows RENAME TO keiro_workflows_hidden"+      worker <- forkIO (runWorkflowResumeWorkerPush store opts registry)+      logged <- waitForPassFailure+      logged `shouldBe` Just ()+      Right () <-+        Store.runStoreIO store $+          Store.runTransaction $+            Tx.sql "ALTER TABLE keiro.keiro_workflows_hidden RENAME TO keiro_workflows"+      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"+      -- Park a workflow on the first of two gates, then journal that gate's+      -- result. The append flips the instance row to running, which is what+      -- makes exact discovery return it; the re-invocation then parks on the+      -- second gate and stays Suspended, which still counts as a re-invocation.+      suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (twoGateWorkflow counter)+      suspended `shouldBe` Right Suspended+      gateAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "awk:first" (toJSON ()) gateAt)+      -- Register one pending awakeable (independent of the suspended workflow's+      -- own await) so the pending gauge has something to count.+      let aid = awakeableIdToUuid (generation0AwakeableId (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 -> twoGateWorkflow counter))+          resumeOpts =+            defaultWorkflowResumeOptions+              & #runOptions+              .~ (defaultWorkflowRunOptions & #metrics .~ Just metrics)+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce resumeOpts registry+      (discovered summary, resumed summary, stillSuspended summary) `shouldBe` (1, 1, 1)+      _ <- 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 deterministic id derivation" $ do+    -- Deterministic ids are replay identity: the same seed must yield the same+    -- id on every deploy, forever (ADR 24). Every literal below was captured+    -- from the *previous* derivation — which hashed each character's codepoint+    -- modulo 256 — before it was replaced by UTF-8 seed bytes. For ASCII seeds+    -- the two encodings agree byte for byte, so a failure here means a+    -- deployed id moved. Regenerate a literal only alongside a versioned+    -- derivation and a migration story, never to make the suite green.+    let name = WorkflowName "orderFulfillment"+        wid = WorkflowId "wf-1"+        sourceEventId = EventId (uuidLiteral "3f2504e0-4f89-51d3-9a0c-0305e82c3301")++    it "freezes the ASCII journal-event ids, reserved step names included" $ do+      deterministicJournalId name wid 0 "charge-card"+        `shouldBe` EventId (uuidLiteral "1618b21a-5321-536f-998b-99f88f078148")+      deterministicJournalId name wid 1 "charge-card"+        `shouldBe` EventId (uuidLiteral "ddbf5d19-df0d-50f7-9aa2-9c8214bfde00")+      deterministicJournalId name wid 0 completedStepName+        `shouldBe` EventId (uuidLiteral "5ac985e8-4168-5705-91bc-5523833d3f60")+      deterministicJournalId name wid 0 cancelledStepName+        `shouldBe` EventId (uuidLiteral "52493d94-d35a-5a7e-8ce9-40e1111c45f9")+      deterministicJournalId name wid 0 failedStepName+        `shouldBe` EventId (uuidLiteral "b7ed900d-0fac-54dd-87b1-a01f6782a298")+      deterministicJournalId name wid 0 continuedAsNewStepName+        `shouldBe` EventId (uuidLiteral "338f8962-ef47-5992-a4e0-c314358a2f05")+      deterministicJournalId name wid 0 continueSeedStepName+        `shouldBe` EventId (uuidLiteral "268c2031-b026-564a-be24-85cab59c3ce7")+      deterministicJournalId name wid 0 patchSetStepName+        `shouldBe` EventId (uuidLiteral "c188a7f9-617d-59e5-8e09-4498d7daf477")+      deterministicJournalId name wid 0 (patchStepName (PatchId "new-tax"))+        `shouldBe` EventId (uuidLiteral "64de4580-0a2d-522b-b397-e25c6ee3eacc")+      deterministicJournalId name wid 0 (sleepStepName (StepName "cool"))+        `shouldBe` EventId (uuidLiteral "e3a009bd-f287-5331-9f72-8e273f0040cf")++    it "freezes the ASCII sleep, awakeable, and process-manager ids" $ do+      sleepTimerId name wid 0 "sleep:cool"+        `shouldBe` TimerId (uuidLiteral "cfebe58e-b34c-5031-af98-18e71e6f4cfa")+      sleepTimerId name wid 1 "sleep:cool"+        `shouldBe` TimerId (uuidLiteral "e9696450-3993-59da-902e-4e5ebcfd1ab0")+      sleepTimerId name wid 2 "sleep:cool"+        `shouldBe` TimerId (uuidLiteral "6affc998-5cf2-51d0-9bbb-22e792581433")+      generation0AwakeableId name wid "approval"+        `shouldBe` AwakeableId (uuidLiteral "f677231c-8a27-51b6-9a5e-69015262b26f")+      deterministicCommandId "counter-pm" "order-1" sourceEventId 0+        `shouldBe` EventId (uuidLiteral "ff20892c-6665-5e92-8c99-d1569d2ce629")+      deterministicCommandId "counter-pm" "order-1" sourceEventId (-1)+        `shouldBe` EventId (uuidLiteral "4f3aa6bc-b12c-5dae-8eb5-81f6364f41ef")++    -- Each pair below produced one shared id under the old derivation, because+    -- U+0101 and U+0001 (and U+4E2D/U+2E2D, U+6587/U+2587) agree modulo 256.+    it "separates seeds the codepoint-truncating derivation collapsed" $ do+      deterministicJournalId name wid 0 "\x0101"+        `shouldNotBe` deterministicJournalId name wid 0 "\SOH"+      deterministicJournalId name wid 0 "\x4E2D\x6587"+        `shouldNotBe` deterministicJournalId name wid 0 "\x2E2D\x2587"+      sleepTimerId name wid 0 "\x0101"+        `shouldNotBe` sleepTimerId name wid 0 "\SOH"+      generation0AwakeableId name wid "\x0101"+        `shouldNotBe` generation0AwakeableId name wid "\SOH"+      deterministicCommandId "counter-pm" "\x0101" sourceEventId 0+        `shouldNotBe` deterministicCommandId "counter-pm" "\SOH" sourceEventId 0++    it "keeps the seed components positional" $ do+      deterministicJournalId (WorkflowName "a") (WorkflowId "b") 0 "s"+        `shouldNotBe` deterministicJournalId (WorkflowName "b") (WorkflowId "a") 0 "s"+      deterministicCommandId "a" "b" sourceEventId 0+        `shouldNotBe` deterministicCommandId "b" "a" sourceEventId 0++    around (withFreshStore fixture) $+      -- End to end: under the old derivation both step names hashed to one+      -- event id, so the second append lost to the store's global event-id+      -- uniqueness and this example returned @Left (DuplicateEvent Nothing)@ —+      -- deterministically, on every retry, until the resume worker's+      -- crash-backoff ladder marked the workflow failed. Now both steps+      -- journal and the workflow completes.+      it "runs a workflow whose step names collided under the old derivation" $ \storeHandle -> do+        counter <- newIORef (0 :: Int)+        let wfName = WorkflowName "unicodeSteps"+            wfId = WorkflowId "us-1"+        outcome <-+          Store.runStoreIO storeHandle $+            runWorkflow wfName wfId (collidingStepWorkflow counter)+        outcome `shouldBe` Right (Completed (1, 2))+        readIORef counter `shouldReturn` 2+        Right firstRecorded <- Store.runStoreIO storeHandle $ stepExists wfName wfId 0 "\x0101"+        firstRecorded `shouldBe` True+        Right secondRecorded <- Store.runStoreIO storeHandle $ stepExists wfName wfId 0 "\SOH"+        secondRecorded `shouldBe` True++  describe "Keiro deterministic id legacy-encoding bridge" $ do+    -- These values were captured by running the pre-UTF-8 implementation at+    -- 7d7a200b in an isolated worktree. Do not regenerate them from the bridge+    -- implementation: they are the independent evidence that it reproduces+    -- deployed identity.+    let sourceEventId = EventId (uuidLiteral "3f2504e0-4f89-51d3-9a0c-0305e82c3301")+        name = WorkflowName "legacy-awake"+        wid = WorkflowId "la-1"++    it "reproduces every captured process-manager command id" $ do+      let commandGoldens =+            [ ("order-1", 0, "ff20892c-6665-5e92-8c99-d1569d2ce629"),+              ("order-1", -1, "4f3aa6bc-b12c-5dae-8eb5-81f6364f41ef"),+              ("Jos\x00E9", 0, "78cbd6e1-c15f-58c3-be0e-14c861de6c85"),+              ("\x4E2D\x6587", 0, "58e6ef7b-a2c9-5e46-b580-db8df2ce72c7"),+              ("\x4E2D\x6587", -1, "f276cf1b-0f5c-5427-a27a-f6d4ad2ca577"),+              ("\x1F600", 0, "ddc163fc-3563-5ae6-a7f8-fbe1af2712b2"),+              ("\x0101", 0, "cfa5de78-8cc7-5eb2-8edd-da847221541d"),+              ("\SOH", 0, "cfa5de78-8cc7-5eb2-8edd-da847221541d"),+              ("\x0169ser", 0, "4fb869b4-d5b7-5c99-8c5d-c4552c5d4115"),+              ("iser", 0, "4fb869b4-d5b7-5c99-8c5d-c4552c5d4115")+            ]+      for_ commandGoldens $ \(correlation, emitIndex, golden) ->+        legacyDeterministicCommandId "counter-pm" correlation sourceEventId emitIndex+          `shouldBe` EventId (uuidLiteral golden)+      legacyDeterministicCommandId "demo-router" "g-\x4E2D\x6587" sourceEventId 0+        `shouldBe` EventId (uuidLiteral "379ebaad-62e1-5265-9605-340789ae6af7")++    it "reproduces every captured deterministic awakeable id" $ do+      preUtf8Generation0AwakeableId name wid "\x627F\x8A8D"+        `shouldBe` AwakeableId (uuidLiteral "c4eb4dfa-4108-577d-8e92-84edb337a48b")+      preUtf8Generation0AwakeableId name wid "caf\x00E9"+        `shouldBe` AwakeableId (uuidLiteral "446e5258-0697-525d-af06-0c2c3911ded7")+      preUtf8Generation0AwakeableId name wid "\x4E2D"+        `shouldBe` AwakeableId (uuidLiteral "7b252ef4-c7c0-579e-8f15-8f26c73196de")+      preUtf8Generation0AwakeableId name wid "-"+        `shouldBe` AwakeableId (uuidLiteral "7b252ef4-c7c0-579e-8f15-8f26c73196de")++    it "keeps ASCII identity stable and moves every non-ASCII capture" $ do+      legacyDeterministicCommandId "counter-pm" "order-1" sourceEventId 0+        `shouldBe` deterministicCommandId "counter-pm" "order-1" sourceEventId 0+      legacyDeterministicCommandId "counter-pm" "\SOH" sourceEventId 0+        `shouldBe` deterministicCommandId "counter-pm" "\SOH" sourceEventId 0+      legacyDeterministicCommandId "counter-pm" "iser" sourceEventId 0+        `shouldBe` deterministicCommandId "counter-pm" "iser" sourceEventId 0+      for_ ["Jos\x00E9", "\x4E2D\x6587", "\x1F600", "\x0101", "\x0169ser"] $ \correlation ->+        legacyDeterministicCommandId "counter-pm" correlation sourceEventId 0+          `shouldNotBe` deterministicCommandId "counter-pm" correlation sourceEventId 0+      legacyDeterministicCommandId "counter-pm" "\x4E2D\x6587" sourceEventId (-1)+        `shouldNotBe` deterministicCommandId "counter-pm" "\x4E2D\x6587" sourceEventId (-1)+      preUtf8Generation0AwakeableId name wid "legacy"+        `shouldBe` generation0AwakeableId name wid "legacy"+      preUtf8Generation0AwakeableId name wid "-"+        `shouldBe` generation0AwakeableId name wid "-"+      for_ ["\x627F\x8A8D", "caf\x00E9", "\x4E2D"] $ \label ->+        preUtf8Generation0AwakeableId name wid label+          `shouldNotBe` generation0AwakeableId name wid label++    it "documents the historical truncation collisions and their UTF-8 separation" $ do+      legacyDeterministicCommandId "counter-pm" "\x0101" sourceEventId 0+        `shouldBe` legacyDeterministicCommandId "counter-pm" "\SOH" sourceEventId 0+      deterministicCommandId "counter-pm" "\x0101" sourceEventId 0+        `shouldNotBe` deterministicCommandId "counter-pm" "\SOH" sourceEventId 0+      legacyDeterministicCommandId "counter-pm" "\x0169ser" sourceEventId 0+        `shouldBe` legacyDeterministicCommandId "counter-pm" "iser" sourceEventId 0+      deterministicCommandId "counter-pm" "\x0169ser" sourceEventId 0+        `shouldNotBe` deterministicCommandId "counter-pm" "iser" sourceEventId 0+      preUtf8Generation0AwakeableId name wid "\x4E2D"+        `shouldBe` preUtf8Generation0AwakeableId name wid "-"+      generation0AwakeableId name wid "\x4E2D"+        `shouldNotBe` generation0AwakeableId name wid "-"++    it "adds a legacy command probe only when the seed moved" $ do+      NonEmpty.toList (deterministicCommandIdProbes "counter-pm" "order-1" sourceEventId 0)+        `shouldBe` [deterministicCommandId "counter-pm" "order-1" sourceEventId 0]+      NonEmpty.toList (deterministicCommandIdProbes "counter-pm" "\x4E2D\x6587" sourceEventId 0)+        `shouldBe` [ deterministicCommandId "counter-pm" "\x4E2D\x6587" sourceEventId 0,+                     legacyDeterministicCommandId "counter-pm" "\x4E2D\x6587" sourceEventId 0+                   ]++    it "builds one current probe for an ASCII seed" $ do+      let seed = "keiro:probe:ascii"+      NonEmpty.toList (deterministicIdProbes seed)+        `shouldBe` [UUID.V5.generateNamed UUID.V5.namespaceURL (identitySeedBytes seed)]++    it "orders the current and legacy probes for a non-ASCII seed" $ do+      let seed = "keiro:probe:\x4E2D"+      NonEmpty.toList (deterministicIdProbes seed)+        `shouldBe` [ UUID.V5.generateNamed UUID.V5.namespaceURL (identitySeedBytes seed),+                     UUID.V5.generateNamed UUID.V5.namespaceURL (legacySeedBytes seed)+                   ]++  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.Timer batched drain" $ around (withFreshStore fixture) $ do+    -- The single-claim worker drains a backlog at one timer per invocation, so+    -- ten due sleeps take ten poll ticks and the last workflow wakes ticks late.+    -- One drain pass wakes them all, with one requeue-and-gauge preamble instead+    -- of ten.+    it "drains a mixed backlog of sleeps and process-manager timers in one pass" $ \storeHandle -> do+      firedPm <- newIORef ([] :: [Text])+      let sleepers = [1 .. 4 :: Int]+          sleeperName = WorkflowName "drain-sleeper"+          sleeperId i = WorkflowId ("ds-" <> Text.pack (show i))+      for_ sleepers $ \i -> do+        outcome <-+          Store.runStoreIO storeHandle $+            runWorkflow sleeperName (sleeperId i) (sleepNamed (StepName "wait") 0)+        outcome `shouldBe` Right Suspended+      for_ [1 .. 6 :: Int] $ \i ->+        Store.runStoreIO storeHandle (Store.runTransaction (scheduleTimerTx (plainTimerRequest i)))+          `shouldReturn` Right ()+      now <- addUTCTime 1 <$> getCurrentTime+      Right drained <-+        Store.runStoreIO storeHandle $+          drainWorkflowSleepTimers Nothing now 20 $ \row -> do+            liftIO (modifyIORef' firedPm (row ^. #correlationId :))+            pure (Just (EventId sampleUuid2))+      drained `shouldBe` 10+      -- Every sleep actually woke: the completion is journaled, not merely+      -- claimed.+      for_ sleepers $ \i -> do+        Right woke <- Store.runStoreIO storeHandle $ stepExists sleeperName (sleeperId i) 0 "sleep:wait"+        woke `shouldBe` True+      readIORef firedPm >>= \fired -> length fired `shouldBe` 6+      -- Nothing is left claimable.+      Right leftovers <- Store.runStoreIO storeHandle $ drainDueTimers Nothing now 20 (\_ -> pure Nothing)+      leftovers `shouldBe` 0++    it "stops at the batch limit and leaves the rest claimable" $ \storeHandle -> do+      for_ [1 .. 10 :: Int] $ \i ->+        Store.runStoreIO storeHandle (Store.runTransaction (scheduleTimerTx (plainTimerRequest i)))+          `shouldReturn` Right ()+      now <- addUTCTime 1 <$> getCurrentTime+      let fireOne _ = pure (Just (EventId sampleUuid2))+      Right firstBatch <- Store.runStoreIO storeHandle $ drainDueTimers Nothing now 3 fireOne+      firstBatch `shouldBe` 3+      Right restBatch <- Store.runStoreIO storeHandle $ drainDueTimers Nothing now 20 fireOne+      restBatch `shouldBe` 7+      -- A limit of zero still runs the preamble but claims nothing, and an+      -- empty backlog costs exactly what a single-claim pass costs.+      Right noneLeft <- Store.runStoreIO storeHandle $ drainDueTimers Nothing now 20 fireOne+      noneLeft `shouldBe` 0++  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 wake-lifecycle visibility" $ around (withFreshStore fixture) $ do+    -- Cancelling an awakeable writes no journal entry, so it is the one+    -- wake-source lifecycle transition that would otherwise leave the owning+    -- instance row untouched. It must still leave the workflow discoverable, or+    -- the workflow can never reach its await arm to observe the cancellation.+    it "flips the owner instance to running when its awakeable is cancelled" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "cancel-visible"+          wid = WorkflowId "cv-1"+          opts = defaultWorkflowResumeOptions & #logEvent .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> approvalFlowWithId aidRef))+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right (Just parked) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      parked ^. #status `shouldBe` Instance.WfSuspended+      Right cancelled <- Store.runStoreIO storeHandle $ cancelAwakeable aid+      cancelled `shouldBe` True+      Right (Just woken) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      woken ^. #status `shouldBe` Instance.WfRunning+      woken ^. #generation `shouldBe` 0+      now <- getCurrentTime+      Right unfinished <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      unfinished `shouldBe` [("cv-1", "cancel-visible")]+      -- The pass re-invokes the workflow; its await arm sees the cancelled row+      -- and throws, which the worker records as a crash attempt.+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      (discovered summary, resumed summary, completed summary) `shouldBe` (1, 1, 0)+      Right (Just crashed) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      crashed ^. #attempts `shouldBe` 1+      fmap Text.unpack (crashed ^. #lastError)+        `shouldSatisfy` maybe False (isInfixOf "WorkflowAwakeableCancelled")++    -- Only the first arm writes wake_after, so a stale re-fire that clears it+    -- erases a hint nothing will rewrite. Only a fresh append is a successful+    -- fire in ADR 7's sense.+    it "leaves a newer sleep's wake hint intact when a stale timer re-fires" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "sleep-refire"+          wid = WorkflowId "sr-2"+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (twoSleepWorkflow counter)+      claimTime <- getCurrentTime+      Right (Just claimed) <- Store.runStoreIO storeHandle $ claimDueTimer claimTime+      claimed ^. #timerId `shouldBe` sleepTimerId name wid 0 (sleepStepName (StepName "first"))+      -- Fire the first sleep, then "crash" before the worker marks the timer+      -- fired: the row stays in `firing` and is requeued below.+      Right firstFire <- Store.runStoreIO storeHandle $ workflowSleepFireAction claimed+      firstFire `shouldSatisfy` isJust+      Right cleared <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid+      cleared `shouldBe` Nothing+      -- The next run replays past the first sleep and arms the second one,+      -- whose insert writes the live wake hint.+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (twoSleepWorkflow counter)+      Right (Just liveHint) <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid+      liveHint `shouldSatisfy` (> claimTime)+      requeueTime <- getCurrentTime+      Right requeued <-+        Store.runStoreIO storeHandle $ requeueStuckTimers 0 (addUTCTime 1 requeueTime)+      requeued `shouldBe` 1+      Right (Just stale) <-+        Store.runStoreIO storeHandle $ claimDueTimer (addUTCTime 2 requeueTime)+      (stale ^. #timerId) `shouldBe` (claimed ^. #timerId)+      Right staleFire <- Store.runStoreIO storeHandle $ workflowSleepFireAction stale+      -- Still idempotent: the re-fire reports the same deterministic event id.+      staleFire `shouldBe` firstFire+      Right hintAfter <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid+      hintAfter `shouldBe` Just liveHint+      readIORef counter >>= (`shouldBe` 1)++  describe "Keiro.Workflow exact discovery" $ around (withFreshStore fixture) $ do+    it "hides a workflow parked on an awakeable until it is signalled" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "quiet-awk"+          wid = WorkflowId "qa-1"+          registry = Map.singleton name (WorkflowDef (\_ -> approvalFlowWithId aidRef))+          pass = Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      parkedAt <- getCurrentTime+      Right parked <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds parkedAt)+      parked `shouldBe` []+      -- The whole point: a parked workflow costs a pass nothing at all.+      Right idle <- pass+      idle `shouldBe` emptyResumeSummary+      Right signalled <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+      signalled `shouldBe` True+      wokenAt <- getCurrentTime+      Right woken <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds wokenAt)+      woken `shouldBe` [("qa-1", "quiet-awk")]+      Right finish <- pass+      (discovered finish, completed finish) `shouldBe` (1, 1)+      doneAt <- getCurrentTime+      Right afterCompletion <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds doneAt)+      afterCompletion `shouldBe` []++    -- The parent is invisible while it waits, but the freshly spawned child is+    -- discovered from the instance row spawnChild writes in the spawn step's+    -- transaction — which is why the resume worker no longer needs a separate+    -- findRunningChildIds seed.+    it "hides a parent parked on a child while still discovering the zero-step child" $ \storeHandle -> do+      let parentName = WorkflowName "quiet-parent"+          parentWid = WorkflowId "qp-1"+          childName = WorkflowName "ship"+          childWid = WorkflowId "ship-quiet"+          registry = Map.singleton parentName (WorkflowDef (\_ -> parentWorkflow childWid))+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow parentName parentWid (parentWorkflow childWid)+      parkedAt <- getCurrentTime+      Right parked <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds parkedAt)+      parked `shouldBe` [("ship-quiet", "ship")]+      Right (Completed _) <-+        Store.runStoreIO storeHandle $+          runChildWorkflow defaultWorkflowRunOptions childName childWid shipWorkflow+      wokenAt <- getCurrentTime+      Right woken <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds wokenAt)+      woken `shouldBe` [("qp-1", "quiet-parent")]+      Right finish <-+        Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+      (discovered finish, completed finish) `shouldBe` (1, 1)++    -- Wake-wins ordering. markInstanceSuspendedAwaiting is exactly the write a+    -- run performs after its (now stale) index miss, so calling it directly+    -- after a signal reproduces the race deterministically.+    it "writes running when the wake landed before the suspend write" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "race-wake-first"+          wid = WorkflowId "rwf-1"+          registry = Map.singleton name (WorkflowDef (\_ -> approvalFlowWithId aidRef))+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right True <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+      Right () <-+        Store.runStoreIO storeHandle $+          Instance.markInstanceSuspendedAwaiting name wid 0 (awakeableStepPrefix <> awakeableIdText aid)+      Right (Just arbitrated) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      arbitrated ^. #status `shouldBe` Instance.WfRunning+      wokenAt <- getCurrentTime+      Right woken <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds wokenAt)+      woken `shouldBe` [("rwf-1", "race-wake-first")]+      Right finish <-+        Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+      completed finish `shouldBe` 1++    -- Suspend-wins ordering: the wake, queued behind the suspend write on the+    -- same per-step lock, flips the instance itself.+    it "flips a suspended instance to running when the wake lands after the suspend write" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "race-suspend-first"+          wid = WorkflowId "rsf-1"+          registry = Map.singleton name (WorkflowDef (\_ -> approvalFlowWithId aidRef))+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right () <-+        Store.runStoreIO storeHandle $+          Instance.markInstanceSuspendedAwaiting name wid 0 (awakeableStepPrefix <> awakeableIdText aid)+      Right (Just parkedRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      parkedRow ^. #status `shouldBe` Instance.WfSuspended+      parkedAt <- getCurrentTime+      Right invisible <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds parkedAt)+      invisible `shouldBe` []+      Right True <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+      Right (Just wokenRow) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      wokenRow ^. #status `shouldBe` Instance.WfRunning+      Right finish <-+        Store.runStoreIO storeHandle (resumeWorkflowsOnce defaultWorkflowResumeOptions registry)+      completed finish `shouldBe` 1++    it "stays discoverable when a cancel lands before the stale suspend write" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "race-cancel-first"+          wid = WorkflowId "rcf-1"+          opts = defaultWorkflowResumeOptions & #logEvent .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> approvalFlowWithId aidRef))+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right True <- Store.runStoreIO storeHandle $ cancelAwakeable aid+      Right () <-+        Store.runStoreIO storeHandle $+          Instance.markInstanceSuspendedAwaiting name wid 0 (awakeableStepPrefix <> awakeableIdText aid)+      Right (Just arbitrated) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      arbitrated ^. #status `shouldBe` Instance.WfRunning+      wokenAt <- getCurrentTime+      Right woken <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds wokenAt)+      woken `shouldBe` [("rcf-1", "race-cancel-first")]+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      (discovered summary, resumed summary, completed summary) `shouldBe` (1, 1, 0)+      Right (Just crashed) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      crashed ^. #attempts `shouldBe` 1+      fmap Text.unpack (crashed ^. #lastError)+        `shouldSatisfy` maybe False (isInfixOf "WorkflowAwakeableCancelled")++    it "flips a suspended instance to running when the cancel lands after the suspend write" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "race-cancel-second"+          wid = WorkflowId "rcs-1"+          opts = defaultWorkflowResumeOptions & #logEvent .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> approvalFlowWithId aidRef))+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      Right () <-+        Store.runStoreIO storeHandle $+          Instance.markInstanceSuspendedAwaiting name wid 0 (awakeableStepPrefix <> awakeableIdText aid)+      Right (Just parked) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      parked ^. #status `shouldBe` Instance.WfSuspended+      parkedAt <- getCurrentTime+      Right invisible <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds parkedAt)+      invisible `shouldBe` []+      Right True <- Store.runStoreIO storeHandle $ cancelAwakeable aid+      Right (Just woken) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      woken ^. #status `shouldBe` Instance.WfRunning+      wokenAt <- getCurrentTime+      Right discoveredNow <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds wokenAt)+      discoveredNow `shouldBe` [("rcs-1", "race-cancel-second")]+      Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+      (discovered summary, resumed summary, completed summary) `shouldBe` (1, 1, 0)+      Right (Just crashed) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      crashed ^. #attempts `shouldBe` 1+      fmap Text.unpack (crashed ^. #lastError)+        `shouldSatisfy` maybe False (isInfixOf "WorkflowAwakeableCancelled")++    it "surfaces a due sleep through the wake hint and a fired sleep through running" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "quiet-sleep"+          wid = WorkflowId "qs-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 60)+      now <- getCurrentTime+      Right early <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds now)+      early `shouldBe` []+      -- Due, but the timer worker has not fired it yet: the suspended arm.+      let dueAt = addUTCTime 61 now+      Right due <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds dueAt)+      due `shouldBe` [("qs-1", "quiet-sleep")]+      Right (Just _) <-+        Store.runStoreIO storeHandle $+          runWorkflowTimerWorker Nothing dueAt (\_ -> pure Nothing)+      Right (Just fired) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      fired ^. #status `shouldBe` Instance.WfRunning+      Right hint <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid+      hint `shouldBe` Nothing+      -- Now discovered through the running arm, with no hint left to expire.+      firedAt <- getCurrentTime+      Right visible <- Store.runStoreIO storeHandle (findUnfinishedWorkflowIds firedAt)+      visible `shouldBe` [("qs-1", "quiet-sleep")]++    -- A crashed workflow stays 'running', so exact discovery keeps returning it;+    -- what paces the retry is claimInstance's next_attempt_at gate, which is+    -- reported distinctly from a live foreign lease.+    it "keeps a crashed workflow discovered while its backoff gate paces retries" $ \storeHandle -> do+      let name = WorkflowName "crash-visible"+          wid = WorkflowId "cvz-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 3+              & #logEvent+              .~ const (pure ())+          registry = Map.singleton name (WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))+          pass = Store.runStoreIO storeHandle (resumeWorkflowsOnce opts registry)+      seededAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "seed" (toJSON True) seededAt)+      Right first <- pass+      (discovered first, resumed first, failed first) `shouldBe` (1, 1, 0)+      Right (Just crashed) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      crashed ^. #status `shouldBe` Instance.WfRunning+      crashed ^. #attempts `shouldBe` 1+      Right second <- pass+      (discovered second, paced second, leaseSkipped second) `shouldBe` (1, 1, 0)++    it "a bounded drain loop terminates over a pool that cannot advance" $ \storeHandle -> do+      let crashName = WorkflowName "drain-crash"+          crashWid = WorkflowId "drain-crash-1"+          ghostName = WorkflowName "drain-ghost"+          ghostWid = WorkflowId "drain-ghost-1"+          opts =+            defaultWorkflowResumeOptions+              & #maxAttempts+              .~ 3+              & #logEvent+              .~ const (pure ())+          registry =+            Map.singleton+              crashName+              (WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))+          pass = Store.runStoreIO storeHandle (resumeWorkflowsOnce opts registry)+          drain 0 acc = pure acc+          drain n acc = do+            Right summary <- pass+            if advanced summary > 0+              then drain (n - 1 :: Int) (acc <> [summary])+              else pure (acc <> [summary])+      seededAt <- getCurrentTime+      for_ [(crashName, crashWid), (ghostName, ghostWid)] $ \(name, wid) -> do+        Right () <-+          Store.runStoreIO storeHandle $+            appendJournalEntry name wid (StepRecorded "seed" (toJSON True) seededAt)+        pure ()+      passes <- drain 10 []+      length passes `shouldBe` 1+      case passes of+        [summary] -> do+          (discovered summary, resumed summary, unknownName summary, advanced summary)+            `shouldBe` (2, 1, 1, 0)+          unregisteredNames summary `shouldBe` Set.singleton "drain-ghost"+        other -> expectationFailure ("expected one drain pass, got " <> show other)+      Right blocked <- pass+      (discovered blocked, paced blocked, unknownName blocked, advanced blocked)+        `shouldBe` (2, 1, 1, 0)+      unregisteredNames blocked `shouldBe` Set.singleton "drain-ghost"++    it "a bounded drain loop terminates over a due sleep with no timer worker" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "drain-due-sleep"+          wid = WorkflowId "dds-1"+          opts = defaultWorkflowResumeOptions & #logEvent .~ const (pure ())+          registry =+            Map.singleton name (WorkflowDef (\_ -> sleepDemoNamed counter (StepName "wait") (-1)))+          pass = Store.runStoreIO storeHandle (resumeWorkflowsOnce opts registry)+          drain 0 acc = pure acc+          drain n acc = do+            Right summary <- pass+            if advanced summary > 0+              then drain (n - 1 :: Int) (acc <> [summary])+              else pure (acc <> [summary])+      -- Arm the sleep with an already-due fire time. No timer worker ever fires it.+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (sleepDemoNamed counter (StepName "wait") (-1))+      readIORef counter `shouldReturn` 1+      passes <- drain 5 []+      length passes `shouldBe` 1+      case passes of+        [summary] ->+          (discovered summary, resumed summary, stillSuspended summary, advanced summary, sleepDue summary)+            `shouldBe` (1, 1, 1, 0, 1)+        other -> expectationFailure ("expected one drain pass, got " <> show other)+      Right blocked <- pass+      (discovered blocked, stillSuspended blocked, advanced blocked, sleepDue blocked)+        `shouldBe` (1, 1, 0, 1)+      -- Replay-only: neither step body re-ran.+      readIORef counter `shouldReturn` 1+      -- The candidate is still discoverable, blocked on the timer worker rather than lost.+      Right (Just row) <- Store.runStoreIO storeHandle $ Instance.lookupInstance name wid+      row ^. #status `shouldBe` Instance.WfSuspended+      Right hint <- Store.runStoreIO storeHandle $ workflowWakeAfter name wid+      hint `shouldSatisfy` isJust++  describe "Keiro.Workflow terminal boundaries" $ around (withFreshStore fixture) $ do+    -- The asymmetry this closes: cancellation stopped a run at the next step+    -- boundary, terminal failure did not. Before the append transaction checked+    -- both markers, this workflow ran step "two" and reported Completed.+    it "stops at the next step boundary when a workflow is failed mid-run" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "self-fail"+          wid = WorkflowId "sf-1"+      outcome <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (selfFailingWorkflow name wid counter)+      outcome `shouldBe` Right Keiro.Workflow.Failed+      -- Step one's action ran (its side effect is at-least-once at boundaries);+      -- step two's never did.+      readIORef counter `shouldReturn` 1+      -- Step one's own append is the one the in-transaction check has to refuse:+      -- the marker landed *inside* that action, after the pre-action probe had+      -- already passed. Nothing more is journaled into a terminal workflow.+      Right recordedOne <- Store.runStoreIO storeHandle $ stepExists name wid 0 "one"+      recordedOne `shouldBe` False+      Right recordedTwo <- Store.runStoreIO storeHandle $ stepExists name wid 0 "two"+      recordedTwo `shouldBe` False+      Right recorded <-+        Store.runStoreIO storeHandle $+          Store.readStreamForward (StreamName "wf:self-fail-sf-1") (StreamVersion 0) 10+      Right decoded <- pure (traverse (decodeRecorded workflowJournalCodec) (Vector.toList recorded))+      any (\case WorkflowFailed {} -> True; _ -> False) decoded `shouldBe` True+      any (\case StepRecorded "two" _ _ -> True; _ -> False) decoded `shouldBe` False++    it "declines an ordinary append into a cancelled workflow without erroring" $ \storeHandle -> do+      let name = WorkflowName "refuse-cancelled"+          wid = WorkflowId "rc-1"+      now <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $ appendJournalEntry name wid (WorkflowCancelled now)+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (StepRecorded "late" (toJSON True) now)+      Right present <- Store.runStoreIO storeHandle $ stepExists name wid 0 "late"+      present `shouldBe` False++    -- A wake source settles its own durable row even when it cannot deliver:+    -- the promise is resolved, the journal entry is not written.+    it "completes an awakeable owned by a failed workflow but journals nothing" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "refuse-signal"+          wid = WorkflowId "rs-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      failedAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (WorkflowFailed "ceiling reached" failedAt)+      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+      Right delivered <-+        Store.runStoreIO storeHandle $+          stepExists name wid 0 (awakeableStepPrefix <> awakeableIdText aid)+      delivered `shouldBe` False++    -- The refusal reads the derived failure-marker index row, which+    -- resurrection deletes, so a revived workflow accepts deliveries again by+    -- construction (ADR 8: failure history is immutable, derived state is+    -- revivable).+    it "accepts a wake append again after the workflow is resurrected" $ \storeHandle -> do+      aidRef <- newIORef Nothing+      let name = WorkflowName "revive-delivery"+          wid = WorkflowId "rd-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $ runWorkflow name wid (approvalFlowWithId aidRef)+      aid <- readRequiredAwakeableId aidRef+      failedAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $+          appendJournalEntry name wid (WorkflowFailed "ceiling reached" failedAt)+      Right Instance.WorkflowResurrected <-+        Store.runStoreIO storeHandle $ Instance.resurrectFailedWorkflow name wid+      Right signalled <- Store.runStoreIO storeHandle $ signalAwakeable aid ("ok" :: Text)+      signalled `shouldBe` True+      Right delivered <-+        Store.runStoreIO storeHandle $+          stepExists name wid 0 (awakeableStepPrefix <> awakeableIdText aid)+      delivered `shouldBe` True+      Store.runStoreIO storeHandle (runWorkflow name wid (approvalFlowWithId aidRef))+        `shouldReturn` Right (Completed "ok!")++    -- Defense in depth for the sleep fire: its instance-status guard cannot see+    -- a cancellation whose instance row was already collected, but the append+    -- transaction still refuses. The timer is marked fired regardless, so it is+    -- not requeued forever against a workflow that will never accept it.+    it "marks a sleep timer fired without delivering into a cancelled workflow" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let name = WorkflowName "refuse-sleep"+          wid = WorkflowId "rsl-1"+      Right Suspended <-+        Store.runStoreIO storeHandle $+          runWorkflow name wid (sleepDemoNamed counter (StepName "wait") 0)+      cancelledAt <- getCurrentTime+      Right () <-+        Store.runStoreIO storeHandle $ appendJournalEntry name wid (WorkflowCancelled cancelledAt)+      -- Partial GC: the instance row is gone, so the fire action's terminal+      -- guard finds nothing and proceeds to the append.+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("rsl-1", "refuse-sleep") deleteWorkflowInstanceStmt+      claimTime <- getCurrentTime+      Right (Just claimed) <- Store.runStoreIO storeHandle $ claimDueTimer claimTime+      Right fired <- Store.runStoreIO storeHandle $ workflowSleepFireAction claimed+      fired `shouldSatisfy` isJust+      Right delivered <- Store.runStoreIO storeHandle $ stepExists name wid 0 "sleep:wait"+      delivered `shouldBe` False++  describe "Keiro.Workflow.Awakeable" $ do+    -- Pure (no-DB) check of the frozen generation-0 compatibility derivation.+    it "reproduces a stable, label-sensitive generation-0 AwakeableId" $ do+      let aid1 = generation0AwakeableId (WorkflowName "w") (WorkflowId "1") "approval"+          aid2 = generation0AwakeableId (WorkflowName "w") (WorkflowId "1") "approval"+          aidOther = generation0AwakeableId (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 (generation0AwakeableId (WorkflowName "sch") (WorkflowId "1") "a")+            aidB = awakeableIdToUuid (generation0AwakeableId (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. The guarded+        -- UPDATE returns the owner coordinates so the caller can flip the+        -- owning instance row in the same transaction.+        Right cancelled <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.cancelAwakeableTx aidB+        cancelled `shouldBe` Just ("sch", "1")+        Right reCancelled <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.cancelAwakeableTx aidB+        reCancelled `shouldBe` Nothing+        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 = generation0AwakeableId 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 = generation0AwakeableId 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 "adopts a pre-UTF-8 generation-0 row for a non-ASCII label" $ \storeHandle -> do+        aidRef <- newIORef Nothing+        let name = WorkflowName "legacy-awake"+            wid = WorkflowId "la-1"+            legacy = AwakeableId (uuidLiteral "c4eb4dfa-4108-577d-8e92-84edb337a48b")+        preUtf8Generation0AwakeableId name wid "\x627F\x8A8D" `shouldBe` legacy+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              Awk.registerAwakeableTx (awakeableIdToUuid legacy) (unWorkflowName name) (unWorkflowId wid)+        Right Suspended <- Store.runStoreIO storeHandle $ runWorkflow name wid (unicodeApprovalFlowWithId aidRef)+        adopted <- readRequiredAwakeableId aidRef+        adopted `shouldBe` legacy+        Right True <- Store.runStoreIO storeHandle $ signalAwakeable legacy ("ok" :: Text)+        completed <- Store.runStoreIO storeHandle $ runWorkflow name wid (unicodeApprovalFlowWithId 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++    -- One failing deletion used to take the whole batch with it, and the+    -- summary claimed everything eligible had been deleted regardless. The+    -- sabotage is a workflow id long enough that its derived journal stream+    -- name exceeds kiroku's 512-byte limit, so `hardDeleteStream` throws+    -- `StreamNameTooLong` every time — no timing, no concurrency.+    it "isolates a failing deletion, reports it honestly, and re-scans it" $ \storeHandle -> do+      counter <- newIORef (0 :: Int)+      let healthyName = WorkflowName "gc-isolated"+          healthyId = WorkflowId "gi-1"+          sabotagedId = Text.replicate 600 "x"+          policy = WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+      Right (Completed _) <-+        Store.runStoreIO storeHandle $+          runWorkflow healthyName healthyId (demoWorkflow counter)+      -- Written directly: a workflow with this id could never journal anything,+      -- because the same limit rejects its appends. GC eligibility reads only+      -- the instance row, which is exactly the surface under test.+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement (sabotagedId, "gc-sabotaged") insertTerminalGcInstanceStmt+      now <- getCurrentTime+      Right summary <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce (addUTCTime 1 now) policy+      summary `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 2, deleted = 1}+      -- The healthy workflow was collected despite the other one failing.+      Right healthyGone <- Store.runStoreIO storeHandle $ Instance.lookupInstance healthyName healthyId+      healthyGone `shouldBe` Nothing+      -- The sabotaged one kept its instance row, so it stays eligible: a+      -- partially collected workflow converges instead of leaking.+      Right nextSummary <-+        Store.runStoreIO storeHandle $+          WorkflowGc.gcWorkflowsOnce (addUTCTime 2 now) policy+      nextSummary `shouldBe` WorkflowGc.WorkflowGcSummary {scanned = 1, deleted = 0}++    it "keeps the gc loop alive across a pass it cannot finish" $ \storeHandle -> do+      logged <- newIORef ([] :: [Text])+      let sabotagedId = Text.replicate 600 "x"+          policy = WorkflowGc.WorkflowGcPolicy {retention = 0, batchSize = 10}+          -- A bare `forever` loop would report at most once and then die on the+          -- error; per-pass isolation keeps it reporting every tick.+          waitForTwoPasses = timeout 5_000_000 $ do+            let go = do+                  seen <- readIORef logged+                  if length seen >= 2+                    then pure ()+                    else threadDelay 20_000 >> go+            go+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement (sabotagedId, "gc-loop-sabotaged") insertTerminalGcInstanceStmt+      worker <-+        forkIO . void . Store.runStoreIO storeHandle $+          WorkflowGc.runWorkflowGcWorkerWith policy 20_000 (\msg -> modifyIORef' logged (msg :))+      reported <- waitForTwoPasses `finally` killThread worker+      reported `shouldBe` Just ()+      messages <- readIORef logged+      messages `shouldSatisfy` all ("stay eligible" `Text.isInfixOf`)++-- | One resume pass over four candidates that exercise every outcome a pass+-- can report: one that completes, one that suspends, one whose name is absent+-- from the registry, and one that crashes into terminal failure at a ceiling of+-- one attempt. Parameterised by @maxConcurrentAdvances@ so the sequential and+-- concurrent runs are literally the same scenario.+runMixedResumePass :: Store.KirokuStore -> Int -> IO ResumeSummary+runMixedResumePass storeHandle concurrency = do+  healthyCounter <- newIORef (0 :: Int)+  let healthyName = WorkflowName "mixed-healthy"+      suspendedName = WorkflowName "mixed-suspended"+      poisonName = WorkflowName "mixed-poison"+      orphanName = WorkflowName "mixed-orphan"+      opts =+        defaultWorkflowResumeOptions+          & #maxAttempts+          .~ 1+          & #maxConcurrentAdvances+          .~ concurrency+          & #logEvent+          .~ const (pure ())+      registry =+        Map.fromList+          [ (healthyName, WorkflowDef (\_ -> threeStep healthyCounter)),+            (suspendedName, WorkflowDef (\_ -> neverArmingWorkflow)),+            (poisonName, WorkflowDef (\_ -> liftIO (throwIO SimulatedCrash) *> pure (0 :: Int)))+          ]+  now <- getCurrentTime+  for_ [healthyName, suspendedName, poisonName, orphanName] $ \name ->+    Store.runStoreIO+      storeHandle+      (appendJournalEntry name (WorkflowId "mixed-1") (StepRecorded "seed" (toJSON True) now))+      `shouldReturn` Right ()+  Right summary <- Store.runStoreIO storeHandle $ resumeWorkflowsOnce opts registry+  readIORef healthyCounter `shouldReturn` 3+  pure summary++expectedMixedResumeSummary :: ResumeSummary+expectedMixedResumeSummary =+  emptyResumeSummary+    { discovered = 4,+      advanced = 2,+      resumed = 3,+      completed = 1,+      stillSuspended = 1,+      unknownName = 1,+      failed = 1,+      unregisteredNames = Set.singleton "mixed-orphan"+    }++-- | Do two recorded execution windows intersect? Used to tell a concurrent+-- resume pass from a sequential one without measuring throughput.+windowsOverlap :: [(Text, UTCTime, UTCTime)] -> Bool+windowsOverlap = \case+  [(_, startA, endA), (_, startB, endB)] -> startA < endB && startB < endA+  _ -> False++-- | 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.+-- | Two steps whose names collided under the codepoint-truncating id+-- derivation: U+0101 and U+0001 both hashed as the single byte @0x01@, so the+-- second step's journal append was rejected as a duplicate event id.+collidingStepWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es (Int, Int)+collidingStepWorkflow counter = do+  a <- step (StepName "\x0101") (liftIO (incrementAndRead counter))+  b <- step (StepName "\SOH") (liftIO (incrementAndRead counter))+  pure (a, b)++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 <> "!"))++unicodeApprovalFlowWithId :: (Workflow :> es, Store :> es, IOE :> es) => IORef (Maybe AwakeableId) -> Eff es Text+unicodeApprovalFlowWithId ref = do+  (aid, await) <- awakeableNamed (StepName "\x627F\x8A8D")+  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)++-- | Two sleeps on one generation with a step between them: the first is due+-- immediately, the second far in the future. Firing the first and resuming+-- moves the live wake hint onto the second sleep, which is the state a stale+-- re-fire of the first timer must not disturb.+twoSleepWorkflow ::+  (Workflow :> es, Store :> es, IOE :> es) =>+  IORef Int -> Eff es Int+twoSleepWorkflow counter = do+  sleepNamed (StepName "first") 0+  n <- step (StepName "mid") (liftIO (incrementAndRead counter))+  sleepNamed (StepName "second") 3600+  pure n++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 ())++-- | Two sequential gates. Journaling the first makes the workflow discoverable+-- again; the resulting re-invocation replays past it and parks on the second,+-- so the run is re-invoked and still suspends.+twoGateWorkflow :: (Workflow :> es, IOE :> es) => IORef Int -> Eff es Int+twoGateWorkflow counter = do+  _ <- step (StepName "s1") (liftIO (incrementAndRead counter))+  (_ :: ()) <- awaitStep (StepName "awk:first") (pure ())+  awaitStep (StepName "awk:second") (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++-- | The failure counterpart of 'selfCancellingWorkflow': step one's action+-- writes this workflow's own terminal failure marker, standing in for the+-- resume worker marking it failed while another runner is mid-run.+selfFailingWorkflow :: (Workflow :> es, Store :> es, IOE :> es) => WorkflowName -> WorkflowId -> IORef Int -> Eff es Int+selfFailingWorkflow name wid counter = do+  _ <-+    step (StepName "one") $ do+      now <- liftIO getCurrentTime+      appendJournalEntry name wid (WorkflowFailed "ceiling reached" now)+      liftIO (incrementAndRead counter)+  step (StepName "two") (liftIO (incrementAndRead counter))++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++data SilentChoiceCommand+  = RejectSilently+  | NoOpSilently+  | UnmatchedSilently+  deriving stock (Generic, Eq, Show)++data CoordinatorCommand+  = CoordinatorAccept !Int+  | CoordinatorReject !Text+  | CoordinatorNoOp !Text+  | CoordinatorUnmatched+  deriving stock (Generic, Eq, Show)++data DomainDispatchInput = DomainDispatchInput !Text ![CoordinatorCommand]+  deriving stock (Generic, Eq, Show)++type SilentChoiceEventStream = EventStream (HsPred '[] SilentChoiceCommand) '[] CounterState SilentChoiceCommand CounterEvent++type ValidatedSilentChoiceEventStream = ValidatedEventStream (HsPred '[] SilentChoiceCommand) '[] CounterState SilentChoiceCommand CounterEvent++type CoordinatorEventStream = EventStream (HsPred '[] CoordinatorCommand) '[] CounterState CoordinatorCommand CounterEvent++type ValidatedCoordinatorEventStream = ValidatedEventStream (HsPred '[] CoordinatorCommand) '[] CounterState CoordinatorCommand CounterEvent++type RetryDecisionEventStream = EventStream (HsPred '[] CounterCommand) '[] DrainState CounterCommand CounterEvent++type ValidatedRetryDecisionEventStream = ValidatedEventStream (HsPred '[] CounterCommand) '[] DrainState CounterCommand 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+    }++silentChoiceEventStream :: ValidatedSilentChoiceEventStream+silentChoiceEventStream = mkEventStreamOrThrow "silent-choice-command" silentChoiceEventStreamDef++silentChoiceEventStreamDef :: SilentChoiceEventStream+silentChoiceEventStreamDef =+  EventStream+    { transducer = silentChoiceTransducer,+      initialState = Counting,+      initialRegisters = RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++retryDecisionEventStream :: ValidatedRetryDecisionEventStream+retryDecisionEventStream = mkEventStreamOrThrow "retry-domain-decision" retryDecisionEventStreamDef++retryDecisionEventStreamDef :: RetryDecisionEventStream+retryDecisionEventStreamDef =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \case+              Draining ->+                [ Edge+                    { guard = matchInCtor addCtor,+                      update = UKeep,+                      output = [pack addCtor counterAddedCtor (inpCtor addCtor #amount *: oNil)],+                      target = Drained,+                      mode = Keiki.Live+                    }+                ]+              Drained ->+                [ Edge+                    { guard = matchInCtor addCtor,+                      update = UKeep,+                      output = [],+                      target = Drained,+                      mode = Keiki.Live+                    }+                ],+            initial = Draining,+            initialRegs = RNil,+            isFinal = const False+          },+      initialState = Draining,+      initialRegisters = RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++coordinatorEventStream :: ValidatedCoordinatorEventStream+coordinatorEventStream = mkEventStreamOrThrow "coordinator-domain" coordinatorEventStreamDef++coordinatorEventStreamDef :: CoordinatorEventStream+coordinatorEventStreamDef =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \case+              Counting ->+                [ Edge+                    { guard = matchInCtor coordinatorAcceptCtor,+                      update = UKeep,+                      output = [pack coordinatorAcceptCtor counterAddedCtor (inpCtor coordinatorAcceptCtor #amount *: oNil)],+                      target = Counting,+                      mode = Keiki.Live+                    },+                  Edge+                    { guard = matchInCtor coordinatorRejectCtor,+                      update = UKeep,+                      output = [],+                      target = Counting,+                      mode = Keiki.Live+                    },+                  Edge+                    { guard = matchInCtor coordinatorNoOpCtor,+                      update = UKeep,+                      output = [],+                      target = Counting,+                      mode = Keiki.Live+                    }+                ],+            initial = Counting,+            initialRegs = RNil,+            isFinal = const False+          },+      initialState = Counting,+      initialRegisters = RNil,+      eventCodec = counterCodec,+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++type CoordinatorMessageFields = '[ '("message", Text)]++coordinatorAcceptCtor :: InCtor CoordinatorCommand AddFields+coordinatorAcceptCtor =+  Keiki.unavailableInCtor+    "CoordinatorAccept"+    (\case CoordinatorAccept amount -> Just (RCons Proxy amount RNil); _ -> Nothing)+    (\case RCons _ amount RNil -> CoordinatorAccept amount)++coordinatorRejectCtor :: InCtor CoordinatorCommand CoordinatorMessageFields+coordinatorRejectCtor =+  Keiki.unavailableInCtor+    "CoordinatorReject"+    (\case CoordinatorReject message -> Just (RCons Proxy message RNil); _ -> Nothing)+    (\case RCons _ message RNil -> CoordinatorReject message)++coordinatorNoOpCtor :: InCtor CoordinatorCommand CoordinatorMessageFields+coordinatorNoOpCtor =+  Keiki.unavailableInCtor+    "CoordinatorNoOp"+    (\case CoordinatorNoOp message -> Just (RCons Proxy message RNil); _ -> Nothing)+    (\case RCons _ message RNil -> CoordinatorNoOp message)++silentChoiceTransducer :: SymTransducer (HsPred '[] SilentChoiceCommand) '[] CounterState SilentChoiceCommand CounterEvent+silentChoiceTransducer =+  SymTransducer+    { edgesOut = \case+        Counting ->+          [ Edge+              { guard = matchInCtor rejectSilentlyCtor,+                update = UKeep,+                output = [],+                target = Counting,+                mode = Keiki.Live+              },+            Edge+              { guard = matchInCtor noOpSilentlyCtor,+                update = UKeep,+                output = [],+                target = Counting,+                mode = Keiki.Live+              }+          ],+      initial = Counting,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++rejectSilentlyCtor :: InCtor SilentChoiceCommand '[]+rejectSilentlyCtor =+  Keiki.unavailableInCtor+    "RejectSilently"+    (\case RejectSilently -> Just RNil; _ -> Nothing)+    (\RNil -> RejectSilently)++noOpSilentlyCtor :: InCtor SilentChoiceCommand '[]+noOpSilentlyCtor =+  Keiki.unavailableInCtor+    "NoOpSilently"+    (\case NoOpSilently -> Just RNil; _ -> Nothing)+    (\RNil -> NoOpSilently)++multiCounterDomainHandler :: DomainCommandHandler (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent Text Text+multiCounterDomainHandler =+  DomainCommandHandler+    { eventStream = multiCounterEventStream,+      classifySilent = \_ -> error "multiCounterDomainHandler: eventful edge classified as silent"+    }++ambiguousCounterDomainHandler :: DomainCommandHandler (HsPred '[] CounterCommand) '[] CounterState CounterCommand CounterEvent Text Text+ambiguousCounterDomainHandler =+  DomainCommandHandler+    { eventStream = ambiguousCounterEventStream,+      classifySilent = \_ -> error "ambiguousCounterDomainHandler: no edge should be selected"+    }++silentChoiceDomainHandler :: DomainCommandHandler (HsPred '[] SilentChoiceCommand) '[] CounterState SilentChoiceCommand CounterEvent Text Text+silentChoiceDomainHandler =+  DomainCommandHandler+    { eventStream = silentChoiceEventStream,+      classifySilent = \SilentCommandContext {command = selectedCommand, selectedEdge} ->+        case (selectedCommand, Keiki.edgeIndex selectedEdge) of+          (RejectSilently, 0) -> SilentRejected "edge-0: rejected"+          (NoOpSilently, 1) -> SilentNoOp "edge-1: already complete"+          other -> error ("silentChoiceDomainHandler: unexpected selected edge " <> show other)+    }++retryDecisionDomainHandler :: DomainCommandHandler (HsPred '[] CounterCommand) '[] DrainState CounterCommand CounterEvent Text Text+retryDecisionDomainHandler =+  DomainCommandHandler+    { eventStream = retryDecisionEventStream,+      classifySilent = \SilentCommandContext {state, selectedEdge} ->+        case (state, Keiki.edgeIndex selectedEdge) of+          (Drained, 0) -> SilentNoOp "already drained"+          other -> error ("retryDecisionDomainHandler: unexpected selected edge " <> show other)+    }++coordinatorDomainHandler :: DomainCommandHandler (HsPred '[] CoordinatorCommand) '[] CounterState CoordinatorCommand CounterEvent Text Text+coordinatorDomainHandler =+  DomainCommandHandler+    { eventStream = coordinatorEventStream,+      classifySilent = \SilentCommandContext {command, selectedEdge} ->+        case (command, Keiki.edgeIndex selectedEdge) of+          (CoordinatorReject reason, 1) -> SilentRejected reason+          (CoordinatorNoOp explanation, 2) -> SilentNoOp explanation+          other -> error ("coordinatorDomainHandler: unexpected selected edge " <> show other)+    }++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 =+  Keiki.unavailableInCtor+    "SAdd"+    ( \case+        SAdd amount -> Just (RCons Proxy amount RNil)+        SSkip -> Nothing+    )+    ( \case+        RCons _ amount RNil -> SAdd amount+    )++sSkipCtor :: InCtor SkipCommand '[]+sSkipCtor =+  Keiki.unavailableInCtor+    "SSkip"+    ( \case+        SAdd {} -> Nothing+        SSkip -> Just RNil+    )+    ( \case+        RNil -> SSkip+    )++addCtor :: InCtor CounterCommand AddFields+addCtor =+  Keiki.unavailableInCtor+    "Add"+    ( \case+        Add amount -> Just (RCons Proxy amount RNil)+    )+    ( \case+        RCons _ amount RNil -> Add amount+    )++counterAddedCtor :: WireCtor CounterEvent (Int, ())+counterAddedCtor =+  Keiki.unavailableWireCtor+    "CounterAdded"+    ( \case+        CounterAdded amount -> Just (amount, ())+        CounterAudited {} -> Nothing+    )+    ( \case+        (amount, ()) -> CounterAdded amount+    )++counterAuditedCtor :: WireCtor CounterEvent (Int, ())+counterAuditedCtor =+  Keiki.unavailableWireCtor+    "CounterAudited"+    ( \case+        CounterAudited amount -> Just (amount, ())+        CounterAdded {} -> Nothing+    )+    ( \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 =+  Keiki.unavailableInCtor+    "ConfirmDivert"+    ( \case+        ConfirmDivert acuityBlack -> Just (RCons Proxy acuityBlack RNil)+    )+    ( \case+        RCons _ acuityBlack RNil -> ConfirmDivert acuityBlack+    )++divertConfirmedCtor :: WireCtor DivertEvent (Bool, ())+divertConfirmedCtor =+  Keiki.unavailableWireCtor+    "DivertConfirmed"+    ( \case+        DivertConfirmed acuityBlack -> Just (acuityBlack, ())+    )+    ( \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+        ]+    )++domainProcessManager ::+  DomainProcessManager+    DomainDispatchInput+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    (HsPred '[] CoordinatorCommand)+    '[]+    CounterState+    CoordinatorCommand+    CounterEvent+    Text+    Text+domainProcessManager =+  DomainProcessManager+    { name = "domain-pm",+      correlate = \(DomainDispatchInput correlationId _) -> correlationId,+      eventStream = counterEventStream,+      streamFor = \correlationId -> stream ("domain-pm:" <> correlationId),+      targetHandler = coordinatorDomainHandler,+      targetProjections = const [],+      handle = \(DomainDispatchInput correlationId targetCommands) ->+        ProcessManagerAction+          { command = Add 1,+            commands =+              Prelude.zipWith+                (\targetIndex targetCommand -> PMCommand {target = stream ("domain-pm-target:" <> correlationId <> ":" <> Text.pack (show targetIndex)), command = targetCommand})+                [0 :: Int ..]+                targetCommands,+            timers = []+          }+    }++domainRouter ::+  DomainRouter+    DomainDispatchInput+    (HsPred '[] CoordinatorCommand)+    '[]+    CounterState+    CoordinatorCommand+    CounterEvent+    Text+    Text+    es+domainRouter =+  DomainRouter+    { name = "domain-router",+      key = \(DomainDispatchInput correlationId _) -> correlationId,+      resolve = \(DomainDispatchInput correlationId targetCommands) ->+        pure+          ( Prelude.zipWith+              (\targetIndex targetCommand -> PMCommand {target = stream ("domain-router-target:" <> correlationId <> ":" <> Text.pack (show targetIndex)), command = targetCommand})+              [0 :: Int ..]+              targetCommands+          ),+      targetHandler = coordinatorDomainHandler,+      targetProjections = const []+    }++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 = []+            }+    }++unicodeCounterProcessManager ::+  ProcessManager+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+unicodeCounterProcessManager =+  counterProcessManager+    { name = "unicode-pm",+      correlate = const "\x4E2D\x6587-42",+      streamFor = const (stream "pm:counter-unicode"),+      handle = \case+        CounterAdded amount ->+          ProcessManagerAction+            { command = Add amount,+              commands = [PMCommand {target = stream "counter-target-unicode", command = Add amount}],+              timers = []+            }+        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)++-- | An ordinary (non-sleep) process-manager timer, already due, distinguished+-- only by index. Used to build a drainable backlog.+plainTimerRequest :: Int -> TimerRequest+plainTimerRequest i =+  TimerRequest+    { timerId = TimerId (fromWords64 0xd7a10 (fromIntegral i)),+      processManagerName = "drain-pm",+      correlationId = "drain-" <> Text.pack (show i),+      fireAt = dueTimerTime,+      payload = object ["kind" Aeson..= ("drain-timeout" :: Text)]+    }++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)))++catalogInlineProjectionCatalog :: ProjectionCatalog+catalogInlineProjectionCatalog =+  ProjectionCatalog+    { sources =+        [ SourceDeclaration+            { sourceId = catalogInlineSourceId,+              sourceScope = CategorySource (CategoryName "counter"),+              codecFingerprint = "counter-codec-v1",+              claimSite = catalogClaimSite "test:catalog-inline-source"+            }+        ],+      targets =+        [ TargetDeclaration+            { targetId = catalogInlineTargetId,+              qualifiedTable = QualifiedTable "app" "catalog_inline_counter",+              resetPolicy = ClearBeforeReplay,+              dependsOn = [],+              claimSite = catalogClaimSite "test:catalog-inline-target"+            }+        ],+      rebuildGroups =+        [ RebuildGroupDeclaration+            { rebuildGroupId = catalogInlineGroupId,+              orderedTargets = [catalogInlineTargetId],+              verificationHooks = [],+              claimSite = catalogClaimSite "test:catalog-inline-group"+            }+        ],+      projectionRevisions =+        [ catalogInlineProjectionRevision catalogInlineRevisionV1Id 101,+          catalogInlineProjectionRevision catalogInlineRevisionV2Id 202+        ],+      externalReadContracts = [],+      subscriptions = [],+      dedupKeys = [],+      queryModels = [],+      projectionSets = [SomeProjectionSet catalogInlineProjectionSet]+    }++catalogInlineV1Catalog :: ProjectionCatalog+catalogInlineV1Catalog =+  catalogInlineProjectionCatalog+    { projectionRevisions =+        [catalogInlineProjectionRevision catalogInlineRevisionV1Id 101]+    }++catalogInlineProjectionRevision :: ProjectionRevisionId -> Int64 -> ProjectionRevision+catalogInlineProjectionRevision revisionId marker =+  ProjectionRevision+    { revisionId,+      rebuildGroup = catalogInlineGroupId,+      targetProvisioners =+        Map.singleton+          catalogInlineTargetId+          TargetProvisioner+            { provisionerId = "catalog-inline-provisioner-" <> projectionRevisionIdText revisionId,+              provisionerVersion = 1,+              schemaVersion = TargetSchemaVersion (projectionRevisionIdText revisionId),+              expectedShapeId = "catalog-inline-shape-" <> projectionRevisionIdText revisionId,+              provisionTarget = \_ -> pure (),+              validatorId = "catalog-inline-validator-" <> projectionRevisionIdText revisionId,+              validatorVersion = 1,+              validateTarget =+                Just+                  ( \_ ->+                      pure+                        ( Right+                            TargetSchemaEvidence+                              { relationOid = 1,+                                observedShapeFingerprint = "catalog-inline-observed",+                                observedPromotionObjects = [],+                                catalogSnapshot = "catalog-inline-snapshot"+                              }+                        )+                  ),+              promotionObjectNames = []+            },+      liveHandlers =+        [ RevisionLiveHandler+            ("catalog-inline-live-" <> projectionRevisionIdText revisionId)+            1+            (RevisionInlineDelivery catalogInlineProjectionId "catalog-inline-counter")+            [catalogInlineTargetId]+            (\physicalTargets _recorded -> insertCatalogInlineRevisionMarker physicalTargets marker)+        ],+      replayAdapters =+        [ RevisionReplayAdapter+            ("catalog-inline-replay-" <> projectionRevisionIdText revisionId)+            1+            [catalogInlineTargetId]+            (\_ _ -> pure (Right False))+        ],+      revisionVerifications = [],+      streamScopedReplays = [],+      claimSite = catalogClaimSite ("test:catalog-inline-revision:" <> projectionRevisionIdText revisionId)+    }++insertCatalogInlineRevisionMarker :: PhysicalTargets -> Int64 -> Tx.Transaction ()+insertCatalogInlineRevisionMarker physicalTargets marker =+  case resolvePhysicalTarget catalogInlineTargetId physicalTargets of+    Nothing -> error "catalog inline revision target was not resolved"+    Just table ->+      Tx.sql+        ( TE.encodeUtf8+            ( "INSERT INTO "+                <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+                <> " (amount) VALUES ("+                <> Text.pack (show marker)+                <> ")"+            )+        )++catalogInlineProjectionSet :: ProjectionSet CounterEvent+catalogInlineProjectionSet = catalogInlineProjectionSetWith applyCatalogInlineCounter++catalogSlowInlineProjectionSet :: ProjectionSet CounterEvent+catalogSlowInlineProjectionSet = catalogInlineProjectionSetWith applySlowCatalogInlineCounter++catalogInlineProjectionSetWith :: (CounterEvent -> RecordedEvent -> Tx.Transaction ()) -> ProjectionSet CounterEvent+catalogInlineProjectionSetWith applyEvent =+  ProjectionSet+    { projectionSource = catalogInlineSourceId,+      projectionDefinitions =+        ProjectionDefinition+          { projectionId = catalogInlineProjectionId,+            rebuildGroup = catalogInlineGroupId,+            ownedTargets = catalogInlineTargetId :| [],+            replayPolicy = Replayable (replayAdapterFromCodec counterCodec applyCatalogInlineCounter),+            handlers =+              InlineHandler+                InlineProjection+                  { name = "catalog-inline-counter",+                    apply = applyEvent+                  }+                (catalogClaimSite "test:catalog-inline-handler")+                :| [],+            claimSite = catalogClaimSite "test:catalog-inline-projection"+          }+          :| [],+      claimSite = catalogClaimSite "test:catalog-inline-set"+    }++applyCatalogInlineCounter :: CounterEvent -> RecordedEvent -> Tx.Transaction ()+applyCatalogInlineCounter event _recorded =+  case event of+    CounterAdded amount -> Tx.statement (Prelude.fromIntegral amount) catalogInlineInsertStmt+    CounterAudited amount -> Tx.statement (Prelude.fromIntegral amount) catalogInlineInsertStmt++applySlowCatalogInlineCounter :: CounterEvent -> RecordedEvent -> Tx.Transaction ()+applySlowCatalogInlineCounter event recorded = do+  Tx.sql "SELECT pg_sleep(1)"+  applyCatalogInlineCounter event recorded++catalogInlineSourceId :: SourceId+catalogInlineSourceId = catalogIdentity mkSourceId "catalog-inline-source"++catalogInlineTargetId :: TargetId+catalogInlineTargetId = catalogIdentity mkTargetId "catalog-inline-target"++catalogInlineGroupId :: RebuildGroupId+catalogInlineGroupId = catalogIdentity mkRebuildGroupId "catalog-inline-group"++catalogInlineProjectionId :: ProjectionId+catalogInlineProjectionId = catalogIdentity mkProjectionId "catalog-inline-projection"++catalogInlineRevisionV1Id :: ProjectionRevisionId+catalogInlineRevisionV1Id = catalogIdentity mkProjectionRevisionId "catalog-inline-v1"++catalogInlineRevisionV2Id :: ProjectionRevisionId+catalogInlineRevisionV2Id = catalogIdentity mkProjectionRevisionId "catalog-inline-v2"++catalogInlineRunId :: Rebuild.RebuildRunId+catalogInlineRunId =+  case Rebuild.mkRebuildRunId "catalog-inline-run" of+    Left err -> error (Text.unpack err)+    Right value -> value++catalogClaimSite :: Text -> ClaimSite+catalogClaimSite = catalogIdentity mkClaimSite++catalogIdentity :: (Text -> Either CatalogIdentityError identity) -> Text -> identity+catalogIdentity constructor raw =+  case constructor raw of+    Left err -> error (show err)+    Right value -> value++expectValidatedCatalog :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValidatedCatalog catalog =+  case validateProjectionCatalog catalog of+    Failure diagnostics ->+      expectationFailure ("catalog fixture failed validation: " <> show diagnostics)+        >> error "unreachable"+    Success validated -> pure validated++catalogInlineFixtureSql :: ByteString+catalogInlineFixtureSql =+  "CREATE SCHEMA app; CREATE TABLE app.catalog_inline_counter (amount bigint NOT NULL);"++seedCatalogInlineVersionedV1Sql :: ByteString+seedCatalogInlineVersionedV1Sql =+  """+  INSERT INTO keiro.keiro_projection_target_generations+    (generation_id, group_id, target_id, revision_id, schema_name, relation_name,+     relation_oid, schema_version, expected_shape_id, observed_shape_fingerprint,+     observed_catalog_snapshot, lifecycle, served_at)+  SELECT '10000000-0000-0000-0000-000000000001'::uuid,+         'catalog-inline-group', 'catalog-inline-target', 'catalog-inline-v1',+         'app', 'catalog_inline_counter', classes.oid::bigint, 'v1',+         'catalog-inline-shape-v1', 'catalog-inline-observed-v1',+         'catalog-inline-snapshot-v1', 'serving', now()+  FROM pg_catalog.pg_class AS classes+  JOIN pg_catalog.pg_namespace AS namespaces+    ON namespaces.oid = classes.relnamespace+  WHERE namespaces.nspname = 'app' AND classes.relname = 'catalog_inline_counter';++  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'serving-versioned',+      serving_revision_id = 'catalog-inline-v1',+      reads_allowed = TRUE,+      writes_allowed = TRUE,+      updated_at = now()+  WHERE group_id = 'catalog-inline-group';++  CREATE TABLE app.catalog_inline_counter_v2 (amount bigint NOT NULL);+  """++promoteCatalogInlineV2Sql :: ByteString+promoteCatalogInlineV2Sql =+  """+  ALTER TABLE app.catalog_inline_counter RENAME TO catalog_inline_counter_retired_v1;+  ALTER TABLE app.catalog_inline_counter_v2 RENAME TO catalog_inline_counter;++  UPDATE keiro.keiro_projection_target_generations+  SET relation_name = 'catalog_inline_counter_retired_v1',+      lifecycle = 'retired',+      retired_at = now()+  WHERE generation_id = '10000000-0000-0000-0000-000000000001'::uuid;++  INSERT INTO keiro.keiro_projection_target_generations+    (generation_id, group_id, target_id, revision_id, schema_name, relation_name,+     relation_oid, schema_version, expected_shape_id, observed_shape_fingerprint,+     observed_catalog_snapshot, lifecycle, served_at)+  SELECT '20000000-0000-0000-0000-000000000002'::uuid,+         'catalog-inline-group', 'catalog-inline-target', 'catalog-inline-v2',+         'app', 'catalog_inline_counter', classes.oid::bigint, 'v2',+         'catalog-inline-shape-v2', 'catalog-inline-observed-v2',+         'catalog-inline-snapshot-v2', 'serving', now()+  FROM pg_catalog.pg_class AS classes+  JOIN pg_catalog.pg_namespace AS namespaces+    ON namespaces.oid = classes.relnamespace+  WHERE namespaces.nspname = 'app' AND classes.relname = 'catalog_inline_counter';++  UPDATE keiro.keiro_projection_rebuild_groups+  SET serving_revision_id = 'catalog-inline-v2',+      serving_epoch = serving_epoch + 1,+      updated_at = now()+  WHERE group_id = 'catalog-inline-group';+  """++catalogInlineInsertStmt :: Statement Int64 ()+catalogInlineInsertStmt =+  preparable+    "INSERT INTO app.catalog_inline_counter (amount) VALUES ($1)"+    (E.param (E.nonNullable E.int8))+    D.noResult++catalogInlineCountStmt :: Statement () Int64+catalogInlineCountStmt =+  preparable+    "SELECT count(*) FROM app.catalog_inline_counter"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++catalogInlineAmountsStmt :: Statement () [Int64]+catalogInlineAmountsStmt =+  preparable+    "SELECT amount FROM app.catalog_inline_counter ORDER BY amount"+    E.noParams+    (D.rowList (D.column (D.nonNullable D.int8)))++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+    }++counterImmediateReadModel :: ReadModel Text Int+counterImmediateReadModel =+  immediateReadModel (counterReadModelBlueprint NoQueryCursor)++counterCursorlessRebuildReadModel :: ReadModel Text Int+counterCursorlessRebuildReadModel =+  immediateReadModel ((counterReadModelBlueprint NoQueryCursor) & #schema .~ "kiroku")++counterCursorReadModel :: ReadModel Text Int+counterCursorReadModel =+  immediateReadModel+    (counterReadModelBlueprint (DurableQueryCursor "counter-read-model-sub"))++counterReadModelBlueprint :: QueryCursorAuthority -> ReadModelBlueprint Text Int+counterReadModelBlueprint authority =+  ReadModelBlueprint+    { name = "counter-read-model",+      tableName = "counter_read_model",+      schema = "public",+      version = 1,+      shapeHash = "counter-read-model-v1",+      cursorAuthority = authority,+      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)++testSelectionContract :: EmptySelectionPolicy -> SelectionFailurePolicy -> Natural -> IO RouterSelectionContract+testSelectionContract selectedEmptyPolicy selectedFailurePolicy limitValue = do+  recipientLimit <- shouldBeRight (mkRecipientLimit limitValue)+  selectionVersion <- shouldBeRight (mkSelectionVersion 1)+  pure+    RouterSelectionContract+      { identity = SelectionIdentity "router-test-selection",+        version = selectionVersion,+        fingerprint = SelectionFingerprint (Text.replicate 64 "a"),+        limit = recipientLimit,+        order = OrderByTargetStream,+        dedupe = DedupeByTargetStream,+        emptyPolicy = selectedEmptyPolicy,+        failurePolicy = selectedFailurePolicy,+        redeliveryPolicy = StableUnion,+        partialPolicy = RetainSuccesses+      }++-- | 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 []+    }++selectionRouter ::+  RouterSelectionContract ->+  Eff es (Either RouterSelectionFailure [PMCommand CounterCommand]) ->+  DeclarativeRouter+    RouteGroup+    (HsPred '[] CounterCommand)+    '[]+    CounterState+    CounterCommand+    CounterEvent+    es+selectionRouter contract selection =+  DeclarativeRouter+    { name = "declarative-test-router",+      key = \(RouteGroup groupName) -> groupName,+      selectionContract = contract,+      select = const selection,+      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++isFailed :: PMCommandResult target -> Bool+isFailed = \case+  PMCommandFailed {} -> 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 scalar points while retaining their bounded metric attributes.+flattenScalarPointsWithAttributes :: [ResourceMetricsExport] -> [(Text, NumberValue, Attributes)]+flattenScalarPointsWithAttributes rmes =+  [ point+  | rme <- rmes,+    scope <- Vector.toList (resourceMetricsScopes rme),+    export <- Vector.toList (scopeMetricsExports scope),+    point <- pointsOf export+  ]+  where+    pointsOf (MetricExportSum name _ _ _ _ _ _ points) =+      [ (name, sumDataPointValue point, sumDataPointAttributes point)+      | point <- Vector.toList points+      ]+    pointsOf (MetricExportGauge name _ _ _ _ points) =+      [ (name, gaugeDataPointValue point, gaugeDataPointAttributes point)+      | point <- Vector.toList points+      ]+    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++-- | Write a terminal, GC-eligible instance row directly, with no journal+-- stream behind it. Used to plant a workflow whose deletion is guaranteed to+-- fail without having to make a real workflow misbehave.+insertTerminalGcInstanceStmt :: Statement (Text, Text) ()+insertTerminalGcInstanceStmt =+  preparable+    """+    INSERT INTO keiro.keiro_workflows+      (workflow_id, workflow_name, generation, status, completed_at)+    VALUES ($1, $2, 0, 'cancelled', now())+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (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++-- | Instance rows covering every status the discovery predicate must classify:+-- a running instance, a suspended instance with no wake hint (parked on a wake+-- source), a suspended instance whose sleep is due, one whose sleep is still in+-- the future, and the three terminal statuses. Only the running row and the+-- due-sleep row have progress to make.+discoveryFixtureRows :: UTCTime -> [(Text, Text, Text, Maybe UTCTime)]+discoveryFixtureRows now =+  [ ("a-running", "discovery-index", "running", Nothing),+    ("b-suspended", "discovery-index", "suspended", Nothing),+    ("c-due-sleep", "discovery-index", "suspended", Just (addUTCTime (-60) now)),+    ("d-future-sleep", "discovery-index", "suspended", Just (addUTCTime 3600 now)),+    ("e-completed", "discovery-index", "completed", Nothing),+    ("f-cancelled", "discovery-index", "cancelled", Nothing),+    ("g-failed", "discovery-index", "failed", Nothing)+  ]++insertWorkflowInstanceStmt :: Statement (Text, Text, Text, Maybe UTCTime) ()+insertWorkflowInstanceStmt =+  preparable+    """+    INSERT INTO keiro.keiro_workflows+      (workflow_id, workflow_name, generation, status, wake_after)+    VALUES ($1, $2, 0, $3, $4)+    ON CONFLICT (workflow_id, workflow_name) DO NOTHING+    """+    ( contrazip4+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.timestamptz))+    )+    D.noResult++-- | The discovery predicate as 'findUnfinishedWorkflowIdsStmt' states it, run+-- through EXPLAIN so a test can assert which index the planner picks. Kept+-- textually in step with that statement.+explainDiscoveryStmt :: Statement () [Text]+explainDiscoveryStmt =+  preparable+    """+    EXPLAIN (FORMAT TEXT)+    SELECT workflow_id, workflow_name+    FROM keiro.keiro_workflows+    WHERE status = 'running'+       OR (status = 'suspended' AND wake_after IS NOT NULL AND wake_after <= now())+    ORDER BY workflow_name, workflow_id+    """+    E.noParams+    (D.rowList (D.column (D.nonNullable D.text)))  deleteWorkflowInstanceStmt :: Statement (Text, Text) () deleteWorkflowInstanceStmt =
+ test/PreCanonicalRecoverySpec.hs view
@@ -0,0 +1,303 @@+{-# LANGUAGE MultilineStrings #-}++module PreCanonicalRecoverySpec+  ( spec,+  )+where++import CatalogSpec qualified as Catalog+import Contravariant.Extras (contrazip2)+import Data.ByteString (ByteString)+import Data.Text qualified as Text+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog qualified as CatalogApi+import Keiro.ReadModel (ReadModelStatus (Live), lookupReadModel)+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Types (GlobalPosition (..))+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx++spec :: Fixture -> Spec+spec fixture = describe "pre-canonical rebuild recovery" $ around (withFreshStore fixture) $ do+  it "recovers a group stranded mid-rebuild by migration 0024" $ \store -> do+    healthy <- strandPreCanonicalRun store "recovery-run"+    let strandedRun = runId "recovery-run"+        legacySlice = Text.replicate 64 "a"++    expectStore store (registerProjectionCatalog healthy)+      `shouldReturn` Left (RegisteredGroupStaleFingerprint Catalog.mainGroupId legacySlice)+    expectStore store (resumeCatalogRebuild healthy strandedRun (options "ignored"))+      `shouldReturn` Left (CatalogRebuildRunPreCanonical strandedRun Catalog.mainGroupId)+    expectStore store (adoptCatalogGroups healthy (Catalog.mainGroupId :| []))+      `shouldReturn` Left (AdoptGroupNotLive Catalog.mainGroupId GroupRebuilding (Just strandedRun))+    beforeRecovery <-+      expectStore store (startCatalogRebuild healthy Catalog.mainGroupId (options "before-recovery"))+    beforeRecovery `shouldSatisfy` \case+      Left (CatalogRebuildStartFailed RebuildGroupSliceDrift {}) -> True+      _ -> False++    abandoned <-+      expectStore+        store+        ( abandonCatalogRebuild+            healthy+            strandedRun+            (RebuildFailure "operator.pre-canonical" "discard run stranded by the 0024 upgrade")+        )+        >>= shouldBeRight+    abandoned ^. #runStatus `shouldBe` RebuildRunFailed+    groupAfterAbandon <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    groupAfterAbandon ^? _Just . #status `shouldBe` Just GroupFailed+    groupAfterAbandon ^? _Just . #failureCode `shouldBe` Just (Just "operator.pre-canonical")++    _ <-+      expectStore+        store+        (abandonCatalogRebuild healthy strandedRun (RebuildFailure "again" "must not replace group evidence"))+        >>= shouldBeRight+    groupAfterSecondAbandon <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    groupAfterSecondAbandon ^? _Just . #failureCode `shouldBe` Just (Just "operator.pre-canonical")++    adopted <-+      expectStore store (adoptCatalogGroups healthy (Catalog.mainGroupId :| []))+        >>= shouldBeRight+    map (^. #status) (adopted ^. #adoptedGroups) `shouldBe` [GroupFailed]+    map (^. #sliceFingerprint) (adopted ^. #adoptedGroups)+      `shouldBe` [currentSlice healthy]+    _ <- expectStore store (registerProjectionCatalog healthy) >>= shouldBeRight++    promoted <-+      expectStore store (startCatalogRebuild healthy Catalog.mainGroupId (options "recovery-fresh"))+        >>= shouldBeRight+    promoted ^. #runStatus `shouldBe` RebuildRunPromoted+    recoveredGroup <- expectStore store (lookupProjectionRebuildGroup Catalog.mainGroupId)+    recoveredGroup ^? _Just . #status `shouldBe` Just GroupLive+    counter <- expectStore store (lookupReadModel "catalog-counter-query")+    counter ^? _Just . #status `shouldBe` Just Live++  it "abandons a pre-canonical run whose process died while running" $ \store -> do+    healthy <- strandPreCanonicalRun store "running-recovery"+    expectStore store (Store.runTransaction (Tx.statement (rebuildRunIdText (runId "running-recovery")) markRunRunningStmt))+    abandoned <-+      expectStore+        store+        ( abandonCatalogRebuild+            healthy+            (runId "running-recovery")+            (RebuildFailure "operator.pre-canonical" "discard interrupted run")+        )+        >>= shouldBeRight+    abandoned ^. #runStatus `shouldBe` RebuildRunFailed++  it "never abandons a pre-canonical run that is no longer the group's active run" $ \store -> do+    healthy <- strandPreCanonicalRun store "inactive-recovery"+    expectStore+      store+      ( Store.runTransaction+          ( Tx.statement+              (rebuildGroupIdText Catalog.mainGroupId, "replacement-run")+              setActiveRunStmt+          )+      )+    expectStore+      store+      ( abandonCatalogRebuild+          healthy+          (runId "inactive-recovery")+          (RebuildFailure "operator.pre-canonical" "must not abandon a replaced run")+      )+      `shouldReturn` Left+        ( CatalogRebuildAbandonFailed+            (RebuildHandleNoLongerActive Catalog.mainGroupId (runId "inactive-recovery"))+        )++  it "never abandons a terminal pre-canonical run" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))+    healthy <- expectValid (operationsCatalog passingVerification)+    _ <- expectStore store (registerProjectionCatalog healthy) >>= shouldBeRight+    promoted <-+      expectStore store (startCatalogRebuild healthy Catalog.mainGroupId (options "terminal-recovery"))+        >>= shouldBeRight+    promoted ^. #runStatus `shouldBe` RebuildRunPromoted+    expectStore+      store+      (Store.runTransaction (Tx.statement (rebuildRunIdText (runId "terminal-recovery")) markRunPreCanonicalStmt))+    expectStore+      store+      ( abandonCatalogRebuild+          healthy+          (runId "terminal-recovery")+          (RebuildFailure "operator.pre-canonical" "must not abandon a promoted run")+      )+      `shouldReturn` Left (CatalogRebuildRunNotActive (runId "terminal-recovery"))++strandPreCanonicalRun :: Store.KirokuStore -> Text -> IO ValidatedProjectionCatalog+strandPreCanonicalRun store runIdentity = do+  expectStore store (Store.runTransaction (Tx.sql operationsFixtureSql))+  faulted <- expectValid (operationsCatalog failingVerification)+  healthy <- expectValid (operationsCatalog passingVerification)+  _ <- expectStore store (registerProjectionCatalog faulted) >>= shouldBeRight+  first <- expectStore store (startCatalogRebuild faulted Catalog.mainGroupId (options runIdentity))+  first `shouldSatisfy` \case+    Left CatalogRebuildVerificationFailed {} -> True+    _ -> False+  expectStore+    store+    ( Store.runTransaction $ do+        Tx.statement (rebuildRunIdText (runId runIdentity)) markRunPreCanonicalStmt+        Tx.statement+          (rebuildGroupIdText Catalog.mainGroupId, Text.replicate 64 "a")+          setStoredSliceStmt+    )+  pure healthy++operationsCatalog :: RebuildVerification -> ProjectionCatalog+operationsCatalog verificationHook =+  Catalog.validCatalog+    { targets =+        [ if target ^. #targetId == Catalog.counterTargetId+            then target & #resetPolicy .~ PreserveAndReconcile+            else target & #resetPolicy .~ ClearBeforeReplay+        | target <- Catalog.validCatalog ^. #targets+        ],+      rebuildGroups =+        [ group & #verificationHooks .~ [verificationHook]+        | group <- Catalog.validCatalog ^. #rebuildGroups+        ]+    }++passingVerification :: RebuildVerification+passingVerification = verification (pure (Right ()))++failingVerification :: RebuildVerification+failingVerification = verification (pure (Left "fault injected by pre-canonical recovery spec"))++verification :: Tx.Transaction (Either Text ()) -> RebuildVerification+verification action =+  RebuildVerification+    { verificationId = "pre-canonical-row-check",+      verificationVersion = "v1",+      verifyRebuild = action+    }++options :: Text -> RebuildOptions+options identity =+  defaultRebuildOptions+    RebuildRequest+      { rebuildRunId = runId identity,+        requestedBy = "pre-canonical-recovery-spec",+        requestReason = "exercise migration 0024 recovery",+        replayFrom = GlobalPosition 0+      }++runId :: Text -> RebuildRunId+runId identity =+  case mkRebuildRunId identity of+    Left err -> error (Text.unpack err)+    Right value -> value++currentSlice :: ValidatedProjectionCatalog -> Text+currentSlice catalog =+  maybe+    (error "pre-canonical recovery catalog has no main-group slice")+    groupSliceFingerprintText+    (CatalogApi.groupSliceFingerprint catalog Catalog.mainGroupId)++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics ->+      expectationFailure ("expected valid catalog, got " <> show diagnostics)+        >> error "unreachable"++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure ("store action failed: " <> show err) >> error "unreachable"+    Right value -> pure value++shouldBeRight :: (Show err) => Either err value -> IO value+shouldBeRight = \case+  Left err -> expectationFailure ("expected Right, got Left " <> show err) >> error "unreachable"+  Right value -> pure value++operationsFixtureSql :: ByteString+operationsFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY);+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    counter_id bigint REFERENCES app.counter(id)+  );+  INSERT INTO subscriptions (subscription_name, last_seen)+  VALUES ('catalog-async-subscription', 0);+  """++markRunPreCanonicalStmt :: Statement Text ()+markRunPreCanonicalStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET group_slice_fingerprint = '$pre-canonical',+        contract_fingerprint = 'contract-v2:' || repeat('c', 64),+        runner_format = 'keiro/projection-replay/v2'+    WHERE run_id = $1+    """+    (E.param (E.nonNullable E.text))+    D.noResult++setStoredSliceStmt :: Statement (Text, Text) ()+setStoredSliceStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET slice_fingerprint = $2+    WHERE group_id = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult++markRunRunningStmt :: Statement Text ()+markRunRunningStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET status = 'running', failed_at = NULL, failure_code = NULL,+        failure_detail = NULL, failure_source_id = NULL,+        failure_projection_id = NULL, failure_position = NULL+    WHERE run_id = $1+    """+    (E.param (E.nonNullable E.text))+    D.noResult++setActiveRunStmt :: Statement (Text, Text) ()+setActiveRunStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_groups+    SET active_run_id = $2+    WHERE group_id = $1+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult
+ test/PreimageSpec.hs view
@@ -0,0 +1,149 @@+module PreimageSpec+  ( spec,+  )+where++import CatalogSpec qualified as Catalog+import Data.ByteString qualified as ByteString+import Data.List qualified as List+import Data.Text qualified as Text+import Keiro.Prelude+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog qualified as CatalogApi+import Keiro.Projection.Catalog.Preimage+import Test.Hspec++spec :: Spec+spec = describe "canonical catalog fingerprint preimages" $ do+  it "renders adversarial trees injectively" $ do+    let renderings = renderPreimage <$> adversarialPreimages+    length renderings `shouldBe` length (List.nub renderings)+    traverse_ (`shouldSatisfy` (not . ByteString.null)) renderings++  it "separates the boundary-shift source collision" $ do+    left <- expectValid (sourceOnlyCatalog "a" "$all|c")+    right <- expectValid (sourceOnlyCatalog "a|$all" "c")+    CatalogApi.catalogFingerprint left `shouldNotBe` CatalogApi.catalogFingerprint right++  it "separates newline line forgery through dedup names" $ do+    left <- expectValid (dedupOnlyCatalog [("d", "x\ndedup|d2|y")])+    right <- expectValid (dedupOnlyCatalog [("d", "x"), ("d2", "y")])+    CatalogApi.catalogFingerprint left `shouldNotBe` CatalogApi.catalogFingerprint right++  it "separates colliding sources inside one rebuild group slice" $ do+    left <- expectValid (fullSourceCollisionCatalog "a" "$all|c")+    right <- expectValid (fullSourceCollisionCatalog "a|$all" "c")+    groupSliceFingerprint left Catalog.mainGroupId+      `shouldNotBe` groupSliceFingerprint right Catalog.mainGroupId++  it "retains order-insensitive catalog and slice identities" $ do+    current <- expectValid Catalog.validCatalog+    reordered <- expectValid (reverseCatalog Catalog.validCatalog)+    CatalogApi.catalogFingerprint reordered `shouldBe` CatalogApi.catalogFingerprint current+    groupSliceFingerprint reordered Catalog.mainGroupId+      `shouldBe` groupSliceFingerprint current Catalog.mainGroupId++  it "keeps an existing group slice stable under an additive catalog change" $ do+    current <- expectValid Catalog.validCatalog+    additive <- expectValid Catalog.additiveCatalog+    CatalogApi.catalogFingerprint additive `shouldNotBe` CatalogApi.catalogFingerprint current+    groupSliceFingerprint additive Catalog.mainGroupId+      `shouldBe` groupSliceFingerprint current Catalog.mainGroupId++  it "spells canonical identities with explicit format prefixes" $ do+    validated <- expectValid Catalog.validCatalog+    catalogFingerprintText (CatalogApi.catalogFingerprint validated)+      `shouldSatisfy` Text.isPrefixOf "catalog-v7:"+    fmap groupSliceFingerprintText (groupSliceFingerprint validated Catalog.mainGroupId)+      `shouldSatisfy` maybe False (Text.isPrefixOf "slice-v6:")++sourceOnlyCatalog :: Text -> Text -> ProjectionCatalog+sourceOnlyCatalog identity codec =+  emptyProjectionCatalog+    { sources =+        [ SourceDeclaration+            { sourceId = identityOrError mkSourceId identity,+              sourceScope = AllStreams,+              codecFingerprint = codec,+              claimSite = identityOrError mkClaimSite ("preimage:source:" <> identity)+            }+        ]+    }++dedupOnlyCatalog :: [(Text, Text)] -> ProjectionCatalog+dedupOnlyCatalog entries =+  emptyProjectionCatalog+    { dedupKeys =+        [ DedupKeyDeclaration+            { dedupKeyId = identityOrError mkDedupKeyId identity,+              dedupName = name,+              claimSite = identityOrError mkClaimSite ("preimage:dedup:" <> identity)+            }+        | (identity, name) <- entries+        ]+    }++fullSourceCollisionCatalog :: Text -> Text -> ProjectionCatalog+fullSourceCollisionCatalog identity codec =+  Catalog.validCatalog+    { sources =+        [ SourceDeclaration+            { sourceId = collisionSourceId,+              sourceScope = AllStreams,+              codecFingerprint = codec,+              claimSite = identityOrError mkClaimSite ("preimage:full-source:" <> identity)+            }+        ],+      subscriptions =+        [ subscription & #subscriptionSource .~ collisionSourceId+        | subscription <- Catalog.validCatalog ^. #subscriptions+        ],+      projectionSets =+        [ SomeProjectionSet+            (Catalog.validProjectionSet & #projectionSource .~ collisionSourceId)+        ]+    }+  where+    collisionSourceId = identityOrError mkSourceId identity++reverseCatalog :: ProjectionCatalog -> ProjectionCatalog+reverseCatalog catalog =+  catalog+    { sources = reverse (catalog ^. #sources),+      targets = reverse (catalog ^. #targets),+      rebuildGroups = reverse (catalog ^. #rebuildGroups),+      projectionRevisions = reverse (catalog ^. #projectionRevisions),+      externalReadContracts = reverse (catalog ^. #externalReadContracts),+      subscriptions = reverse (catalog ^. #subscriptions),+      dedupKeys = reverse (catalog ^. #dedupKeys),+      queryModels = reverse (catalog ^. #queryModels),+      projectionSets = reverse (catalog ^. #projectionSets)+    }++adversarialPreimages :: [Preimage]+adversarialPreimages =+  [ PText "",+    PText "a",+    PText "a|b",+    PText "3:abc",+    PText "a\nb",+    PText "ab",+    PList [],+    PList [PText "a", PText "b"],+    PList [PText "ab"],+    PRecord "t" [],+    PRecord "t" [PText ""],+    PRecord "t|n1:" [PText "payload"]+  ]++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics -> expectationFailure (show diagnostics) >> error "unreachable"++identityOrError :: (Text -> Either err value) -> Text -> value+identityOrError constructor identity =+  case constructor identity of+    Left _ -> error ("invalid test identity: " <> show identity)+    Right value -> value
+ test/ProjectionReplaySpec.hs view
@@ -0,0 +1,1478 @@+{-# LANGUAGE MultilineStrings #-}++module ProjectionReplaySpec+  ( spec,+  )+where++import Contravariant.Extras (contrazip2, contrazip3)+import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Data.Aeson qualified as Aeson+import Data.ByteString (ByteString)+import Data.Either (isRight)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.Int (Int32)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import Data.Word (Word64)+import Effectful (Eff, IOE, (:>))+import Effectful.Dispatch.Dynamic (interpose, passthrough)+import Effectful.Error.Static (Error)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Keiro.Prelude+import Keiro.Projection+  ( AsyncProjection (..),+    CatalogAsyncApplyOutcome (..),+    InlineProjection (..),+    applyAsyncProjectionFromCatalog,+  )+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog qualified as CatalogApi+import Keiro.ReadModel (ConsistencyMode (..), ReadModel (..), StrongScope (..))+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshStore)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect qualified as StoreEffect+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Subscription.Types (MissingCheckpointPolicy (..), SubscriptionName (..))+import Kiroku.Store.Types+  ( CategoryName (..),+    EventData (..),+    EventType (..),+    ExpectedVersion (..),+    GlobalPosition (..),+    RecordedEvent,+    StreamName (..),+  )+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx+import Prelude (map, replicate, (&&))+import Prelude qualified++spec :: Fixture -> Spec+spec fixture = do+  catalogReplaySpec fixture+  redeliverySpec fixture++catalogReplaySpec :: Fixture -> Spec+catalogReplaySpec fixture = describe "catalog replay runner" $ around (withFreshStore fixture) $ do+  it "merges interleaved categories in global order and promotes only complete evidence" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendInterleaved store+    validated <- expectValid (replayCatalog goodDecoder passingVerification)+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight++    report <-+      expectStore+        store+        (startCatalogRebuild validated replayGroupId (options "ordered-run" 2))+        >>= shouldBeRight++    report ^. #runStatus `shouldBe` RebuildRunPromoted+    report ^. #capturedHead `shouldBe` GlobalPosition 6+    map (^. #exhaustedThrough) (report ^. #sources)+      `shouldBe` replicate 3 (Just (GlobalPosition 6))+    map (^. #evaluationCount) (report ^. #adapters) `shouldBe` [2, 2, 2]+    map (^. #applyCount) (report ^. #adapters) `shouldBe` [2, 2, 2]+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` [1, 2, 3, 4, 5, 6]++  it "reads each source event once while draining a multi-source rebuild" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendCountingFixture store+    validated <- expectValid (replayCatalog goodDecoder passingVerification)+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    reads <- newIORef emptyStoreReadCounts++    report <-+      expectStore+        store+        ( countStoreReads reads+            $ startCatalogRebuild validated replayGroupId (options "counted-run" 2)+        )+        >>= shouldBeRight+    counts <- readIORef reads++    report ^. #runStatus `shouldBe` RebuildRunPromoted+    Map.size (counts ^. #categoryPageReads) `shouldBe` 3+    Prelude.sum (Prelude.map Prelude.snd (Map.elems (counts ^. #categoryPageReads)))+      `shouldSatisfy` (<= 18 Prelude.+ 3 Prelude.* 2)+    Prelude.map Prelude.fst (Map.elems (counts ^. #categoryPageReads))+      `shouldSatisfy` Prelude.all (<= 6 `Prelude.div` 2 Prelude.+ 1)++  it "counts irrelevant adapter participation even when no event applies" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    traverse_+      (appendRaw store)+      [ (StreamName "orders-1", NoStream, EventType "Unrelated", Aeson.Null),+        (StreamName "customers-1", NoStream, EventType "Unrelated", Aeson.Null),+        (StreamName "billing-1", NoStream, EventType "Unrelated", Aeson.Null)+      ]+    validated <- expectValid (replayCatalog goodDecoder passingVerification)+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    report <-+      expectStore store (startCatalogRebuild validated replayGroupId (options "irrelevant-run" 2))+        >>= shouldBeRight++    report ^. #runStatus `shouldBe` RebuildRunPromoted+    map (^. #evaluationCount) (report ^. #adapters) `shouldBe` [1, 1, 1]+    map (^. #applyCount) (report ^. #adapters) `shouldBe` [0, 0, 0]+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` []++  it "never extends the captured head when matching events arrive during verification" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendInterleaved store+    validated <- expectValid (replayCatalog goodDecoder delayedVerification)+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    finished <- newEmptyMVar+    _ <-+      forkIO+        $ Store.runStoreIO store (startCatalogRebuild validated replayGroupId (options "fixed-head-run" 2))+        >>= putMVar finished+    threadDelay 200_000+    appendRaw store (StreamName "orders-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (70 :: Int64))+    outcome <- takeMVar finished+    report <-+      case outcome of+        Right (Right value) -> pure value+        other -> expectationFailure (show other) >> error "unreachable"+    report ^. #capturedHead `shouldBe` GlobalPosition 6+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` [1, 2, 3, 4, 5, 6]++  it "reconciles a preserved target without deleting roots absent from history" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    expectStore store (Store.runTransaction (Tx.statement (999, 999) ordersInsertStmt))+    appendInterleaved store+    let preserved =+          replayCatalog goodDecoder passingVerification+            & #targets+            . traversed+            . filtered ((== ordersTargetId) . (^. #targetId))+            . #resetPolicy+            .~ PreserveAndReconcile+    validated <- expectValid preserved+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    report <-+      expectStore store (startCatalogRebuild validated replayGroupId (options "preserve-run" 2))+        >>= shouldBeRight+    report ^. #runStatus `shouldBe` RebuildRunPromoted+    expectStore store (Store.runTransaction (Tx.statement () ordersPositionsStmt))+      `shouldReturn` [1, 4, 999]++  it "rolls a failed chunk back and resumes the exact contract without duplicate writes" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendInterleaved store+    faultedCatalog <- expectValid (replayCatalog failAtThirdPosition passingVerification)+    _ <- expectStore store (registerProjectionCatalog faultedCatalog) >>= shouldBeRight+    first <- expectStore store (startCatalogRebuild faultedCatalog replayGroupId (options "decode-run" 4))+    first `shouldSatisfy` \case+      Left CatalogRebuildDecodeFailed {} -> True+      _ -> False+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` []+    failedReport <- expectStore store (inspectCatalogRebuild (runId "decode-run")) >>= shouldBeRight+    map (^. #cursorPosition) (failedReport ^. #sources)+      `shouldBe` replicate 3 (GlobalPosition 0)+    interruptedStatus <- expectStore store (lookupProjectionGroupStatus replayGroupId)+    interruptedStatus ^? _Just . #lifecyclePhase `shouldBe` Just "rebuilding"+    interruptedStatus ^? _Just . #readsAllowed `shouldBe` Just False+    interruptedStatus ^? _Just . #writesAllowed `shouldBe` Just False+    interruptedStatus ^? _Just . #activeRunId+      `shouldBe` Just (Just (runId "decode-run"))+    interruptedStatus ^? _Just . #candidateRebuildPosition+      `shouldBe` Just (Just (GlobalPosition 0))+    interruptedStatus ^? _Just . #candidateRebuildHead+      `shouldBe` Just (Just (GlobalPosition 6))++    repaired <- expectValid (replayCatalog goodDecoder passingVerification)+    resumed <-+      expectStore store (resumeCatalogRebuild repaired (runId "decode-run") (options "ignored" 2))+        >>= shouldBeRight+    resumed ^. #runStatus `shouldBe` RebuildRunPromoted+    promotedStatus <- expectStore store (lookupProjectionGroupStatus replayGroupId)+    promotedStatus ^? _Just . #lifecyclePhase `shouldBe` Just "live"+    promotedStatus ^? _Just . #readsAllowed `shouldBe` Just True+    promotedStatus ^? _Just . #activeRunId `shouldBe` Just Nothing+    promotedStatus ^? _Just . #candidateRebuildPosition `shouldBe` Just Nothing+    promotedStatus ^? _Just . #candidateRebuildHead `shouldBe` Just Nothing+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` [1, 2, 3, 4, 5, 6]++  it "refuses to resume after replay-adapter declaration order changes" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql pairFixtureSql))+    appendPairEvents store+    interrupted <- expectValid (pairCatalog False failAtThirdPosition)+    _ <- expectStore store (registerProjectionCatalog interrupted) >>= shouldBeRight+    first <- expectStore store (startCatalogRebuild interrupted pairGroupId (options "order-swap-run" 2))+    first `shouldSatisfy` \case+      Left CatalogRebuildDecodeFailed {} -> True+      _ -> False+    expectStore store (Store.runTransaction (Tx.statement () pairTraceStmt))+      `shouldReturn` [(1, "first"), (1, "second"), (2, "first"), (2, "second")]++    reordered <- expectValid (pairCatalog True goodDecoder)+    CatalogApi.groupSliceFingerprint reordered pairGroupId+      `shouldBe` CatalogApi.groupSliceFingerprint interrupted pairGroupId+    resumeResult <-+      expectStore store (resumeCatalogRebuild reordered (runId "order-swap-run") (options "ignored" 2))+    resumeResult `shouldSatisfy` \case+      Left (CatalogRebuildContractMismatch mismatchedRun expected actual) ->+        mismatchedRun+          == runId "order-swap-run"+          && Text.isPrefixOf "contract-v4:" expected+          && Text.isPrefixOf "contract-v4:" actual+          && expected /= actual+      _ -> False++  it "resumes an interrupted two-adapter rebuild when declaration order is unchanged" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql pairFixtureSql))+    appendPairEvents store+    interrupted <- expectValid (pairCatalog False failAtThirdPosition)+    _ <- expectStore store (registerProjectionCatalog interrupted) >>= shouldBeRight+    _ <- expectStore store (startCatalogRebuild interrupted pairGroupId (options "order-keep-run" 2))+    repaired <- expectValid (pairCatalog False goodDecoder)+    resumed <-+      expectStore store (resumeCatalogRebuild repaired (runId "order-keep-run") (options "ignored" 2))+        >>= shouldBeRight+    resumed ^. #runStatus `shouldBe` RebuildRunPromoted+    expectStore store (Store.runTransaction (Tx.statement () pairTraceStmt))+      `shouldReturn` [ (position, label)+                     | position <- [1 .. 4],+                       label <- ["first", "second"]+                     ]++  it "keeps an order swap registration-compatible and abandonable but refuses drifted abandon" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql pairFixtureSql))+    appendPairEvents store+    interrupted <- expectValid (pairCatalog False failAtThirdPosition)+    _ <- expectStore store (registerProjectionCatalog interrupted) >>= shouldBeRight+    _ <- expectStore store (startCatalogRebuild interrupted pairGroupId (options "order-abandon-run" 2))++    drifted <-+      expectValid+        ((pairCatalog True goodDecoder) & #sources . ix 0 . #codecFingerprint .~ "pair-v2")+    driftedAbandon <-+      expectStore+        store+        (abandonCatalogRebuild drifted (runId "order-abandon-run") (RebuildFailure "operator.abandoned" "drift probe"))+    driftedAbandon `shouldSatisfy` \case+      Left CatalogRebuildSliceMismatch {} -> True+      _ -> False++    reordered <- expectValid (pairCatalog True goodDecoder)+    _ <- expectStore store (registerProjectionCatalog reordered) >>= shouldBeRight+    abandoned <-+      expectStore+        store+        (abandonCatalogRebuild reordered (runId "order-abandon-run") (RebuildFailure "operator.abandoned" "declaration order changed"))+        >>= shouldBeRight+    abandoned ^. #runStatus `shouldBe` RebuildRunFailed+    group <- expectStore store (lookupProjectionRebuildGroup pairGroupId)+    group ^? _Just . #status `shouldBe` Just GroupFailed++  it "applies replay-adapter effects in declaration order" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql pairFixtureSql))+    appendPairEvents store+    reordered <- expectValid (pairCatalog True goodDecoder)+    _ <- expectStore store (registerProjectionCatalog reordered) >>= shouldBeRight+    report <-+      expectStore store (startCatalogRebuild reordered pairGroupId (options "order-scratch-run" 2))+        >>= shouldBeRight+    report ^. #runStatus `shouldBe` RebuildRunPromoted+    expectStore store (Store.runTransaction (Tx.statement () pairTraceStmt))+      `shouldReturn` [ (position, label)+                     | position <- [1 .. 4],+                       label <- ["second", "first"]+                     ]++  it "retains committed pages across verification failure and rejects catalog drift on resume" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendInterleaved store+    faultedCatalog <- expectValid (replayCatalog goodDecoder failingVerification)+    _ <- expectStore store (registerProjectionCatalog faultedCatalog) >>= shouldBeRight+    first <- expectStore store (startCatalogRebuild faultedCatalog replayGroupId (options "verify-run" 2))+    first `shouldSatisfy` \case+      Left CatalogRebuildVerificationFailed {} -> True+      _ -> False+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` [1, 2, 3, 4, 5, 6]++    drifted <- expectValid ((replayCatalog goodDecoder passingVerification) & #sources . ix 0 . #codecFingerprint .~ "replay-v2")+    drift <- expectStore store (resumeCatalogRebuild drifted (runId "verify-run") (options "ignored" 3))+    drift `shouldSatisfy` \case+      Left CatalogRebuildContractMismatch {} -> True+      _ -> False++    repaired <- expectValid (replayCatalog goodDecoder passingVerification)+    additive <- expectValid (addUnrelatedReplaySource (replayCatalog goodDecoder passingVerification))+    CatalogApi.groupSliceFingerprint additive replayGroupId+      `shouldBe` CatalogApi.groupSliceFingerprint repaired replayGroupId+    resumed <-+      expectStore store (resumeCatalogRebuild additive (runId "verify-run") (options "ignored" 3))+        >>= shouldBeRight+    resumed ^. #runStatus `shouldBe` RebuildRunPromoted+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` [1, 2, 3, 4, 5, 6]++  it "refuses to resume a stale replay contract under the v4 runner" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendInterleaved store+    faulted <- expectValid (replayCatalog goodDecoder failingVerification)+    _ <- expectStore store (registerProjectionCatalog faulted) >>= shouldBeRight+    first <- expectStore store (startCatalogRebuild faulted replayGroupId (options "v2-contract-run" 2))+    first `shouldSatisfy` \case+      Left CatalogRebuildVerificationFailed {} -> True+      _ -> False+    let staleContract = "contract-v2:" <> Text.replicate 64 "a"+    expectStore+      store+      ( Store.runTransaction+          ( Tx.statement+              ("v2-contract-run", staleContract, "keiro/projection-replay/v2")+              setRunContractStmt+          )+      )+    inspected <- expectStore store (inspectCatalogRebuild (runId "v2-contract-run")) >>= shouldBeRight+    inspected ^. #contractFingerprint `shouldBe` staleContract+    inspected ^. #runnerFormatVersion `shouldBe` "keiro/projection-replay/v2"++    repaired <- expectValid (replayCatalog goodDecoder passingVerification)+    resumeResult <-+      expectStore store (resumeCatalogRebuild repaired (runId "v2-contract-run") (options "ignored" 2))+    resumeResult `shouldSatisfy` \case+      Left (CatalogRebuildContractMismatch mismatchedRun expected actual) ->+        mismatchedRun+          == runId "v2-contract-run"+          && expected+            == staleContract+          && Text.isPrefixOf "contract-v4:" actual+      _ -> False++  it "refuses promotion when a required adapter participation row is missing" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendInterleaved store+    faultedCatalog <- expectValid (replayCatalog goodDecoder failingVerification)+    _ <- expectStore store (registerProjectionCatalog faultedCatalog) >>= shouldBeRight+    _ <- expectStore store (startCatalogRebuild faultedCatalog replayGroupId (options "omitted-run" 2))+    expectStore store (Store.runTransaction (Tx.statement "omitted-run" deleteAdapterStmt))++    repaired <- expectValid (replayCatalog goodDecoder passingVerification)+    result <- expectStore store (resumeCatalogRebuild repaired (runId "omitted-run") (options "ignored" 2))+    result `shouldSatisfy` \case+      Left CatalogRebuildInvariantFailed {} -> True+      _ -> False+    group <- expectStore store (lookupProjectionRebuildGroup replayGroupId)+    group ^? _Just . #status `shouldBe` Just GroupRebuilding++  it "applies merged multi-source chunks in ascending global order across buffer boundaries" $ \store -> do+    expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+    appendStaggered store+    validated <- expectValid (replayCatalog goodDecoder passingVerification)+    _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+    report <-+      expectStore store (startCatalogRebuild validated replayGroupId (options "staggered-run" 2))+        >>= shouldBeRight+    report ^. #runStatus `shouldBe` RebuildRunPromoted+    expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+      `shouldReturn` [1, 2, 3, 4, 7, 8, 9]++  describe "cross-source ordering sweep"+    $ traverse_+      ( \(seed, pageSize) ->+          it ("preserves ascending order for seed " <> show seed <> " at page size " <> show pageSize) $ \store -> do+            expectStore store (Store.runTransaction (Tx.sql replayFixtureSql))+            expectedPositions <- appendSweep store seed 14+            validated <- expectValid (replayCatalog goodDecoder passingVerification)+            _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+            report <-+              expectStore+                store+                ( startCatalogRebuild+                    validated+                    replayGroupId+                    (options ("sweep-" <> Text.pack (show seed) <> "-" <> Text.pack (show pageSize)) pageSize)+                )+                >>= shouldBeRight+            report ^. #runStatus `shouldBe` RebuildRunPromoted+            expectStore store (Store.runTransaction (Tx.statement () tracePositionsStmt))+              `shouldReturn` expectedPositions+            map (^. #exhaustedThrough) (report ^. #sources)+              `shouldBe` replicate 3 (Just (GlobalPosition 14))+      )+      [(seed, pageSize) | seed <- [1 .. 6], pageSize <- [1, 2, 3]]++redeliverySpec :: Fixture -> Spec+redeliverySpec fixture =+  describe "catalog rebuild promotion redelivery"+    $ around (withFreshStore fixture)+    $ do+      it "promotion leaves redelivery safe for a clear-before-replay async projection" $ \store -> do+        expectStore store (Store.runTransaction (Tx.sql redeliveryFixtureSql))+        events <- appendAuditEvents store+        validated <- expectValid (redeliveryCatalog ClearAuditTotals passingVerification)+        case catalogAsyncIdempotencyKeys validated auditGroupId of+          [dedupSpec] ->+            ( dedupSpec ^. #specSubscriptionName,+              dedupSpec ^. #specDedupName,+              dedupSpec ^. #specSourceId,+              dedupSpec ^. #specSourceScope+            )+              `shouldBe` ( "audit-subscription",+                           "audit-async",+                           auditSourceId,+                           CategorySource (CategoryName "audit")+                         )+          specs -> expectationFailure ("unexpected async dedup specs: " <> show (Prelude.length specs))+        liveOnly <-+          expectValid (redeliveryCatalogWithReplayability PreserveAuditEntries passingVerification False)+        Prelude.null (catalogAsyncIdempotencyKeys liveOnly auditGroupId) `shouldBe` True+        _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+        liveApplyAuditEvents store validated auditTotalsProjection events+        expectStore store (Store.runTransaction (Tx.statement () auditTotalStmt))+          `shouldReturn` 60+        expectStore store (Store.runTransaction (Tx.statement "audit-async" auditDedupCountStmt))+          `shouldReturn` 3++        report <-+          expectStore+            store+            (startCatalogRebuild validated auditGroupId (options "redelivery-clear-run" 2))+            >>= shouldBeRight+        report ^. #runStatus `shouldBe` RebuildRunPromoted+        outcomes <- redeliverAuditEvents store validated auditTotalsProjection events+        total <- expectStore store (Store.runTransaction (Tx.statement () auditTotalStmt))+        checkpoints <- expectStore store (Store.runTransaction (Tx.statement () auditCheckpointsStmt))+        dedupCount <- expectStore store (Store.runTransaction (Tx.statement "audit-async" auditDedupCountStmt))+        let GlobalPosition capturedHead = report ^. #capturedHead+        (outcomes, total, checkpoints, dedupCount)+          `shouldBe` ( replicate 3 CatalogAsyncDuplicate,+                       60,+                       replicate 2 capturedHead,+                       3+                     )+        secondOutcomes <- redeliverAuditEvents store validated auditTotalsProjection events+        secondTotal <- expectStore store (Store.runTransaction (Tx.statement () auditTotalStmt))+        (secondOutcomes, secondTotal)+          `shouldBe` (replicate 3 CatalogAsyncDuplicate, 60)++      it "promotion leaves redelivery safe for a preserve-and-reconcile async projection" $ \store -> do+        expectStore store (Store.runTransaction (Tx.sql redeliveryFixtureSql))+        events <- appendAuditEvents store+        validated <- expectValid (redeliveryCatalog PreserveAuditEntries passingVerification)+        _ <- expectStore store (registerProjectionCatalog validated) >>= shouldBeRight+        liveApplyAuditEvents store validated auditEntriesProjection events++        report <-+          expectStore+            store+            (startCatalogRebuild validated auditGroupId (options "redelivery-preserve-run" 2))+            >>= shouldBeRight+        report ^. #runStatus `shouldBe` RebuildRunPromoted+        outcomes <- redeliverAuditEvents store validated auditEntriesProjection events+        liveApplies <- expectStore store (Store.runTransaction (Tx.statement () auditEntryAppliesStmt))+        (outcomes, liveApplies)+          `shouldBe` (replicate 3 CatalogAsyncDuplicate, replicate 3 1)++      it "resumes a verification failure with redelivery safety and an honest fence" $ \store -> do+        expectStore store (Store.runTransaction (Tx.sql redeliveryFixtureSql))+        events <- appendAuditEvents store+        faulted <- expectValid (redeliveryCatalog ClearAuditTotals failingVerification)+        _ <- expectStore store (registerProjectionCatalog faulted) >>= shouldBeRight+        liveApplyAuditEvents store faulted auditTotalsProjection events+        (firstEvent, lastEvent) <-+          case events of+            [first, _, last] -> pure (first, last)+            _ -> expectationFailure "expected exactly three audit events" >> error "unreachable"+        let GlobalPosition capturedHead = lastEvent ^. #globalPosition+            slowerFloor = GlobalPosition (capturedHead Prelude.- 1)+        expectStore+          store+          ( Store.runTransaction+              (Tx.statement ("audit-subscription", 1, capturedHead Prelude.- 1) setAuditMemberCheckpointStmt)+          )+        beforeRebuild <-+          expectStore store (collectAsyncDedupBackfill faulted auditGroupId 2 (GlobalPosition capturedHead))+            >>= shouldBeRight+        (beforeRebuild ^. #backfillFloors, Prelude.length (beforeRebuild ^. #backfillPairs))+          `shouldBe` ([("audit-subscription", slowerFloor)], 1)++        failed <-+          expectStore+            store+            (startCatalogRebuild faulted auditGroupId (options "redelivery-resume-run" 2))+        case failed of+          Left (CatalogRebuildVerificationFailed failedRun _ _) ->+            failedRun `shouldBe` runId "redelivery-resume-run"+          other -> expectationFailure ("expected verification failure, got " <> show other)+        failedReport <-+          expectStore store (inspectCatalogRebuild (runId "redelivery-resume-run"))+            >>= shouldBeRight+        failedReport ^. #runStatus `shouldBe` RebuildRunFailed+        group <- expectStore store (lookupProjectionRebuildGroup auditGroupId)+        fmap (^. #status) group `shouldBe` Just GroupRebuilding+        expectStore store (Store.runTransaction (Tx.statement () auditCheckpointsStmt))+          `shouldReturn` [0, 0]+        expectStore store (Store.runTransaction (Tx.statement "audit-async" auditDedupCountStmt))+          `shouldReturn` 0+        fenced <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog faulted auditProjectionId auditTotalsProjection firstEvent)+            )+        fenced `shouldBe` CatalogAsyncFenced auditGroupId (runId "redelivery-resume-run")++        repaired <- expectValid (redeliveryCatalog ClearAuditTotals passingVerification)+        report <-+          expectStore+            store+            (resumeCatalogRebuild repaired (runId "redelivery-resume-run") (options "ignored" 2))+            >>= shouldBeRight+        report ^. #runStatus `shouldBe` RebuildRunPromoted+        firstOutcomes <- redeliverAuditEvents store repaired auditTotalsProjection events+        secondOutcomes <- redeliverAuditEvents store repaired auditTotalsProjection events+        total <- expectStore store (Store.runTransaction (Tx.statement () auditTotalStmt))+        checkpoints <- expectStore store (Store.runTransaction (Tx.statement () auditCheckpointsStmt))+        dedupCount <- expectStore store (Store.runTransaction (Tx.statement "audit-async" auditDedupCountStmt))+        (firstOutcomes, secondOutcomes, total, checkpoints, dedupCount)+          `shouldBe` ( replicate 3 CatalogAsyncDuplicate,+                       replicate 3 CatalogAsyncDuplicate,+                       60,+                       replicate 2 capturedHead,+                       3+                     )++      it "reports vanished checkpoint rows and resumes promotion after repair" $ \store -> do+        expectStore store (Store.runTransaction (Tx.sql redeliveryFixtureSql))+        events <- appendAuditEvents store+        deleting <- expectValid (redeliveryCatalog ClearAuditTotals deletingVerification)+        _ <- expectStore store (registerProjectionCatalog deleting) >>= shouldBeRight+        liveApplyAuditEvents store deleting auditTotalsProjection events++        failed <-+          expectStore+            store+            (startCatalogRebuild deleting auditGroupId (options "redelivery-missing-run" 2))+        case failed of+          Left (CatalogRebuildPromotionCheckpointsMissing failedRun missing) ->+            (failedRun, missing)+              `shouldBe` (runId "redelivery-missing-run", [SubscriptionName "audit-subscription"])+          other -> expectationFailure ("expected missing checkpoints, got " <> show other)+        failedReport <-+          expectStore store (inspectCatalogRebuild (runId "redelivery-missing-run"))+            >>= shouldBeRight+        (failedReport ^. #runStatus, failedReport ^. #failureEvidence . _Just . #failureCode)+          `shouldBe` (RebuildRunFailed, "promotion.checkpoints-missing")+        group <- expectStore store (lookupProjectionRebuildGroup auditGroupId)+        fmap (^. #status) group `shouldBe` Just GroupRebuilding++        expectStore store (Store.runTransaction (Tx.sql restoreAuditSubscriptionsSql))+        repaired <- expectValid (redeliveryCatalog ClearAuditTotals passingVerification)+        report <-+          expectStore+            store+            (resumeCatalogRebuild repaired (runId "redelivery-missing-run") (options "ignored" 2))+            >>= shouldBeRight+        outcomes <- redeliverAuditEvents store repaired auditTotalsProjection events+        total <- expectStore store (Store.runTransaction (Tx.statement () auditTotalStmt))+        checkpoints <- expectStore store (Store.runTransaction (Tx.statement () auditCheckpointsStmt))+        let GlobalPosition capturedHead = report ^. #capturedHead+        (report ^. #runStatus, outcomes, total, checkpoints)+          `shouldBe` (RebuildRunPromoted, replicate 3 CatalogAsyncDuplicate, 60, replicate 2 capturedHead)++data ReplayEvent = ReplayEvent !Int64+  deriving stock (Eq, Show)++type ReplayDecoder = RecordedEvent -> ReplayDecodeResult ReplayEvent++goodDecoder :: ReplayDecoder+goodDecoder recorded+  | recorded ^. #eventType /= EventType "ReplayEvent" = ReplayIrrelevant+  | otherwise =+      case Aeson.fromJSON (recorded ^. #payload) of+        Aeson.Error detail -> ReplayDecodeFailure (ReplayDecodeError (Text.pack detail))+        Aeson.Success value -> ReplayRelevant (ReplayEvent value)++failAtThirdPosition :: ReplayDecoder+failAtThirdPosition recorded+  | recorded ^. #globalPosition == GlobalPosition 3 =+      ReplayDecodeFailure (ReplayDecodeError "fault injected before chunk commit")+  | otherwise = goodDecoder recorded++passingVerification :: RebuildVerification+passingVerification = verificationWith (pure (Right ()))++failingVerification :: RebuildVerification+failingVerification = verificationWith (pure (Left "fault injected after committed pages"))++deletingVerification :: RebuildVerification+deletingVerification =+  verificationWith+    (Tx.sql "DELETE FROM subscriptions WHERE subscription_name = 'audit-subscription'" >> pure (Right ()))++delayedVerification :: RebuildVerification+delayedVerification = verificationWith (Tx.sql "SELECT pg_sleep(1)" >> pure (Right ()))++verificationWith :: Tx.Transaction (Either Text ()) -> RebuildVerification+verificationWith action =+  RebuildVerification+    { verificationId = "trace-is-ordered",+      verificationVersion = "v1",+      verifyRebuild = action+    }++replayCatalog :: ReplayDecoder -> RebuildVerification -> ProjectionCatalog+replayCatalog decoder verification =+  ProjectionCatalog+    { sources =+        [ source "orders" ordersSourceId,+          source "customers" customersSourceId,+          source "billing" billingSourceId+        ],+      targets =+        [ target traceTargetId "replay_trace",+          target ordersTargetId "orders_projection",+          target customersTargetId "customers_projection",+          target billingTargetId "billing_projection"+        ],+      rebuildGroups =+        [ RebuildGroupDeclaration+            { rebuildGroupId = replayGroupId,+              orderedTargets = [traceTargetId, ordersTargetId, customersTargetId, billingTargetId],+              verificationHooks = [verification],+              claimSite = site "test:replay-group"+            }+        ],+      projectionRevisions = [],+      externalReadContracts = [],+      subscriptions = [],+      dedupKeys = [],+      queryModels = [],+      projectionSets =+        [ SomeProjectionSet (projectionSet ordersSourceId ordersProjectionId (traceTargetId :| [ordersTargetId]) "orders" ordersInsertStmt),+          SomeProjectionSet (projectionSet customersSourceId customersProjectionId (customersTargetId :| []) "customers" customersInsertStmt),+          SomeProjectionSet (projectionSet billingSourceId billingProjectionId (billingTargetId :| []) "billing" billingInsertStmt)+        ]+    }+  where+    source category sourceId =+      SourceDeclaration+        { sourceId,+          sourceScope = CategorySource (CategoryName category),+          codecFingerprint = "replay-v1",+          claimSite = site ("test:source:" <> category)+        }+    target targetId tableName =+      TargetDeclaration+        { targetId,+          qualifiedTable = QualifiedTable "app" tableName,+          resetPolicy = ClearBeforeReplay,+          dependsOn = [],+          claimSite = site ("test:target:" <> tableName)+        }+    projectionSet sourceId projectionId ownedTargets label insertStmt =+      ProjectionSet+        { projectionSource = sourceId,+          projectionDefinitions =+            ProjectionDefinition+              { projectionId,+                rebuildGroup = replayGroupId,+                ownedTargets,+                replayPolicy =+                  Replayable+                    ReplayAdapter+                      { decodeForReplay = decoder,+                        applyForReplay = applyReplay label insertStmt+                      },+                handlers =+                  InlineHandler+                    InlineProjection+                      { name = "live-" <> label,+                        apply = \_ _ -> pure ()+                      }+                    (site ("test:handler:" <> label))+                    :| [],+                claimSite = site ("test:projection:" <> label)+              }+              :| [],+          claimSite = site ("test:set:" <> label)+        }++addUnrelatedReplaySource :: ProjectionCatalog -> ProjectionCatalog+addUnrelatedReplaySource catalog =+  catalog+    { sources =+        catalog+          ^. #sources+          <> [ SourceDeclaration+                 { sourceId = identity mkSourceId "unrelated-source",+                   sourceScope = CategorySource (CategoryName "unrelated"),+                   codecFingerprint = "unrelated-v1",+                   claimSite = site "test:source:unrelated"+                 }+             ]+    }++data AuditProjectionKind+  = ClearAuditTotals+  | PreserveAuditEntries++redeliveryCatalog :: AuditProjectionKind -> RebuildVerification -> ProjectionCatalog+redeliveryCatalog projectionKind verification =+  redeliveryCatalogWithReplayability projectionKind verification True++redeliveryCatalogWithReplayability :: AuditProjectionKind -> RebuildVerification -> Bool -> ProjectionCatalog+redeliveryCatalogWithReplayability projectionKind verification replayable =+  ProjectionCatalog+    { sources =+        [ SourceDeclaration+            { sourceId = auditSourceId,+              sourceScope = CategorySource (CategoryName "audit"),+              codecFingerprint = "audit-v1",+              claimSite = site "test:audit-source"+            }+        ],+      targets =+        [ TargetDeclaration+            { targetId = selectedTargetId,+              qualifiedTable = QualifiedTable "app" selectedTable,+              resetPolicy = selectedResetPolicy,+              dependsOn = [],+              claimSite = site "test:audit-target"+            }+        ],+      rebuildGroups =+        [ RebuildGroupDeclaration+            { rebuildGroupId = auditGroupId,+              orderedTargets = [selectedTargetId],+              verificationHooks = [verification],+              claimSite = site "test:audit-group"+            }+        ],+      projectionRevisions = [],+      externalReadContracts = [],+      subscriptions =+        [ SubscriptionDeclaration+            { subscriptionId = auditSubscriptionId,+              subscriptionName = "audit-subscription",+              subscriptionSource = auditSourceId,+              checkpointOnMissing = FromBeginning,+              claimSite = site "test:audit-subscription"+            }+        ],+      dedupKeys =+        [ DedupKeyDeclaration+            { dedupKeyId = auditDedupId,+              dedupName = "audit-async",+              claimSite = site "test:audit-dedup"+            }+        ],+      queryModels =+        [ SomeQueryModelBinding+            QueryModelBinding+              { queryModelId = auditQueryId,+                readModel = auditReadModel selectedTable,+                rebuildGroup = auditGroupId,+                observedTargets = [selectedTargetId],+                claimSite = site "test:audit-query"+              }+        ],+      projectionSets =+        [ SomeProjectionSet+            ProjectionSet+              { projectionSource = auditSourceId,+                projectionDefinitions =+                  ProjectionDefinition+                    { projectionId = auditProjectionId,+                      rebuildGroup = auditGroupId,+                      ownedTargets = selectedTargetId :| [],+                      replayPolicy =+                        if replayable+                          then Replayable (auditReplayAdapter projectionKind)+                          else LiveOnly (LiveOnlyReason "membership-test"),+                      handlers =+                        AsyncHandler selectedProjection auditSubscriptionId auditDedupId (site "test:audit-handler")+                          :| [],+                      claimSite = site "test:audit-projection"+                    }+                    :| [],+                claimSite = site "test:audit-set"+              }+        ]+    }+  where+    (selectedTargetId, selectedTable, selectedResetPolicy, selectedProjection) =+      case projectionKind of+        ClearAuditTotals ->+          (auditTotalsTargetId, "audit_totals", ClearBeforeReplay, auditTotalsProjection)+        PreserveAuditEntries ->+          (auditEntriesTargetId, "audit_entries", PreserveAndReconcile, auditEntriesProjection)++auditReplayAdapter :: AuditProjectionKind -> ReplayAdapter Int64+auditReplayAdapter projectionKind =+  ReplayAdapter+    { decodeForReplay = decodeAuditEvent,+      applyForReplay = \value recorded ->+        case projectionKind of+          ClearAuditTotals -> Tx.statement value addAuditTotalStmt+          PreserveAuditEntries ->+            let GlobalPosition position = recorded ^. #globalPosition+             in Tx.statement (position, value) reconcileAuditEntryStmt+    }++decodeAuditEvent :: RecordedEvent -> ReplayDecodeResult Int64+decodeAuditEvent recorded+  | recorded ^. #eventType /= EventType "AuditEvent" = ReplayIrrelevant+  | otherwise =+      case Aeson.fromJSON (recorded ^. #payload) of+        Aeson.Error detail -> ReplayDecodeFailure (ReplayDecodeError (Text.pack detail))+        Aeson.Success value -> ReplayRelevant value++auditTotalsProjection :: AsyncProjection+auditTotalsProjection =+  AsyncProjection+    { name = "audit-async",+      readModelName = "audit-query",+      subscriptionName = "audit-subscription",+      applyRecorded = \recorded -> Tx.statement (auditEventValue recorded) addAuditTotalStmt,+      idempotencyKey = (^. #eventId)+    }++auditEntriesProjection :: AsyncProjection+auditEntriesProjection =+  auditTotalsProjection+    { applyRecorded = \recorded ->+        let GlobalPosition position = recorded ^. #globalPosition+         in Tx.statement (position, auditEventValue recorded) applyAuditEntryStmt+    }++auditEventValue :: RecordedEvent -> Int64+auditEventValue recorded =+  case Aeson.fromJSON (recorded ^. #payload) of+    Aeson.Error detail -> error detail+    Aeson.Success value -> value++auditReadModel :: Text -> ReadModel Text ()+auditReadModel tableName =+  ReadModel+    { name = "audit-query",+      tableName,+      schema = "app",+      subscriptionName = "audit-subscription",+      version = 1,+      shapeHash = "audit-query-v1",+      defaultConsistency = Eventual,+      strongScope = EntireLog,+      query = \_ -> pure ()+    }++appendAuditEvents :: Store.KirokuStore -> IO [RecordedEvent]+appendAuditEvents store = do+  traverse_+    (appendRaw store)+    [ (StreamName "audit-1", NoStream, EventType "AuditEvent", Aeson.toJSON (10 :: Int64)),+      (StreamName "audit-1", AnyVersion, EventType "AuditEvent", Aeson.toJSON (20 :: Int64)),+      (StreamName "audit-1", AnyVersion, EventType "AuditEvent", Aeson.toJSON (30 :: Int64))+    ]+  Vector.toList+    <$> expectStore store (Store.readCategory (CategoryName "audit") (GlobalPosition 0) 100)++liveApplyAuditEvents ::+  Store.KirokuStore ->+  ValidatedProjectionCatalog ->+  AsyncProjection ->+  [RecordedEvent] ->+  IO ()+liveApplyAuditEvents store catalog projection events =+  for_ events $ \recorded -> do+    outcome <-+      expectStore+        store+        ( Store.runTransaction $ do+            applied <- applyAsyncProjectionFromCatalog catalog auditProjectionId projection recorded+            let GlobalPosition position = recorded ^. #globalPosition+            Tx.statement ("audit-subscription", position) setAuditCheckpointStmt+            pure applied+        )+    outcome `shouldBe` CatalogAsyncApplied++redeliverAuditEvents ::+  Store.KirokuStore ->+  ValidatedProjectionCatalog ->+  AsyncProjection ->+  [RecordedEvent] ->+  IO [CatalogAsyncApplyOutcome]+redeliverAuditEvents store catalog projection =+  traverse+    ( \recorded ->+        expectStore+          store+          ( Store.runTransaction+              (applyAsyncProjectionFromCatalog catalog auditProjectionId projection recorded)+          )+    )++pairCatalog :: Bool -> ReplayDecoder -> ProjectionCatalog+pairCatalog swapOrder decoder =+  ProjectionCatalog+    { sources =+        [ SourceDeclaration+            { sourceId = pairSourceId,+              sourceScope = CategorySource (CategoryName "pair"),+              codecFingerprint = "pair-v1",+              claimSite = site "test:source:pair"+            }+        ],+      targets =+        [ TargetDeclaration+            { targetId = pairTraceTargetId,+              qualifiedTable = QualifiedTable "app" "pair_trace",+              resetPolicy = ClearBeforeReplay,+              dependsOn = [],+              claimSite = site "test:target:pair-trace"+            },+          TargetDeclaration+            { targetId = pairSecondTargetId,+              qualifiedTable = QualifiedTable "app" "pair_second",+              resetPolicy = ClearBeforeReplay,+              dependsOn = [],+              claimSite = site "test:target:pair-second"+            }+        ],+      rebuildGroups =+        [ RebuildGroupDeclaration+            { rebuildGroupId = pairGroupId,+              orderedTargets = [pairTraceTargetId, pairSecondTargetId],+              verificationHooks = [],+              claimSite = site "test:pair-group"+            }+        ],+      projectionRevisions = [],+      externalReadContracts = [],+      subscriptions = [],+      dedupKeys = [],+      queryModels = [],+      projectionSets =+        [ SomeProjectionSet+            ProjectionSet+              { projectionSource = pairSourceId,+                projectionDefinitions =+                  if swapOrder+                    then secondDefinition :| [firstDefinition]+                    else firstDefinition :| [secondDefinition],+                claimSite = site "test:set:pair"+              }+        ]+    }+  where+    firstDefinition = pairDefinition "pair-first" pairTraceTargetId "first"+    secondDefinition = pairDefinition "pair-second" pairSecondTargetId "second"+    pairDefinition projectionName targetId label =+      ProjectionDefinition+        { projectionId = identity mkProjectionId projectionName,+          rebuildGroup = pairGroupId,+          ownedTargets = targetId :| [],+          replayPolicy =+            Replayable+              ReplayAdapter+                { decodeForReplay = decoder,+                  applyForReplay = applyPair label+                },+          handlers =+            InlineHandler+              InlineProjection {name = "live-" <> label, apply = \_ _ -> pure ()}+              (site ("test:handler:" <> label))+              :| [],+          claimSite = site ("test:projection:" <> label)+        }++applyPair :: Text -> ReplayEvent -> RecordedEvent -> Tx.Transaction ()+applyPair label (ReplayEvent value) recorded = do+  let GlobalPosition position = recorded ^. #globalPosition+  Tx.statement (position, label, value) insertPairTraceStmt++appendPairEvents :: Store.KirokuStore -> IO ()+appendPairEvents store =+  traverse_+    (appendRaw store)+    [ (StreamName "pair-1", NoStream, EventType "ReplayEvent", Aeson.toJSON (10 :: Int64)),+      (StreamName "pair-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (20 :: Int64)),+      (StreamName "pair-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (30 :: Int64)),+      (StreamName "pair-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (40 :: Int64))+    ]++applyReplay :: Text -> Statement (Int64, Int64) () -> ReplayEvent -> RecordedEvent -> Tx.Transaction ()+applyReplay sourceLabel insertTarget (ReplayEvent value) recorded = do+  let GlobalPosition position = recorded ^. #globalPosition+  Tx.statement (position, sourceLabel, value) insertTraceStmt+  Tx.statement (position, value) insertTarget++appendInterleaved :: Store.KirokuStore -> IO ()+appendInterleaved store =+  traverse_+    (appendRaw store)+    [ (StreamName "orders-1", NoStream, EventType "ReplayEvent", Aeson.toJSON (10 :: Int64)),+      (StreamName "customers-1", NoStream, EventType "ReplayEvent", Aeson.toJSON (20 :: Int64)),+      (StreamName "billing-1", NoStream, EventType "ReplayEvent", Aeson.toJSON (30 :: Int64)),+      (StreamName "orders-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (40 :: Int64)),+      (StreamName "customers-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (50 :: Int64)),+      (StreamName "billing-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (60 :: Int64))+    ]++appendStaggered :: Store.KirokuStore -> IO ()+appendStaggered store =+  traverse_+    (appendRaw store)+    [ (StreamName "orders-1", NoStream, EventType "ReplayEvent", Aeson.toJSON (11 :: Int64)),+      (StreamName "customers-1", NoStream, EventType "ReplayEvent", Aeson.toJSON (21 :: Int64)),+      (StreamName "customers-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (22 :: Int64)),+      (StreamName "customers-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (23 :: Int64)),+      (StreamName "padding-1", NoStream, EventType "PaddingEvent", Aeson.Null),+      (StreamName "padding-1", AnyVersion, EventType "PaddingEvent", Aeson.Null),+      (StreamName "customers-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (24 :: Int64)),+      (StreamName "orders-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (12 :: Int64)),+      (StreamName "orders-1", AnyVersion, EventType "ReplayEvent", Aeson.toJSON (13 :: Int64))+    ]++sweepStep :: Word64 -> Word64+sweepStep seed = seed Prelude.* 6364136223846793005 Prelude.+ 1442695040888963407++sweepCategories :: Word64 -> Int -> [Text]+sweepCategories seed count =+  Prelude.take+    count+    [ ["orders", "customers", "billing", "padding"]+        Prelude.!! Prelude.fromIntegral ((state `Prelude.div` 7) `Prelude.mod` 4)+    | state <- Prelude.iterate sweepStep (sweepStep seed)+    ]++appendSweep :: Store.KirokuStore -> Word64 -> Int -> IO [Int64]+appendSweep store seed count = go Map.empty [] indexedCategories+  where+    indexedCategories = Prelude.zip ([1 ..] :: [Int64]) (sweepCategories seed count)++    go _ expected [] = pure (Prelude.reverse expected)+    go seen expected ((position, category) : remaining) = do+      appendRaw+        store+        ( StreamName (category <> "-sweep"),+          if Map.member category seen then AnyVersion else NoStream,+          if category == "padding" then EventType "PaddingEvent" else EventType "ReplayEvent",+          if category == "padding" then Aeson.Null else Aeson.toJSON position+        )+      go+        (Map.insert category () seen)+        (if category == "padding" then expected else position : expected)+        remaining++appendCountingFixture :: Store.KirokuStore -> IO ()+appendCountingFixture store =+  traverse_ (appendRaw store) (Prelude.concatMap eventsForRound ([1 .. 6] :: [Int]))+  where+    eventsForRound roundNo =+      [ event "orders" 10,+        event "customers" 20,+        event "billing" 30+      ]+      where+        event category offset =+          ( StreamName (category <> "-counted"),+            if roundNo == 1 then NoStream else AnyVersion,+            EventType "ReplayEvent",+            Aeson.toJSON (Prelude.fromIntegral (roundNo Prelude.* 100 Prelude.+ offset) :: Int64)+          )++data StoreReadCounts = StoreReadCounts+  { categoryPageReads :: !(Map CategoryName (Int, Int)),+    allPageReads :: !(Int, Int)+  }+  deriving stock (Eq, Show, Generic)++emptyStoreReadCounts :: StoreReadCounts+emptyStoreReadCounts = StoreReadCounts Map.empty (0, 0)++countStoreReads ::+  (Store :> es, IOE :> es) =>+  IORef StoreReadCounts ->+  Eff es value ->+  Eff es value+countStoreReads reads = interpose @Store $ \environment operation ->+  case operation of+    StoreEffect.ReadCategoryForward category _ _ -> do+      events <- passthrough environment operation+      liftIO+        $ modifyIORef' reads+        $ \counts ->+          counts+            { categoryPageReads =+                Map.insertWith addReads category (1, Vector.length events) (counts ^. #categoryPageReads)+            }+      pure events+    StoreEffect.ReadAllForward {} -> do+      events <- passthrough environment operation+      liftIO+        $ modifyIORef' reads+        $ \counts ->+          counts {allPageReads = addReads (1, Vector.length events) (counts ^. #allPageReads)}+      pure events+    _ -> passthrough environment operation+  where+    addReads (newCalls, newEvents) (oldCalls, oldEvents) =+      (newCalls Prelude.+ oldCalls, newEvents Prelude.+ oldEvents)++appendRaw :: Store.KirokuStore -> (StreamName, ExpectedVersion, EventType, Aeson.Value) -> IO ()+appendRaw store (streamName, expectedVersion, eventType, payload) = do+  result <-+    Store.runStoreIO store+      $ Store.appendToStream+        streamName+        expectedVersion+        [ EventData+            { eventId = Nothing,+              eventType,+              payload,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        ]+  result `shouldSatisfy` isRight++options :: Text -> Int32 -> RebuildOptions+options runName pageSize =+  (defaultRebuildOptions (request runName)) {replayPageSize = pageSize}++request :: Text -> RebuildRequest+request runName =+  RebuildRequest+    { rebuildRunId = runId runName,+      requestedBy = "projection-replay-spec",+      requestReason = "integration proof",+      replayFrom = GlobalPosition 0+    }++runId :: Text -> RebuildRunId+runId runName =+  either (error . Text.unpack) Prelude.id (mkRebuildRunId runName)++expectValid :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValid catalog =+  case validateProjectionCatalog catalog of+    Success validated -> pure validated+    Failure diagnostics -> expectationFailure (show diagnostics) >> error "unreachable"++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure ("store action failed: " <> show err) >> error "unreachable"+    Right value -> pure value++shouldBeRight :: (Show err) => Either err value -> IO value+shouldBeRight = \case+  Left err -> expectationFailure ("expected Right, got Left " <> show err) >> error "unreachable"+  Right value -> pure value++identity :: (Text -> Either CatalogIdentityError value) -> Text -> value+identity constructor raw = either (error . show) Prelude.id (constructor raw)++site :: Text -> ClaimSite+site = identity mkClaimSite++ordersSourceId, customersSourceId, billingSourceId :: SourceId+ordersSourceId = identity mkSourceId "orders-source"+customersSourceId = identity mkSourceId "customers-source"+billingSourceId = identity mkSourceId "billing-source"++traceTargetId, ordersTargetId, customersTargetId, billingTargetId :: TargetId+traceTargetId = identity mkTargetId "replay-trace"+ordersTargetId = identity mkTargetId "orders-projection"+customersTargetId = identity mkTargetId "customers-projection"+billingTargetId = identity mkTargetId "billing-projection"++ordersProjectionId, customersProjectionId, billingProjectionId :: ProjectionId+ordersProjectionId = identity mkProjectionId "orders-projection"+customersProjectionId = identity mkProjectionId "customers-projection"+billingProjectionId = identity mkProjectionId "billing-projection"++replayGroupId :: RebuildGroupId+replayGroupId = identity mkRebuildGroupId "replay-group"++pairSourceId :: SourceId+pairSourceId = identity mkSourceId "pair-source"++pairTraceTargetId, pairSecondTargetId :: TargetId+pairTraceTargetId = identity mkTargetId "pair-trace"+pairSecondTargetId = identity mkTargetId "pair-second"++pairGroupId :: RebuildGroupId+pairGroupId = identity mkRebuildGroupId "pair-group"++auditSourceId :: SourceId+auditSourceId = identity mkSourceId "audit-source"++auditTotalsTargetId, auditEntriesTargetId :: TargetId+auditTotalsTargetId = identity mkTargetId "audit-totals-target"+auditEntriesTargetId = identity mkTargetId "audit-entries-target"++auditGroupId :: RebuildGroupId+auditGroupId = identity mkRebuildGroupId "audit-group"++auditProjectionId :: ProjectionId+auditProjectionId = identity mkProjectionId "audit-projection"++auditSubscriptionId :: SubscriptionId+auditSubscriptionId = identity mkSubscriptionId "audit-subscription-id"++auditDedupId :: DedupKeyId+auditDedupId = identity mkDedupKeyId "audit-dedup-id"++auditQueryId :: QueryModelId+auditQueryId = identity mkQueryModelId "audit-query-id"++replayFixtureSql :: ByteString+replayFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.replay_trace (+    sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,+    global_position bigint NOT NULL UNIQUE,+    source text NOT NULL,+    value bigint NOT NULL+  );+  CREATE TABLE app.orders_projection (global_position bigint PRIMARY KEY, value bigint NOT NULL);+  CREATE TABLE app.customers_projection (global_position bigint PRIMARY KEY, value bigint NOT NULL);+  CREATE TABLE app.billing_projection (global_position bigint PRIMARY KEY, value bigint NOT NULL);+  """++pairFixtureSql :: ByteString+pairFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.pair_trace (+    sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,+    global_position bigint NOT NULL,+    projection text NOT NULL,+    value bigint NOT NULL+  );+  CREATE TABLE app.pair_second (global_position bigint PRIMARY KEY, value bigint NOT NULL);+  """++redeliveryFixtureSql :: ByteString+redeliveryFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.audit_totals (+    id text PRIMARY KEY,+    total bigint NOT NULL+  );+  CREATE TABLE app.audit_entries (+    event_pos bigint PRIMARY KEY,+    value bigint NOT NULL,+    live_applies bigint NOT NULL+  );+  INSERT INTO subscriptions (+    subscription_name,+    consumer_group_member,+    consumer_group_size,+    last_seen+  ) VALUES+    ('audit-subscription', 0, 2, 0),+    ('audit-subscription', 1, 2, 0);+  """++restoreAuditSubscriptionsSql :: ByteString+restoreAuditSubscriptionsSql =+  """+  INSERT INTO subscriptions (+    subscription_name,+    consumer_group_member,+    consumer_group_size,+    last_seen+  ) VALUES+    ('audit-subscription', 0, 2, 0),+    ('audit-subscription', 1, 2, 0);+  """++insertTraceStmt :: Statement (Int64, Text, Int64) ()+insertTraceStmt =+  preparable+    "INSERT INTO app.replay_trace (global_position, source, value) VALUES ($1, $2, $3)"+    (contrazip3 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int8)))+    D.noResult++insertPairTraceStmt :: Statement (Int64, Text, Int64) ()+insertPairTraceStmt =+  preparable+    "INSERT INTO app.pair_trace (global_position, projection, value) VALUES ($1, $2, $3)"+    (contrazip3 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int8)))+    D.noResult++addAuditTotalStmt :: Statement Int64 ()+addAuditTotalStmt =+  preparable+    """+    INSERT INTO app.audit_totals (id, total) VALUES ('audit', $1)+    ON CONFLICT (id) DO UPDATE+      SET total = app.audit_totals.total + EXCLUDED.total+    """+    (E.param (E.nonNullable E.int8))+    D.noResult++applyAuditEntryStmt :: Statement (Int64, Int64) ()+applyAuditEntryStmt =+  preparable+    """+    INSERT INTO app.audit_entries (event_pos, value, live_applies)+    VALUES ($1, $2, 1)+    ON CONFLICT (event_pos) DO UPDATE+      SET live_applies = app.audit_entries.live_applies + 1+    """+    (contrazip2 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)))+    D.noResult++reconcileAuditEntryStmt :: Statement (Int64, Int64) ()+reconcileAuditEntryStmt =+  preparable+    """+    INSERT INTO app.audit_entries (event_pos, value, live_applies)+    VALUES ($1, $2, 1)+    ON CONFLICT (event_pos) DO NOTHING+    """+    (contrazip2 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)))+    D.noResult++setAuditCheckpointStmt :: Statement (Text, Int64) ()+setAuditCheckpointStmt =+  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++setAuditMemberCheckpointStmt :: Statement (Text, Int32, Int64) ()+setAuditMemberCheckpointStmt =+  preparable+    """+    UPDATE subscriptions+    SET last_seen = $3,+        updated_at = now()+    WHERE subscription_name = $1+      AND consumer_group_member = $2+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++auditTotalStmt :: Statement () Int64+auditTotalStmt =+  preparable+    "SELECT COALESCE((SELECT total FROM app.audit_totals WHERE id = 'audit'), 0)"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++auditEntryAppliesStmt :: Statement () [Int64]+auditEntryAppliesStmt =+  preparable+    "SELECT live_applies FROM app.audit_entries ORDER BY event_pos"+    E.noParams+    (D.rowList (D.column (D.nonNullable D.int8)))++auditCheckpointsStmt :: Statement () [Int64]+auditCheckpointsStmt =+  preparable+    """+    SELECT last_seen+    FROM subscriptions+    WHERE subscription_name = 'audit-subscription'+    ORDER BY consumer_group_member+    """+    E.noParams+    (D.rowList (D.column (D.nonNullable D.int8)))++auditDedupCountStmt :: Statement Text Int64+auditDedupCountStmt =+  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)))++ordersInsertStmt, customersInsertStmt, billingInsertStmt :: Statement (Int64, Int64) ()+ordersInsertStmt = targetInsertStmt "orders_projection"+customersInsertStmt = targetInsertStmt "customers_projection"+billingInsertStmt = targetInsertStmt "billing_projection"++targetInsertStmt :: Text -> Statement (Int64, Int64) ()+targetInsertStmt tableName =+  preparable+    ("INSERT INTO app." <> tableName <> " (global_position, value) VALUES ($1, $2)")+    (contrazip2 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)))+    D.noResult++tracePositionsStmt :: Statement () [Int64]+tracePositionsStmt =+  preparable+    "SELECT global_position FROM app.replay_trace ORDER BY sequence"+    E.noParams+    (D.rowList (D.column (D.nonNullable D.int8)))++pairTraceStmt :: Statement () [(Int64, Text)]+pairTraceStmt =+  preparable+    "SELECT global_position, projection FROM app.pair_trace ORDER BY sequence"+    E.noParams+    (D.rowList ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.text)))++ordersPositionsStmt :: Statement () [Int64]+ordersPositionsStmt =+  preparable+    "SELECT global_position FROM app.orders_projection ORDER BY global_position"+    E.noParams+    (D.rowList (D.column (D.nonNullable D.int8)))++deleteAdapterStmt :: Statement Text ()+deleteAdapterStmt =+  preparable+    """+    DELETE FROM keiro.keiro_projection_rebuild_adapters+    WHERE run_id = $1 AND projection_id = 'billing-projection'+    """+    (E.param (E.nonNullable E.text))+    D.noResult++setRunContractStmt :: Statement (Text, Text, Text) ()+setRunContractStmt =+  preparable+    """+    UPDATE keiro.keiro_projection_rebuild_runs+    SET contract_fingerprint = $2,+        runner_format = $3+    WHERE run_id = $1+    """+    ( contrazip3+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+    )+    D.noResult
+ test/ReadModelSpec.hs view
@@ -0,0 +1,83 @@+{-# OPTIONS_GHC -Wno-deprecations #-}++module ReadModelSpec+  ( spec,+  )+where++import Keiro.ReadModel+import Kiroku.Store.Types (GlobalPosition (..))+import Test.Hspec++spec :: Spec+spec = describe "truthful read-model construction" $ do+  it "builds an immediate inline model without exposing a durable cursor" $ do+    let readModel = immediateReadModel (blueprint NoQueryCursor)+    readModelCursorAuthority readModel `shouldBe` NoQueryCursor+    readModelDefaultFreshness readModel `shouldBe` Immediate++  it "allows an immediate model to retain a cursor for per-call waits" $ do+    let readModel = immediateReadModel (blueprint (DurableQueryCursor "query-cursor"))+    readModelCursorAuthority readModel+      `shouldBe` DurableQueryCursor "query-cursor"+    readModelDefaultFreshness readModel `shouldBe` Immediate++  it "rejects waiting defaults without a durable cursor" $ do+    case headWaitingReadModel EntireVisibleLog (blueprint NoQueryCursor) of+      Left err ->+        err+          `shouldBe` ReadModelDefinitionMissingCursor "truthful-model" (WaitForHead EntireVisibleLog)+      Right _ -> expectationFailure "expected missing-cursor failure"+    case positionWaitingReadModel concretePositionOptions (blueprint NoQueryCursor) of+      Left err ->+        err+          `shouldBe` ReadModelDefinitionMissingCursor "truthful-model" (WaitForPosition concretePositionOptions)+      Right _ -> expectationFailure "expected missing-cursor failure"++  it "rejects a position-waiting default without a concrete target" $ do+    case positionWaitingReadModel defaultHeadWaitOptions (blueprint (DurableQueryCursor "query-cursor")) of+      Left err -> err `shouldBe` ReadModelDefinitionMissingPosition "truthful-model"+      Right _ -> expectationFailure "expected missing-position failure"++  it "round-trips honest waiting defaults through the compatibility record" $ do+    case headWaitingReadModel+      (CategoryVisibleHead "orders")+      (blueprint (DurableQueryCursor "query-cursor")) of+      Right readModel ->+        readModelDefaultFreshness readModel+          `shouldBe` WaitForHead (CategoryVisibleHead "orders")+      Left err -> expectationFailure (show err)+    case positionWaitingReadModel+      concretePositionOptions+      (blueprint (DurableQueryCursor "query-cursor")) of+      Right readModel ->+        readModelDefaultFreshness readModel+          `shouldBe` WaitForPosition concretePositionOptions+      Left err -> expectationFailure (show err)++  it "normalizes the historical no-target position wait to immediate" $ do+    let legacy =+          (immediateReadModel (blueprint (DurableQueryCursor "query-cursor")))+            { defaultConsistency = PositionWait defaultHeadWaitOptions+            }+    readModelDefaultFreshness legacy `shouldBe` Immediate++blueprint :: QueryCursorAuthority -> ReadModelBlueprint () ()+blueprint authority =+  ReadModelBlueprint+    { name = "truthful-model",+      tableName = "truthful_model",+      schema = "public",+      version = 1,+      shapeHash = "truthful-model-v1",+      cursorAuthority = authority,+      query = \() -> pure ()+    }++concretePositionOptions :: PositionWaitOptions+concretePositionOptions =+  PositionWaitOptions+    { target = Just (GlobalPosition 42),+      timeoutMicros = 5000000,+      pollMicros = 10000+    }
+ test/VersionedRebuildSpec.hs view
@@ -0,0 +1,3066 @@+{-# LANGUAGE MultilineStrings #-}++module VersionedRebuildSpec+  ( spec,+  )+where++import CatalogSpec+  ( asyncProjectionId,+    auditTargetId,+    bridgeCatalog,+    bridgeRevisionV1,+    bridgeRevisionV2,+    catalogAsyncProjection,+    counterBinding,+    counterReadContract,+    counterTargetId,+    inlineProjectionId,+    mainGroupId,+  )+import Contravariant.Extras (contrazip2, contrazip3)+import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (isEmptyMVar, newEmptyMVar, putMVar, takeMVar)+import Control.Exception (bracket)+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString (ByteString)+import Data.ByteString qualified as ByteString+import Data.Functor.Contravariant ((>$<))+import Data.Int (Int32)+import Data.List qualified as List+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.Time (UTCTime (..), diffUTCTime, secondsToDiffTime)+import Data.Time.Calendar (Day (ModifiedJulianDay))+import Data.UUID qualified as UUID+import Data.UUID.V5 qualified as UUID.V5+import Data.Vector qualified as Vector+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Connection.Settings qualified as ConnectionSettings+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSessions+import Keiro.Connection (qualifyTable)+import Keiro.Prelude+import Keiro.Projection (CatalogAsyncApplyOutcome (..), applyAsyncProjectionFromCatalog)+import Keiro.Projection.Catalog+import Keiro.Projection.Catalog qualified as Catalog+import Keiro.Projection.Catalog.Operations qualified as CatalogOperations+import Keiro.ReadModel (ReadModel (..))+import Keiro.ReadModel.External (reconcileExternalReadContracts)+import Keiro.ReadModel.Rebuild+import Keiro.Test.Postgres (Fixture, withFreshDatabase, withFreshStore)+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError (..))+import Kiroku.Store.HistoryRetention+  ( HistoryRetentionLeaseRequest (..),+    HistoryRetentionRenewalError (..),+    mkHistoryRetentionLeaseDuration,+    mkHistoryRetentionLeaseOwner,+    mkHistoryRetentionLeaseReason,+  )+import Kiroku.Store.Transaction qualified as StoreTransaction+import Kiroku.Store.Types+  ( CategoryName (..),+    EventData (..),+    EventId (..),+    EventType (..),+    ExpectedVersion (..),+    GlobalPosition (..),+    RecordedEvent (..),+    StreamId (..),+    StreamName (..),+    StreamVersion (..),+  )+import Test.Hspec++spec :: Fixture -> Spec+spec fixture = do+  describe "schema-versioned rebuild lifecycle" $+    around (withFreshStore fixture) $ do+      it "persists serving and candidate generations and resumes the same run without reprovisioning" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        let request = versionedRequest "versioned-retry" physicalTargets++        first <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        second <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        second `shouldBe` first+        length (first ^. #candidateGenerations) `shouldBe` 2+        map (^. #lifecycle) (first ^. #candidateGenerations)+          `shouldBe` [GenerationStaging, GenerationStaging]+        runStatement store () activeLifecycleFactsStmt+          `shouldReturn` ("rebuilding-versioned", True, True, "counter-v1", 0, 2, 2, 1, 1)++        conflict <-+          expectStore store (beginVersionedRebuild catalog (request & #cutoverThreshold .~ 99))+        conflict+          `shouldSatisfy` \case+            Left (VersionedRunIdentityConflict _ detail) -> "threshold" `Text.isInfixOf` detail+            _ -> False+        dedupConflict <-+          expectStore store (beginVersionedRebuild catalog (request & #promotionDedupLimit .~ 99))+        dedupConflict+          `shouldSatisfy` \case+            Left (VersionedRunIdentityConflict _ detail) -> "dedup limit" `Text.isInfixOf` detail+            _ -> False++      it "rolls provisioner failures back with no run, lease, or untracked sibling" $ \store -> do+        setupBridge store+        (failingCatalog, physicalTargets) <-+          validatedBridgeFrom+            ( replaceCandidateProvisionerInCatalog+                counterTargetId+                ( \provisioner ->+                    provisioner+                      { provisionTarget = \targetContext -> do+                          createV2Counter targetContext+                          Tx.sql "SELECT 1 / 0"+                      }+                )+                runtimeBridgeCatalog+            )+        registerBridge store failingCatalog++        result <-+          Store.runStoreIO+            store+            (beginVersionedRebuild failingCatalog (versionedRequest "versioned-provision-failure" physicalTargets))+        result `shouldSatisfy` isLeft+        runStatement store () rolledBackLifecycleFactsStmt+          `shouldReturn` ("live", 0, 0, 0, 0)++      it "rolls typed schema-validation failure back after candidate DDL" $ \store -> do+        setupBridge store+        (failingCatalog, physicalTargets) <-+          validatedBridgeFrom+            ( replaceCandidateProvisionerInCatalog+                counterTargetId+                ( \provisioner ->+                    provisioner+                      { validateTarget =+                          Just+                            ( \_ ->+                                pure+                                  ( Left+                                      [TargetSchemaViolation "shape.counter-v2" "subtotal column was rejected by the application validator"]+                                  )+                            )+                      }+                )+                runtimeBridgeCatalog+            )+        registerBridge store failingCatalog++        result <-+          expectStore store (beginVersionedRebuild failingCatalog (versionedRequest "versioned-validation-failure" physicalTargets))+        result+          `shouldSatisfy` \case+            Left (VersionedSchemaValidationFailed targetId violations) ->+              targetId == counterTargetId && length violations == 1+            _ -> False+        runStatement store () rolledBackLifecycleFactsStmt+          `shouldReturn` ("live", 0, 0, 0, 0)++      it "uses the restricted clone path only for an exact-shape repair" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridgeFrom cloneBridgeCatalog+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-clone" 3+        let request =+              versionedRequest "versioned-clone" physicalTargets+                & #targetMode+                .~ RestrictedClone+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        final <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 12++        final ^. #phase `shouldBe` VersionedPromoted+        final ^. #servingRevisionId `shouldBe` identity mkProjectionRevisionId "counter-v2"+        runStatement store () servingCountsStmt `shouldReturn` (3, 3)+        runStatement store () cloneShapeStmt `shouldReturn` True++      it "remaps cloned primary-key and identity-sequence names during promotion" $ \store -> do+        runScript store identityBridgeSql+        (catalog, physicalTargets) <- validatedBridgeFrom identityCloneBridgeCatalog+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-identity-clone" 2+        let request =+              versionedRequest "versioned-identity-clone" physicalTargets+                & #targetMode+                .~ RestrictedClone+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        final <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 12++        final ^. #phase `shouldBe` VersionedPromoted+        runStatement store () identityCloneObjectsStmt `shouldReturn` True++      it "returns typed restricted-clone findings without leaving a candidate" $ \store -> do+        setupBridge store+        runScript store cloneTriggerSql+        (catalog, physicalTargets) <- validatedBridgeFrom cloneBridgeCatalog+        registerBridge store catalog+        let request =+              versionedRequest "versioned-clone-refused" physicalTargets+                & #targetMode+                .~ RestrictedClone++        refused <- expectStore store (beginVersionedRebuild catalog request)++        refused+          `shouldSatisfy` \case+            Left (VersionedCloneRefused targetId table findings) ->+              targetId == counterTargetId+                && table == QualifiedTable "app" "counter"+                && findings == ["triggers"]+            _ -> False+        runStatement store () rolledBackLifecycleFactsStmt+          `shouldReturn` ("live", 0, 0, 0, 0)++      it "refuses a deterministic staging-name collision and rolls earlier target work back" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        let runId = run "versioned-name-collision"+            collision = candidateTable runId counterTargetId+        runScript+          store+          ( Text.Encoding.encodeUtf8+              ( "CREATE TABLE "+                  <> qualifyTable (collision ^. #schemaName) (collision ^. #tableName)+                  <> " (sentinel bigint NOT NULL)"+              )+          )++        result <-+          expectStore store (beginVersionedRebuild catalog (rebuildRequestFor runId physicalTargets))+        collisionOid <- relationOidFor store collision+        result+          `shouldBe` Left (VersionedStagingNameCollision counterTargetId collision collisionOid)+        runStatement store () collisionRollbackFactsStmt+          `shouldReturn` ("live", 0, 0, 0, 1)++      it "abandons staging generations, releases retention, and repeats as a no-op" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        let request = versionedRequest "versioned-abandon" physicalTargets+        handle <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        first <- expectStore store (abandonVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+        second <- expectStore store (abandonVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight++        first ^. #alreadyAbandoned `shouldBe` False+        second ^. #alreadyAbandoned `shouldBe` True+        map (^. #lifecycle) (first ^. #droppedGenerations)+          `shouldBe` replicate 2 GenerationDropped+        for_ (handle ^. #candidateGenerations) $ \generation ->+          runStatement store (generation ^. #physicalTable) relationExistsStmt `shouldReturn` False+        runStatement store () abandonedLifecycleFactsStmt+          `shouldReturn` ("serving-versioned", True, True, "abandoned", 2, 1, 2)++      it "dispatches async writes through the persisted serving revision and fails closed when code is absent" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        let request = versionedRequest "versioned-dispatch" physicalTargets+        handle <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        legacyView <- expectStore store (lookupProjectionRebuildGroup mainGroupId)+        legacyView ^? _Just . #status+          `shouldBe` Just (UnknownGroupStatus "rebuilding-versioned")++        replayed <-+          expectStore+            store+            (applyVersionedReplayEvent catalog (request ^. #rebuildRunId) (recorded 99))+        replayed `shouldBe` Right 1+        verified <-+          expectStore+            store+            (verifyVersionedCandidate catalog (request ^. #rebuildRunId))+        verified `shouldBe` Right ()++        first <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog catalog asyncProjectionId catalogAsyncProjection (recorded 1))+            )+        first `shouldBe` CatalogAsyncApplied+        runStatement store () servingCountsStmt `shouldReturn` (0, 1)+        for_ (handle ^. #candidateGenerations) $ \generation ->+          rowCount store (generation ^. #physicalTable) `shouldReturn` 1++        runScript store promoteDispatchMetadataSql+        second <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog catalog asyncProjectionId catalogAsyncProjection (recorded 2))+            )+        second `shouldBe` CatalogAsyncApplied+        runStatement store () servingCountsStmt `shouldReturn` (0, 1)+        for_ (handle ^. #candidateGenerations) $ \generation ->+          rowCount store (generation ^. #physicalTable)+            `shouldReturn` if generation ^. #targetId == counterTargetId then 1 else 2++        (v1Only, _) <- validatedBridgeFrom runtimeV1OnlyCatalog+        missing <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog v1Only asyncProjectionId catalogAsyncProjection (recorded 3))+            )+        missing+          `shouldBe` CatalogAsyncServingRevisionUnavailable+            mainGroupId+            (identity mkProjectionRevisionId "counter-v2")+        runStatement store () dispatchDedupCountStmt `shouldReturn` 2+        runStatement store () servingCountsStmt `shouldReturn` (0, 1)+        for_ (handle ^. #candidateGenerations) $ \generation ->+          rowCount store (generation ^. #physicalTable)+            `shouldReturn` if generation ^. #targetId == counterTargetId then 1 else 2++      it "captures a durable final head and atomically promotes every target" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        let request = versionedRequest "versioned-promote" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        rebuildingStatus <- expectStore store (lookupProjectionGroupStatus mainGroupId)+        rebuildingStatus ^? _Just . #lifecyclePhase+          `shouldBe` Just "rebuilding-versioned"+        rebuildingStatus ^? _Just . #readsAllowed `shouldBe` Just True+        rebuildingStatus ^? _Just . #writesAllowed `shouldBe` Just True+        rebuildingStatus ^? _Just . #servingRevisionId+          `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v1"))+        rebuildingStatus ^? _Just . #servingEpoch `shouldBe` Just 0+        rebuildingStatus ^? _Just . #activeRunId+          `shouldBe` Just (Just (request ^. #rebuildRunId))+        rebuildingStatus ^? _Just . #candidateRevisionId+          `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v2"))+        rebuildingStatus ^? _Just . #candidateRebuildPosition+          `shouldBe` Just (Just (GlobalPosition 0))+        rebuildingStatus ^? _Just . #candidateRebuildHead+          `shouldBe` Just (Just (GlobalPosition 0))++        final <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10++        final ^. #phase `shouldBe` VersionedPromoted+        final ^. #servingRevisionId `shouldBe` identity mkProjectionRevisionId "counter-v2"+        final ^. #servingEpoch `shouldBe` 1+        map (^. #lifecycle) (final ^. #servingGenerations)+          `shouldBe` [GenerationServing, GenerationServing]+        runStatement store () promotedLifecycleFactsStmt+          `shouldReturn` ("serving-versioned", True, True, "counter-v2", 1, "promoted", 2, 2, 1)+        runStatement store () promotedCounterShapeStmt `shouldReturn` True+        promotedStatus <- expectStore store (lookupProjectionGroupStatus mainGroupId)+        promotedStatus ^? _Just . #lifecyclePhase+          `shouldBe` Just "serving-versioned"+        promotedStatus ^? _Just . #servingRevisionId+          `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v2"))+        promotedStatus ^? _Just . #servingEpoch `shouldBe` Just 1+        promotedStatus ^? _Just . #activeRunId `shouldBe` Just Nothing+        promotedStatus ^? _Just . #candidateRevisionId `shouldBe` Just Nothing+        promotedStatus ^? _Just . #candidateRebuildPosition `shouldBe` Just Nothing+        promotedStatus ^? _Just . #candidateRebuildHead `shouldBe` Just Nothing+        promotedStatus ^? _Just . #lastPromotedAt `shouldSatisfy` maybe False isJust++      it "keeps an additive all-row v1 contract on the old generation during replay and projects the promoted table to its stable result type" $ \store -> do+        setupExternalBridge store+        (catalog, physicalTargets) <- validatedBridgeFrom compatibleExternalReadCatalog+        registerBridge store catalog+        runScript store servingOnlyRowsSql+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-external-compatible" 2++        let request = versionedRequest "versioned-external-compatible" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        expectStore store (reconcileExternalReadContracts catalog) >>= requireRight+        runStatement store () externalV1RowsStmt `shouldReturn` [(100, 42)]+        ready <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+        ready ^. #phase `shouldBe` VersionedCutoverReplaying+        runStatement store () externalV1RowsStmt `shouldReturn` [(100, 42)]++        promoted <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+        promoted ^. #phase `shouldBe` VersionedPromoted+        promotedRows <- runStatement store () externalV1RowsStmt+        promotedRows `shouldSatisfy` (not . null)+        promotedRows `shouldSatisfy` (all ((== 10) . snd))+        promotedRows `shouldSatisfy` (all ((/= 100) . fst))++      it "activates a breaking v2 contract atomically and fails the old contract with KR003" $ \store -> do+        setupExternalBridge store+        (catalog, physicalTargets) <- validatedBridgeFrom breakingExternalReadCatalog+        registerBridge store catalog+        runScript store servingOnlyRowsSql+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-external-breaking" 2++        let request = versionedRequest "versioned-external-breaking" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        expectStore store (reconcileExternalReadContracts catalog) >>= requireRight+        runStatement store () externalV1RowsStmt `shouldReturn` [(100, 42)]+        _ <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+        runStatement store () externalV1RowsStmt `shouldReturn` [(100, 42)]++        promoted <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+        promoted ^. #phase `shouldBe` VersionedPromoted+        oldRead <- Store.runStoreIO store (Store.runTransaction (Tx.statement () externalV1RowsStmt))+        oldRead `shouldSatisfy` hasSqlState "KR003"+        newRows <- runStatement store () externalV2RowsStmt+        newRows `shouldSatisfy` (not . null)+        newRows `shouldSatisfy` (all (\(_, subtotal, tax, total) -> subtotal == 8 && tax == 2 && total == 10))++      it "keeps v1 current after a breaking promotion only through an explicit compatibility implementation" $ \store -> do+        setupExternalBridge store+        (catalog, physicalTargets) <- validatedBridgeFrom compatibilityImplementationCatalog+        registerBridge store catalog+        runScript store servingOnlyRowsSql+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-external-compatibility-implementation" 2++        let request = versionedRequest "versioned-external-compatibility-implementation" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        expectStore store (reconcileExternalReadContracts catalog) >>= requireRight+        _ <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10++        compatibleRows <- runStatement store () externalV1RowsStmt+        compatibleRows `shouldSatisfy` (not . null)+        compatibleRows `shouldSatisfy` (all ((== 10) . snd))+        v2Rows <- runStatement store () externalV2RowsStmt+        v2Rows `shouldSatisfy` (all (\(_, subtotal, tax, total) -> subtotal == 8 && tax == 2 && total == 10))++      it "converges across two replay rounds while live v1 stays serving and backfills async dedup" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-live" 4+        originalEvents <-+          expectStore store (Store.readCategory (CategoryName "counter") (GlobalPosition 0) 10)+        let request =+              versionedRequest "versioned-converge" physicalTargets+                & #cutoverThreshold+                .~ 0+        handle <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        first <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+        first ^. #phase `shouldBe` VersionedReplayRunning+        for_ (handle ^. #candidateGenerations) $ \generation ->+          rowCount store (generation ^. #physicalTable) `shouldReturn` 2++        live <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog catalog asyncProjectionId catalogAsyncProjection (recorded 101))+            )+        live `shouldBe` CatalogAsyncApplied+        runStatement store () servingCountsStmt `shouldReturn` (0, 1)+        for_ (handle ^. #candidateGenerations) $ \generation ->+          rowCount store (generation ^. #physicalTable) `shouldReturn` 2++        appendVersionedEvents store "counter-later" 2+        second <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+        allVersionedComplete (second ^. #sources) `shouldBe` True+        for_ (handle ^. #candidateGenerations) $ \generation ->+          rowCount store (generation ^. #physicalTable) `shouldReturn` 4++        extended <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+        extended ^. #capturedHead `shouldBe` GlobalPosition 6+        allVersionedComplete (extended ^. #sources) `shouldBe` False+        replayedAgain <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+        allVersionedComplete (replayedAgain ^. #sources) `shouldBe` True++        final <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+        final ^. #phase `shouldBe` VersionedPromoted+        runStatement store () servingCountsStmt `shouldReturn` (6, 6)+        runStatement store () convergeDedupCountStmt `shouldReturn` 7++        redelivered <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog catalog asyncProjectionId catalogAsyncProjection (Vector.head originalEvents))+            )+        redelivered `shouldBe` CatalogAsyncDuplicate+        runStatement store () servingCountsStmt `shouldReturn` (6, 6)++        (v1Only, _) <- validatedBridgeFrom runtimeV1OnlyCatalog+        refused <-+          expectStore+            store+            ( Store.runTransaction+                (applyAsyncProjectionFromCatalog v1Only asyncProjectionId catalogAsyncProjection (recorded 102))+            )+        refused+          `shouldBe` CatalogAsyncServingRevisionUnavailable+            mainGroupId+            (identity mkProjectionRevisionId "counter-v2")++      it "refuses oversized staged dedup evidence before fencing and succeeds after checkpoint lag is reduced" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        appendVersionedEvents store "counter-large-dedup-lag" 20+        let request =+              versionedRequest "versioned-dedup-limit" physicalTargets+                & #replayPageSize+                .~ 4+                & #cutoverThreshold+                .~ 0+                & #promotionDedupLimit+                .~ 5+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        replayed <- driveVersionedReplayComplete store catalog (request ^. #rebuildRunId) 10+        replayed ^. #stagedDedupCount `shouldBe` 20++        refused <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+        refused+          `shouldBe` Left+            (VersionedPromotionDedupLimitExceeded (request ^. #rebuildRunId) 5 20)+        unfenced <- expectStore store (inspectVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+        unfenced ^. #phase `shouldBe` VersionedReplayRunning+        unfenced ^. #dedupProvisionalHead `shouldBe` Nothing+        runStatement store () activeLifecycleFactsStmt+          `shouldReturn` ("rebuilding-versioned", True, True, "counter-v1", 0, 2, 2, 1, 1)++        runStatement store ("catalog-async-subscription", 20) upsertSubscriptionCursorStmt+        promoted <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+        promoted ^. #phase `shouldBe` VersionedPromoted+        promoted ^. #stagedDedupCount `shouldBe` 0+        runStatement store () servingCountsStmt `shouldReturn` (20, 20)++      it "uses the Kiroku lease to refuse hard deletion for the full active run" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        appendVersionedEvents store "counter-retained" 1+        let request = versionedRequest "versioned-retention" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++        blocked <- Store.runStoreIO store (Store.hardDeleteStream (StreamName "counter-retained"))+        blocked+          `shouldSatisfy` \case+            Left HistoryRetentionActive {} -> True+            _ -> False++        _ <- expectStore store (abandonVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+        deleted <- Store.runStoreIO store (Store.hardDeleteStream (StreamName "counter-retained"))+        deleted `shouldSatisfy` isRight++      it "fails closed when the original retention lease expires" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridge+        registerBridge store catalog+        let request =+              versionedRequest "versioned-expired-retention" physicalTargets+                & #retentionLeaseRequest+                . #duration+                .~ requireIdentity (mkHistoryRetentionLeaseDuration (secondsToDiffTime 1))+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        threadDelay 1_200_000++        renewal <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+        renewal+          `shouldSatisfy` \case+            Left (VersionedRetentionRenewalFailed runId HistoryRetentionRenewalExpired) ->+              runId == request ^. #rebuildRunId+            _ -> False+        failed <- expectStore store (inspectVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+        failed ^. #phase `shouldBe` VersionedFailed+        runStatement store () expiredRetentionFactsStmt+          `shouldReturn` ("failed-versioned", True, False, "failed", "retention.renewal-failed")++      it "revalidates candidate DDL under the cutover locks and resumes after repair" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridgeFrom raceBridgeCatalog+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        let request = versionedRequest "versioned-ddl-race" physicalTargets+        handle <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        ready <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+        ready ^. #phase `shouldBe` VersionedCutoverReplaying+        let counterCandidate =+              fromMaybe+                (error "counter candidate missing")+                (List.find ((== counterTargetId) . (^. #targetId)) (handle ^. #candidateGenerations))+        runScript+          store+          ( Text.Encoding.encodeUtf8+              ( "ALTER TABLE "+                  <> qualifyTable+                    (counterCandidate ^. #physicalTable . #schemaName)+                    (counterCandidate ^. #physicalTable . #tableName)+                  <> " ADD COLUMN rogue bigint"+              )+          )++        raced <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+        raced+          `shouldSatisfy` \case+            Left (VersionedObservedShapeMismatch targetId _ actual) ->+              targetId == counterTargetId && "rogue" `Text.isSuffixOf` actual+            _ -> False+        stillReady <- expectStore store (inspectVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+        stillReady ^. #phase `shouldBe` VersionedCutoverReplaying+        runStatement store () servingCountsStmt `shouldReturn` (0, 0)++        runScript+          store+          ( Text.Encoding.encodeUtf8+              ( "ALTER TABLE "+                  <> qualifyTable+                    (counterCandidate ^. #physicalTable . #schemaName)+                    (counterCandidate ^. #physicalTable . #tableName)+                  <> " DROP COLUMN rogue"+              )+          )+        repaired <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+        repaired ^. #phase `shouldBe` VersionedPromoted++      it "previews retired blockers and drops only an unreferenced generation" $ \store -> do+        setupExternalBridge store+        runScript store retiredReaderSql+        (catalog, physicalTargets) <- validatedBridgeFrom compatibleExternalReadCatalog+        registerBridge store catalog+        runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+        let request = versionedRequest "versioned-retired-drop" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+        _ <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+        retired <- expectStore store listVersionedRetiredGenerations+        length retired `shouldBe` 2+        let counterRetired =+              fromMaybe+                (error "retired counter generation missing")+                (List.find ((== counterTargetId) . (^. #targetId)) retired)++        contractBlocked <-+          expectStore store (previewVersionedRetiredDrop catalog (counterRetired ^. #generationId))+            >>= requireRight+        contractBlocked ^. #supportedReadContracts `shouldBe` ["counter_reader/v1"]+        contractBlocked ^. #droppable `shouldBe` False++        noContractCatalog <-+          fst <$> validatedBridgeFrom (runtimeBridgeCatalog {externalReadContracts = []})+        dependencyBlocked <-+          expectStore+            store+            (previewVersionedRetiredDrop noContractCatalog (counterRetired ^. #generationId))+            >>= requireRight+        dependencyBlocked ^. #supportedReadContracts `shouldBe` []+        dependencyBlocked ^. #externalDependencies `shouldSatisfy` (not . null)+        refused <-+          expectStore+            store+            (dropVersionedRetiredGeneration noContractCatalog (counterRetired ^. #generationId))+        refused+          `shouldSatisfy` \case+            Left (VersionedRetiredDropBlocked generationId blockers) ->+              generationId == counterRetired ^. #generationId+                && any ("postgres-dependency:" `Text.isPrefixOf`) blockers+            _ -> False++        runScript store "DROP VIEW app.retired_counter_reader"+        clear <-+          expectStore+            store+            (previewVersionedRetiredDrop noContractCatalog (counterRetired ^. #generationId))+            >>= requireRight+        clear ^. #droppable `shouldBe` True+        dropped <-+          expectStore+            store+            (dropVersionedRetiredGeneration noContractCatalog (counterRetired ^. #generationId))+            >>= requireRight+        dropped ^. #alreadyDropped `shouldBe` False+        runStatement store (counterRetired ^. #physicalTable) relationExistsStmt `shouldReturn` False+        secondDrop <-+          expectStore+            store+            (dropVersionedRetiredGeneration noContractCatalog (counterRetired ^. #generationId))+            >>= requireRight+        secondDrop ^. #alreadyDropped `shouldBe` True++  describe "schema-versioned cutover concurrency" $+    around (withFreshDatabase fixture) $ do+      it "bounds writer-fence row contention and remains unfenced" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupBridge store+            runScript store servingOnlyRowsSql+            (catalog, physicalTargets) <- validatedBridge+            registerBridge store catalog+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            let request =+                  versionedRequest "versioned-writer-fence-timeout" physicalTargets+                    & #cutoverLockTimeoutMs+                    .~ 100+            _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+            _ <- driveVersionedReplayComplete store catalog (request ^. #rebuildRunId) 5++            holderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          void (Tx.statement (rebuildGroupIdText mainGroupId) lockGroupRowStmt)+                          Tx.sql "SELECT pg_sleep(1)"+                      )+                  )+                  >>= putMVar holderDone+            waitForGroupRowLock pool 50++            startedAt <- getCurrentTime+            timedOut <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            finishedAt <- getCurrentTime+            timedOut+              `shouldBe` Right+                (Left (VersionedCutoverDeadlineExceeded (request ^. #rebuildRunId) "writer-fence"))+            diffUTCTime finishedAt startedAt `shouldSatisfy` (< 0.35)+            stillRunning <- expectStore store (inspectVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+            stillRunning ^. #phase `shouldBe` VersionedReplayRunning+            runStatement store () servingCountsStmt `shouldReturn` (1, 1)++            _ <- expectPoolUsage =<< takeMVar holderDone+            promoted <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+            promoted ^. #phase `shouldBe` VersionedPromoted++      it "bounds promotion group-row contention without undoing prepared evidence" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupBridge store+            runScript store servingOnlyRowsSql+            (catalog, physicalTargets) <- validatedBridge+            registerBridge store catalog+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            let request =+                  versionedRequest "versioned-promotion-row-timeout" physicalTargets+                    & #cutoverLockTimeoutMs+                    .~ 100+            _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+            _ <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+            prepared <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            prepared ^. #promotionPrepared `shouldBe` True+            safetyBefore <-+              (,)+                <$> runStatement store () repairDedupCountStmt+                <*> runStatement store () repairCheckpointStmt++            holderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          void (Tx.statement (rebuildGroupIdText mainGroupId) lockGroupRowStmt)+                          Tx.sql "SELECT pg_sleep(1)"+                      )+                  )+                  >>= putMVar holderDone+            waitForGroupRowLock pool 50++            startedAt <- getCurrentTime+            timedOut <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            finishedAt <- getCurrentTime+            timedOut+              `shouldBe` Right+                (Left (VersionedCutoverDeadlineExceeded (request ^. #rebuildRunId) "promotion-group"))+            diffUTCTime finishedAt startedAt `shouldSatisfy` (< 0.35)+            stillPrepared <- expectStore store (inspectVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+            stillPrepared ^. #promotionPrepared `shouldBe` True+            safetyAfter <-+              (,)+                <$> runStatement store () repairDedupCountStmt+                <*> runStatement store () repairCheckpointStmt+            safetyAfter `shouldBe` safetyBefore++            _ <- expectPoolUsage =<< takeMVar holderDone+            promoted <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            promoted ^. #phase `shouldBe` VersionedPromoted++      it "bounds two independently contended target relations, keeps readers on v1, and resumes promotion" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupBridge store+            runScript store servingOnlyRowsSql+            (catalog, physicalTargets) <- validatedBridge+            registerBridge store catalog+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            let request =+                  versionedRequest "versioned-lock-timeout" physicalTargets+                    & #cutoverLockTimeoutMs+                    .~ 500+            handle <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+            ready <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+            ready ^. #phase `shouldBe` VersionedCutoverReplaying+            prepared <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            prepared ^. #promotionPrepared `shouldBe` True+            safetyBefore <-+              (,)+                <$> runStatement store () repairDedupCountStmt+                <*> runStatement store () repairCheckpointStmt++            counterReaderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          count <- Tx.statement () counterCountStmt+                          Tx.sql "SELECT pg_sleep(0.3)"+                          pure count+                      )+                  )+                  >>= putMVar counterReaderDone+            waitForCounterReader pool 50++            auditReaderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          count <- Tx.statement () auditCountStmt+                          Tx.sql "SELECT pg_sleep(1)"+                          pure count+                      )+                  )+                  >>= putMVar auditReaderDone+            waitForAuditReader pool 50++            startedAt <- getCurrentTime+            timedOut <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            finishedAt <- getCurrentTime+            timedOut+              `shouldBe` Right+                (Left (VersionedCutoverDeadlineExceeded (request ^. #rebuildRunId) "target-relations"))+            diffUTCTime finishedAt startedAt `shouldSatisfy` (< 0.65)+            stillReady <- expectStore store (inspectVersionedRebuild (request ^. #rebuildRunId)) >>= requireRight+            stillReady ^. #phase `shouldBe` VersionedCutoverReplaying+            stillReady ^. #promotionPrepared `shouldBe` True+            safetyAfter <-+              (,)+                <$> runStatement store () repairDedupCountStmt+                <*> runStatement store () repairCheckpointStmt+            safetyAfter `shouldBe` safetyBefore+            counterReaderCount <- expectPoolUsage =<< takeMVar counterReaderDone+            counterReaderCount `shouldBe` 1+            auditReaderCount <- expectPoolUsage =<< takeMVar auditReaderDone+            auditReaderCount `shouldBe` 1+            runStatement store () servingCountsStmt `shouldReturn` (1, 1)+            for_ (handle ^. #candidateGenerations) $ \generation ->+              rowCount store (generation ^. #physicalTable) `shouldReturn` 0++            promoted <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            promoted ^. #phase `shouldBe` VersionedPromoted+            runStatement store () servingCountsStmt `shouldReturn` (0, 0)++      it "exposes only the old or new status tuple during a promotion transaction" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupBridge store+            (catalog, physicalTargets) <- validatedBridge+            registerBridge store catalog+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            let request = versionedRequest "versioned-status-atomicity" physicalTargets+            _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight++            beforePromotion <- expectStore store (lookupProjectionGroupStatus mainGroupId)+            beforePromotion ^? _Just . #lifecyclePhase `shouldBe` Just "rebuilding-versioned"+            beforePromotion ^? _Just . #servingRevisionId+              `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v1"))+            beforePromotion ^? _Just . #servingEpoch `shouldBe` Just 0+            beforePromotion ^? _Just . #activeRunId+              `shouldBe` Just (Just (request ^. #rebuildRunId))+            beforePromotion ^? _Just . #candidateRevisionId+              `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v2"))++            promoterDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      (Tx.sql statusPromotionUpdateAndSleepSql)+                  )+                  >>= putMVar promoterDone+            waitForGroupRowLock pool 50++            during <- expectStore store (lookupProjectionGroupStatus mainGroupId)+            during `shouldBe` beforePromotion++            _ <- expectPoolUsage =<< takeMVar promoterDone+            afterPromotion <- expectStore store (lookupProjectionGroupStatus mainGroupId)+            afterPromotion ^? _Just . #lifecyclePhase `shouldBe` Just "serving-versioned"+            afterPromotion ^? _Just . #servingRevisionId+              `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v2"))+            afterPromotion ^? _Just . #servingEpoch `shouldBe` Just 1+            afterPromotion ^? _Just . #activeRunId `shouldBe` Just Nothing+            afterPromotion ^? _Just . #candidateRevisionId `shouldBe` Just Nothing++      it "bounds promotion behind a slow guarded reader and resumes after it exits" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupExternalBridge store+            (catalog, physicalTargets) <- validatedBridgeFrom compatibleExternalReadCatalog+            registerBridge store catalog+            runScript store servingOnlyRowsSql+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            appendVersionedEvents store "versioned-external-slow-reader" 2+            let request =+                  versionedRequest "versioned-external-slow-reader" physicalTargets+                    & #cutoverLockTimeoutMs+                    .~ 100+            _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+            _ <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+            prepared <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            prepared ^. #promotionPrepared `shouldBe` True++            readerDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      ( do+                          Tx.sql "SET LOCAL application_name = 'keiro-slow-guarded-reader'"+                          rows <- Tx.statement () externalV1RowsStmt+                          Tx.sql "SELECT pg_sleep(1)"+                          pure rows+                      )+                  )+                  >>= putMVar readerDone+            waitForNamedSession pool "keiro-slow-guarded-reader" 50++            startedAt <- getCurrentTime+            timedOut <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            finishedAt <- getCurrentTime+            timedOut+              `shouldBe` Right+                (Left (VersionedCutoverDeadlineExceeded (request ^. #rebuildRunId) "promotion-group"))+            diffUTCTime finishedAt startedAt `shouldSatisfy` (< 0.35)+            oldRows <- expectPoolUsage =<< takeMVar readerDone+            oldRows `shouldBe` [(100, 42)]++            promoted <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            promoted ^. #phase `shouldBe` VersionedPromoted++      it "refuses a reader whose statement snapshot crosses promotion and succeeds on retry" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupExternalBridge store+            (catalog, physicalTargets) <- validatedBridgeFrom compatibleExternalReadCatalog+            registerBridge store catalog+            runScript store servingOnlyRowsSql+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            let request =+                  versionedRequest "versioned-external-late-reader" physicalTargets+                    & #cutoverLockTimeoutMs+                    .~ 2000+            _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+            _ <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+            prepared <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            prepared ^. #promotionPrepared `shouldBe` True++            targetHolderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Read+                      ( do+                          count <- Tx.statement () counterCountStmt+                          Tx.sql "SELECT pg_sleep(1)"+                          pure count+                      )+                  )+                  >>= putMVar targetHolderDone+            waitForCounterReader pool 50++            promoterDone <- newEmptyMVar+            _ <-+              forkIO+                ( Store.runStoreIO+                    store+                    (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+                    >>= putMVar promoterDone+                )+            waitForPromotionGroupLock pool 50++            lateReaderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      (Tx.statement () externalV1RowsStmt)+                  )+                  >>= putMVar lateReaderDone+            threadDelay 150_000+            isEmptyMVar lateReaderDone `shouldReturn` True++            _ <- expectPoolUsage =<< takeMVar targetHolderDone+            promotedResult <- takeMVar promoterDone+            promoted <-+              case promotedResult of+                Left err -> expectationFailure (show err) >> error "unreachable"+                Right result -> requireRight result+            promoted ^. #phase `shouldBe` VersionedPromoted+            crossedEpoch <- takeMVar lateReaderDone+            crossedEpoch `shouldSatisfy` hasSqlState "KR001"+            promotedRows <-+              expectPoolUsage+                =<< Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Write+                      (Tx.statement () externalV1RowsStmt)+                  )+            promotedRows `shouldBe` []+            runStatement store () servingCountsStmt `shouldReturn` (0, 0)++  describe "schema-versioned release fault injection" $+    around (withFreshDatabase fixture) $ do+      it "rolls every cutover boundary back to one v1 authority and resumes the same incompatible v2 promotion" $ \connectionString ->+        Store.withStore (Store.defaultConnectionSettings connectionString) $ \store ->+          withPool connectionString $ \pool -> do+            setupExternalBridge store+            (catalog, physicalTargets) <- validatedBridgeFrom breakingExternalReadCatalog+            registerBridge store catalog+            runScript store servingOnlyRowsSql+            runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+            appendVersionedEvents store "counter-release-faults" 2+            let request =+                  versionedRequest "versioned-release-faults" physicalTargets+                    & #cutoverLockTimeoutMs+                    .~ 100+            handle <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+            expectStore store (reconcileExternalReadContracts catalog) >>= requireRight+            captured <- driveVersionedToCutoverReady store catalog (request ^. #rebuildRunId) 10+            captured ^. #phase `shouldBe` VersionedCutoverReplaying+            captured ^. #capturedHead `shouldBe` GlobalPosition 2+            assertPreparedV1Authority store (request ^. #rebuildRunId) False++            prepared <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            prepared ^. #promotionPrepared `shouldBe` True+            assertPreparedV1Authority store (request ^. #rebuildRunId) True+            assertCandidateCounts store handle 2++            relationHolderDone <- newEmptyMVar+            _ <-+              forkIO $+                Pool.use+                  pool+                  ( TxSessions.transactionNoRetry+                      TxSessions.ReadCommitted+                      TxSessions.Read+                      ( do+                          count <- Tx.statement () counterCountStmt+                          Tx.sql "SELECT pg_sleep(1)"+                          pure count+                      )+                  )+                  >>= putMVar relationHolderDone+            waitForCounterReader pool 50+            relationRefusal <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            relationRefusal+              `shouldBe` Right+                (Left (VersionedCutoverDeadlineExceeded (request ^. #rebuildRunId) "target-relations"))+            _ <- expectPoolUsage =<< takeMVar relationHolderDone+            assertPreparedV1Authority store (request ^. #rebuildRunId) True+            assertCandidateCounts store handle 2++            runScript store failAfterFirstPromotionRenameSql+            renameFailure <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            renameFailure `shouldSatisfy` isLeft+            runScript store dropPromotionRenameFaultSql+            assertPreparedV1Authority store (request ^. #rebuildRunId) True+            assertCandidateCounts store handle 2++            runScript store failAfterPromotionMetadataSql+            metadataFailure <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            metadataFailure `shouldSatisfy` isLeft+            runScript store dropPromotionMetadataFaultSql+            assertPreparedV1Authority store (request ^. #rebuildRunId) True+            assertCandidateCounts store handle 2++            runScript store failAfterManagedWrapperSql+            wrapperFailure <- Store.runStoreIO store (resumeVersionedRebuild catalog (request ^. #rebuildRunId))+            wrapperFailure `shouldSatisfy` isLeft+            runScript store dropManagedWrapperFaultSql+            assertPreparedV1Authority store (request ^. #rebuildRunId) True+            assertCandidateCounts store handle 2++            promoted <- expectStore store (resumeVersionedRebuild catalog (request ^. #rebuildRunId)) >>= requireRight+            promoted ^. #phase `shouldBe` VersionedPromoted+            oldRead <- Store.runStoreIO store (Store.runTransaction (Tx.statement () externalV1RowsStmt))+            oldRead `shouldSatisfy` hasSqlState "KR003"+            runStatement store () servingCountsStmt `shouldReturn` (2, 2)+            newRows <- runStatement store () externalV2RowsStmt+            newRows `shouldSatisfy` (not . null)++  describe "targeted stream reprojection" $+    around (withFreshStore fixture) $ do+      it "repairs only the selected stream and transactionally backfills redelivery evidence" $ \store -> do+        catalog <- prepareStreamRepair store streamRepairCatalog+        appendRepairEvents store "counter-1" 1 [1, 2]+        appendRepairEvents store "counter-2" 2 [7]+        runStatement store () seedRepairRowsStmt+        let operations = CatalogOperations.projectionCatalogOperations catalog++        previewed <-+          expectStore store (CatalogOperations.previewStreamReprojection operations (streamRepairRequest "counter-1"))+            >>= requireRight+        previewed ^. #servingRevisionId `shouldBe` identity mkProjectionRevisionId "counter-v2"+        map (^. #targetId) (previewed ^. #targets) `shouldBe` [auditTargetId]+        map (^. #dedupKeyId) (previewed ^. #affectedDedup)+          `shouldBe` [identity mkDedupKeyId "counter-dedup"]+        previewed ^. #streamVersion `shouldBe` Just (StreamVersion 2)+        previewed ^. #eventCount `shouldBe` Just 2+        previewed ^. #expectedDedupClaims `shouldBe` Just 2+        previewed ^. #maxEvents `shouldBe` 1000+        previewed ^. #eligible `shouldBe` True+        previewed ^. #refusal `shouldBe` Nothing+        previewed ^. #forceOperation+          `shouldBe` "rebuild reproject-stream counter-group audit-owner counter-1 --page-size 1 --max-events 1000 --force"+        previewed ^. #reportSchema `shouldBe` "keiro/catalog-stream-reprojection-preview/v2"+        case Aeson.toJSON previewed of+          Aeson.Object fields -> do+            KeyMap.size fields `shouldBe` 16+            all+              (`KeyMap.member` fields)+              [ "schema",+                "groupId",+                "projectionId",+                "streamName",+                "servingRevisionId",+                "targets",+                "affectedDedup",+                "streamVersion",+                "eventCount",+                "expectedDedupClaims",+                "maxEvents",+                "softDeleted",+                "truncateBefore",+                "eligible",+                "refusal",+                "forceOperation"+              ]+              `shouldBe` True+          other -> expectationFailure ("expected stream-reprojection preview JSON object, got " <> show other)++        repaired <-+          expectStore store (CatalogOperations.reprojectCatalogStream operations (streamRepairRequest "counter-1"))+            >>= requireRight+        repaired ^. #reportSchema `shouldBe` "keiro/catalog-stream-reprojection-outcome/v2"+        case Aeson.toJSON repaired of+          Aeson.Object fields -> do+            KeyMap.size fields `shouldBe` 2+            all (`KeyMap.member` fields) ["schema", "repair"] `shouldBe` True+          other -> expectationFailure ("expected stream-reprojection outcome JSON object, got " <> show other)+        let repair = repaired ^. #repair++        repair ^. #servingRevisionId `shouldBe` identity mkProjectionRevisionId "counter-v2"+        repair ^. #streamVersion `shouldBe` StreamVersion 2+        repair ^. #maxEvents `shouldBe` 1000+        repair ^. #clearedRows `shouldBe` [StreamClearCount auditTargetId 1]+        repair ^. #replayedEvents `shouldBe` 2+        repair ^. #appliedEvents `shouldBe` 2+        repair ^. #dedupInserted `shouldBe` 2+        repair ^. #dedupExisting `shouldBe` 0+        runStatement store () repairRowsStmt `shouldReturn` [(1, "3"), (2, "7")]+        runStatement store () repairDedupCountStmt `shouldReturn` 2+        runStatement store () repairCheckpointStmt `shouldReturn` Just 0++        events <-+          expectStore store (Store.readStreamForward (StreamName "counter-1") (StreamVersion 0) 10)+        case Vector.toList events of+          firstEvent : _ -> do+            redelivery <-+              expectStore+                store+                ( Store.runTransaction+                    (applyAsyncProjectionFromCatalog catalog asyncProjectionId catalogAsyncProjection firstEvent)+                )+            redelivery `shouldBe` CatalogAsyncDuplicate+          [] -> expectationFailure "repair stream unexpectedly had no events"+        runStatement store () repairRowsStmt `shouldReturn` [(1, "3"), (2, "7")]++        secondRepair <-+          expectStore store (reprojectStream catalog (streamRepairRequest "counter-1"))+            >>= requireRight+        secondRepair ^. #dedupInserted `shouldBe` 0+        secondRepair ^. #dedupExisting `shouldBe` 2+        runStatement store () repairRowsStmt `shouldReturn` [(1, "3"), (2, "7")]++      it "refuses an oversized stream before taking the group-wide repair fence" $ \store -> do+        catalog <- prepareStreamRepair store streamRepairCatalog+        appendRepairEvents store "counter-1" 1 (replicate 101 1)+        runStatement store () seedSingleRepairRowStmt+        let request = streamRepairRequest "counter-1" & #maxEvents .~ 100+            operations = CatalogOperations.projectionCatalogOperations catalog++        previewed <-+          expectStore store (CatalogOperations.previewStreamReprojection operations request)+            >>= requireRight+        previewed ^. #eventCount `shouldBe` Just 101+        previewed ^. #expectedDedupClaims `shouldBe` Just 101+        previewed ^. #maxEvents `shouldBe` 100+        previewed ^. #eligible `shouldBe` False+        previewed ^. #refusal `shouldBe` Just "stream-event-limit-exceeded"++        holderDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO+              store+              ( Store.runTransaction $ do+                  Tx.sql "SET LOCAL application_name = 'keiro-repair-admission-group-holder'"+                  _ <- Tx.statement (rebuildGroupIdText mainGroupId) sanctionedRepairReaderLockStmt+                  Tx.sql "SELECT pg_sleep(0.75)"+              )+              >>= putMVar holderDone+        waitForNamedSession (store ^. #pool) "keiro-repair-admission-group-holder" 50++        startedAt <- getCurrentTime+        refused <- expectStore store (reprojectStream catalog request)+        finishedAt <- getCurrentTime+        refused+          `shouldBe` Left+            ( StreamReprojectionEventLimitExceeded+                (StreamName "counter-1")+                101+                100+            )+        diffUTCTime finishedAt startedAt `shouldSatisfy` (< 0.35)+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999")]+        runStatement store () repairDedupCountStmt `shouldReturn` 0+        holderResult <- takeMVar holderDone+        holderResult `shouldBe` Right ()++      it "rolls clear, replay, and dedup back when verification fails" $ \store -> do+        _ <- prepareStreamRepair store streamRepairCatalog+        appendRepairEvents store "counter-1" 1 [1, 2]+        runStatement store () seedSingleRepairRowStmt+        failingCatalog <- expectValidated (streamRepairCatalogWith (repairPolicy False True))++        failed <- expectStore store (reprojectStream failingCatalog (streamRepairRequest "counter-1"))+        failed+          `shouldSatisfy` \case+            Left (StreamReprojectionVerificationFailed "forced verification failure") -> True+            _ -> False+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999")]+        runStatement store () repairDedupCountStmt `shouldReturn` 0++      it "rolls back clearer and decode failures without dedup evidence" $ \store -> do+        catalog <- prepareStreamRepair store streamRepairCatalog+        appendRepairEvents store "counter-1" 1 [1]+        runStatement store () seedSingleRepairRowStmt++        clearerCatalog <- expectValidated (streamRepairCatalogWith clearFailureRepairPolicy)+        clearerFailed <- expectStore store (reprojectStream clearerCatalog (streamRepairRequest "counter-1"))+        clearerFailed `shouldBe` Left (StreamReprojectionClearFailed "forced clearer failure")+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999")]+        runStatement store () repairDedupCountStmt `shouldReturn` 0++        appendMalformedRepairEvent store "counter-1"+        decodeFailed <- expectStore store (reprojectStream catalog (streamRepairRequest "counter-1"))+        decodeFailed+          `shouldSatisfy` \case+            Left (StreamReprojectionDecodeFailed (StreamVersion 2) _) -> True+            _ -> False+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999")]+        runStatement store () repairDedupCountStmt `shouldReturn` 0++      it "refuses truncated and soft-deleted streams before target mutation" $ \store -> do+        catalog <- prepareStreamRepair store streamRepairCatalog+        appendRepairEvents store "counter-1" 1 [1]+        appendRepairEvents store "counter-2" 2 [7]+        runStatement store () seedRepairRowsStmt+        _ <- expectStore store (Store.setStreamTruncateBefore (StreamName "counter-1") (StreamVersion 1))+        _ <- expectStore store (Store.softDeleteStream (StreamName "counter-2"))++        truncated <- expectStore store (reprojectStream catalog (streamRepairRequest "counter-1"))+        truncated+          `shouldSatisfy` \case+            Left (StreamReprojectionTruncated (StreamName "counter-1") (StreamVersion 1)) -> True+            _ -> False+        deleted <- expectStore store (reprojectStream catalog (streamRepairRequest "counter-2"))+        deleted+          `shouldSatisfy` \case+            Left (StreamReprojectionSoftDeleted (StreamName "counter-2")) -> True+            _ -> False+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999"), (2, "7")]+        runStatement store () repairDedupCountStmt `shouldReturn` 0++      it "refuses an active rebuild and an unavailable persisted serving revision" $ \store -> do+        setupBridge store+        (catalog, physicalTargets) <- validatedBridgeFrom streamRepairCatalog+        registerBridge store catalog+        appendRepairEvents store "counter-1" 1 [1]+        let activeRequest = versionedRequest "stream-repair-active-rebuild" physicalTargets+        _ <- expectStore store (beginVersionedRebuild catalog activeRequest) >>= requireRight+        active <- expectStore store (reprojectStream catalog (streamRepairRequest "counter-1"))+        active+          `shouldBe` Left (StreamReprojectionActiveRebuild mainGroupId (activeRequest ^. #rebuildRunId))++        abandoned <- expectStore store (abandonVersionedRebuild (activeRequest ^. #rebuildRunId))+        abandoned `shouldSatisfy` isRight+        unavailableCatalog <-+          expectValidated+            ( streamRepairCatalog+                & #projectionRevisions+                %~ filter+                  (\revision -> revision ^. #revisionId /= identity mkProjectionRevisionId "counter-v1")+            )+        unavailableSlice <-+          maybe+            (expectationFailure "unavailable-revision catalog has no group slice" >> error "unreachable")+            pure+            (Catalog.groupSliceFingerprint unavailableCatalog mainGroupId)+        runStatement store (groupSliceFingerprintText unavailableSlice) forceGroupSliceStmt+        missing <- expectStore store (reprojectStream unavailableCatalog (streamRepairRequest "counter-1"))+        missing+          `shouldBe` Left+            ( StreamReprojectionServingRevisionUnavailable+                mainGroupId+                (identity mkProjectionRevisionId "counter-v1")+            )++      it "waits for an append-first catalog writer and replays its committed event" $ \store -> do+        catalog <- prepareStreamRepair store streamRepairCatalog+        appendRepairEvents store "counter-1" 1 [1]+        appendRepairEvents store "counter-2" 2 [7]+        runStatement store () seedRepairRowsStmt+        prepared <-+          StoreTransaction.prepareEventsIO+            [ EventData+                { eventId = Nothing,+                  eventType = EventType "RepairDelta",+                  payload = Aeson.toJSON ([1, 5] :: [Int64]),+                  metadata = Nothing,+                  causationId = Nothing,+                  correlationId = Nothing+                }+            ]+        now <- getCurrentTime++        writerDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO+              store+              ( Store.runTransaction $ do+                  appended <-+                    StoreTransaction.appendToStreamTx+                      (StreamName "counter-1")+                      (ExactVersion (StreamVersion 1))+                      prepared+                      now+                  case appended of+                    Left conflict -> Tx.condemn >> pure (Left conflict)+                    Right result -> do+                      Tx.sql "SELECT pg_sleep(0.75)"+                      _ <- Tx.statement (rebuildGroupIdText mainGroupId) sanctionedRepairReaderLockStmt+                      pure (Right result)+              )+              >>= putMVar writerDone+        threadDelay 150_000++        repairDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO store (reprojectStream catalog (streamRepairRequest "counter-1"))+              >>= putMVar repairDone+        threadDelay 150_000+        isEmptyMVar repairDone `shouldReturn` True++        writerResult <- takeMVar writerDone+        writerResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False+        repairResult <- takeMVar repairDone+        case repairResult of+          Right (Right report) -> report ^. #streamVersion `shouldBe` StreamVersion 2+          other -> expectationFailure ("append-first repair failed: " <> show other)+        runStatement store () repairRowsStmt `shouldReturn` [(1, "6"), (2, "7")]+        runStatement store () repairDedupCountStmt `shouldReturn` 2++      it "holds the group writer fence until one-stream repair commits" $ \store -> do+        _ <- prepareStreamRepair store streamRepairCatalog+        slowCatalog <- expectValidated (streamRepairCatalogWith (repairPolicy True False))+        appendRepairEvents store "counter-1" 1 [1]+        appendRepairEvents store "counter-2" 2 [7]+        runStatement store () seedRepairRowsStmt+        secondStream <-+          expectStore store (Store.readStreamForward (StreamName "counter-2") (StreamVersion 0) 10)+        writerEvent <-+          case Vector.toList secondStream of+            event : _ -> pure event+            [] -> expectationFailure "writer stream unexpectedly empty" >> error "unreachable"++        repairDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO store (reprojectStream slowCatalog (streamRepairRequest "counter-1"))+              >>= putMVar repairDone+        threadDelay 150_000++        readerDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO+              store+              ( Store.runTransaction $ do+                  _ <- Tx.statement (rebuildGroupIdText mainGroupId) sanctionedRepairReaderLockStmt+                  Tx.statement (1 :: Int64) repairDetailByIdStmt+              )+              >>= putMVar readerDone++        writerDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO+              store+              ( Store.runTransaction+                  (applyAsyncProjectionFromCatalog slowCatalog asyncProjectionId catalogAsyncProjection writerEvent)+              )+              >>= putMVar writerDone+        threadDelay 150_000+        isEmptyMVar writerDone `shouldReturn` True+        isEmptyMVar readerDone `shouldReturn` True+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999"), (2, "7")]++        repairResult <- takeMVar repairDone+        repairResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False+        readerResult <- takeMVar readerDone+        readerResult `shouldBe` Right "1"+        writerResult <- takeMVar writerDone+        writerResult `shouldBe` Right CatalogAsyncApplied++      it "serializes hard deletion behind the stream guard" $ \store -> do+        _ <- prepareStreamRepair store streamRepairCatalog+        slowCatalog <- expectValidated (streamRepairCatalogWith (repairPolicy True False))+        appendRepairEvents store "counter-1" 1 [1]+        runStatement store () seedSingleRepairRowStmt++        repairDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO store (reprojectStream slowCatalog (streamRepairRequest "counter-1"))+              >>= putMVar repairDone+        _ <- waitForApplicationPid (store ^. #pool) "keiro-stream-repair-after-clear" 50++        deleteDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO store (Store.hardDeleteStream (StreamName "counter-1"))+              >>= putMVar deleteDone+        threadDelay 150_000+        isEmptyMVar deleteDone `shouldReturn` True++        repairResult <- takeMVar repairDone+        repairResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False+        deleted <- takeMVar deleteDone+        deleted `shouldSatisfy` \case Right (Just _) -> True; _ -> False+        runStatement store () repairRowsStmt `shouldReturn` [(1, "1")]+        missing <- expectStore store (reprojectStream slowCatalog (streamRepairRequest "counter-1"))+        missing+          `shouldSatisfy` \case+            Left (StreamReprojectionHistoryUnavailable _) -> True+            _ -> False++      it "rolls target and dedup changes back when the repair backend is interrupted" $ \store -> do+        _ <- prepareStreamRepair store streamRepairCatalog+        slowCatalog <- expectValidated (streamRepairCatalogWith (repairPolicy True False))+        appendRepairEvents store "counter-1" 1 [1]+        runStatement store () seedSingleRepairRowStmt++        repairDone <- newEmptyMVar+        _ <-+          forkIO $+            Store.runStoreIO store (reprojectStream slowCatalog (streamRepairRequest "counter-1"))+              >>= putMVar repairDone+        backendPid <- waitForApplicationPid (store ^. #pool) "keiro-stream-repair-after-clear" 50+        terminated <-+          expectPoolUsage+            =<< Pool.use (store ^. #pool) (Session.statement backendPid terminateBackendStmt)+        terminated `shouldBe` True++        interrupted <- takeMVar repairDone+        interrupted `shouldSatisfy` isLeft+        runStatement store () repairRowsStmt `shouldReturn` [(1, "999")]+        runStatement store () repairDedupCountStmt `shouldReturn` 0++validatedBridge :: IO (ValidatedProjectionCatalog, PhysicalTargets)+validatedBridge = validatedBridgeFrom runtimeBridgeCatalog++validatedBridgeFrom :: ProjectionCatalog -> IO (ValidatedProjectionCatalog, PhysicalTargets)+validatedBridgeFrom catalogDefinition = do+  catalog <-+    case validateProjectionCatalog catalogDefinition of+      Failure diagnostics -> expectationFailure (show diagnostics) >> error "unreachable"+      Success validated -> pure validated+  physicalTargets <-+    case mkPhysicalTargets+      [counterTargetId, auditTargetId]+      ( Map.fromList+          [ (counterTargetId, QualifiedTable "app" "counter"),+            (auditTargetId, QualifiedTable "app" "counter_audit")+          ]+      ) of+      Left errors -> expectationFailure (show errors) >> error "unreachable"+      Right targets -> pure targets+  pure (catalog, physicalTargets)++runtimeBridgeCatalog :: ProjectionCatalog+runtimeBridgeCatalog =+  bridgeCatalog+    { projectionRevisions =+        [ runtimeRevision "v1" bridgeRevisionV1,+          runtimeRevision "v2" bridgeRevisionV2+        ],+      externalReadContracts = []+    }++streamRepairCatalog :: ProjectionCatalog+streamRepairCatalog = streamRepairCatalogWith (repairPolicy False False)++streamRepairCatalogWith :: StreamScopedReplay -> ProjectionCatalog+streamRepairCatalogWith policy =+  runtimeBridgeCatalog+    { projectionRevisions =+        [ revision & #streamScopedReplays .~ [policy]+        | revision <- runtimeBridgeCatalog ^. #projectionRevisions+        ]+    }++repairPolicy :: Bool -> Bool -> StreamScopedReplay+repairPolicy pauseAfterClear failVerification =+  StreamScopedReplay+    { streamProjectionId = asyncProjectionId,+      streamOwnedTargets = auditTargetId :| [],+      clearerId = "runtime-audit/clear-stream",+      clearerVersion = 1,+      clearStreamRows = \physicalTargets streamName ->+        case repairAggregateId streamName of+          Left detail -> pure (Left detail)+          Right aggregateId -> do+            let auditTable = requireTargetFrom physicalTargets auditTargetId+            cleared <- Tx.statement aggregateId (deleteRepairRowStmt auditTable)+            when pauseAfterClear $ do+              Tx.sql "SET LOCAL application_name = 'keiro-stream-repair-after-clear'"+              Tx.sql "SELECT pg_sleep(0.75)"+            pure (Right [StreamClearCount auditTargetId cleared]),+      streamReplayId = "runtime-audit/replay-stream",+      streamReplayVersion = 1,+      replayStreamEvent = \physicalTargets repairRecorded ->+        case Aeson.fromJSON (repairRecorded ^. #payload) :: Aeson.Result [Int64] of+          Aeson.Success [aggregateId, delta] -> do+            let auditTable = requireTargetFrom physicalTargets auditTargetId+                StreamVersion sourceVersion = repairRecorded ^. #streamVersion+            Tx.statement (aggregateId, delta, sourceVersion) (upsertRepairRowStmt auditTable)+            pure (Right True)+          Aeson.Success _ -> pure (Left (ReplayDecodeError "repair payload must contain aggregate id and delta"))+          Aeson.Error detail -> pure (Left (ReplayDecodeError (Text.pack detail))),+      streamVerificationId = "runtime-audit/verify-stream",+      streamVerificationVersion = 1,+      verifyStreamRows = \physicalTargets streamName ->+        if failVerification+          then pure (Left "forced verification failure")+          else case repairAggregateId streamName of+            Left detail -> pure (Left detail)+            Right aggregateId -> do+              let auditTable = requireTargetFrom physicalTargets auditTargetId+              rows <- Tx.statement aggregateId (countRepairRowsStmt auditTable)+              pure+                ( if rows == 1+                    then Right ()+                    else Left "repair must leave exactly one aggregate row"+                ),+      affectedAsyncDedup = [identity mkDedupKeyId "counter-dedup"],+      claimSite = identity mkClaimSite "versioned:runtime-audit-stream-repair"+    }++clearFailureRepairPolicy :: StreamScopedReplay+clearFailureRepairPolicy =+  (repairPolicy False False)+    { clearStreamRows = \_ _ -> pure (Left "forced clearer failure")+    }++prepareStreamRepair :: Store.KirokuStore -> ProjectionCatalog -> IO ValidatedProjectionCatalog+prepareStreamRepair store catalogDefinition = do+  setupBridge store+  (catalog, physicalTargets) <- validatedBridgeFrom catalogDefinition+  registerBridge store catalog+  runStatement store ("catalog-async-subscription", 0) upsertSubscriptionCursorStmt+  let request = versionedRequest "stream-repair-bootstrap" physicalTargets+  _ <- expectStore store (beginVersionedRebuild catalog request) >>= requireRight+  promoted <- driveVersionedToPromotion store catalog (request ^. #rebuildRunId) 10+  promoted ^. #phase `shouldBe` VersionedPromoted+  pure catalog++expectValidated :: ProjectionCatalog -> IO ValidatedProjectionCatalog+expectValidated catalogDefinition =+  case validateProjectionCatalog catalogDefinition of+    Failure diagnostics -> expectationFailure (show diagnostics) >> error "unreachable"+    Success catalog -> pure catalog++streamRepairRequest :: Text -> StreamReprojectionRequest+streamRepairRequest stream =+  StreamReprojectionRequest+    { rebuildGroupId = mainGroupId,+      projectionId = asyncProjectionId,+      streamName = StreamName stream,+      pageSize = 1,+      maxEvents = 1000+    }++repairAggregateId :: StreamName -> Either Text Int64+repairAggregateId = \case+  StreamName "counter-1" -> Right 1+  StreamName "counter-2" -> Right 2+  StreamName name -> Left ("unexpected repair stream: " <> name)++requireTargetFrom :: PhysicalTargets -> TargetId -> QualifiedTable+requireTargetFrom physicalTargets targetId =+  fromMaybe+    (error ("missing repair target: " <> Text.unpack (targetIdText targetId)))+    (resolvePhysicalTarget targetId physicalTargets)++compatibleExternalReadCatalog :: ProjectionCatalog+compatibleExternalReadCatalog =+  runtimeBridgeCatalog+    { externalReadContracts = [counterReadContract]+    }++breakingExternalReadCatalog :: ProjectionCatalog+breakingExternalReadCatalog = externalReadCatalogWith counterV1BreakingContract++compatibilityImplementationCatalog :: ProjectionCatalog+compatibilityImplementationCatalog = externalReadCatalogWith counterV1CompatibilityContract++externalReadCatalogWith :: ExternalReadContract -> ProjectionCatalog+externalReadCatalogWith v1Contract =+  runtimeBridgeCatalog+    { queryModels = runtimeBridgeCatalog ^. #queryModels <> [SomeQueryModelBinding counterV2Binding],+      externalReadContracts = [v1Contract, counterV2Contract]+    }++counterV2Binding :: QueryModelBinding Text ()+counterV2Binding =+  counterBinding+    { queryModelId = identity mkQueryModelId "catalog-counter-query-v2",+      readModel =+        (counterBinding ^. #readModel)+          { name = "catalog-counter-query-v2",+            version = 2,+            shapeHash = "catalog-counter-query-v2"+          }+    }++counterV1BreakingContract :: ExternalReadContract+counterV1BreakingContract =+  counterReadContract+    & #compatibleRevisions+    .~ (identity mkProjectionRevisionId "counter-v1" :| [])++counterV1CompatibilityContract :: ExternalReadContract+counterV1CompatibilityContract =+  KeyedExternalRead+    { readContractId = counterReadContract ^. #readContractId,+      contractVersion = counterReadContract ^. #contractVersion,+      queryModelId = counterReadContract ^. #queryModelId,+      arguments = [],+      resultContractType = counterReadContract ^. #resultContractType,+      privateImplementation = QualifiedFunction "app_private" "counter_v1_compat",+      privateImplementationVersion = 1,+      resultShapeHash = counterReadContract ^. #resultShapeHash,+      compatibleRevisions =+        identity mkProjectionRevisionId "counter-v1"+          :| [identity mkProjectionRevisionId "counter-v2"],+      surfaceGeneration = 1,+      claimSite = identity mkClaimSite "versioned:counter-v1-compatibility"+    }++counterV2Contract :: ExternalReadContract+counterV2Contract =+  AllRowsExternalRead+    { readContractId = counterReadContract ^. #readContractId,+      contractVersion = ExternalReadContractVersion 2,+      queryModelId = identity mkQueryModelId "catalog-counter-query-v2",+      resultContractType = QualifiedSqlType "app_contract" "counter_row_v2",+      resultShapeHash = "catalog-counter-query-v2",+      compatibleRevisions = identity mkProjectionRevisionId "counter-v2" :| [],+      surfaceGeneration = 2,+      claimSite = identity mkClaimSite "versioned:counter-reader-v2"+    }++runtimeV1OnlyCatalog :: ProjectionCatalog+runtimeV1OnlyCatalog =+  runtimeBridgeCatalog+    { projectionRevisions = [runtimeRevision "v1" bridgeRevisionV1],+      externalReadContracts = []+    }++cloneBridgeCatalog :: ProjectionCatalog+cloneBridgeCatalog =+  runtimeBridgeCatalog+    { projectionRevisions =+        [ runtimeRevision "v1" bridgeRevisionV1,+          runtimeRevision "v1" bridgeRevisionV2+        ]+    }++identityCloneBridgeCatalog :: ProjectionCatalog+identityCloneBridgeCatalog =+  cloneBridgeCatalog+    { projectionRevisions =+        [ revision+            & #targetProvisioners+            %~ Map.adjust identityCloneProvisioner counterTargetId+        | revision <- cloneBridgeCatalog ^. #projectionRevisions+        ]+    }++identityCloneProvisioner :: TargetProvisioner -> TargetProvisioner+identityCloneProvisioner provisioner =+  provisioner+    { validateTarget = Just (validateRuntimeTarget counterTargetId "v1" identityCloneObjects),+      promotionObjectNames = identityCloneObjects+    }++identityCloneObjects :: [PromotionObjectName]+identityCloneObjects =+  [ PromotionObjectName PromotionConstraint "counter_pkey__clone" "counter_pkey",+    PromotionObjectName PromotionOwnedSequence "counter_id_seq__clone" "counter_id_seq"+  ]++raceBridgeCatalog :: ProjectionCatalog+raceBridgeCatalog =+  replaceCandidateProvisionerInCatalog+    counterTargetId+    (\provisioner -> provisioner {validateTarget = Just validateRaceCounter})+    runtimeBridgeCatalog++validateRaceCounter ::+  TargetProvisioningContext ->+  Tx.Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence)+validateRaceCounter targetContext = do+  baseline <- validateRuntimeTarget counterTargetId "v2" promotionObjects targetContext+  rogue <- Tx.statement (targetContext ^. #stagingTable) rogueColumnStmt+  pure $+    baseline <&> \evidence ->+      if rogue+        then evidence & #observedShapeFingerprint %~ (<> ":rogue")+        else evidence+  where+    promotionObjects =+      [PromotionObjectName PromotionIndex "counter_total_idx__v2" "counter_total_idx"]++runtimeRevision :: Text -> ProjectionRevision -> ProjectionRevision+runtimeRevision schema revision =+  revision+    & #targetProvisioners+    .~ Map.fromList+      [ (counterTargetId, counterProvisioner schema),+        (auditTargetId, auditProvisioner schema)+      ]+    & #liveHandlers+    .~ [ RevisionLiveHandler+           ("runtime-inline-live-" <> schema)+           1+           (RevisionInlineDelivery inlineProjectionId "catalog-inline")+           [counterTargetId]+           (applyRuntimeCounter schema),+         RevisionLiveHandler+           ("runtime-async-live-" <> schema)+           1+           ( RevisionSubscriptionDelivery+               asyncProjectionId+               (identity mkSubscriptionId "counter-subscription")+               (identity mkDedupKeyId "counter-dedup")+           )+           [auditTargetId]+           (applyRuntimeAudit schema)+       ]+    & #replayAdapters+    .~ [ RevisionReplayAdapter+           ("runtime-replay-" <> schema)+           1+           [counterTargetId, auditTargetId]+           ( \physicalTargets event -> do+               applyRuntimeLive schema physicalTargets event+               pure (Right True)+           )+       ]++applyRuntimeLive :: Text -> PhysicalTargets -> RecordedEvent -> Tx.Transaction ()+applyRuntimeLive schema physicalTargets event = do+  applyRuntimeCounter schema physicalTargets event+  applyRuntimeAudit schema physicalTargets event++applyRuntimeCounter :: Text -> PhysicalTargets -> RecordedEvent -> Tx.Transaction ()+applyRuntimeCounter schema physicalTargets event = do+  let counterTable = requireTarget counterTargetId+      eventPosition = positionValue (event ^. #globalPosition)+      counterQualified = qualifyTable (counterTable ^. #schemaName) (counterTable ^. #tableName)+  if schema == "v1"+    then+      Tx.sql+        ( Text.Encoding.encodeUtf8+            ( "INSERT INTO "+                <> counterQualified+                <> " (id, total) VALUES ("+                <> Text.pack (show eventPosition)+                <> ", 10)"+            )+        )+    else+      Tx.sql+        ( Text.Encoding.encodeUtf8+            ( "INSERT INTO "+                <> counterQualified+                <> " (id, subtotal, tax) VALUES ("+                <> Text.pack (show eventPosition)+                <> ", 8, 2)"+            )+        )+  where+    requireTarget targetId =+      fromMaybe+        (error ("runtime live handler missing target " <> show targetId))+        (resolvePhysicalTarget targetId physicalTargets)++applyRuntimeAudit :: Text -> PhysicalTargets -> RecordedEvent -> Tx.Transaction ()+applyRuntimeAudit schema physicalTargets event = do+  let auditTable =+        fromMaybe+          (error "runtime async live handler missing audit target")+          (resolvePhysicalTarget auditTargetId physicalTargets)+      eventPosition = positionValue (event ^. #globalPosition)+      auditQualified = qualifyTable (auditTable ^. #schemaName) (auditTable ^. #tableName)+  if schema == "v1"+    then+      Tx.sql+        ( Text.Encoding.encodeUtf8+            ( "INSERT INTO "+                <> auditQualified+                <> " (id, detail) VALUES ("+                <> Text.pack (show eventPosition)+                <> ", 'v1')"+            )+        )+    else+      Tx.sql+        ( Text.Encoding.encodeUtf8+            ( "INSERT INTO "+                <> auditQualified+                <> " (id, detail, source_position) VALUES ("+                <> Text.pack (show eventPosition)+                <> ", 'v2', "+                <> Text.pack (show eventPosition)+                <> ")"+            )+        )++positionValue :: GlobalPosition -> Int64+positionValue (GlobalPosition value) = value++recorded :: Int64 -> RecordedEvent+recorded value =+  RecordedEvent+    { eventId = EventId (UUID.fromWords64 7 (fromIntegral value)),+      eventType = EventType "VersionedDispatch",+      streamVersion = StreamVersion value,+      globalPosition = GlobalPosition value,+      originalStreamId = StreamId value,+      originalVersion = StreamVersion value,+      payload = Aeson.Null,+      metadata = Just (Aeson.object []),+      causationId = Nothing,+      correlationId = Nothing,+      createdAt = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)+    }++counterProvisioner :: Text -> TargetProvisioner+counterProvisioner schema =+  TargetProvisioner+    { provisionerId = "runtime-counter-" <> schema,+      provisionerVersion = 1,+      schemaVersion = TargetSchemaVersion schema,+      expectedShapeId = "runtime-counter-shape-" <> schema,+      provisionTarget =+        if schema == "v1"+          then \_ -> pure ()+          else createV2Counter,+      validatorId = "runtime-counter-validator-" <> schema,+      validatorVersion = 1,+      validateTarget = Just (validateRuntimeTarget counterTargetId schema promotionObjects),+      promotionObjectNames = promotionObjects+    }+  where+    promotionObjects =+      [PromotionObjectName PromotionIndex "counter_total_idx__v2" "counter_total_idx" | schema == "v2"]++auditProvisioner :: Text -> TargetProvisioner+auditProvisioner schema =+  TargetProvisioner+    { provisionerId = "runtime-audit-" <> schema,+      provisionerVersion = 1,+      schemaVersion = TargetSchemaVersion schema,+      expectedShapeId = "runtime-audit-shape-" <> schema,+      provisionTarget =+        if schema == "v1"+          then \_ -> pure ()+          else createV2Audit,+      validatorId = "runtime-audit-validator-" <> schema,+      validatorVersion = 1,+      validateTarget = Just (validateRuntimeTarget auditTargetId schema []),+      promotionObjectNames = []+    }++validateRuntimeTarget ::+  TargetId ->+  Text ->+  [PromotionObjectName] ->+  TargetProvisioningContext ->+  Tx.Transaction (Either [TargetSchemaViolation] TargetSchemaEvidence)+validateRuntimeTarget targetId schema promotionObjects targetContext = do+  maybeOid <- Tx.statement (targetContext ^. #stagingTable) relationOidStmt+  pure $ case maybeOid of+    Nothing -> Left [TargetSchemaViolation "relation.missing" (targetIdText targetId)]+    Just oid ->+      Right+        TargetSchemaEvidence+          { relationOid = oid,+            observedShapeFingerprint = "runtime-" <> targetIdText targetId <> "-shape-" <> schema,+            observedPromotionObjects = promotionObjects,+            catalogSnapshot = "runtime-catalog-snapshot-" <> schema+          }++createV2Counter :: TargetProvisioningContext -> Tx.Transaction ()+createV2Counter targetContext = do+  let table = targetContext ^. #stagingTable+      qualified = qualifyTable (table ^. #schemaName) (table ^. #tableName)+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "CREATE TABLE "+            <> qualified+            <> " (id bigint PRIMARY KEY, subtotal bigint NOT NULL, tax bigint NOT NULL, total bigint GENERATED ALWAYS AS (subtotal + tax) STORED)"+        )+    )+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "CREATE INDEX \"counter_total_idx__v2\" ON "+            <> qualified+            <> " (total)"+        )+    )++createV2Audit :: TargetProvisioningContext -> Tx.Transaction ()+createV2Audit targetContext = do+  let table = targetContext ^. #stagingTable+  Tx.sql+    ( Text.Encoding.encodeUtf8+        ( "CREATE TABLE "+            <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+            <> " (id bigint PRIMARY KEY, detail text NOT NULL, source_position bigint NOT NULL)"+        )+    )++replaceCandidateProvisionerInCatalog ::+  TargetId ->+  (TargetProvisioner -> TargetProvisioner) ->+  ProjectionCatalog ->+  ProjectionCatalog+replaceCandidateProvisionerInCatalog targetId update catalog =+  catalog+    { projectionRevisions =+        [ if revision ^. #revisionId == identity mkProjectionRevisionId "counter-v2"+            then revision & #targetProvisioners %~ Map.adjust update targetId+            else revision+        | revision <- catalog ^. #projectionRevisions+        ]+    }++registerBridge :: Store.KirokuStore -> ValidatedProjectionCatalog -> IO ()+registerBridge store catalog = do+  result <- expectStore store (registerProjectionCatalog catalog)+  case result of+    Left err -> expectationFailure (show err)+    Right _ -> pure ()++versionedRequest :: Text -> PhysicalTargets -> VersionedRebuildRequest+versionedRequest identityText = rebuildRequestFor (run identityText)++rebuildRequestFor :: RebuildRunId -> PhysicalTargets -> VersionedRebuildRequest+rebuildRequestFor runId physicalTargets =+  VersionedRebuildRequest+    { rebuildRunId = runId,+      rebuildGroupId = mainGroupId,+      servingRevisionId = identity mkProjectionRevisionId "counter-v1",+      candidateRevisionId = identity mkProjectionRevisionId "counter-v2",+      servingTargets = physicalTargets,+      targetMode = ApplicationProvisioned,+      replayPageSize = 2,+      cutoverThreshold = 10,+      cutoverLockTimeoutMs = 2_000,+      promotionDedupLimit = 1_000_000,+      retentionLeaseRequest = retentionRequest runId,+      requestedBy = "versioned-rebuild-spec",+      requestReason = "exercise M3 lifecycle persistence"+    }++retentionRequest :: RebuildRunId -> HistoryRetentionLeaseRequest+retentionRequest runId =+  HistoryRetentionLeaseRequest+    { owner = requireIdentity (mkHistoryRetentionLeaseOwner ("keiro-rebuild/" <> rebuildRunIdText runId)),+      reason = requireIdentity (mkHistoryRetentionLeaseReason "schema-versioned projection rebuild"),+      duration = requireIdentity (mkHistoryRetentionLeaseDuration (secondsToDiffTime 600))+    }++candidateTable :: RebuildRunId -> TargetId -> QualifiedTable+candidateTable runId targetId =+  QualifiedTable "app" ("keiro_g_" <> Text.filter (/= '-') (UUID.toText generationId))+  where+    generationId =+      UUID.V5.generateNamed+        UUID.V5.namespaceURL+        ( ByteString.unpack+            ( Text.Encoding.encodeUtf8+                ( Text.intercalate+                    "\NUL"+                    [ "keiro/versioned-candidate-generation/v1",+                      rebuildRunIdText runId,+                      rebuildGroupIdText mainGroupId,+                      targetIdText targetId+                    ]+                )+            )+        )++setupBridge :: Store.KirokuStore -> IO ()+setupBridge store = runScript store bridgeSql++setupExternalBridge :: Store.KirokuStore -> IO ()+setupExternalBridge store = runScript store externalBridgeSql++bridgeSql :: ByteString+bridgeSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (+    id bigint PRIMARY KEY,+    total bigint NOT NULL+  );+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    detail text NOT NULL+  );+  """++externalBridgeSql :: ByteString+externalBridgeSql =+  bridgeSql+    <> """+       CREATE SCHEMA app_contract;+       CREATE TYPE app_contract.counter_row_v1 AS (id bigint, total bigint);+       CREATE TYPE app_contract.counter_row_v2 AS+         (id bigint, subtotal bigint, tax bigint, total bigint);+       CREATE SCHEMA app_private;+       CREATE FUNCTION app_private.counter_v1_compat()+       RETURNS SETOF app_contract.counter_row_v1+       LANGUAGE plpgsql+       STABLE+       AS $compatibility$+       BEGIN+         RETURN QUERY EXECUTE+           'SELECT counter.id, counter.total FROM app.counter AS counter ORDER BY counter.id';+       END+       $compatibility$;+       """++identityBridgeSql :: ByteString+identityBridgeSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (+    id bigint GENERATED BY DEFAULT AS IDENTITY,+    total bigint NOT NULL,+    CONSTRAINT counter_pkey PRIMARY KEY (id)+  );+  CREATE TABLE app.counter_audit (+    id bigint PRIMARY KEY,+    detail text NOT NULL+  );+  """++cloneTriggerSql :: ByteString+cloneTriggerSql =+  """+  CREATE FUNCTION app.clone_refusal_trigger() RETURNS trigger+  LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;+  CREATE TRIGGER clone_refusal+    BEFORE INSERT ON app.counter+    FOR EACH ROW EXECUTE FUNCTION app.clone_refusal_trigger();+  """++retiredReaderSql :: ByteString+retiredReaderSql =+  "CREATE VIEW app.retired_counter_reader AS SELECT id, total FROM app.counter"++servingOnlyRowsSql :: ByteString+servingOnlyRowsSql =+  """+  INSERT INTO app.counter (id, total) VALUES (100, 42);+  INSERT INTO app.counter_audit (id, detail) VALUES (100, 'serving-v1');+  """++failAfterFirstPromotionRenameSql :: ByteString+failAfterFirstPromotionRenameSql =+  """+  CREATE FUNCTION public.keiro_test_fail_after_promotion_rename()+  RETURNS event_trigger+  LANGUAGE plpgsql+  AS $$+  BEGIN+    RAISE EXCEPTION 'injected failure after a promotion rename';+  END+  $$;+  CREATE EVENT TRIGGER keiro_test_fail_after_promotion_rename+    ON ddl_command_end+    WHEN TAG IN ('ALTER TABLE')+    EXECUTE FUNCTION public.keiro_test_fail_after_promotion_rename();+  """++dropPromotionRenameFaultSql :: ByteString+dropPromotionRenameFaultSql =+  """+  DROP EVENT TRIGGER keiro_test_fail_after_promotion_rename;+  DROP FUNCTION public.keiro_test_fail_after_promotion_rename();+  """++failAfterPromotionMetadataSql :: ByteString+failAfterPromotionMetadataSql =+  """+  CREATE FUNCTION public.keiro_test_fail_after_promotion_metadata()+  RETURNS trigger+  LANGUAGE plpgsql+  AS $$+  BEGIN+    RAISE EXCEPTION 'injected failure after the promotion metadata transition';+  END+  $$;+  CREATE TRIGGER keiro_test_fail_after_promotion_metadata+    AFTER UPDATE ON keiro.keiro_projection_rebuild_groups+    FOR EACH ROW+    WHEN (OLD.status = 'cutover-versioned' AND NEW.status = 'serving-versioned')+    EXECUTE FUNCTION public.keiro_test_fail_after_promotion_metadata();+  """++dropPromotionMetadataFaultSql :: ByteString+dropPromotionMetadataFaultSql =+  """+  DROP TRIGGER keiro_test_fail_after_promotion_metadata+    ON keiro.keiro_projection_rebuild_groups;+  DROP FUNCTION public.keiro_test_fail_after_promotion_metadata();+  """++failAfterManagedWrapperSql :: ByteString+failAfterManagedWrapperSql =+  """+  CREATE FUNCTION public.keiro_test_fail_after_managed_wrapper()+  RETURNS trigger+  LANGUAGE plpgsql+  AS $$+  BEGIN+    RAISE EXCEPTION 'injected failure after managed wrapper reconciliation';+  END+  $$;+  CREATE TRIGGER keiro_test_fail_after_managed_wrapper+    AFTER INSERT OR UPDATE ON keiro.keiro_managed_read_objects+    FOR EACH ROW+    WHEN (NEW.object_kind = 'wrapper-function')+    EXECUTE FUNCTION public.keiro_test_fail_after_managed_wrapper();+  """++dropManagedWrapperFaultSql :: ByteString+dropManagedWrapperFaultSql =+  """+  DROP TRIGGER keiro_test_fail_after_managed_wrapper+    ON keiro.keiro_managed_read_objects;+  DROP FUNCTION public.keiro_test_fail_after_managed_wrapper();+  """++promoteDispatchMetadataSql :: ByteString+promoteDispatchMetadataSql =+  """+  UPDATE keiro.keiro_projection_target_generations+  SET lifecycle = 'retired', retired_at = now()+  WHERE group_id = 'counter-group' AND lifecycle = 'serving';++  UPDATE keiro.keiro_projection_target_generations AS generations+  SET lifecycle = 'serving', served_at = now()+  FROM keiro.keiro_projection_rebuild_run_targets AS targets+  WHERE targets.run_id = 'versioned-dispatch'+    AND targets.candidate_generation_id = generations.generation_id+    AND generations.lifecycle = 'staging';++  UPDATE keiro.keiro_projection_rebuild_runs+  SET status = 'promoted', history_retention_released_at = now(), updated_at = now()+  WHERE run_id = 'versioned-dispatch';++  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'serving-versioned',+      active_run_id = NULL,+      serving_revision_id = 'counter-v2',+      serving_epoch = serving_epoch + 1,+      reads_allowed = TRUE,+      writes_allowed = TRUE,+      completed_at = now(),+      updated_at = now()+  WHERE group_id = 'counter-group';+  """++runScript :: Store.KirokuStore -> ByteString -> IO ()+runScript store sql = expectStore store (Store.runTransaction (Tx.sql sql))++runStatement :: Store.KirokuStore -> params -> Statement params result -> IO result+runStatement store params statement =+  expectStore store (Store.runTransaction (Tx.statement params statement))++withPool :: Text -> (Pool.Pool -> IO a) -> IO a+withPool connectionString =+  bracket+    ( Pool.acquire $+        PoolConfig.settings+          [ PoolConfig.staticConnectionSettings (ConnectionSettings.connectionString connectionString),+            PoolConfig.size 4+          ]+    )+    Pool.release++expectPoolUsage :: (Show error) => Either error value -> IO value+expectPoolUsage = \case+  Left err -> expectationFailure ("database action failed: " <> show err) >> error "unreachable"+  Right value -> pure value++waitForCounterReader :: Pool.Pool -> Int -> IO ()+waitForCounterReader _ 0 = expectationFailure "reader did not acquire ACCESS SHARE in time"+waitForCounterReader pool remaining = do+  locked <-+    expectPoolUsage+      =<< Pool.use pool (TxSessions.transactionNoRetry TxSessions.ReadCommitted TxSessions.Read (Tx.statement () counterAccessShareStmt))+  if locked+    then pure ()+    else threadDelay 20_000 >> waitForCounterReader pool (remaining - 1)++waitForAuditReader :: Pool.Pool -> Int -> IO ()+waitForAuditReader _ 0 = expectationFailure "audit reader did not acquire ACCESS SHARE in time"+waitForAuditReader pool remaining = do+  locked <-+    expectPoolUsage+      =<< Pool.use pool (TxSessions.transactionNoRetry TxSessions.ReadCommitted TxSessions.Read (Tx.statement () auditAccessShareStmt))+  if locked+    then pure ()+    else threadDelay 20_000 >> waitForAuditReader pool (remaining - 1)++waitForGroupRowLock :: Pool.Pool -> Int -> IO ()+waitForGroupRowLock _ 0 = expectationFailure "group row holder did not acquire its lock in time"+waitForGroupRowLock pool remaining = do+  locked <-+    expectPoolUsage+      =<< Pool.use pool (TxSessions.transactionNoRetry TxSessions.ReadCommitted TxSessions.Read (Tx.statement () groupRowShareLockStmt))+  if locked+    then pure ()+    else threadDelay 20_000 >> waitForGroupRowLock pool (remaining - 1)++waitForPromotionGroupLock :: Pool.Pool -> Int -> IO ()+waitForPromotionGroupLock _ 0 = expectationFailure "promotion did not acquire its group lock in time"+waitForPromotionGroupLock pool remaining = do+  locked <-+    expectPoolUsage+      =<< Pool.use pool (Session.statement () promotionGroupLockStmt)+  if locked+    then pure ()+    else threadDelay 20_000 >> waitForPromotionGroupLock pool (remaining - 1)++waitForNamedSession :: Pool.Pool -> Text -> Int -> IO ()+waitForNamedSession _ _ 0 = expectationFailure "named session did not acquire the group share lock in time"+waitForNamedSession pool applicationName remaining = do+  locked <- expectPoolUsage =<< Pool.use pool (Session.statement applicationName namedSessionGroupShareStmt)+  if locked+    then pure ()+    else threadDelay 20_000 >> waitForNamedSession pool applicationName (remaining - 1)++waitForApplicationPid :: Pool.Pool -> Text -> Int -> IO Int32+waitForApplicationPid _ _ 0 = expectationFailure "named repair session did not become active in time" >> error "unreachable"+waitForApplicationPid pool applicationName remaining = do+  maybePid <- expectPoolUsage =<< Pool.use pool (Session.statement applicationName applicationPidStmt)+  case maybePid of+    Just pid -> pure pid+    Nothing -> threadDelay 20_000 >> waitForApplicationPid pool applicationName (remaining - 1)++expectStore ::+  Store.KirokuStore ->+  Eff '[Store, Error StoreError, IOE] value ->+  IO value+expectStore store action =+  Store.runStoreIO store action >>= \case+    Left err -> expectationFailure (show err) >> error "unreachable"+    Right value -> pure value++requireRight :: (Show error) => Either error value -> IO value+requireRight = \case+  Left err -> expectationFailure (show err) >> error "unreachable"+  Right value -> pure value++hasSqlState :: (Show error) => String -> Either error value -> Bool+hasSqlState wanted = \case+  Left err -> wanted `List.isInfixOf` show err+  Right _ -> False++requireIdentity :: (Show error) => Either error value -> value+requireIdentity = either (error . show) id++identity :: (Text -> Either error value) -> Text -> value+identity constructor = either (error . const "invalid test identity") id . constructor++run :: Text -> RebuildRunId+run = either (error . Text.unpack) id . mkRebuildRunId++driveVersionedToPromotion ::+  Store.KirokuStore ->+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Int ->+  IO VersionedRebuildReport+driveVersionedToPromotion store catalog runId attempts+  | attempts <= 0 = expectationFailure "versioned rebuild did not promote" >> error "unreachable"+  | otherwise = do+      report <- expectStore store (resumeVersionedRebuild catalog runId) >>= requireRight+      if report ^. #phase == VersionedPromoted+        then pure report+        else driveVersionedToPromotion store catalog runId (attempts - 1)++driveVersionedToCutoverReady ::+  Store.KirokuStore ->+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Int ->+  IO VersionedRebuildReport+driveVersionedToCutoverReady store catalog runId attempts+  | attempts <= 0 = expectationFailure "versioned rebuild did not reach cutover" >> error "unreachable"+  | otherwise = do+      report <- expectStore store (resumeVersionedRebuild catalog runId) >>= requireRight+      if report ^. #phase == VersionedCutoverReplaying && allVersionedComplete (report ^. #sources)+        then pure report+        else driveVersionedToCutoverReady store catalog runId (attempts - 1)++driveVersionedReplayComplete ::+  Store.KirokuStore ->+  ValidatedProjectionCatalog ->+  RebuildRunId ->+  Int ->+  IO VersionedRebuildReport+driveVersionedReplayComplete store catalog runId attempts+  | attempts <= 0 = expectationFailure "versioned rebuild did not complete ordinary replay" >> error "unreachable"+  | otherwise = do+      report <- expectStore store (resumeVersionedRebuild catalog runId) >>= requireRight+      if report ^. #phase == VersionedReplayRunning && allVersionedComplete (report ^. #sources)+        then pure report+        else driveVersionedReplayComplete store catalog runId (attempts - 1)++assertPreparedV1Authority :: Store.KirokuStore -> RebuildRunId -> Bool -> IO ()+assertPreparedV1Authority store runId expectedPrepared = do+  report <- expectStore store (inspectVersionedRebuild runId) >>= requireRight+  report ^. #servingRevisionId `shouldBe` identity mkProjectionRevisionId "counter-v1"+  report ^. #servingEpoch `shouldBe` 0+  report ^. #promotionPrepared `shouldBe` expectedPrepared+  status <- expectStore store (lookupProjectionGroupStatus mainGroupId)+  status ^? _Just . #lifecyclePhase `shouldBe` Just "cutover-versioned"+  status ^? _Just . #servingRevisionId+    `shouldBe` Just (Just (identity mkProjectionRevisionId "counter-v1"))+  status ^? _Just . #servingEpoch `shouldBe` Just 0+  runStatement store () externalV1RowsStmt `shouldReturn` [(100, 42)]+  runStatement store () servingCountsStmt `shouldReturn` (1, 1)++assertCandidateCounts :: Store.KirokuStore -> VersionedRebuildHandle -> Int64 -> IO ()+assertCandidateCounts store handle expected =+  for_ (handle ^. #candidateGenerations) $ \generation ->+    rowCount store (generation ^. #physicalTable) `shouldReturn` expected++allVersionedComplete :: [VersionedSourceProgress] -> Bool+allVersionedComplete =+  all (\source -> source ^. #exhaustedThrough == Just (source ^. #targetPosition))++appendVersionedEvents :: Store.KirokuStore -> Text -> Int -> IO ()+appendVersionedEvents store streamName count = do+  appended <-+    Store.runStoreIO store $+      Store.appendToStream+        (StreamName streamName)+        NoStream+        [ EventData+            { eventId = Nothing,+              eventType = EventType "VersionedDispatch",+              payload = Aeson.Null,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | _ <- [1 .. count]+        ]+  appended `shouldSatisfy` isRight++appendRepairEvents :: Store.KirokuStore -> Text -> Int64 -> [Int64] -> IO ()+appendRepairEvents store streamName aggregateId deltas = do+  appended <-+    Store.runStoreIO store $+      Store.appendToStream+        (StreamName streamName)+        NoStream+        [ EventData+            { eventId = Nothing,+              eventType = EventType "RepairDelta",+              payload = Aeson.toJSON [aggregateId, delta],+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | delta <- deltas+        ]+  appended `shouldSatisfy` isRight++appendMalformedRepairEvent :: Store.KirokuStore -> Text -> IO ()+appendMalformedRepairEvent store streamName = do+  appended <-+    Store.runStoreIO store $+      Store.appendToStream+        (StreamName streamName)+        AnyVersion+        [ EventData+            { eventId = Nothing,+              eventType = EventType "RepairDelta",+              payload = Aeson.object ["malformed" Aeson..= True],+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        ]+  appended `shouldSatisfy` isRight++deleteRepairRowStmt :: QualifiedTable -> Statement Int64 Int64+deleteRepairRowStmt table =+  preparable+    ( "DELETE FROM "+        <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+        <> " WHERE id = $1"+    )+    (E.param (E.nonNullable E.int8))+    D.rowsAffected++upsertRepairRowStmt :: QualifiedTable -> Statement (Int64, Int64, Int64) ()+upsertRepairRowStmt table =+  preparable+    ( "INSERT INTO "+        <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+        <> " AS current_row"+        <> " (id, detail, source_position) VALUES ($1, $2::text, $3) "+        <> "ON CONFLICT (id) DO UPDATE SET "+        <> "detail = (current_row.detail::bigint + $2)::text, source_position = $3"+    )+    ( contrazip3+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++countRepairRowsStmt :: QualifiedTable -> Statement Int64 Int64+countRepairRowsStmt table =+  preparable+    ( "SELECT count(*) FROM "+        <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+        <> " WHERE id = $1"+    )+    (E.param (E.nonNullable E.int8))+    (D.singleRow (D.column (D.nonNullable D.int8)))++seedRepairRowsStmt :: Statement () ()+seedRepairRowsStmt =+  preparable+    """+    INSERT INTO app.counter_audit (id, detail, source_position)+    VALUES (1, '999', 99), (2, '7', 1)+    """+    E.noParams+    D.noResult++seedSingleRepairRowStmt :: Statement () ()+seedSingleRepairRowStmt =+  preparable+    """+    INSERT INTO app.counter_audit (id, detail, source_position)+    VALUES (1, '999', 99)+    """+    E.noParams+    D.noResult++repairRowsStmt :: Statement () [(Int64, Text)]+repairRowsStmt =+  preparable+    "SELECT id, detail FROM app.counter_audit ORDER BY id"+    E.noParams+    ( D.rowList+        ( (,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.text)+        )+    )++sanctionedRepairReaderLockStmt :: Statement Text Text+sanctionedRepairReaderLockStmt =+  preparable+    "SELECT group_id FROM keiro.keiro_projection_rebuild_groups WHERE group_id = $1 AND reads_allowed FOR SHARE"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.text)))++repairDetailByIdStmt :: Statement Int64 Text+repairDetailByIdStmt =+  preparable+    "SELECT detail FROM app.counter_audit WHERE id = $1"+    (E.param (E.nonNullable E.int8))+    (D.singleRow (D.column (D.nonNullable D.text)))++repairDedupCountStmt :: Statement () Int64+repairDedupCountStmt =+  preparable+    "SELECT count(*) FROM keiro.keiro_projection_dedup WHERE projection_name = 'catalog-async'"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++repairCheckpointStmt :: Statement () (Maybe Int64)+repairCheckpointStmt =+  preparable+    """+    SELECT checkpoint_position+    FROM kiroku.subscription_checkpoints_v1+    WHERE subscription_name = 'catalog-async-subscription'+    """+    E.noParams+    (D.rowMaybe (D.column (D.nonNullable D.int8)))++forceGroupSliceStmt :: Statement Text ()+forceGroupSliceStmt =+  preparable+    "UPDATE keiro.keiro_projection_rebuild_groups SET slice_fingerprint = $1 WHERE group_id = 'counter-group'"+    (E.param (E.nonNullable E.text))+    D.noResult++relationOidFor :: Store.KirokuStore -> QualifiedTable -> IO Int64+relationOidFor store table = do+  result <- runStatement store table relationOidStmt+  maybe (expectationFailure "relation missing" >> error "unreachable") pure result++relationOidStmt :: Statement QualifiedTable (Maybe Int64)+relationOidStmt =+  preparable+    """+    SELECT classes.oid::bigint+    FROM pg_catalog.pg_class AS classes+    JOIN pg_catalog.pg_namespace AS namespaces+      ON namespaces.oid = classes.relnamespace+    WHERE namespaces.nspname = $1 AND classes.relname = $2+    """+    ( (\table -> (table ^. #schemaName, table ^. #tableName))+        >$< contrazip2+          (E.param (E.nonNullable E.text))+          (E.param (E.nonNullable E.text))+    )+    (D.rowMaybe (D.column (D.nonNullable D.int8)))++relationExistsStmt :: Statement QualifiedTable Bool+relationExistsStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_catalog.pg_class AS classes+      JOIN pg_catalog.pg_namespace AS namespaces+        ON namespaces.oid = classes.relnamespace+      WHERE namespaces.nspname = $1 AND classes.relname = $2+    )+    """+    ( (\table -> (table ^. #schemaName, table ^. #tableName))+        >$< contrazip2+          (E.param (E.nonNullable E.text))+          (E.param (E.nonNullable E.text))+    )+    (D.singleRow (D.column (D.nonNullable D.bool)))++rogueColumnStmt :: Statement QualifiedTable Bool+rogueColumnStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM information_schema.columns+      WHERE table_schema = $1 AND table_name = $2 AND column_name = 'rogue'+    )+    """+    ( (\table -> (table ^. #schemaName, table ^. #tableName))+        >$< contrazip2+          (E.param (E.nonNullable E.text))+          (E.param (E.nonNullable E.text))+    )+    (D.singleRow (D.column (D.nonNullable D.bool)))++rowCount :: Store.KirokuStore -> QualifiedTable -> IO Int64+rowCount store table =+  runStatement store () $+    preparable+      ( "SELECT count(*) FROM "+          <> qualifyTable (table ^. #schemaName) (table ^. #tableName)+      )+      E.noParams+      (D.singleRow (D.column (D.nonNullable D.int8)))++servingCountsStmt :: Statement () (Int64, Int64)+servingCountsStmt =+  preparable+    """+    SELECT (SELECT count(*) FROM app.counter),+           (SELECT count(*) FROM app.counter_audit)+    """+    E.noParams+    ( D.singleRow+        ( (,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+        )+    )++counterCountStmt :: Statement () Int64+counterCountStmt =+  preparable+    "SELECT count(*) FROM app.counter"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++auditCountStmt :: Statement () Int64+auditCountStmt =+  preparable+    "SELECT count(*) FROM app.counter_audit"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++externalV1RowsStmt :: Statement () [(Int64, Int64)]+externalV1RowsStmt =+  preparable+    "SELECT id, total FROM keiro_read.counter_reader_v1() ORDER BY id"+    E.noParams+    ( D.rowList+        ( (,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+        )+    )++externalV2RowsStmt :: Statement () [(Int64, Int64, Int64, Int64)]+externalV2RowsStmt =+  preparable+    "SELECT id, subtotal, tax, total FROM keiro_read.counter_reader_v2() ORDER BY id"+    E.noParams+    ( D.rowList+        ( (,,,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+        )+    )++counterAccessShareStmt :: Statement () Bool+counterAccessShareStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_locks+      WHERE relation = 'app.counter'::regclass+        AND mode = 'AccessShareLock'+        AND granted+        AND pid <> pg_backend_pid()+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++auditAccessShareStmt :: Statement () Bool+auditAccessShareStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_locks+      WHERE relation = 'app.counter_audit'::regclass+        AND mode = 'AccessShareLock'+        AND granted+        AND pid <> pg_backend_pid()+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++lockGroupRowStmt :: Statement Text Text+lockGroupRowStmt =+  preparable+    "UPDATE keiro.keiro_projection_rebuild_groups SET updated_at = updated_at WHERE group_id = $1 RETURNING group_id"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.text)))++groupRowShareLockStmt :: Statement () Bool+groupRowShareLockStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_locks+      WHERE relation = 'keiro.keiro_projection_rebuild_groups'::regclass+        AND mode = 'RowExclusiveLock'+        AND granted+        AND pid <> pg_backend_pid()+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++promotionGroupLockStmt :: Statement () Bool+promotionGroupLockStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_catalog.pg_locks+      WHERE relation = 'keiro.keiro_projection_rebuild_groups'::regclass+        AND mode = 'RowShareLock'+        AND granted+        AND pid <> pg_backend_pid()+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++namedSessionGroupShareStmt :: Statement Text Bool+namedSessionGroupShareStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1 FROM pg_catalog.pg_stat_activity AS activity+      JOIN pg_catalog.pg_locks AS locks+        ON locks.pid = activity.pid+      WHERE activity.application_name = $1+        AND locks.relation = 'keiro.keiro_projection_rebuild_groups'::regclass+        AND locks.mode = 'RowShareLock'+        AND locks.granted+    )+    """+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++applicationPidStmt :: Statement Text (Maybe Int32)+applicationPidStmt =+  preparable+    "SELECT pid FROM pg_catalog.pg_stat_activity WHERE application_name = $1 AND state = 'active' ORDER BY pid LIMIT 1"+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (D.column (D.nonNullable D.int4)))++terminateBackendStmt :: Statement Int32 Bool+terminateBackendStmt =+  preparable+    "SELECT pg_catalog.pg_terminate_backend($1)"+    (E.param (E.nonNullable E.int4))+    (D.singleRow (D.column (D.nonNullable D.bool)))++statusPromotionUpdateAndSleepSql :: ByteString+statusPromotionUpdateAndSleepSql =+  """+  UPDATE keiro.keiro_projection_rebuild_groups+  SET status = 'serving-versioned',+      active_run_id = NULL,+      serving_revision_id = 'counter-v2',+      serving_epoch = 1,+      completed_at = now(),+      updated_at = now()+  WHERE group_id = 'counter-group';+  SELECT pg_sleep(1);+  """++dispatchDedupCountStmt :: Statement () Int64+dispatchDedupCountStmt =+  preparable+    """+    SELECT count(*)+    FROM keiro.keiro_projection_dedup+    WHERE projection_name = 'catalog-async'+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++convergeDedupCountStmt :: Statement () Int64+convergeDedupCountStmt = dispatchDedupCountStmt++expiredRetentionFactsStmt :: Statement () (Text, Bool, Bool, Text, Text)+expiredRetentionFactsStmt =+  preparable+    """+    SELECT groups.status, groups.reads_allowed, groups.writes_allowed,+           runs.status, runs.failure_code+    FROM keiro.keiro_projection_rebuild_groups AS groups+    JOIN keiro.keiro_projection_rebuild_runs AS runs+      ON runs.group_id = groups.group_id+    WHERE runs.run_id = 'versioned-expired-retention'+    """+    E.noParams+    ( D.singleRow+        ( (,,,,)+            <$> column D.text+            <*> column D.bool+            <*> column D.bool+            <*> column D.text+            <*> column D.text+        )+    )+  where+    column = D.column . D.nonNullable++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++promotedLifecycleFactsStmt :: Statement () (Text, Bool, Bool, Text, Int64, Text, Int64, Int64, Int64)+promotedLifecycleFactsStmt =+  preparable+    """+    SELECT groups.status, groups.reads_allowed, groups.writes_allowed,+           groups.serving_revision_id, groups.serving_epoch, runs.status,+           (SELECT count(*) FROM keiro.keiro_projection_target_generations WHERE lifecycle = 'serving'),+           (SELECT count(*) FROM keiro.keiro_projection_target_generations WHERE lifecycle = 'retired'),+           (SELECT count(*) FROM kiroku.history_retention_leases WHERE released_at IS NOT NULL)+    FROM keiro.keiro_projection_rebuild_groups AS groups+    JOIN keiro.keiro_projection_rebuild_runs AS runs ON runs.group_id = groups.group_id+    WHERE runs.run_id = 'versioned-promote'+    """+    E.noParams+    ( D.singleRow+        ( (,,,,,,,,)+            <$> column D.text+            <*> column D.bool+            <*> column D.bool+            <*> column D.text+            <*> column D.int8+            <*> column D.text+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+        )+    )+  where+    column = D.column . D.nonNullable++promotedCounterShapeStmt :: Statement () Bool+promotedCounterShapeStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM information_schema.columns+      WHERE table_schema = 'app' AND table_name = 'counter'+        AND column_name = 'subtotal'+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++cloneShapeStmt :: Statement () Bool+cloneShapeStmt =+  preparable+    """+    SELECT+      (SELECT count(*) FROM information_schema.columns+       WHERE table_schema = 'app' AND table_name = 'counter') = 2+      AND EXISTS (+        SELECT 1 FROM information_schema.columns+        WHERE table_schema = 'app' AND table_name = 'counter'+          AND column_name = 'total'+      )+      AND NOT EXISTS (+        SELECT 1 FROM information_schema.columns+        WHERE table_schema = 'app' AND table_name = 'counter'+          AND column_name = 'subtotal'+      )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++identityCloneObjectsStmt :: Statement () Bool+identityCloneObjectsStmt =+  preparable+    """+    SELECT+      pg_catalog.pg_get_serial_sequence('app.counter', 'id') = 'app.counter_id_seq'+      AND EXISTS (+        SELECT 1+        FROM pg_catalog.pg_constraint+        WHERE conrelid = 'app.counter'::regclass+          AND conname = 'counter_pkey'+          AND contype = 'p'+      )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++activeLifecycleFactsStmt :: Statement () (Text, Bool, Bool, Text, Int64, Int64, Int64, Int64, Int64)+activeLifecycleFactsStmt =+  preparable+    """+    SELECT groups.status, groups.reads_allowed, groups.writes_allowed,+           groups.serving_revision_id, groups.serving_epoch,+           (SELECT count(*) FROM keiro.keiro_projection_target_generations WHERE lifecycle = 'serving'),+           (SELECT count(*) FROM keiro.keiro_projection_target_generations WHERE lifecycle = 'staging'),+           (SELECT count(*) FROM keiro.keiro_projection_rebuild_runs WHERE rebuild_mode = 'versioned'),+           (SELECT count(*) FROM kiroku.history_retention_leases WHERE released_at IS NULL)+    FROM keiro.keiro_projection_rebuild_groups AS groups+    WHERE groups.group_id = 'counter-group'+    """+    E.noParams+    ( D.singleRow+        ( (,,,,,,,,)+            <$> column D.text+            <*> column D.bool+            <*> column D.bool+            <*> column D.text+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+        )+    )+  where+    column = D.column . D.nonNullable++rolledBackLifecycleFactsStmt :: Statement () (Text, Int64, Int64, Int64, Int64)+rolledBackLifecycleFactsStmt =+  preparable+    """+    SELECT groups.status,+           (SELECT count(*) FROM keiro.keiro_projection_target_generations),+           (SELECT count(*) FROM keiro.keiro_projection_rebuild_runs WHERE rebuild_mode = 'versioned'),+           (SELECT count(*) FROM kiroku.history_retention_leases),+           (SELECT count(*) FROM pg_catalog.pg_class AS classes JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.oid = classes.relnamespace WHERE namespaces.nspname = 'app' AND classes.relname LIKE 'keiro_g_%')+    FROM keiro.keiro_projection_rebuild_groups AS groups+    WHERE groups.group_id = 'counter-group'+    """+    E.noParams+    ( D.singleRow+        ( (,,,,)+            <$> column D.text+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+        )+    )+  where+    column = D.column . D.nonNullable++collisionRollbackFactsStmt :: Statement () (Text, Int64, Int64, Int64, Int64)+collisionRollbackFactsStmt = rolledBackLifecycleFactsStmt++abandonedLifecycleFactsStmt :: Statement () (Text, Bool, Bool, Text, Int64, Int64, Int64)+abandonedLifecycleFactsStmt =+  preparable+    """+    SELECT groups.status, groups.reads_allowed, groups.writes_allowed, runs.status,+           (SELECT count(*) FROM keiro.keiro_projection_target_generations WHERE lifecycle = 'dropped'),+           (SELECT count(*) FROM kiroku.history_retention_leases WHERE released_at IS NOT NULL),+           (SELECT count(*) FROM keiro.keiro_projection_target_generations WHERE lifecycle = 'serving')+    FROM keiro.keiro_projection_rebuild_groups AS groups+    JOIN keiro.keiro_projection_rebuild_runs AS runs+      ON runs.group_id = groups.group_id+    WHERE runs.run_id = 'versioned-abandon'+    """+    E.noParams+    ( D.singleRow+        ( (,,,,,,)+            <$> column D.text+            <*> column D.bool+            <*> column D.bool+            <*> column D.text+            <*> column D.int8+            <*> column D.int8+            <*> column D.int8+        )+    )+  where+    column = D.column . D.nonNullable++isLeft :: Either a b -> Bool+isLeft = \case+  Left _ -> True+  Right _ -> False++isRight :: Either a b -> Bool+isRight = not . isLeft
+ test/VersionedTargetPostgresSpec.hs view
@@ -0,0 +1,567 @@+{-# LANGUAGE MultilineStrings #-}++module VersionedTargetPostgresSpec+  ( spec,+  )+where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (bracket)+import Data.ByteString (ByteString)+import Data.Int (Int64)+import Data.List (sort)+import Data.Text (Text)+import Data.Text.Encoding qualified as Text+import Hasql.Connection.Settings qualified as ConnectionSettings+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as PoolConfig+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSessions+import Keiro.Test.Postgres (Fixture, withFreshDatabase)+import Test.Hspec++spec :: Fixture -> Spec+spec fixture =+  describe "versioned target PostgreSQL mechanics" $+    around (withFreshDatabase fixture) $ do+      it "provisions an incompatible candidate transactionally and rolls failed provisioning back" $ \connectionString ->+        withPool connectionString $ \pool -> do+          runScript pool servingV1Sql++          candidateWasValid <-+            runTransaction pool $ do+              Tx.sql candidateV2Sql+              valid <- Tx.statement () candidateShapeStmt+              Tx.condemn+              pure valid+          candidateWasValid `shouldBe` True+          runStatement pool ("app.counter_g2" :: Text) relationExistsStmt `shouldReturn` False+          runStatement pool () servingValueStmt `shouldReturn` (1, 42)++          runTransaction pool (Tx.sql candidateV2Sql)+          runStatement pool ("app.counter_g2" :: Text) relationExistsStmt `shouldReturn` True+          runStatement pool () candidateShapeStmt `shouldReturn` True++      it "keeps OID identity explicit and demonstrates that dependent views follow the retired relation" $ \connectionString ->+        withPool connectionString $ \pool -> do+          runScript pool identitySwapFixtureSql+          (servingBefore, candidateBefore) <- runStatement pool () preSwapOidsStmt++          runTransaction pool (Tx.sql promoteIdentityTargetsSql)++          facts <- runStatement pool () promotedIdentityFactsStmt+          facts+            `shouldBe` ( candidateBefore,+                         servingBefore,+                         10,+                         12,+                         "app.counter_id_seq",+                         True,+                         True+                       )++      it "detects every deliberately unsupported clone feature without mutating the serving table" $ \connectionString ->+        withPool connectionString $ \pool -> do+          runScript pool cloneEligibilityFixtureSql+          runStatement pool () cloneRefusalsStmt+            `shouldReturn` "dependent-view,external-nextval,foreign-keys,inheritance,non-default-owner-or-acl,non-default-replica-identity,partitioning,publication,row-level-security,rules,triggers"+          runStatement pool () identitySequenceIsOwnedStmt `shouldReturn` True+          runStatement pool () servingValueStmt `shouldReturn` (1, 42)++      it "uses deterministic all-target order and one deadline that rolls a blocked cutover back" $ \connectionString ->+        withPool connectionString $ \pool -> do+          runScript pool lockFixtureSql+          sort unorderedLockNames+            `shouldBe` [ "app.serving_a",+                         "app.serving_b",+                         "app.staging_a",+                         "app.staging_b"+                       ]++          holderDone <- newEmptyMVar+          _ <-+            forkIO $+              Pool.use+                pool+                ( TxSessions.transactionNoRetry+                    TxSessions.ReadCommitted+                    TxSessions.Write+                    (Tx.sql holdServingReadLockSql)+                )+                >>= putMVar holderDone+          waitForOtherAccessShare pool 50++          timedOut <-+            Pool.use+              pool+              ( TxSessions.transactionNoRetry+                  TxSessions.ReadCommitted+                  TxSessions.Write+                  (Tx.sql blockedPromotionSql)+              )+          timedOut `shouldSatisfy` isLeft+          takeMVar holderDone `shouldReturn` Right ()+          runStatement pool () lockFixtureFactsStmt+            `shouldReturn` (1, 2, 11, 22, False, False)++      it "detects when a paused generation name is rebound to a different relation" $ \connectionString ->+        withPool connectionString $ \pool -> do+          runScript pool reboundFixtureSql+          originalOid <- runStatement pool ("app.counter_g2" :: Text) relationOidStmt+          runScript pool reboundReplacementSql+          replacementOid <- runStatement pool ("app.counter_g2" :: Text) relationOidStmt+          replacementOid `shouldNotBe` originalOid+          runStatement pool () servingValueStmt `shouldReturn` (1, 42)++withPool :: Text -> (Pool.Pool -> IO a) -> IO a+withPool connectionString =+  bracket+    ( Pool.acquire $+        PoolConfig.settings+          [ PoolConfig.staticConnectionSettings (ConnectionSettings.connectionString connectionString),+            PoolConfig.size 4+          ]+    )+    Pool.release++runScript :: Pool.Pool -> ByteString -> IO ()+runScript pool sql = expectUsage =<< Pool.use pool (Session.script (Text.decodeUtf8 sql))++runTransaction :: Pool.Pool -> Tx.Transaction a -> IO a+runTransaction pool transaction =+  expectUsage+    =<< Pool.use+      pool+      (TxSessions.transactionNoRetry TxSessions.ReadCommitted TxSessions.Write transaction)++runStatement :: Pool.Pool -> params -> Statement params result -> IO result+runStatement pool params statement =+  expectUsage =<< Pool.use pool (Session.statement params statement)++expectUsage :: (Show error) => Either error value -> IO value+expectUsage = \case+  Left err -> expectationFailure ("database action failed: " <> show err) >> error "unreachable"+  Right value -> pure value++waitForOtherAccessShare :: Pool.Pool -> Int -> IO ()+waitForOtherAccessShare _ 0 = expectationFailure "reader did not acquire ACCESS SHARE in time"+waitForOtherAccessShare pool remaining = do+  locked <- runStatement pool () otherAccessShareStmt+  if locked+    then pure ()+    else threadDelay 20_000 >> waitForOtherAccessShare pool (remaining - 1)++isLeft :: Either a b -> Bool+isLeft = \case+  Left _ -> True+  Right _ -> False++unorderedLockNames :: [Text]+unorderedLockNames =+  [ "app.staging_b",+    "app.serving_b",+    "app.staging_a",+    "app.serving_a"+  ]++servingV1Sql :: ByteString+servingV1Sql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (+    id bigint PRIMARY KEY,+    total bigint NOT NULL+  );+  INSERT INTO app.counter VALUES (1, 42);+  """++candidateV2Sql :: ByteString+candidateV2Sql =+  """+  CREATE TABLE app.counter_g2 (+    id bigint PRIMARY KEY,+    subtotal bigint NOT NULL,+    tax bigint NOT NULL CHECK (tax >= 0),+    total bigint GENERATED ALWAYS AS (subtotal + tax) STORED+  );+  INSERT INTO app.counter_g2 (id, subtotal, tax) VALUES (1, 40, 2);+  """++identitySwapFixtureSql :: ByteString+identitySwapFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (+    id bigint GENERATED BY DEFAULT AS IDENTITY,+    total bigint NOT NULL,+    CONSTRAINT counter_pkey PRIMARY KEY (id)+  );+  CREATE VIEW app.counter_v1_reader AS SELECT id, total FROM app.counter;+  INSERT INTO app.counter (total) VALUES (12);++  CREATE TABLE app.counter_g2 (+    id bigint GENERATED BY DEFAULT AS IDENTITY,+    subtotal bigint NOT NULL,+    tax bigint NOT NULL,+    total bigint GENERATED ALWAYS AS (subtotal + tax) STORED,+    CONSTRAINT counter_g2_pkey PRIMARY KEY (id),+    CONSTRAINT counter_g2_tax_nonnegative CHECK (tax >= 0)+  );+  CREATE INDEX counter_g2_subtotal_idx ON app.counter_g2 (subtotal);+  INSERT INTO app.counter_g2 (subtotal, tax) VALUES (7, 3);+  """++promoteIdentityTargetsSql :: ByteString+promoteIdentityTargetsSql =+  """+  LOCK TABLE app.counter, app.counter_g2 IN ACCESS EXCLUSIVE MODE;++  ALTER TABLE app.counter RENAME CONSTRAINT counter_pkey TO counter_g1_pkey;+  ALTER SEQUENCE app.counter_id_seq RENAME TO counter_g1_id_seq;+  ALTER TABLE app.counter RENAME TO counter_g1;++  ALTER TABLE app.counter_g2 RENAME CONSTRAINT counter_g2_pkey TO counter_pkey;+  ALTER TABLE app.counter_g2 RENAME CONSTRAINT counter_g2_tax_nonnegative TO counter_tax_nonnegative;+  ALTER INDEX app.counter_g2_subtotal_idx RENAME TO counter_subtotal_idx;+  ALTER SEQUENCE app.counter_g2_id_seq RENAME TO counter_id_seq;+  ALTER TABLE app.counter_g2 RENAME TO counter;+  """++cloneEligibilityFixtureSql :: ByteString+cloneEligibilityFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE ROLE clone_reader;+  CREATE ROLE clone_owner;++  CREATE TABLE app.counter (id bigint PRIMARY KEY, total bigint NOT NULL);+  INSERT INTO app.counter VALUES (1, 42);++  CREATE TABLE app.identity_target (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY);+  CREATE TABLE app.serial_target (id bigserial PRIMARY KEY);++  CREATE TABLE app.fk_parent (id bigint PRIMARY KEY);+  CREATE TABLE app.fk_out (id bigint PRIMARY KEY, parent_id bigint REFERENCES app.fk_parent(id));+  CREATE TABLE app.fk_in (id bigint PRIMARY KEY);+  CREATE TABLE app.fk_ref (id bigint PRIMARY KEY, target_id bigint REFERENCES app.fk_in(id));++  CREATE TABLE app.trigger_target (id bigint PRIMARY KEY);+  CREATE FUNCTION app.clone_trigger_fn() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;+  CREATE TRIGGER clone_trigger BEFORE INSERT ON app.trigger_target FOR EACH ROW EXECUTE FUNCTION app.clone_trigger_fn();++  CREATE TABLE app.rule_target (id bigint PRIMARY KEY);+  CREATE RULE clone_rule AS ON INSERT TO app.rule_target DO ALSO NOTHING;++  CREATE TABLE app.rls_target (id bigint PRIMARY KEY);+  ALTER TABLE app.rls_target ENABLE ROW LEVEL SECURITY;+  CREATE POLICY clone_policy ON app.rls_target USING (true);++  CREATE TABLE app.partition_target (id bigint) PARTITION BY RANGE (id);+  CREATE TABLE app.partition_target_p0 PARTITION OF app.partition_target FOR VALUES FROM (0) TO (10);++  CREATE TABLE app.publication_target (id bigint PRIMARY KEY);+  CREATE PUBLICATION clone_publication FOR TABLE app.publication_target;++  CREATE TABLE app.acl_target (id bigint PRIMARY KEY);+  GRANT SELECT ON app.acl_target TO clone_reader;+  CREATE TABLE app.owner_target (id bigint PRIMARY KEY);+  ALTER TABLE app.owner_target OWNER TO clone_owner;++  CREATE TABLE app.replica_target (id bigint PRIMARY KEY);+  ALTER TABLE app.replica_target REPLICA IDENTITY FULL;++  CREATE TABLE app.inherit_parent (id bigint PRIMARY KEY);+  CREATE TABLE app.inherit_child () INHERITS (app.inherit_parent);++  CREATE TABLE app.dependent_target (id bigint PRIMARY KEY);+  CREATE VIEW app.dependent_reader AS SELECT id FROM app.dependent_target;+  """++lockFixtureSql :: ByteString+lockFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.serving_a (value bigint NOT NULL);+  CREATE TABLE app.serving_b (value bigint NOT NULL);+  CREATE TABLE app.staging_a (value bigint NOT NULL);+  CREATE TABLE app.staging_b (value bigint NOT NULL);+  INSERT INTO app.serving_a VALUES (1);+  INSERT INTO app.serving_b VALUES (2);+  INSERT INTO app.staging_a VALUES (11);+  INSERT INTO app.staging_b VALUES (22);+  """++holdServingReadLockSql :: ByteString+holdServingReadLockSql =+  """+  LOCK TABLE app.serving_a IN ACCESS SHARE MODE;+  SELECT pg_sleep(1);+  """++blockedPromotionSql :: ByteString+blockedPromotionSql =+  """+  SET LOCAL statement_timeout = '200ms';+  LOCK TABLE app.serving_a IN ACCESS EXCLUSIVE MODE;+  LOCK TABLE app.serving_b IN ACCESS EXCLUSIVE MODE;+  LOCK TABLE app.staging_a IN ACCESS EXCLUSIVE MODE;+  LOCK TABLE app.staging_b IN ACCESS EXCLUSIVE MODE;+  ALTER TABLE app.serving_a RENAME TO retired_a;+  ALTER TABLE app.staging_a RENAME TO serving_a;+  ALTER TABLE app.serving_b RENAME TO retired_b;+  ALTER TABLE app.staging_b RENAME TO serving_b;+  """++reboundFixtureSql :: ByteString+reboundFixtureSql =+  """+  CREATE SCHEMA app;+  CREATE TABLE app.counter (id bigint PRIMARY KEY, total bigint NOT NULL);+  INSERT INTO app.counter VALUES (1, 42);+  CREATE TABLE app.counter_g2 (id bigint PRIMARY KEY, subtotal bigint NOT NULL, tax bigint NOT NULL);+  """++reboundReplacementSql :: ByteString+reboundReplacementSql =+  """+  DROP TABLE app.counter_g2;+  CREATE TABLE app.counter_g2 (id bigint PRIMARY KEY, replacement_marker text NOT NULL);+  """++relationExistsStmt :: Statement Text Bool+relationExistsStmt =+  preparable+    "SELECT to_regclass($1) IS NOT NULL"+    (E.param (E.nonNullable E.text))+    (D.singleRow (D.column (D.nonNullable D.bool)))++relationOidStmt :: Statement Text Int64+relationOidStmt =+  preparable+    "SELECT to_regclass($1)::oid::bigint"+    (E.param (E.nonNullable E.text))+    (D.singleRow int8Column)++servingValueStmt :: Statement () (Int64, Int64)+servingValueStmt =+  preparable+    "SELECT count(*)::bigint, sum(total)::bigint FROM app.counter"+    E.noParams+    (D.singleRow ((,) <$> int8Column <*> int8Column))++candidateShapeStmt :: Statement () Bool+candidateShapeStmt =+  preparable+    """+    SELECT+      count(*) = 4+      AND bool_or(attname = 'subtotal')+      AND bool_or(attname = 'tax')+      AND bool_or(attname = 'total' AND attgenerated = 's')+    FROM pg_attribute+    WHERE attrelid = 'app.counter_g2'::regclass+      AND attnum > 0+      AND NOT attisdropped+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++preSwapOidsStmt :: Statement () (Int64, Int64)+preSwapOidsStmt =+  preparable+    "SELECT 'app.counter'::regclass::oid::bigint, 'app.counter_g2'::regclass::oid::bigint"+    E.noParams+    (D.singleRow ((,) <$> int8Column <*> int8Column))++promotedIdentityFactsStmt :: Statement () (Int64, Int64, Int64, Int64, Text, Bool, Bool)+promotedIdentityFactsStmt =+  preparable+    """+    SELECT+      'app.counter'::regclass::oid::bigint,+      'app.counter_g1'::regclass::oid::bigint,+      (SELECT total FROM app.counter),+      (SELECT total FROM app.counter_v1_reader),+      pg_get_serial_sequence('app.counter', 'id'),+      to_regclass('app.counter_subtotal_idx') IS NOT NULL,+      EXISTS (+        SELECT 1+        FROM pg_constraint+        WHERE conrelid = 'app.counter'::regclass+          AND conname = 'counter_tax_nonnegative'+      )+    """+    E.noParams+    ( D.singleRow+        ( (,,,,,,)+            <$> int8Column+            <*> int8Column+            <*> int8Column+            <*> int8Column+            <*> textColumn+            <*> boolColumn+            <*> boolColumn+        )+    )++cloneRefusalsStmt :: Statement () Text+cloneRefusalsStmt =+  preparable+    """+    WITH findings(feature) AS (+      SELECT 'external-nextval' WHERE EXISTS (+        SELECT 1+        FROM pg_attribute a+        JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum+        WHERE a.attrelid = 'app.serial_target'::regclass+          AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'+      )+      UNION ALL+      SELECT 'foreign-keys' WHERE+        EXISTS (+          SELECT 1 FROM pg_constraint+          WHERE contype = 'f'+            AND conrelid = 'app.fk_out'::regclass+            AND confrelid = 'app.fk_parent'::regclass+        )+        AND EXISTS (+          SELECT 1 FROM pg_constraint+          WHERE contype = 'f'+            AND conrelid = 'app.fk_ref'::regclass+            AND confrelid = 'app.fk_in'::regclass+        )+      UNION ALL+      SELECT 'triggers' WHERE EXISTS (+        SELECT 1 FROM pg_trigger+        WHERE tgrelid = 'app.trigger_target'::regclass AND NOT tgisinternal+      )+      UNION ALL+      SELECT 'rules' WHERE EXISTS (+        SELECT 1 FROM pg_rewrite+        WHERE ev_class = 'app.rule_target'::regclass AND rulename <> '_RETURN'+      )+      UNION ALL+      SELECT 'row-level-security' WHERE EXISTS (+        SELECT 1 FROM pg_class+        WHERE oid = 'app.rls_target'::regclass AND relrowsecurity+      ) AND EXISTS (+        SELECT 1 FROM pg_policy WHERE polrelid = 'app.rls_target'::regclass+      )+      UNION ALL+      SELECT 'partitioning' WHERE EXISTS (+        SELECT 1 FROM pg_class+        WHERE oid = 'app.partition_target'::regclass AND relkind = 'p'+      )+      UNION ALL+      SELECT 'publication' WHERE EXISTS (+        SELECT 1 FROM pg_publication_rel+        WHERE prrelid = 'app.publication_target'::regclass+      )+      UNION ALL+      SELECT 'non-default-owner-or-acl' WHERE+        EXISTS (+          SELECT 1 FROM pg_class+          WHERE oid = 'app.acl_target'::regclass AND relacl IS NOT NULL+        )+        AND EXISTS (+          SELECT 1+          FROM pg_class c+          JOIN pg_roles r ON r.oid = c.relowner+          WHERE c.oid = 'app.owner_target'::regclass AND r.rolname = 'clone_owner'+        )+      UNION ALL+      SELECT 'non-default-replica-identity' WHERE EXISTS (+        SELECT 1 FROM pg_class+        WHERE oid = 'app.replica_target'::regclass AND relreplident = 'f'+      )+      UNION ALL+      SELECT 'inheritance' WHERE EXISTS (+        SELECT 1 FROM pg_inherits+        WHERE inhrelid = 'app.inherit_child'::regclass+          AND inhparent = 'app.inherit_parent'::regclass+      )+      UNION ALL+      SELECT 'dependent-view' WHERE EXISTS (+        SELECT 1+        FROM information_schema.view_table_usage+        WHERE view_schema = 'app'+          AND view_name = 'dependent_reader'+          AND table_schema = 'app'+          AND table_name = 'dependent_target'+      )+    )+    SELECT string_agg(feature, ',' ORDER BY feature) FROM findings+    """+    E.noParams+    (D.singleRow textColumn)++identitySequenceIsOwnedStmt :: Statement () Bool+identitySequenceIsOwnedStmt =+  preparable+    """+    SELECT+      pg_get_serial_sequence('app.identity_target', 'id') IS NOT NULL+      AND EXISTS (+        SELECT 1+        FROM pg_depend d+        WHERE d.objid = pg_get_serial_sequence('app.identity_target', 'id')::regclass+          AND d.refobjid = 'app.identity_target'::regclass+          AND d.deptype = 'i'+      )+    """+    E.noParams+    (D.singleRow boolColumn)++otherAccessShareStmt :: Statement () Bool+otherAccessShareStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_locks+      WHERE relation = 'app.serving_a'::regclass+        AND mode = 'AccessShareLock'+        AND granted+        AND pid <> pg_backend_pid()+    )+    """+    E.noParams+    (D.singleRow boolColumn)++lockFixtureFactsStmt :: Statement () (Int64, Int64, Int64, Int64, Bool, Bool)+lockFixtureFactsStmt =+  preparable+    """+    SELECT+      (SELECT value FROM app.serving_a),+      (SELECT value FROM app.serving_b),+      (SELECT value FROM app.staging_a),+      (SELECT value FROM app.staging_b),+      to_regclass('app.retired_a') IS NOT NULL,+      to_regclass('app.retired_b') IS NOT NULL+    """+    E.noParams+    ( D.singleRow+        ( (,,,,,)+            <$> int8Column+            <*> int8Column+            <*> int8Column+            <*> int8Column+            <*> boolColumn+            <*> boolColumn+        )+    )++int8Column :: D.Row Int64+int8Column = D.column (D.nonNullable D.int8)++textColumn :: D.Row Text+textColumn = D.column (D.nonNullable D.text)++boolColumn :: D.Row Bool+boolColumn = D.column (D.nonNullable D.bool)