packages feed

keiro 0.21.0.0 → 0.22.0.0

raw patch · 31 files changed

+3994/−368 lines, 31 filesdep +base64-bytestringdep ~keiro-coredep ~keiro-migrationsdep ~keiro-test-supportPVP ok

version bump matches the API change (PVP)

Dependencies added: base64-bytestring

Dependency ranges changed: keiro-core, keiro-migrations, keiro-test-support, kiroku-store, shibuya-core, shibuya-kiroku-adapter

API changes (from Hackage documentation)

+ Keiro.Command: CaptureInspectionProvenance :: InspectionProvenancePolicy
+ Keiro.Command: FromSnapshot :: !StreamVersion -> HydrationSource
+ Keiro.Command: FullReplay :: !FullReplayReason -> HydrationSource
+ Keiro.Command: NoInspectionProvenance :: InspectionProvenancePolicy
+ Keiro.Command: NoStateCodec :: FullReplayReason
+ Keiro.Command: SeededReplayFailed :: !CommandError -> FullReplayReason
+ Keiro.Command: SnapshotMissed :: !SnapshotMissReason -> FullReplayReason
+ Keiro.Command: [inspectionProvenance] :: RunCommandOptions -> !InspectionProvenancePolicy
+ Keiro.Command: data FullReplayReason
+ Keiro.Command: data HydrationSource
+ Keiro.Command: data InspectionProvenancePolicy
+ Keiro.Command: hydrateWithSource :: forall phi (rs :: [Slot]) s ci co (es :: [Effect]). (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => RunCommandOptions -> EventStream phi rs s ci co -> Stream (EventStream phi rs s ci co) -> Eff es (Either CommandError (Hydrated rs s, HydrationSource))
+ Keiro.Command: inspectionRunCommandOptions :: RunCommandOptions
+ Keiro.Command: instance GHC.Classes.Eq Keiro.Command.FullReplayReason
+ Keiro.Command: instance GHC.Classes.Eq Keiro.Command.HydrationSource
+ Keiro.Command: instance GHC.Classes.Eq Keiro.Command.InspectionProvenancePolicy
+ Keiro.Command: instance GHC.Internal.Generics.Generic Keiro.Command.FullReplayReason
+ Keiro.Command: instance GHC.Internal.Generics.Generic Keiro.Command.HydrationSource
+ Keiro.Command: instance GHC.Internal.Generics.Generic Keiro.Command.InspectionProvenancePolicy
+ Keiro.Command: instance GHC.Internal.Show.Show Keiro.Command.FullReplayReason
+ Keiro.Command: instance GHC.Internal.Show.Show Keiro.Command.HydrationSource
+ Keiro.Command: instance GHC.Internal.Show.Show Keiro.Command.InspectionProvenancePolicy
+ Keiro.Inspection.Aggregate: AggregateHydrationFailed :: !StreamName -> !CommandError -> AggregateInspectionError
+ Keiro.Inspection.Aggregate: AggregateIdRejected :: !Text -> AggregateInspectionError
+ Keiro.Inspection.Aggregate: AggregateInspector :: !Text -> !ValidatedEventStream phi rs s ci co -> !StreamCategory (EventStream phi rs s ci co) -> !StreamName -> Maybe (Stream (EventStream phi rs s ci co)) -> !s -> RegFile rs -> Value -> !s -> Text -> AggregateInspector phi (rs :: [Slot]) s ci co
+ Keiro.Inspection.Aggregate: AggregateListPage :: !Text -> !Text -> ![AggregateSummary] -> !Int -> !Maybe InspectionCursor -> AggregateListPage
+ Keiro.Inspection.Aggregate: AggregateListRequest :: !Int -> !Maybe InspectionCursor -> AggregateListRequest
+ Keiro.Inspection.Aggregate: AggregateNotFound :: !StreamName -> AggregateInspectionError
+ Keiro.Inspection.Aggregate: AggregateSummary :: !Text -> !StreamName -> !StreamVersion -> !UTCTime -> !Maybe UTCTime -> !StreamVersion -> AggregateSummary
+ Keiro.Inspection.Aggregate: AggregateTypeSummary :: !Text -> !Text -> !Bool -> !Text -> AggregateTypeSummary
+ Keiro.Inspection.Aggregate: AggregateView :: !Text -> !Text -> !Text -> !StreamName -> !StreamVersion -> !UTCTime -> !Maybe UTCTime -> !StreamVersion -> !Maybe HydrationSource -> !Maybe Text -> !Maybe Value -> !SnapshotStatus -> !HistoryReference -> AggregateView
+ Keiro.Inspection.Aggregate: Compatible :: SnapshotCompatibility
+ Keiro.Inspection.Aggregate: HistoryReference :: !Text -> !StreamName -> !StreamVersion -> HistoryReference
+ Keiro.Inspection.Aggregate: Incompatible :: ![Text] -> SnapshotCompatibility
+ Keiro.Inspection.Aggregate: InvalidCursor :: !CursorError -> AggregateInspectionError
+ Keiro.Inspection.Aggregate: InvalidPageSize :: !Int -> AggregateInspectionError
+ Keiro.Inspection.Aggregate: ResultTooLarge :: AggregateInspectionError
+ Keiro.Inspection.Aggregate: SnapshotConfig :: !Text -> !Int -> !Text -> !Text -> SnapshotConfig
+ Keiro.Inspection.Aggregate: SnapshotStatus :: !UTCTime -> !Maybe SnapshotConfig -> !Maybe StoredSnapshot -> SnapshotStatus
+ Keiro.Inspection.Aggregate: StoredSnapshot :: !Bool -> !StreamVersion -> !UTCTime -> !UTCTime -> !Double -> !Int64 -> !Int -> !Text -> !Text -> !SnapshotCompatibility -> StoredSnapshot
+ Keiro.Inspection.Aggregate: Unused :: SnapshotCompatibility
+ Keiro.Inspection.Aggregate: [SomeAggregateInspector] :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => AggregateInspector phi rs s ci co -> SomeAggregateInspector
+ Keiro.Inspection.Aggregate: [after] :: AggregateListRequest -> !Maybe InspectionCursor
+ Keiro.Inspection.Aggregate: [ageSeconds] :: StoredSnapshot -> !Double
+ Keiro.Inspection.Aggregate: [aggregateId] :: AggregateSummary -> !Text
+ Keiro.Inspection.Aggregate: [aheadOfObservedHead] :: StoredSnapshot -> !Bool
+ Keiro.Inspection.Aggregate: [category] :: AggregateListPage -> !Text
+ Keiro.Inspection.Aggregate: [compatibility] :: StoredSnapshot -> !SnapshotCompatibility
+ Keiro.Inspection.Aggregate: [configured] :: SnapshotStatus -> !Maybe SnapshotConfig
+ Keiro.Inspection.Aggregate: [createdAt] :: AggregateSummary -> !UTCTime
+ Keiro.Inspection.Aggregate: [deletedAt] :: AggregateSummary -> !Maybe UTCTime
+ Keiro.Inspection.Aggregate: [displayState] :: AggregateInspector phi (rs :: [Slot]) s ci co -> !s -> RegFile rs -> Value
+ Keiro.Inspection.Aggregate: [eventStream] :: AggregateInspector phi (rs :: [Slot]) s ci co -> !ValidatedEventStream phi rs s ci co
+ Keiro.Inspection.Aggregate: [eventsSince] :: StoredSnapshot -> !Int64
+ Keiro.Inspection.Aggregate: [headVersion] :: HistoryReference -> !StreamVersion
+ Keiro.Inspection.Aggregate: [history] :: AggregateView -> !HistoryReference
+ Keiro.Inspection.Aggregate: [hydration] :: AggregateView -> !Maybe HydrationSource
+ Keiro.Inspection.Aggregate: [items] :: AggregateListPage -> ![AggregateSummary]
+ Keiro.Inspection.Aggregate: [mkStream] :: AggregateInspector phi (rs :: [Slot]) s ci co -> !StreamName -> Maybe (Stream (EventStream phi rs s ci co))
+ Keiro.Inspection.Aggregate: [name] :: AggregateListPage -> !Text
+ Keiro.Inspection.Aggregate: [next] :: AggregateListPage -> !Maybe InspectionCursor
+ Keiro.Inspection.Aggregate: [observedAt] :: SnapshotStatus -> !UTCTime
+ Keiro.Inspection.Aggregate: [owner] :: HistoryReference -> !Text
+ Keiro.Inspection.Aggregate: [pageSize] :: AggregateListRequest -> !Int
+ Keiro.Inspection.Aggregate: [policy] :: SnapshotConfig -> !Text
+ Keiro.Inspection.Aggregate: [regfileShapeHash] :: StoredSnapshot -> !Text
+ Keiro.Inspection.Aggregate: [skipped] :: AggregateListPage -> !Int
+ Keiro.Inspection.Aggregate: [snapshotPolicy] :: AggregateTypeSummary -> !Text
+ Keiro.Inspection.Aggregate: [snapshot] :: AggregateView -> !SnapshotStatus
+ Keiro.Inspection.Aggregate: [snapshotsConfigured] :: AggregateTypeSummary -> !Bool
+ Keiro.Inspection.Aggregate: [stateCodecVersion] :: StoredSnapshot -> !Int
+ Keiro.Inspection.Aggregate: [stateLabel] :: AggregateView -> !Maybe Text
+ Keiro.Inspection.Aggregate: [stateShapeHash] :: StoredSnapshot -> !Text
+ Keiro.Inspection.Aggregate: [state] :: AggregateView -> !Maybe Value
+ Keiro.Inspection.Aggregate: [stored] :: SnapshotStatus -> !Maybe StoredSnapshot
+ Keiro.Inspection.Aggregate: [streamName] :: AggregateSummary -> !StreamName
+ Keiro.Inspection.Aggregate: [streamVersion] :: AggregateSummary -> !StreamVersion
+ Keiro.Inspection.Aggregate: [truncateBefore] :: AggregateSummary -> !StreamVersion
+ Keiro.Inspection.Aggregate: [updatedAt] :: StoredSnapshot -> !UTCTime
+ Keiro.Inspection.Aggregate: aggregateIdOf :: forall phi (rs :: [Slot]) s ci co. AggregateInspector phi rs s ci co -> StreamName -> Maybe Text
+ Keiro.Inspection.Aggregate: aggregateInspector :: forall s phi (rs :: [Slot]) ci co. Show s => Text -> ValidatedEventStream phi rs s ci co -> StreamCategory (EventStream phi rs s ci co) -> (s -> RegFile rs -> Value) -> AggregateInspector phi rs s ci co
+ Keiro.Inspection.Aggregate: aggregateInspectors :: [SomeAggregateInspector] -> Either Text AggregateInspectors
+ Keiro.Inspection.Aggregate: aggregateListCursorKind :: Text
+ Keiro.Inspection.Aggregate: aggregateListPageJson :: AggregateListPage -> Value
+ Keiro.Inspection.Aggregate: aggregateStream :: forall phi (rs :: [Slot]) s ci co. AggregateInspector phi rs s ci co -> Text -> Either AggregateInspectionError (Stream (EventStream phi rs s ci co))
+ Keiro.Inspection.Aggregate: aggregateSummaryJson :: AggregateSummary -> Value
+ Keiro.Inspection.Aggregate: aggregateTypeJson :: AggregateTypeSummary -> Value
+ Keiro.Inspection.Aggregate: aggregateViewJson :: AggregateView -> Value
+ Keiro.Inspection.Aggregate: data AggregateInspectionError
+ Keiro.Inspection.Aggregate: data AggregateInspector phi (rs :: [Slot]) s ci co
+ Keiro.Inspection.Aggregate: data AggregateListPage
+ Keiro.Inspection.Aggregate: data AggregateListRequest
+ Keiro.Inspection.Aggregate: data AggregateSummary
+ Keiro.Inspection.Aggregate: data AggregateTypeSummary
+ Keiro.Inspection.Aggregate: data AggregateView
+ Keiro.Inspection.Aggregate: data HistoryReference
+ Keiro.Inspection.Aggregate: data SnapshotCompatibility
+ Keiro.Inspection.Aggregate: data SnapshotConfig
+ Keiro.Inspection.Aggregate: data SnapshotStatus
+ Keiro.Inspection.Aggregate: data SomeAggregateInspector
+ Keiro.Inspection.Aggregate: data StoredSnapshot
+ Keiro.Inspection.Aggregate: displayViaStateCodec :: forall s (rs :: [Slot]). StateCodec (s, RegFile rs) -> s -> RegFile rs -> Value
+ Keiro.Inspection.Aggregate: hydrationSourceJson :: HydrationSource -> Value
+ Keiro.Inspection.Aggregate: inspectAggregate :: forall (es :: [Effect]) phi (rs :: [Slot]) ci co s. (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => AggregateInspector phi rs s ci co -> Text -> Eff es (Either AggregateInspectionError AggregateView)
+ Keiro.Inspection.Aggregate: inspectionErrorText :: AggregateInspectionError -> Text
+ Keiro.Inspection.Aggregate: inspectorFromAuditTarget :: forall s phi (rs :: [Slot]) ci co. Show s => Text -> AuditTarget phi rs s ci co -> (s -> RegFile rs -> Value) -> Either CategoryError (AggregateInspector phi rs s ci co)
+ Keiro.Inspection.Aggregate: inspectorName :: SomeAggregateInspector -> Text
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.AggregateInspectionError
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.AggregateListPage
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.AggregateListRequest
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.AggregateSummary
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.AggregateTypeSummary
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.AggregateView
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.HistoryReference
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.SnapshotCompatibility
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.SnapshotConfig
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.SnapshotStatus
+ Keiro.Inspection.Aggregate: instance GHC.Classes.Eq Keiro.Inspection.Aggregate.StoredSnapshot
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic (Keiro.Inspection.Aggregate.AggregateInspector phi rs s ci co)
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.AggregateInspectionError
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.AggregateListPage
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.AggregateListRequest
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.AggregateSummary
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.AggregateTypeSummary
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.AggregateView
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.HistoryReference
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.SnapshotCompatibility
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.SnapshotConfig
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.SnapshotStatus
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Generics.Generic Keiro.Inspection.Aggregate.StoredSnapshot
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.AggregateInspectionError
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.AggregateListPage
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.AggregateListRequest
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.AggregateSummary
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.AggregateTypeSummary
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.AggregateView
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.HistoryReference
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.SnapshotCompatibility
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.SnapshotConfig
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.SnapshotStatus
+ Keiro.Inspection.Aggregate: instance GHC.Internal.Show.Show Keiro.Inspection.Aggregate.StoredSnapshot
+ Keiro.Inspection.Aggregate: listAggregateTypes :: AggregateInspectors -> [AggregateTypeSummary]
+ Keiro.Inspection.Aggregate: listAggregates :: forall (es :: [Effect]) phi (rs :: [Slot]) s ci co. Store :> es => AggregateInspector phi rs s ci co -> AggregateListRequest -> Eff es (Either AggregateInspectionError AggregateListPage)
+ Keiro.Inspection.Aggregate: maxAggregatePageSize :: Int
+ Keiro.Inspection.Aggregate: snapshotStatusFor :: forall (es :: [Effect]) phi (rs :: [Slot]) s ci co. Store :> es => AggregateInspector phi rs s ci co -> Text -> Eff es (Either AggregateInspectionError (StreamName, StreamVersion, SnapshotStatus))
+ Keiro.Inspection.Aggregate: snapshotStatusJson :: SnapshotStatus -> Value
+ Keiro.Inspection.Aggregate: type AggregateInspectors = Map Text SomeAggregateInspector
+ Keiro.Inspection.Cursor: CursorKindMismatch :: !Text -> !Text -> CursorError
+ Keiro.Inspection.Cursor: InspectionCursor :: Text -> InspectionCursor
+ Keiro.Inspection.Cursor: MalformedCursor :: !Text -> CursorError
+ Keiro.Inspection.Cursor: [cursorText] :: InspectionCursor -> Text
+ Keiro.Inspection.Cursor: data CursorError
+ Keiro.Inspection.Cursor: decodeCursor :: FromJSON payload => Text -> InspectionCursor -> Either CursorError payload
+ Keiro.Inspection.Cursor: encodeCursor :: ToJSON payload => Text -> payload -> InspectionCursor
+ Keiro.Inspection.Cursor: encodeCursorBounded :: ToJSON payload => Text -> payload -> Maybe InspectionCursor
+ Keiro.Inspection.Cursor: instance GHC.Classes.Eq Keiro.Inspection.Cursor.CursorError
+ Keiro.Inspection.Cursor: instance GHC.Classes.Eq Keiro.Inspection.Cursor.InspectionCursor
+ Keiro.Inspection.Cursor: instance GHC.Internal.Generics.Generic Keiro.Inspection.Cursor.CursorError
+ Keiro.Inspection.Cursor: instance GHC.Internal.Generics.Generic Keiro.Inspection.Cursor.InspectionCursor
+ Keiro.Inspection.Cursor: instance GHC.Internal.Show.Show Keiro.Inspection.Cursor.CursorError
+ Keiro.Inspection.Cursor: instance GHC.Internal.Show.Show Keiro.Inspection.Cursor.InspectionCursor
+ Keiro.Inspection.Cursor: maxCursorBytes :: Int
+ Keiro.Inspection.Cursor: newtype InspectionCursor
+ Keiro.ProcessManager.Inspect: DispatchedCommandStatus :: !Int -> !StreamName -> !EventId -> !DispatchWitness -> DispatchedCommandStatus
+ Keiro.ProcessManager.Inspect: EnabledInput :: !Maybe Text -> !Text -> EnabledInput
+ Keiro.ProcessManager.Inspect: EventPresent :: DispatchWitness
+ Keiro.ProcessManager.Inspect: InstanceListRequest :: !Int -> !Maybe InspectionCursor -> InstanceListRequest
+ Keiro.ProcessManager.Inspect: InstancePage :: ![InstanceSummary] -> !Int -> !Maybe InspectionCursor -> InstancePage
+ Keiro.ProcessManager.Inspect: InstanceResultTooLarge :: InstanceListError
+ Keiro.ProcessManager.Inspect: InstanceSummary :: !Text -> !StreamName -> !StreamVersion -> !UTCTime -> !Maybe UTCTime -> !StreamVersion -> InstanceSummary
+ Keiro.ProcessManager.Inspect: InstanceViewRequest :: !Int -> InstanceViewRequest
+ Keiro.ProcessManager.Inspect: InvalidInstanceCursor :: !CursorError -> InstanceListError
+ Keiro.ProcessManager.Inspect: InvalidInstancePageSize :: !Int -> InstanceListError
+ Keiro.ProcessManager.Inspect: InvalidInstanceViewRequest :: !Text -> ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: LastReaction :: !EventId -> !EventType -> !StreamVersion -> !GlobalPosition -> !UTCTime -> !Maybe ReactionProvenance -> !Maybe StreamName -> ![DispatchedCommandStatus] -> LastReaction
+ Keiro.ProcessManager.Inspect: NoReactionProvenance :: ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: NotObserved :: DispatchWitness
+ Keiro.ProcessManager.Inspect: ProcessManagerHydrationFailed :: !CommandError -> ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: ProcessManagerInspector :: !Text -> !ValidatedEventStream phi rs s ci co -> !Text -> Stream (EventStream phi rs s ci co) -> !StreamCategory (EventStream phi rs s ci co) -> !StreamName -> Maybe Text -> !s -> RegFile rs -> Value -> !s -> Text -> !s -> [EnabledInput] -> !ProcessManagerProvenanceMode -> ProcessManagerInspector phi (rs :: [Slot]) s ci co
+ Keiro.ProcessManager.Inspect: ProcessManagerInstanceView :: !Text -> !Text -> !StreamName -> !StreamVersion -> !StreamVersion -> !UTCTime -> !Maybe UTCTime -> !StreamVersion -> !Maybe HydrationSource -> !Maybe Text -> !Maybe Value -> !Maybe LastReaction -> ![EnabledInput] -> !PendingTimerPage -> !UTCTime -> ProcessManagerInstanceView
+ Keiro.ProcessManager.Inspect: ProcessManagerTimerReadFailed :: !PendingTimerReadError -> ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: ProcessManagerTypeSummary :: !Text -> !Text -> !Bool -> !Text -> !Maybe Bool -> ProcessManagerTypeSummary
+ Keiro.ProcessManager.Inspect: ReactionProvenance :: !EventId -> !GlobalPosition -> !StreamId -> ![(Int, StreamName)] -> ![TimerId] -> !Text -> !Bool -> ReactionProvenance
+ Keiro.ProcessManager.Inspect: ReadLegacyProvenance :: ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: [SomeProcessManagerInspector] :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => ProcessManagerInspector phi rs s ci co -> SomeProcessManagerInspector
+ Keiro.ProcessManager.Inspect: [after] :: InstanceListRequest -> !Maybe InspectionCursor
+ Keiro.ProcessManager.Inspect: [category] :: ProcessManagerTypeSummary -> !Text
+ Keiro.ProcessManager.Inspect: [correlationId] :: InstanceSummary -> !Text
+ Keiro.ProcessManager.Inspect: [correlationOf] :: ProcessManagerInspector phi (rs :: [Slot]) s ci co -> !StreamName -> Maybe Text
+ Keiro.ProcessManager.Inspect: [createdAt] :: InstanceSummary -> !UTCTime
+ Keiro.ProcessManager.Inspect: [deletedAt] :: InstanceSummary -> !Maybe UTCTime
+ Keiro.ProcessManager.Inspect: [dispatchedCommands] :: LastReaction -> ![DispatchedCommandStatus]
+ Keiro.ProcessManager.Inspect: [dispatched] :: ReactionProvenance -> ![(Int, StreamName)]
+ Keiro.ProcessManager.Inspect: [displayState] :: ProcessManagerInspector phi (rs :: [Slot]) s ci co -> !s -> RegFile rs -> Value
+ Keiro.ProcessManager.Inspect: [emitIndex] :: DispatchedCommandStatus -> !Int
+ Keiro.ProcessManager.Inspect: [enabledInputs] :: ProcessManagerInspector phi (rs :: [Slot]) s ci co -> !s -> [EnabledInput]
+ Keiro.ProcessManager.Inspect: [eventId] :: LastReaction -> !EventId
+ Keiro.ProcessManager.Inspect: [eventStream] :: ProcessManagerInspector phi (rs :: [Slot]) s ci co -> !ValidatedEventStream phi rs s ci co
+ Keiro.ProcessManager.Inspect: [eventType] :: LastReaction -> !EventType
+ Keiro.ProcessManager.Inspect: [family] :: ReactionProvenance -> !Text
+ Keiro.ProcessManager.Inspect: [globalPosition] :: LastReaction -> !GlobalPosition
+ Keiro.ProcessManager.Inspect: [hydration] :: ProcessManagerInstanceView -> !Maybe HydrationSource
+ Keiro.ProcessManager.Inspect: [input] :: EnabledInput -> !Maybe Text
+ Keiro.ProcessManager.Inspect: [items] :: InstancePage -> ![InstanceSummary]
+ Keiro.ProcessManager.Inspect: [journalStreamName] :: ProcessManagerInstanceView -> !StreamName
+ Keiro.ProcessManager.Inspect: [lastReaction] :: ProcessManagerInstanceView -> !Maybe LastReaction
+ Keiro.ProcessManager.Inspect: [name] :: ProcessManagerInstanceView -> !Text
+ Keiro.ProcessManager.Inspect: [next] :: InstancePage -> !Maybe InspectionCursor
+ Keiro.ProcessManager.Inspect: [observedAt] :: ProcessManagerInstanceView -> !UTCTime
+ Keiro.ProcessManager.Inspect: [observedHeadVersion] :: ProcessManagerInstanceView -> !StreamVersion
+ Keiro.ProcessManager.Inspect: [pageSize] :: InstanceListRequest -> !Int
+ Keiro.ProcessManager.Inspect: [pendingTimers] :: ProcessManagerInstanceView -> !PendingTimerPage
+ Keiro.ProcessManager.Inspect: [provenanceMode] :: ProcessManagerInspector phi (rs :: [Slot]) s ci co -> !ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: [provenance] :: LastReaction -> !Maybe ReactionProvenance
+ Keiro.ProcessManager.Inspect: [recordedAt] :: LastReaction -> !UTCTime
+ Keiro.ProcessManager.Inspect: [skipped] :: InstancePage -> !Int
+ Keiro.ProcessManager.Inspect: [snapshotPolicy] :: ProcessManagerTypeSummary -> !Text
+ Keiro.ProcessManager.Inspect: [snapshotsConfigured] :: ProcessManagerTypeSummary -> !Bool
+ Keiro.ProcessManager.Inspect: [sourceEventId] :: ReactionProvenance -> !EventId
+ Keiro.ProcessManager.Inspect: [sourceGlobalPosition] :: ReactionProvenance -> !GlobalPosition
+ Keiro.ProcessManager.Inspect: [sourceStreamId] :: ReactionProvenance -> !StreamId
+ Keiro.ProcessManager.Inspect: [sourceStreamName] :: LastReaction -> !Maybe StreamName
+ Keiro.ProcessManager.Inspect: [stateLabel] :: ProcessManagerInstanceView -> !Maybe Text
+ Keiro.ProcessManager.Inspect: [state] :: ProcessManagerInstanceView -> !Maybe Value
+ Keiro.ProcessManager.Inspect: [streamFor] :: ProcessManagerInspector phi (rs :: [Slot]) s ci co -> !Text -> Stream (EventStream phi rs s ci co)
+ Keiro.ProcessManager.Inspect: [streamName] :: InstanceSummary -> !StreamName
+ Keiro.ProcessManager.Inspect: [streamVersion] :: InstanceSummary -> !StreamVersion
+ Keiro.ProcessManager.Inspect: [targetState] :: EnabledInput -> !Text
+ Keiro.ProcessManager.Inspect: [targetStreamName] :: DispatchedCommandStatus -> !StreamName
+ Keiro.ProcessManager.Inspect: [timerIds] :: ReactionProvenance -> ![TimerId]
+ Keiro.ProcessManager.Inspect: [timerPageSize] :: InstanceViewRequest -> !Int
+ Keiro.ProcessManager.Inspect: [timersConfigured] :: ProcessManagerTypeSummary -> !Maybe Bool
+ Keiro.ProcessManager.Inspect: [truncateBefore] :: InstanceSummary -> !StreamVersion
+ Keiro.ProcessManager.Inspect: [truncated] :: ReactionProvenance -> !Bool
+ Keiro.ProcessManager.Inspect: [waitingFor] :: ProcessManagerInstanceView -> ![EnabledInput]
+ Keiro.ProcessManager.Inspect: [witness] :: DispatchedCommandStatus -> !DispatchWitness
+ Keiro.ProcessManager.Inspect: data DispatchWitness
+ Keiro.ProcessManager.Inspect: data DispatchedCommandStatus
+ Keiro.ProcessManager.Inspect: data EnabledInput
+ Keiro.ProcessManager.Inspect: data InstanceListError
+ Keiro.ProcessManager.Inspect: data InstanceListRequest
+ Keiro.ProcessManager.Inspect: data InstancePage
+ Keiro.ProcessManager.Inspect: data InstanceSummary
+ Keiro.ProcessManager.Inspect: data InstanceViewRequest
+ Keiro.ProcessManager.Inspect: data LastReaction
+ Keiro.ProcessManager.Inspect: data ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: data ProcessManagerInspector phi (rs :: [Slot]) s ci co
+ Keiro.ProcessManager.Inspect: data ProcessManagerInstanceView
+ Keiro.ProcessManager.Inspect: data ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: data ProcessManagerTypeSummary
+ Keiro.ProcessManager.Inspect: data ReactionProvenance
+ Keiro.ProcessManager.Inspect: data SomeProcessManagerInspector
+ Keiro.ProcessManager.Inspect: decodeReactionProvenance :: Value -> Maybe ReactionProvenance
+ Keiro.ProcessManager.Inspect: domainProcessManagerInspector :: forall s input (rs :: [Slot]) ci co tPhi (tRs :: [Slot]) tS tCi tCo rej noOp. Show s => DomainProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo rej noOp -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
+ Keiro.ProcessManager.Inspect: encodeReactionProvenance :: ReactionProvenance -> Value
+ Keiro.ProcessManager.Inspect: inspectProcessManagerInstance :: forall (es :: [Effect]) phi (rs :: [Slot]) ci co s. (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => ProcessManagerInspector phi rs s ci co -> InstanceViewRequest -> Text -> Eff es (Either ProcessManagerInspectionError (Maybe ProcessManagerInstanceView))
+ Keiro.ProcessManager.Inspect: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.ProcessManager.Inspect.ProcessManagerTypeSummary
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.DispatchWitness
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.DispatchedCommandStatus
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.EnabledInput
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.InstanceListError
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.InstanceListRequest
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.InstancePage
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.InstanceSummary
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.InstanceViewRequest
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.LastReaction
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.ProcessManagerInstanceView
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: instance GHC.Classes.Eq Keiro.ProcessManager.Inspect.ProcessManagerTypeSummary
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic (Keiro.ProcessManager.Inspect.ProcessManagerInspector phi rs s ci co)
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.DispatchWitness
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.DispatchedCommandStatus
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.EnabledInput
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.InstanceListError
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.InstanceListRequest
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.InstancePage
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.InstanceSummary
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.InstanceViewRequest
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.LastReaction
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.ProcessManagerInstanceView
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Generics.Generic Keiro.ProcessManager.Inspect.ProcessManagerTypeSummary
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.DispatchWitness
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.DispatchedCommandStatus
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.EnabledInput
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.InstanceListError
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.InstanceListRequest
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.InstancePage
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.InstanceSummary
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.InstanceViewRequest
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.LastReaction
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.ProcessManagerInspectionError
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.ProcessManagerInstanceView
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.ProcessManagerProvenanceMode
+ Keiro.ProcessManager.Inspect: instance GHC.Internal.Show.Show Keiro.ProcessManager.Inspect.ProcessManagerTypeSummary
+ Keiro.ProcessManager.Inspect: instanceListCursorKind :: Text
+ Keiro.ProcessManager.Inspect: instancePageJson :: InstancePage -> Value
+ Keiro.ProcessManager.Inspect: listProcessManagerInstances :: forall (es :: [Effect]) phi (rs :: [Slot]) s ci co. Store :> es => ProcessManagerInspector phi rs s ci co -> InstanceListRequest -> Eff es (Either InstanceListError InstancePage)
+ Keiro.ProcessManager.Inspect: listProcessManagerTypes :: ProcessManagerInspectors -> [ProcessManagerTypeSummary]
+ Keiro.ProcessManager.Inspect: processManagerInspector :: forall s input (rs :: [Slot]) ci co tPhi (tRs :: [Slot]) tS tCi tCo. Show s => ProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
+ Keiro.ProcessManager.Inspect: processManagerTypeJson :: ProcessManagerTypeSummary -> Value
+ Keiro.ProcessManager.Inspect: processManagerViewJson :: ProcessManagerInstanceView -> Value
+ Keiro.ProcessManager.Inspect: reactiveProcessManagerInspector :: forall s input (rs :: [Slot]) ci co tPhi (tRs :: [Slot]) tS tCi tCo rej noOp. Show s => ReactiveProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo rej noOp -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co
+ Keiro.ProcessManager.Inspect: type ProcessManagerInspectors = Map Text SomeProcessManagerInspector
+ Keiro.ReadModel.Rebuild: InvalidProjectionStatusCursor :: !CursorError -> ProjectionStatusReadError
+ Keiro.ReadModel.Rebuild: InvalidProjectionStatusPageSize :: !Int -> ProjectionStatusReadError
+ Keiro.ReadModel.Rebuild: ProjectionStatusPage :: !UTCTime -> ![ProjectionGroupStatusV1] -> !Maybe InspectionCursor -> ProjectionStatusPage
+ Keiro.ReadModel.Rebuild: ProjectionStatusPageRequest :: !Int -> !Maybe InspectionCursor -> ProjectionStatusPageRequest
+ Keiro.ReadModel.Rebuild: ProjectionStatusResultTooLarge :: ProjectionStatusReadError
+ Keiro.ReadModel.Rebuild: [after] :: ProjectionStatusPageRequest -> !Maybe InspectionCursor
+ Keiro.ReadModel.Rebuild: [items] :: ProjectionStatusPage -> ![ProjectionGroupStatusV1]
+ Keiro.ReadModel.Rebuild: [next] :: ProjectionStatusPage -> !Maybe InspectionCursor
+ Keiro.ReadModel.Rebuild: [observedAt] :: ProjectionStatusPage -> !UTCTime
+ Keiro.ReadModel.Rebuild: data ProjectionStatusPage
+ Keiro.ReadModel.Rebuild: data ProjectionStatusPageRequest
+ Keiro.ReadModel.Rebuild: data ProjectionStatusReadError
+ Keiro.ReadModel.Rebuild: listProjectionGroupStatusesPage :: forall (es :: [Effect]). Store :> es => ProjectionStatusPageRequest -> Eff es (Either ProjectionStatusReadError ProjectionStatusPage)
+ Keiro.ReadModel.Rebuild: projectionGroupsCursorKind :: Text
+ Keiro.ReadModel.Rebuild: validateProjectionStatusRequest :: ProjectionStatusPageRequest -> Either ProjectionStatusReadError (Maybe Text)
+ Keiro.Snapshot.Schema: SnapshotObservation :: !Maybe SnapshotRow -> !UTCTime -> SnapshotObservation
+ Keiro.Snapshot.Schema: [observedAt] :: SnapshotObservation -> !UTCTime
+ Keiro.Snapshot.Schema: [row] :: SnapshotObservation -> !Maybe SnapshotRow
+ Keiro.Snapshot.Schema: data SnapshotObservation
+ Keiro.Snapshot.Schema: instance GHC.Classes.Eq Keiro.Snapshot.Schema.SnapshotObservation
+ Keiro.Snapshot.Schema: instance GHC.Internal.Generics.Generic Keiro.Snapshot.Schema.SnapshotObservation
+ Keiro.Snapshot.Schema: instance GHC.Internal.Show.Show Keiro.Snapshot.Schema.SnapshotObservation
+ Keiro.Snapshot.Schema: observeSnapshotRow :: forall (es :: [Effect]). Store :> es => StreamId -> Eff es SnapshotObservation
+ Keiro.Subscription.Shard: LeaseExpired :: LeaseState
+ Keiro.Subscription.Shard: LeaseInvalid :: LeaseState
+ Keiro.Subscription.Shard: LeaseLive :: LeaseState
+ Keiro.Subscription.Shard: LeaseUnowned :: LeaseState
+ Keiro.Subscription.Shard: ShardOwnershipRow :: !Int -> !Int -> !Maybe WorkerId -> !Maybe UTCTime -> !Maybe UTCTime -> !UTCTime -> ShardOwnershipRow
+ Keiro.Subscription.Shard: ShardOwnershipView :: !SubscriptionName -> !UTCTime -> ![ShardOwnershipRow] -> ShardOwnershipView
+ Keiro.Subscription.Shard: ShardedSubscriptionList :: !UTCTime -> ![ShardedSubscriptionSummary] -> ShardedSubscriptionList
+ Keiro.Subscription.Shard: ShardedSubscriptionSummary :: !SubscriptionName -> ![(Int, Int)] -> !Int -> !Int -> !Int -> !Int -> !Int -> ShardedSubscriptionSummary
+ Keiro.Subscription.Shard: [bucket] :: ShardOwnershipRow -> !Int
+ Keiro.Subscription.Shard: [bucketsExpired] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard: [bucketsLive] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard: [bucketsTotal] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard: [bucketsUnowned] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard: [buckets] :: ShardOwnershipView -> ![ShardOwnershipRow]
+ Keiro.Subscription.Shard: [heartbeatAt] :: ShardOwnershipRow -> !Maybe UTCTime
+ Keiro.Subscription.Shard: [leaseExpiresAt] :: ShardOwnershipRow -> !Maybe UTCTime
+ Keiro.Subscription.Shard: [liveOwners] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard: [observedAt] :: ShardedSubscriptionList -> !UTCTime
+ Keiro.Subscription.Shard: [owner] :: ShardOwnershipRow -> !Maybe WorkerId
+ Keiro.Subscription.Shard: [shardCounts] :: ShardedSubscriptionSummary -> ![(Int, Int)]
+ Keiro.Subscription.Shard: [subscriptions] :: ShardedSubscriptionList -> ![ShardedSubscriptionSummary]
+ Keiro.Subscription.Shard: [updatedAt] :: ShardOwnershipRow -> !UTCTime
+ Keiro.Subscription.Shard: data LeaseState
+ Keiro.Subscription.Shard: data ShardOwnershipRow
+ Keiro.Subscription.Shard: data ShardOwnershipView
+ Keiro.Subscription.Shard: data ShardedSubscriptionList
+ Keiro.Subscription.Shard: data ShardedSubscriptionSummary
+ Keiro.Subscription.Shard: instance GHC.Classes.Eq Keiro.Subscription.Shard.LeaseState
+ Keiro.Subscription.Shard: instance GHC.Classes.Eq Keiro.Subscription.Shard.ShardOwnershipView
+ Keiro.Subscription.Shard: instance GHC.Classes.Eq Keiro.Subscription.Shard.ShardedSubscriptionList
+ Keiro.Subscription.Shard: instance GHC.Internal.Generics.Generic Keiro.Subscription.Shard.LeaseState
+ Keiro.Subscription.Shard: instance GHC.Internal.Generics.Generic Keiro.Subscription.Shard.ShardOwnershipView
+ Keiro.Subscription.Shard: instance GHC.Internal.Generics.Generic Keiro.Subscription.Shard.ShardedSubscriptionList
+ Keiro.Subscription.Shard: instance GHC.Internal.Show.Show Keiro.Subscription.Shard.LeaseState
+ Keiro.Subscription.Shard: instance GHC.Internal.Show.Show Keiro.Subscription.Shard.ShardOwnershipView
+ Keiro.Subscription.Shard: instance GHC.Internal.Show.Show Keiro.Subscription.Shard.ShardedSubscriptionList
+ Keiro.Subscription.Shard: leaseAgeAt :: UTCTime -> ShardOwnershipRow -> Maybe NominalDiffTime
+ Keiro.Subscription.Shard: leaseStateAt :: UTCTime -> ShardOwnershipRow -> LeaseState
+ Keiro.Subscription.Shard: listShardedSubscriptions :: forall (es :: [Effect]). Store :> es => Eff es ShardedSubscriptionList
+ Keiro.Subscription.Shard: shardOwnershipView :: forall (es :: [Effect]). Store :> es => SubscriptionName -> Eff es ShardOwnershipView
+ Keiro.Subscription.Shard.Schema: ShardOwnershipRow :: !Int -> !Int -> !Maybe WorkerId -> !Maybe UTCTime -> !Maybe UTCTime -> !UTCTime -> ShardOwnershipRow
+ Keiro.Subscription.Shard.Schema: ShardedSubscriptionSummary :: !SubscriptionName -> ![(Int, Int)] -> !Int -> !Int -> !Int -> !Int -> !Int -> ShardedSubscriptionSummary
+ Keiro.Subscription.Shard.Schema: [bucket] :: ShardOwnershipRow -> !Int
+ Keiro.Subscription.Shard.Schema: [bucketsExpired] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard.Schema: [bucketsLive] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard.Schema: [bucketsTotal] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard.Schema: [bucketsUnowned] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard.Schema: [heartbeatAt] :: ShardOwnershipRow -> !Maybe UTCTime
+ Keiro.Subscription.Shard.Schema: [leaseExpiresAt] :: ShardOwnershipRow -> !Maybe UTCTime
+ Keiro.Subscription.Shard.Schema: [liveOwners] :: ShardedSubscriptionSummary -> !Int
+ Keiro.Subscription.Shard.Schema: [owner] :: ShardOwnershipRow -> !Maybe WorkerId
+ Keiro.Subscription.Shard.Schema: [shardCount] :: ShardOwnershipRow -> !Int
+ Keiro.Subscription.Shard.Schema: [shardCounts] :: ShardedSubscriptionSummary -> ![(Int, Int)]
+ Keiro.Subscription.Shard.Schema: [subscriptionName] :: ShardedSubscriptionSummary -> !SubscriptionName
+ Keiro.Subscription.Shard.Schema: [updatedAt] :: ShardOwnershipRow -> !UTCTime
+ Keiro.Subscription.Shard.Schema: currentDatabaseTime :: Transaction UTCTime
+ Keiro.Subscription.Shard.Schema: data ShardOwnershipRow
+ Keiro.Subscription.Shard.Schema: data ShardedSubscriptionSummary
+ Keiro.Subscription.Shard.Schema: instance GHC.Classes.Eq Keiro.Subscription.Shard.Schema.ShardOwnershipRow
+ Keiro.Subscription.Shard.Schema: instance GHC.Classes.Eq Keiro.Subscription.Shard.Schema.ShardedSubscriptionSummary
+ Keiro.Subscription.Shard.Schema: instance GHC.Internal.Generics.Generic Keiro.Subscription.Shard.Schema.ShardOwnershipRow
+ Keiro.Subscription.Shard.Schema: instance GHC.Internal.Generics.Generic Keiro.Subscription.Shard.Schema.ShardedSubscriptionSummary
+ Keiro.Subscription.Shard.Schema: instance GHC.Internal.Show.Show Keiro.Subscription.Shard.Schema.ShardOwnershipRow
+ Keiro.Subscription.Shard.Schema: instance GHC.Internal.Show.Show Keiro.Subscription.Shard.Schema.ShardedSubscriptionSummary
+ Keiro.Subscription.Shard.Schema: listShardOwnershipDetail :: SubscriptionName -> Transaction [ShardOwnershipRow]
+ Keiro.Subscription.Shard.Schema: listShardedSubscriptionSummaries :: Transaction [ShardedSubscriptionSummary]
+ Keiro.Timer: InvalidPendingTimerCursor :: !CursorError -> PendingTimerReadError
+ Keiro.Timer: InvalidPendingTimerPageSize :: !Int -> PendingTimerReadError
+ Keiro.Timer: PendingTimerFilter :: !Maybe Text -> !Maybe Text -> PendingTimerFilter
+ Keiro.Timer: PendingTimerPage :: !UTCTime -> ![TimerInspection] -> !Maybe InspectionCursor -> PendingTimerPage
+ Keiro.Timer: PendingTimerPageRequest :: !Int -> !Maybe InspectionCursor -> PendingTimerPageRequest
+ Keiro.Timer: PendingTimerResultTooLarge :: PendingTimerReadError
+ Keiro.Timer: [after] :: PendingTimerPageRequest -> !Maybe InspectionCursor
+ Keiro.Timer: [next] :: PendingTimerPage -> !Maybe InspectionCursor
+ Keiro.Timer: [observedAt] :: PendingTimerPage -> !UTCTime
+ Keiro.Timer: anyPendingTimer :: PendingTimerFilter
+ Keiro.Timer: data PendingTimerFilter
+ Keiro.Timer: data PendingTimerPage
+ Keiro.Timer: data PendingTimerPageRequest
+ Keiro.Timer: data PendingTimerReadError
+ Keiro.Timer: findPendingTimers :: forall (es :: [Effect]). Store :> es => PendingTimerFilter -> PendingTimerPageRequest -> Eff es (Either PendingTimerReadError PendingTimerPage)
+ Keiro.Timer: pendingTimerCursorKind :: Text
+ Keiro.Timer: pendingTimerItemJson :: UTCTime -> TimerInspection -> Value
+ Keiro.Timer: pendingTimerPageJson :: PendingTimerPage -> Value
+ Keiro.Timer: pendingTimerStatuses :: NonEmpty TimerStatus
+ Keiro.Timer: validatePendingTimerRequest :: PendingTimerFilter -> PendingTimerPageRequest -> Either PendingTimerReadError (Maybe (UTCTime, UUID))
+ Keiro.Timer.Schema: InvalidPendingTimerCursor :: !CursorError -> PendingTimerReadError
+ Keiro.Timer.Schema: InvalidPendingTimerPageSize :: !Int -> PendingTimerReadError
+ Keiro.Timer.Schema: PendingTimerFilter :: !Maybe Text -> !Maybe Text -> PendingTimerFilter
+ Keiro.Timer.Schema: PendingTimerPage :: !UTCTime -> ![TimerInspection] -> !Maybe InspectionCursor -> PendingTimerPage
+ Keiro.Timer.Schema: PendingTimerPageRequest :: !Int -> !Maybe InspectionCursor -> PendingTimerPageRequest
+ Keiro.Timer.Schema: PendingTimerResultTooLarge :: PendingTimerReadError
+ Keiro.Timer.Schema: [after] :: PendingTimerPageRequest -> !Maybe InspectionCursor
+ Keiro.Timer.Schema: [next] :: PendingTimerPage -> !Maybe InspectionCursor
+ Keiro.Timer.Schema: [observedAt] :: PendingTimerPage -> !UTCTime
+ Keiro.Timer.Schema: anyPendingTimer :: PendingTimerFilter
+ Keiro.Timer.Schema: data PendingTimerFilter
+ Keiro.Timer.Schema: data PendingTimerPage
+ Keiro.Timer.Schema: data PendingTimerPageRequest
+ Keiro.Timer.Schema: data PendingTimerReadError
+ Keiro.Timer.Schema: findPendingTimers :: forall (es :: [Effect]). Store :> es => PendingTimerFilter -> PendingTimerPageRequest -> Eff es (Either PendingTimerReadError PendingTimerPage)
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.PendingTimerFilter
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.PendingTimerPage
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.PendingTimerPageRequest
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.PendingTimerReadError
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.PendingTimerFilter
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.PendingTimerPage
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.PendingTimerPageRequest
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.PendingTimerReadError
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.PendingTimerFilter
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.PendingTimerPage
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.PendingTimerPageRequest
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.PendingTimerReadError
+ Keiro.Timer.Schema: pendingTimerCursorKind :: Text
+ Keiro.Timer.Schema: pendingTimerItemJson :: UTCTime -> TimerInspection -> Value
+ Keiro.Timer.Schema: pendingTimerPageJson :: PendingTimerPage -> Value
+ Keiro.Timer.Schema: pendingTimerStatuses :: NonEmpty TimerStatus
+ Keiro.Timer.Schema: validatePendingTimerRequest :: PendingTimerFilter -> PendingTimerPageRequest -> Either PendingTimerReadError (Maybe (UTCTime, UUID))
+ Keiro.Workflow.Awakeable.Schema: lookupAwakeablesOfOwnerPage :: forall (es :: [Effect]). Store :> es => Text -> Text -> Maybe (UTCTime, UUID) -> Int -> Eff es [AwakeableRow]
+ Keiro.Workflow.Child.Schema: lookupChildrenOfParentPage :: forall (es :: [Effect]). Store :> es => Text -> Text -> Maybe (UTCTime, Text, Text) -> Int -> Eff es [ChildRow]
+ Keiro.Workflow.Inspection: ByCreatedDesc :: WorkflowListOrdering
+ Keiro.Workflow.Inspection: ByKey :: WorkflowListOrdering
+ Keiro.Workflow.Inspection: ClassAwaiting :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: ClassCancelled :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: ClassCompleted :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: ClassFailed :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: ClassRetrying :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: ClassRunning :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: ClassSleeping :: WorkflowStatusClass
+ Keiro.Workflow.Inspection: CursorGenerationMismatch :: !Int -> !Int -> InspectionError
+ Keiro.Workflow.Inspection: CursorResultTooLarge :: InspectionError
+ Keiro.Workflow.Inspection: DetailPageQuery :: !Maybe Int -> !Int -> !Maybe InspectionCursor -> DetailPageQuery
+ Keiro.Workflow.Inspection: InvalidCursor :: !CursorError -> InspectionError
+ Keiro.Workflow.Inspection: InvalidPageSize :: !Int -> InspectionError
+ Keiro.Workflow.Inspection: OwnerPageQuery :: !Int -> !Maybe InspectionCursor -> OwnerPageQuery
+ Keiro.Workflow.Inspection: WorkflowAwakeablePage :: ![AwakeableRow] -> !Maybe InspectionCursor -> !UTCTime -> WorkflowAwakeablePage
+ Keiro.Workflow.Inspection: WorkflowChildPage :: ![ChildRow] -> !Maybe InspectionCursor -> !UTCTime -> WorkflowChildPage
+ Keiro.Workflow.Inspection: WorkflowJournalEntry :: !UUID -> !Text -> !Int64 -> !Int64 -> !Text -> !UTCTime -> !Value -> !WorkflowJournalEvent -> WorkflowJournalEntry
+ Keiro.Workflow.Inspection: WorkflowJournalPage :: !Int -> ![WorkflowJournalEntry] -> !Maybe InspectionCursor -> !UTCTime -> WorkflowJournalPage
+ Keiro.Workflow.Inspection: WorkflowListPage :: ![WorkflowInstanceRow] -> !Maybe InspectionCursor -> !UTCTime -> WorkflowListPage
+ Keiro.Workflow.Inspection: WorkflowListQuery :: !Maybe (NonEmpty WorkflowStatusClass) -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe UTCTime -> !WorkflowListOrdering -> !Int -> !Maybe InspectionCursor -> WorkflowListQuery
+ Keiro.Workflow.Inspection: WorkflowStepPage :: !Int -> ![WorkflowStepRow] -> !Maybe InspectionCursor -> !UTCTime -> WorkflowStepPage
+ Keiro.Workflow.Inspection: [classes] :: WorkflowListQuery -> !Maybe (NonEmpty WorkflowStatusClass)
+ Keiro.Workflow.Inspection: [createdBefore] :: WorkflowListQuery -> !Maybe UTCTime
+ Keiro.Workflow.Inspection: [createdSince] :: WorkflowListQuery -> !Maybe UTCTime
+ Keiro.Workflow.Inspection: [cursor] :: OwnerPageQuery -> !Maybe InspectionCursor
+ Keiro.Workflow.Inspection: [eventId] :: WorkflowJournalEntry -> !UUID
+ Keiro.Workflow.Inspection: [eventType] :: WorkflowJournalEntry -> !Text
+ Keiro.Workflow.Inspection: [event] :: WorkflowJournalEntry -> !WorkflowJournalEvent
+ Keiro.Workflow.Inspection: [generation] :: WorkflowStepPage -> !Int
+ Keiro.Workflow.Inspection: [globalPosition] :: WorkflowJournalEntry -> !Int64
+ Keiro.Workflow.Inspection: [items] :: WorkflowAwakeablePage -> ![AwakeableRow]
+ Keiro.Workflow.Inspection: [nextCursor] :: WorkflowAwakeablePage -> !Maybe InspectionCursor
+ Keiro.Workflow.Inspection: [observedAt] :: WorkflowAwakeablePage -> !UTCTime
+ Keiro.Workflow.Inspection: [ordering] :: WorkflowListQuery -> !WorkflowListOrdering
+ Keiro.Workflow.Inspection: [pageSize] :: OwnerPageQuery -> !Int
+ Keiro.Workflow.Inspection: [payload] :: WorkflowJournalEntry -> !Value
+ Keiro.Workflow.Inspection: [recordedAt] :: WorkflowJournalEntry -> !UTCTime
+ Keiro.Workflow.Inspection: [stepName] :: WorkflowJournalEntry -> !Text
+ Keiro.Workflow.Inspection: [streamVersion] :: WorkflowJournalEntry -> !Int64
+ Keiro.Workflow.Inspection: [updatedBefore] :: WorkflowListQuery -> !Maybe UTCTime
+ Keiro.Workflow.Inspection: [updatedSince] :: WorkflowListQuery -> !Maybe UTCTime
+ Keiro.Workflow.Inspection: [workflowName] :: WorkflowListQuery -> !Maybe Text
+ Keiro.Workflow.Inspection: awakeableToJson :: AwakeableRow -> Value
+ Keiro.Workflow.Inspection: childToJson :: ChildRow -> Value
+ Keiro.Workflow.Inspection: classStatus :: WorkflowStatusClass -> WorkflowStatus
+ Keiro.Workflow.Inspection: data DetailPageQuery
+ Keiro.Workflow.Inspection: data InspectionError
+ Keiro.Workflow.Inspection: data OwnerPageQuery
+ Keiro.Workflow.Inspection: data WorkflowAwakeablePage
+ Keiro.Workflow.Inspection: data WorkflowChildPage
+ Keiro.Workflow.Inspection: data WorkflowJournalEntry
+ Keiro.Workflow.Inspection: data WorkflowJournalPage
+ Keiro.Workflow.Inspection: data WorkflowListOrdering
+ Keiro.Workflow.Inspection: data WorkflowListPage
+ Keiro.Workflow.Inspection: data WorkflowListQuery
+ Keiro.Workflow.Inspection: data WorkflowStatusClass
+ Keiro.Workflow.Inspection: data WorkflowStepPage
+ Keiro.Workflow.Inspection: defaultWorkflowListQuery :: WorkflowListQuery
+ Keiro.Workflow.Inspection: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Workflow.Inspection.WorkflowAwakeablePage
+ Keiro.Workflow.Inspection: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Workflow.Inspection.WorkflowChildPage
+ Keiro.Workflow.Inspection: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Workflow.Inspection.WorkflowJournalPage
+ Keiro.Workflow.Inspection: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Workflow.Inspection.WorkflowListPage
+ Keiro.Workflow.Inspection: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Workflow.Inspection.WorkflowStepPage
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.DetailPageQuery
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.InspectionError
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.OwnerPageQuery
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.WorkflowAwakeablePage
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.WorkflowChildPage
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.WorkflowJournalEntry
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.WorkflowJournalPage
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.WorkflowListPage
+ Keiro.Workflow.Inspection: instance GHC.Classes.Eq Keiro.Workflow.Inspection.WorkflowStepPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.DetailPageQuery
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.InspectionError
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.OwnerPageQuery
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.WorkflowAwakeablePage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.WorkflowChildPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.WorkflowJournalEntry
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.WorkflowJournalPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.WorkflowListPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Generics.Generic Keiro.Workflow.Inspection.WorkflowStepPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.DetailPageQuery
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.InspectionError
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.OwnerPageQuery
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.WorkflowAwakeablePage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.WorkflowChildPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.WorkflowJournalEntry
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.WorkflowJournalPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.WorkflowListPage
+ Keiro.Workflow.Inspection: instance GHC.Internal.Show.Show Keiro.Workflow.Inspection.WorkflowStepPage
+ Keiro.Workflow.Inspection: instanceToJson :: WorkflowInstanceRow -> Value
+ Keiro.Workflow.Inspection: journalEntryToJson :: WorkflowJournalEntry -> Value
+ Keiro.Workflow.Inspection: listWorkflowAwakeablesPage :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> OwnerPageQuery -> Eff es (Either InspectionError WorkflowAwakeablePage)
+ Keiro.Workflow.Inspection: listWorkflowChildrenPage :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> OwnerPageQuery -> Eff es (Either InspectionError WorkflowChildPage)
+ Keiro.Workflow.Inspection: listWorkflowInstancesPage :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowListQuery -> Eff es (Either InspectionError WorkflowListPage)
+ Keiro.Workflow.Inspection: loadStepIndexPage :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> DetailPageQuery -> Eff es (Either InspectionError WorkflowStepPage)
+ Keiro.Workflow.Inspection: maxInspectionPageSize :: Int
+ Keiro.Workflow.Inspection: readWorkflowJournalPage :: forall (es :: [Effect]). (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> DetailPageQuery -> Eff es (Either InspectionError WorkflowJournalPage)
+ Keiro.Workflow.Inspection: statusClassFromText :: Text -> Maybe WorkflowStatusClass
+ Keiro.Workflow.Inspection: statusClassOf :: WorkflowInstanceRow -> WorkflowStatusClass
+ Keiro.Workflow.Inspection: statusClassToText :: WorkflowStatusClass -> Text
+ Keiro.Workflow.Inspection: stepToJson :: WorkflowStepRow -> Value
+ Keiro.Workflow.Inspection: validateAwakeableQuery :: WorkflowName -> WorkflowId -> OwnerPageQuery -> Either InspectionError ()
+ Keiro.Workflow.Inspection: validateChildQuery :: WorkflowName -> WorkflowId -> OwnerPageQuery -> Either InspectionError ()
+ Keiro.Workflow.Inspection: validateJournalQuery :: WorkflowName -> WorkflowId -> DetailPageQuery -> Either InspectionError ()
+ Keiro.Workflow.Inspection: validateStepQuery :: WorkflowName -> WorkflowId -> DetailPageQuery -> Either InspectionError ()
+ Keiro.Workflow.Inspection: validateWorkflowListQuery :: WorkflowListQuery -> Either InspectionError ()
+ Keiro.Workflow.Instance: HeaderChanged :: !WorkflowHeaderKey -> !WorkflowInstanceRow -> WorkflowHeaderObservation
+ Keiro.Workflow.Instance: HeaderImageTooLarge :: WorkflowHeaderObservation
+ Keiro.Workflow.Instance: HeaderUnchanged :: WorkflowHeaderObservation
+ Keiro.Workflow.Instance: data WorkflowHeaderKey
+ Keiro.Workflow.Instance: data WorkflowHeaderObservation
+ Keiro.Workflow.Instance: inspectionInstanceRows :: forall (es :: [Effect]). Store :> es => WorkflowListQuery -> Maybe WorkflowListPosition -> Int -> Eff es (UTCTime, [WorkflowInstanceRow])
+ Keiro.Workflow.Instance: instance GHC.Classes.Eq Keiro.Workflow.Instance.WorkflowHeaderKey
+ Keiro.Workflow.Instance: instance GHC.Classes.Eq Keiro.Workflow.Instance.WorkflowHeaderObservation
+ Keiro.Workflow.Instance: instance GHC.Internal.Show.Show Keiro.Workflow.Instance.WorkflowHeaderKey
+ Keiro.Workflow.Instance: instance GHC.Internal.Show.Show Keiro.Workflow.Instance.WorkflowHeaderObservation
+ Keiro.Workflow.Instance: lookupInstanceConditionalSession :: Int -> Maybe WorkflowHeaderKey -> WorkflowName -> WorkflowId -> Session (Maybe WorkflowHeaderObservation)
+ Keiro.Workflow.Instance: lookupInstanceSession :: WorkflowName -> WorkflowId -> Session (Maybe WorkflowInstanceRow)
+ Keiro.Workflow.Instance: workflowHeaderKeyBytes :: WorkflowHeaderKey -> Int
+ Keiro.Workflow.Schema: inspectionObservedAt :: forall (es :: [Effect]). Store :> es => Eff es UTCTime
+ Keiro.Workflow.Schema: loadStepIndexPage :: forall (es :: [Effect]). Store :> es => WorkflowName -> WorkflowId -> Int -> Maybe Text -> Int -> Eff es [WorkflowStepRow]
- Keiro.Command: RunCommandOptions :: !Int -> !Int32 -> ![EventId] -> !IO () -> !Int -> !Maybe KeiroMetrics -> !Bool -> !Int -> !Int -> !Maybe Tracer -> !Maybe Value -> RunCommandOptions
+ Keiro.Command: RunCommandOptions :: !Int -> !Int32 -> ![EventId] -> !IO () -> !Int -> !Maybe KeiroMetrics -> !Bool -> !Int -> !Int -> !Maybe Tracer -> !Maybe Value -> !InspectionProvenancePolicy -> RunCommandOptions
- Keiro.Subscription.Shard: [shardCount] :: ShardLease -> !Int
+ Keiro.Subscription.Shard: [shardCount] :: ShardOwnershipRow -> !Int
- Keiro.Subscription.Shard: [subscriptionName] :: ShardLease -> !SubscriptionName
+ Keiro.Subscription.Shard: [subscriptionName] :: ShardedSubscriptionSummary -> !SubscriptionName
- Keiro.Timer: [correlationId] :: TimerRow -> !Text
+ Keiro.Timer: [correlationId] :: PendingTimerFilter -> !Maybe Text
- Keiro.Timer: [pageSize] :: DeadTimerPageRequest -> !Int
+ Keiro.Timer: [pageSize] :: PendingTimerPageRequest -> !Int
- Keiro.Timer: [timers] :: DeadTimerPage -> ![TimerInspection]
+ Keiro.Timer: [timers] :: PendingTimerPage -> ![TimerInspection]
- Keiro.Timer.Schema: [correlationId] :: TimerRow -> !Text
+ Keiro.Timer.Schema: [correlationId] :: PendingTimerFilter -> !Maybe Text
- Keiro.Timer.Schema: [pageSize] :: DeadTimerPageRequest -> !Int
+ Keiro.Timer.Schema: [pageSize] :: PendingTimerPageRequest -> !Int
- Keiro.Timer.Schema: [timers] :: DeadTimerPage -> ![TimerInspection]
+ Keiro.Timer.Schema: [timers] :: PendingTimerPage -> ![TimerInspection]

Files

CHANGELOG.md view
@@ -6,6 +6,68 @@  ## [Unreleased] +## 0.22.0.0 — 2026-10-27++### Breaking Changes++- Require the Kiroku Store 0.12 cohort from+  mori://shinzui/kiroku/packages/kiroku-store; apply migration 0016 from+  mori://shinzui/kiroku/packages/kiroku-store-migrations with affected workers+  stopped, then restart new binaries before using namespace targets. Update+  exhaustive subscription target/event matches and low-level record constructors.+- Require Shibuya Core `^>=0.12.0.0` from+  mori://shinzui/shibuya/packages/shibuya-core; direct `Ingested` records supply+  `deliveryStatus` (`pure DeliveryCurrent` preserves prior behavior), and+  `AppConfig` should be constructed from `defaultAppConfig`.++- Direct `RunCommandOptions` records must supply `inspectionProvenance`; updates of defaults remain disabled. Direct `ProcessManagerInspector` records must supply `provenanceMode` (`ReadLegacyProvenance` or `NoReactionProvenance`).++### New Features++- Add pure mounted process-manager discovery with sorted catalog lookup names,+  snapshot configuration, and explicitly unknown timer configuration; discovery+  performs no store reads or application callback evaluation.+  Summary JSON supports equivalent canonical values and direct encodings.++- Add bounded projection-status pages and `projection status`, preserving the+  frozen v1 relation and complete-list compatibility API. Page keys precede+  correlated status reads; no writer index is added. HTTP feed protocol and+  canonical view definitions are available as foundations for live feeds.++- Add process-manager persisted-state inspection and bounded raw stream listing,+  pending-timer pages with filter-bound cursors, and database-clock shard+  ownership/listing. Reads add no worker queries, polling tasks or migrations.+  Embedded `pm show/list` uses the additive `AppHooks.processManagers` field;+  `emptyAppHooks` leaves this optional catalog off.+++- Explicit, bounded legacy manager provenance through+  `RunCommandOptions.inspectionProvenance` (default `NoInspectionProvenance`).+  Capture records intended source/target evidence under `keiro.pm`, capped at+  64 targets, 64 timer ids and 16 KiB, with an explicit truncation marker.+  Existing updates of defaultRunCommandOptions remain disabled.++- `Keiro.Inspection.Aggregate` exposes folded aggregate state, snapshot health and cursor pages through explicit application descriptors. `hydrateWithSource` reports the existing hydration algorithm’s source; ordinary hydration constructs no diagnostic result. `observeSnapshotRow` adds a database observation clock without changing runtime snapshot lookup.++- Exact, bounded conditional workflow header sessions and reusable single-SELECT point reads.++- Bounded workflow inspection pages, canonical status classes and JSON, pure validation, and opaque cursors.+++- `reactiveProcessManagerInspector` adapts `ReactiveProcessManager.sagaHandler` for state, live input candidates, pending timers and journal listing. Persisted reaction events report unavailable/null provenance without interpreting legacy metadata or probing invented targets. Legacy positional capture remains opt-in and scheduled for retirement with Languages 1–5 before 1.0.++### Bug Fixes++- Fail oversized workflow continuations explicitly instead of returning a cursor+  that the next request cannot decode.+++- Unfiltered workflow pages range over the existing `(workflow_id, workflow_name)` primary key with `wf-key2` cursors. Remove the withdrawn-index wide split, `InspectionWorkLimitExceeded`, `maxIndexedWorkflowKeyBytes`, `maxWideWorkflowCandidates` and `maxWideWorkflowKeyBytes`. Valid wide keys retain byte-capped continuations. The released legacy listing and `wf-created` contract are unchanged; arbitrary filters retain measured deadlines/cost limits.++### Other Changes++- Keep workflow classification and browsing on the read side. No inspection-specific generated column, index, or migration changes ordinary writer work.+ ## 0.21.0.0 — 2026-10-14  ### Other Changes
+ bench/BenchSetup.hs view
@@ -0,0 +1,72 @@+-- | Selection-aware fixture diagnostics shared by the benchmark groups.+module BenchSetup (selected, selectedWithCleanup, memoSetup, setupTrace, freshCounter, nextIteration, nextBoundedIteration, selectedCounter, selectedCounterWithCleanup) where++import Control.Concurrent.MVar (modifyMVar, newMVar)+import Control.DeepSeq (NFData (..))+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import System.Environment (lookupEnv)+import System.IO (hPutStrLn, stderr)+import Test.Tasty.Bench (Benchmark, envWithCleanup)+import Prelude++-- Fixture handles contain mutable state; force the handle, never its contents.+data Handle a = Handle !a++instance NFData (Handle a) where+  rnf (Handle value) = value `seq` ()++selected :: IO a -> (a -> Benchmark) -> Benchmark+selected acquire = selectedWithCleanup acquire (const (pure ()))++selectedWithCleanup :: IO a -> (a -> IO ()) -> (a -> Benchmark) -> Benchmark+selectedWithCleanup acquire cleanup build =+  envWithCleanup (Handle <$> acquire) (\(Handle value) -> cleanup value) $ \handle ->+    build (case handle of Handle value -> value)++memoSetup :: IO a -> IO (IO a)+memoSetup acquire = do+  cell <- newMVar Nothing+  pure $ modifyMVar cell $ \current -> case current of+    Just value -> pure (current, value)+    Nothing -> do+      value <- acquire+      pure (Just value, value)++setupTrace :: String -> String -> String -> IO ()+setupTrace group scenario detail = do+  enabled <- lookupEnv "KEIRO_BENCH_SETUP_TRACE"+  if enabled == Just "1"+    then hPutStrLn stderr ("BENCH_SETUP group=" <> group <> " scenario=" <> scenario <> " " <> detail)+    else pure ()++freshCounter :: String -> Int -> IO (IORef Int)+freshCounter label units = do+  setupTrace "command" label ("counter budget_iterations=" <> show (iterationBudget units) <> " units_per_iteration=" <> show units)+  newIORef 0++iterationBudget :: Int -> Int+iterationBudget units = 100000 `div` max 1 units++nextIteration :: String -> Int -> IORef Int -> IO Int+nextIteration label units = nextBoundedIteration label (iterationBudget units)++nextBoundedIteration :: String -> Int -> IORef Int -> IO Int+nextBoundedIteration label budget counter = do+  next <- atomicModifyIORef' counter (\current -> let n = current + 1 in (n, n))+  if next > budget+    then fail ("benchmark fixture growth budget exceeded: " <> label <> " iterations=" <> show next)+    else pure next++selectedCounter :: String -> Int -> IO (IORef Int) -> (IORef Int -> Benchmark) -> Benchmark+selectedCounter label units acquire = selectedCounterWithCleanup label units acquire (const (pure ()))++selectedCounterWithCleanup :: String -> Int -> IO (IORef Int) -> (Int -> IO ()) -> (IORef Int -> Benchmark) -> Benchmark+selectedCounterWithCleanup label units acquire cleanup build =+  envWithCleanup+    acquire+    ( \counter -> do+        n <- readIORef counter+        setupTrace "counts" label ("iterations=" <> show n <> " units=" <> show (n * units))+        cleanup n+    )+    build
+ bench/CommandProbe.hs view
@@ -0,0 +1,149 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Inspect the actual stream-read prepared plan on a disposable benchmark store.+module CommandProbe (commandPlanProbe, delegatedPlanProbe, tableResetProbe, workCountProbe, rebuildCountProbe, maintenanceProbe) where++import Control.Monad (replicateM)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy.Char8 qualified as Lazy+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.UUID qualified as UUID+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (preparable)+import Keiro.Prelude+import Kiroku.Store qualified as Store+import Kiroku.Store.SQL qualified as SQL+import System.Environment (lookupEnv)+import System.IO (stderr)+import "hasql-transaction" Hasql.Transaction qualified as Tx++commandPlanProbe :: Store.KirokuStore -> Text -> IO ()+commandPlanProbe store label = do+  outcome <- Store.runStoreIO store $ Store.runTransaction $ do+    _ <- Tx.statement ("bench-probe-absent", 0, 100) SQL.readStreamForwardStmt+    entries <-+      Tx.statement () $+        preparable+          "SELECT name FROM pg_prepared_statements WHERE statement LIKE 'SELECT e.event_id,%' AND statement LIKE '%FROM stream_events se%' AND statement LIKE '%s.stream_name = $1%' AND statement LIKE '%se.stream_version > $2%'"+          E.noParams+          (D.rowList (D.column (D.nonNullable D.text)))+    name <- case entries of+      [name] -> pure name+      _ -> error ("expected actual stream read statement: " <> show entries)+    plan <-+      Tx.statement () $+        preparable+          ("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) EXECUTE \"" <> Text.replace "\"" "\"\"" name <> "\"('bench-probe-absent',0,100)")+          E.noParams+          (D.singleRow (D.column (D.nonNullable D.json)))+    counts <-+      Tx.statement () $+        preparable+          "SELECT jsonb_build_object('streams',(SELECT count(*) FROM streams),'events',(SELECT count(*) FROM events),'stream_events',(SELECT count(*) FROM stream_events),'relation_bytes',pg_total_relation_size('stream_events'),'server_version',current_setting('server_version'),'fsync',current_setting('fsync'),'synchronous_commit',current_setting('synchronous_commit'),'full_page_writes',current_setting('full_page_writes'),'autovacuum',current_setting('autovacuum'))"+          E.noParams+          (D.singleRow (D.column (D.nonNullable D.jsonb)))+    pure (Aeson.object ["label" Aeson..= label, "counts" Aeson..= (counts :: Aeson.Value), "plan" Aeson..= (plan :: Aeson.Value)])+  either (fail . show) (Lazy.putStrLn . Aeson.encode) outcome++-- Reset alternatives use the same populated row count on isolated group stores.+tableResetProbe :: Store.KirokuStore -> Text -> Int -> IO () -> IO ()+tableResetProbe store table expected seed = do+  for_ ["TRUNCATE", "DELETE FROM"] $ \verb -> do+    samples <- replicateM 5 $ do+      seed+      before <- info+      unless (fst before == fromIntegral expected) (fail "reset probe seed count differs")+      started <- getMonotonicTimeNSec+      checked (Tx.sql (Text.encodeUtf8 (verb <> " " <> table)))+      ended <- getMonotonicTimeNSec+      after <- info+      unless (fst after == 0) (fail "reset probe retained rows")+      pure (Aeson.object ["elapsed_ns" Aeson..= (ended - started), "rows_before" Aeson..= fst before, "bytes_before" Aeson..= snd before, "rows_after" Aeson..= fst after, "bytes_after" Aeson..= snd after])+    Lazy.putStrLn (Aeson.encode (Aeson.object ["table" Aeson..= table, "reset" Aeson..= verb, "samples" Aeson..= samples]))+  where+    checked :: Tx.Transaction a -> IO a+    checked action = Store.runStoreIO store (Store.runTransaction action) >>= either (fail . show) pure+    info =+      checked+        ( Tx.statement+            ()+            ( preparable+                ("SELECT count(*), pg_total_relation_size('" <> table <> "') FROM " <> table)+                E.noParams+                (D.singleRow ((,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))+            )+        )++delegatedPlanProbe :: Store.KirokuStore -> Text -> IO ()+delegatedPlanProbe store label = do+  result <- Store.runStoreIO store $ Store.runTransaction $ do+    _ <- Tx.statement ("bench-probe-absent", UUID.nil) SQL.eventExistsInStreamStmt+    names <-+      Tx.statement () $+        preparable+          "SELECT name FROM pg_prepared_statements WHERE statement LIKE 'SELECT EXISTS (%' AND statement LIKE '%FROM stream_events se%' AND statement LIKE '%stream_name = $1%'"+          E.noParams+          (D.rowList (D.column (D.nonNullable D.text)))+    name <- case names of [name] -> pure name; _ -> error ("expected delegated prepared statement: " <> show names)+    plan <-+      Tx.statement () $+        preparable+          ("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) EXECUTE \"" <> Text.replace "\"" "\"\"" name <> "\"('bench-probe-absent','00000000-0000-0000-0000-000000000000'::uuid)")+          E.noParams+          (D.singleRow (D.column (D.nonNullable D.json)))+    pure (Aeson.object ["label" Aeson..= label, "plan" Aeson..= (plan :: Aeson.Value)])+  either (fail . show) (Lazy.hPutStrLn stderr . Aeson.encode) result++-- Run only in diagnostic invocations: these reads must not change timed plans.+workCountProbe :: Store.KirokuStore -> Text -> Text -> Int -> Int -> IO ()+workCountProbe store label prefix expectedStreams expectedEvents = do+  enabled <- lookupEnv "KEIRO_BENCH_WORK_COUNTS"+  when (enabled == Just "1") $ do+    result <-+      Store.runStoreIO store $+        Store.runTransaction $+          Tx.statement prefix $+            preparable+              "SELECT count(DISTINCT s.stream_id), count(se.event_id), pg_total_relation_size('streams'), pg_total_relation_size('events'), pg_total_relation_size('stream_events') FROM streams s LEFT JOIN stream_events se USING (stream_id) WHERE left(s.stream_name, length($1)) = $1"+              (E.param (E.nonNullable E.text))+              (D.singleRow ((,,,,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))+    (streams, events, streamBytes, eventBytes, linkBytes) <- either (fail . show) pure result+    unless (streams == fromIntegral expectedStreams && events == fromIntegral expectedEvents) $+      fail ("benchmark work-count mismatch: " <> Text.unpack label <> " expected=" <> show (expectedStreams, expectedEvents) <> " actual=" <> show (streams, events))+    Lazy.hPutStrLn stderr (Aeson.encode (Aeson.object ["label" Aeson..= label, "prefix" Aeson..= prefix, "streams" Aeson..= streams, "events" Aeson..= events, "streams_bytes" Aeson..= streamBytes, "events_bytes" Aeson..= eventBytes, "stream_events_bytes" Aeson..= linkBytes]))++rebuildCountProbe :: Store.KirokuStore -> Int -> IO ()+rebuildCountProbe store expectedRuns = do+  enabled <- lookupEnv "KEIRO_BENCH_WORK_COUNTS"+  when (enabled == Just "1") $ do+    result <-+      Store.runStoreIO store $+        Store.runTransaction $+          Tx.statement () $+            preparable+              "SELECT (SELECT count(*) FROM keiro.keiro_projection_rebuild_runs), (SELECT count(*) FROM keiro.keiro_projection_rebuild_sources), (SELECT count(*) FROM events), jsonb_build_object('runs_bytes',pg_total_relation_size('keiro.keiro_projection_rebuild_runs'),'sources_bytes',pg_total_relation_size('keiro.keiro_projection_rebuild_sources'),'targets',(SELECT count(*) FROM keiro.keiro_projection_rebuild_run_targets),'adapters',(SELECT count(*) FROM keiro.keiro_projection_rebuild_adapters),'verifications',(SELECT count(*) FROM keiro.keiro_projection_rebuild_verifications))"+              E.noParams+              (D.singleRow ((,,,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.jsonb)))+    (runs, sources, events, bookkeeping) <- either (fail . show) pure result+    unless (runs == fromIntegral expectedRuns && sources == fromIntegral (3 * expectedRuns) && events == 600) $+      fail ("rebuild work-count mismatch: " <> show (runs, sources, events))+    Lazy.hPutStrLn stderr (Aeson.encode (Aeson.object ["label" Aeson..= ("rebuild" :: Text), "runs" Aeson..= runs, "sources" Aeson..= sources, "events" Aeson..= events, "bookkeeping" Aeson..= (bookkeeping :: Aeson.Value)]))++-- Cluster maintenance is shared across database clones. Record it outside timing.+maintenanceProbe :: Store.KirokuStore -> Text -> IO ()+maintenanceProbe store label = do+  enabled <- lookupEnv "KEIRO_BENCH_MAINTENANCE_PROBE"+  when (enabled == Just "1") $ do+    result <-+      Store.runStoreIO store $+        Store.runTransaction $+          Tx.statement () $+            preparable+              "SELECT jsonb_build_object('at',clock_timestamp(),'checkpoint',(SELECT to_jsonb(c) FROM pg_stat_checkpointer c),'wal',(SELECT to_jsonb(w) FROM pg_stat_wal w),'lsn',pg_current_wal_lsn(),'max_wal_size',current_setting('max_wal_size'),'checkpoint_timeout',current_setting('checkpoint_timeout'),'shared_buffers',current_setting('shared_buffers'),'activity',(SELECT jsonb_agg(jsonb_build_object('type',backend_type,'database',datname,'state',state,'wait_type',wait_event_type,'wait',wait_event)) FROM pg_stat_activity WHERE backend_type IN ('checkpointer','autovacuum worker','background writer','walwriter','io worker')),'tables',(SELECT jsonb_agg(jsonb_build_object('table',relname,'live',n_live_tup,'dead',n_dead_tup,'vacuum',vacuum_count,'autovacuum',autovacuum_count,'last_autovacuum',last_autovacuum,'analyze',analyze_count,'last_autoanalyze',last_autoanalyze)) FROM pg_stat_user_tables WHERE relname IN ('streams','stream_events','events','keiro_inbox','keiro_inbox_delegated_bench_effect')))"+              E.noParams+              (D.singleRow (D.column (D.nonNullable D.jsonb)))+    snapshot <- either (fail . show) pure result+    Lazy.hPutStrLn stderr (Aeson.encode (Aeson.object ["maintenance" Aeson..= label, "snapshot" Aeson..= (snapshot :: Aeson.Value)]))
bench/InboxDelegatedBench.hs view
@@ -2,16 +2,19 @@ {-# LANGUAGE OverloadedRecordDot #-}  module InboxDelegatedBench-  ( prepareInboxDelegatedBenchmarks,+  ( inboxDelegatedBenchmarks,     runInboxDelegatedExplainIfRequested,   ) where +import BenchSetup (nextIteration, selectedWithCleanup, setupTrace)+import CommandProbe (delegatedPlanProbe, maintenanceProbe, workCountProbe) import Control.DeepSeq (NFData (..))+import Control.Monad (replicateM_) import Data.Aeson qualified as Aeson import Data.Bifunctor (first) import Data.ByteString qualified as ByteString-import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.IORef (IORef, newIORef, readIORef) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text qualified as Text@@ -42,6 +45,7 @@ import Keiro.Prelude import Keiro.Stream (Stream, stream) import Keiro.Telemetry (KeiroMetrics)+import Keiro.Test.Postgres (checkpointBenchmarkStore) import Kiroku.Store qualified as Store import Kiroku.Store.Effect (PreparedEvent, Store) import Kiroku.Store.SQL qualified as StoreSQL@@ -57,7 +61,7 @@ import System.Directory (createDirectoryIfMissing) import System.Environment (lookupEnv) import System.FilePath (takeDirectory)-import Test.Tasty.Bench (Benchmark, bench, bgroup, env, nfIO)+import Test.Tasty.Bench (Benchmark, bench, bgroup, nfIO) import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude @@ -109,22 +113,21 @@       Map.size downstreamRun.workByMessageId `seq`         () -prepareInboxDelegatedBenchmarks :: Store.KirokuStore -> KeiroMetrics -> IO [Benchmark]-prepareInboxDelegatedBenchmarks store metrics = do-  runStoreChecked store $ Store.runTransaction (Tx.sql businessTableSql)-  scenarios <- concat <$> traverse (prepareScenario store metrics) scenarioInputs-  traverse_ (runScenario store) [scenario | scenario <- scenarios, scenario.traffic == FreshTraffic]-  pure-    ( [ bgroup-          (Text.unpack scenario.name)-          [ bgroup-              (trafficName scenario.traffic)-              [bench (metricsName scenario.metrics) (nfIO (runScenario store scenario))]+inboxDelegatedBenchmarks :: Store.KirokuStore -> KeiroMetrics -> [Benchmark]+inboxDelegatedBenchmarks store metrics =+  [ bgroup+      (Text.unpack name)+      [ bgroup+          (trafficName traffic)+          [ selectedWithCleanup (prepareScenario store name mode chunkSize traffic mMetrics) (checkScenarioCounts store) $ \scenario ->+              bench (metricsName mMetrics) (nfIO (scenario `seq` runScenario store scenario))+          | mMetrics <- [Nothing, Just metrics]           ]-      | scenario <- scenarios+      | traffic <- [FreshTraffic, RepeatedTraffic, DuplicateTraffic]       ]-        <> [bgroup "delegated-history-duplicate" (historyBenchmark store <$> [10, 1000, 100000])]-    )+  | (name, mode, chunkSize) <- scenarioInputs+  ]+    <> [bgroup "delegated-history-duplicate" (historyBenchmark store <$> [10, 1000, 100000])]  runInboxDelegatedExplainIfRequested :: Store.KirokuStore -> IO () runInboxDelegatedExplainIfRequested store =@@ -179,30 +182,56 @@     ("delegated-batch-1000", DelegatedMode, 1000)   ] -prepareScenario :: Store.KirokuStore -> KeiroMetrics -> (Text, IntakeMode, Int) -> IO [Scenario]-prepareScenario store metrics (name, mode, chunkSize) =-  traverse make [(traffic, mMetrics) | traffic <- [FreshTraffic, RepeatedTraffic, DuplicateTraffic], mMetrics <- [Nothing, Just metrics]]-  where-    make (traffic, mMetrics) = do-      runNumber <- newIORef 0-      duplicateRun <--        case traffic of-          DuplicateTraffic -> do-            prepared <- prepareDownstreamRun (scenarioPrefix name traffic mMetrics <> "-seed") RepeatedTraffic-            seedDuplicateRun store mode prepared-            pure (Just prepared)-          _ -> pure Nothing-      pure Scenario {name, mode, chunkSize, traffic, metrics = mMetrics, runNumber, duplicateRun}+prepareScenario :: Store.KirokuStore -> Text -> IntakeMode -> Int -> Traffic -> Maybe KeiroMetrics -> IO Scenario+prepareScenario store name mode chunkSize traffic metrics = do+  let label = Text.unpack (scenarioPrefix name traffic metrics)+  setupTrace "delegated-inbox" label "schema"+  runStoreChecked store $ Store.runTransaction (Tx.sql businessTableSql)+  runNumber <- newIORef 0+  duplicateRun <- case traffic of+    DuplicateTraffic -> do+      setupTrace "delegated-inbox" label "duplicate-seed deliveries=2000"+      prepared <- prepareDownstreamRun (scenarioPrefix name traffic metrics <> "-seed") RepeatedTraffic+      seedDuplicateRun store mode prepared+      pure (Just prepared)+    _ -> pure Nothing+  let scenario = Scenario {name, mode, chunkSize, traffic, metrics, runNumber, duplicateRun}+  setupTrace "delegated-inbox" label "warm-up invocations=3"+  runScenario store scenario+  probe <- lookupEnv "KEIRO_BENCH_INBOX_PLAN_PROBE"+  when (probe == Just "1") (delegatedPlanProbe store (Text.pack label <> "-before-analyze"))+  setupTrace "delegated-inbox" label "analyze=after-first-warm-up remaining_warm_up=2"+  runStoreChecked store $ Store.runTransaction (Tx.sql "ANALYZE streams; ANALYZE stream_events; ANALYZE events; ANALYZE keiro.keiro_inbox")+  when (probe == Just "1") (delegatedPlanProbe store (Text.pack label <> "-after-analyze"))+  setupTrace "delegated-inbox" label "checkpoint=before-final-two-warm-ups"+  checkpointBenchmarkStore store+  replicateM_ 2 (runScenario store scenario)+  maintenanceProbe store (Text.pack label <> "-ready")+  pure scenario  runScenario :: Store.KirokuStore -> Scenario -> IO () runScenario store scenario = do   downstreamRun <- case scenario.duplicateRun of-    Just prepared -> pure prepared+    Just prepared -> do+      _ <- nextIteration (Text.unpack scenario.name) 0 scenario.runNumber+      pure prepared     Nothing -> do-      invocation <- atomicModifyIORef' scenario.runNumber (\current -> let next = current + 1 in (next, next))+      invocation <- nextIteration (Text.unpack scenario.name) (if scenario.traffic == FreshTraffic then deliveryCount else 1) scenario.runNumber       prepareDownstreamRun (scenarioPrefix scenario.name scenario.traffic scenario.metrics <> "-" <> Text.pack (show invocation)) scenario.traffic   runStoreChecked store (runDownstream scenario downstreamRun) +checkScenarioCounts :: Store.KirokuStore -> Scenario -> IO ()+checkScenarioCounts store scenario = do+  n <- readIORef scenario.runNumber+  let prefix = scenarioPrefix scenario.name scenario.traffic scenario.metrics+      expected = case scenario.traffic of+        FreshTraffic -> n * deliveryCount+        RepeatedTraffic -> n+        DuplicateTraffic -> 1+  setupTrace "counts" (Text.unpack prefix) ("iterations=" <> show n <> " expected_events=" <> show expected)+  workCountProbe store prefix ("benchInbox-" <> prefix <> "-") expected expected+  maintenanceProbe store (prefix <> "-finished")+ runDownstream :: (IOE :> es, Store :> es) => Scenario -> DownstreamRun -> Eff es () runDownstream scenario downstreamRun =   case scenario.mode of@@ -358,12 +387,12 @@ prepareDownstreamRun :: Text -> Traffic -> IO DownstreamRun prepareDownstreamRun prefix traffic = do   uniqueWorks <- traverse (prepareWork prefix) uniqueIndexes-  let selected = case (traffic, uniqueWorks) of+  let selectedWorks = case (traffic, uniqueWorks) of         (FreshTraffic, _) -> uniqueWorks         (RepeatedTraffic, work : _) -> Prelude.replicate deliveryCount work         (DuplicateTraffic, work : _) -> Prelude.replicate deliveryCount work         _ -> error "prepareDownstreamRun: traffic generated no work"-      deliveries = [(work.event, Just work.kafka) | work <- selected]+      deliveries = [(work.event, Just work.kafka) | work <- selectedWorks]       workByMessageId = Map.fromList [(work.event.messageId, work) | work <- uniqueWorks]   pure DownstreamRun {deliveries, workByMessageId}   where@@ -398,14 +427,16 @@  historyBenchmark :: Store.KirokuStore -> Int -> Benchmark historyBenchmark store historySize =-  env (prepareHistoryRun store historySize) $ \downstreamRun ->+  selectedWithCleanup (prepareHistoryRun store historySize) (const (workCountProbe store ("history-" <> Text.pack (show historySize)) ("benchInbox-history-" <> Text.pack (show historySize) <> "-") 1 historySize)) $ \downstreamRun ->     bench ("events-" <> show historySize) $       nfIO $-        runStoreChecked store $-          traverse_ (runDelegatedSingle Nothing downstreamRun.workByMessageId) downstreamRun.deliveries+        downstreamRun `seq`+          runStoreChecked store (traverse_ (runDelegatedSingle Nothing downstreamRun.workByMessageId) downstreamRun.deliveries)  prepareHistoryRun :: Store.KirokuStore -> Int -> IO DownstreamRun prepareHistoryRun store historySize = do+  runStoreChecked store $ Store.runTransaction (Tx.sql businessTableSql)+  setupTrace "delegated-inbox" ("events-" <> show historySize) ("history source_events=" <> show historySize)   downstreamRun <- prepareDownstreamRun ("history-" <> Text.pack (show historySize)) DuplicateTraffic   seedDuplicateRun store DelegatedMode downstreamRun   case Map.elems downstreamRun.workByMessageId of
bench/Main.hs view
@@ -6,19 +6,24 @@   ) where +import BenchSetup (memoSetup, nextBoundedIteration, nextIteration, selected, selectedCounterWithCleanup, selectedWithCleanup, setupTrace)+import CommandProbe (commandPlanProbe, maintenanceProbe, rebuildCountProbe, tableResetProbe, workCountProbe) import Control.Concurrent (threadDelay)+import Control.Monad (replicateM_) import Data.Aeson qualified as Aeson import Data.ByteString qualified as BS-import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.IORef (IORef, 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 Data.Vector qualified as Vector import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)+import GHC.Clock (getMonotonicTimeNSec) import GuardedCatalogBench (runGuardedCatalogEvidence)-import InboxDelegatedBench (prepareInboxDelegatedBenchmarks, runInboxDelegatedExplainIfRequested)+import InboxDelegatedBench (inboxDelegatedBenchmarks, runInboxDelegatedExplainIfRequested) import Keiki.Core   ( Edge (..),     HsPred,@@ -75,11 +80,10 @@     startCatalogRebuild,   ) import Keiro.Telemetry qualified as Telemetry-import Keiro.Test.Postgres (StoreRunner (..), withFreshResourceStore, withMigratedSuite)+import Keiro.Test.Postgres (StoreRunner (..), checkpointBenchmarkStore, withFreshResourceStore, withMigratedBenchmarkSuite) 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 (..),@@ -94,18 +98,21 @@ import OpenTelemetry.MeterProvider (createMeterProvider, defaultSdkMeterProviderOptions) import OpenTelemetry.Metric.Core (getMeter) import OpenTelemetry.Resource (emptyMaterializedResources)-import ProducerIdentityBench (producerIdentityBenchmarks)+import ProducerIdentityBench (producerIdentityBenchmarks, runProducerIdentityProbe) import ReadModelBench-  ( readModelBenchmarks,+  ( probeReadModelCounts,+    readModelBenchmarks,     runReadModelExplainEvidenceIfRequested,     runReadModelLatencyEvidenceIfRequested,     setupReadModelBench,   ) import Shibuya.Adapter (Adapter (..)) import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..)) import Shibuya.Core.Types (Envelope (..)) import Streamly.Data.Stream qualified as Streamly+import System.Environment (lookupEnv)+import System.IO (hFlush, stdout) import Test.Tasty.Bench (Benchmark, bcompareWithin, bench, bgroup, defaultMain, nfIO) import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude@@ -152,88 +159,108 @@  ordinaryMain :: IO () ordinaryMain =-  withMigratedSuite \fixture ->-    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-        delegatedInboxBenchmarks <- prepareInboxDelegatedBenchmarks store metrics-        runInboxDelegatedExplainIfRequested store-        defaultMain (benchmarks store runner metrics rebuildRunCounter delegatedInboxBenchmarks <> readModelBenchmarks readModelFixture <> producerIdentityBenchmarks store rebuildRunCounter)+  withMigratedBenchmarkSuite \fixture ->+    withFreshResourceStore fixture \(store, runner) ->+      withFreshResourceStore fixture \(outboxStore, _) ->+        withFreshResourceStore fixture \(inboxStore, _) ->+          withFreshResourceStore fixture \(delegatedStore, _) ->+            withFreshResourceStore fixture \(producerStore, _) ->+              withFreshResourceStore fixture \(rebuildStore, _) ->+                withFreshResourceStore fixture \(readModelStore, readModelRunner) -> do+                  (provider, _env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions+                  meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+                  metrics <- Telemetry.newKeiroMetrics meter+                  producerCounter <- newIORef 0+                  readModelSetup <- memoSetup do+                    setupTrace "read-model" "shared" "schema history=25000"+                    model <- setupReadModelBench readModelStore readModelRunner+                    runReadModelExplainEvidenceIfRequested model+                    runReadModelLatencyEvidenceIfRequested model+                    pure model+                  runInboxDelegatedExplainIfRequested delegatedStore+                  producerProbe <- lookupEnv "KEIRO_BENCH_PRODUCER_PROBE"+                  resetProbe <- lookupEnv "KEIRO_BENCH_RESET_PROBE"+                  probe <- lookupEnv "KEIRO_BENCH_COMMAND_PROBE"+                  if producerProbe == Just "1"+                    then runProducerIdentityProbe producerStore+                    else+                      if probe == Just "1"+                        then runCommandGrowthProbe store+                        else+                          if resetProbe == Just "1"+                            then runResetProbes outboxStore inboxStore producerStore+                            else+                              defaultMain+                                ( benchmarks store runner outboxStore inboxStore rebuildStore metrics (inboxDelegatedBenchmarks delegatedStore metrics)+                                    <> [selectedWithCleanup readModelSetup probeReadModelCounts (\model -> readModelBenchmarks model !! groupIndex) | groupIndex <- [0 .. 2]]+                                    <> producerIdentityBenchmarks producerStore producerCounter+                                ) -benchmarks :: Store.KirokuStore -> StoreRunner -> Telemetry.KeiroMetrics -> IORef Int -> [Benchmark] -> [Benchmark]-benchmarks store runner metrics rebuildRunCounter delegatedInboxBenchmarks =+benchmarks :: Store.KirokuStore -> StoreRunner -> Store.KirokuStore -> Store.KirokuStore -> Store.KirokuStore -> Telemetry.KeiroMetrics -> [Benchmark] -> [Benchmark]+benchmarks store runner outboxStore inboxStore rebuildStore metrics delegatedInboxBenchmarks =   [ bgroup       "outbox"-      [ scenarioBench store hotKey,-        scenarioBench store hotKeyNoLatency,-        scenarioBench store multiKey+      [ scenarioBench outboxStore hotKey,+        scenarioBench outboxStore hotKeyNoLatency,+        scenarioBench outboxStore multiKey       ],     bgroup       "inbox"-      [ inboxScenarioBench store (singleFull metrics),-        inboxScenarioBench store singleNoMetrics,-        inboxScenarioBench store batch100,-        inboxScenarioBench store singleSlim,+      [ inboxScenarioBench inboxStore (singleFull metrics),+        inboxScenarioBench inboxStore singleNoMetrics,+        inboxScenarioBench inboxStore batch100,+        inboxScenarioBench inboxStore singleSlim,         bgroup "downstream" delegatedInboxBenchmarks       ],-    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-              ]-          ]-      ],+    selected (prepareCommandStore store) $ \_ ->+      bgroup+        "command"+        [ bgroup+            "legacy"+            [commandScenarioBench store "accepted-1" legacyAcceptedOneStream legacyAcceptedOneTarget EmitOne],+          bgroup+            "control"+            [commandScenarioBench store "accepted-1" legacyAcceptedOneStream (stream "bench-command-control") EmitOne],+          bgroup+            "domain-warmup"+            [domainCommandScenarioBench store "accepted-1" domainAcceptedOneHandler (stream "bench-command-domain-warmup") 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 store runner fanout+                | fanout <- coordinatorFanouts+                ],+              bgroup+                "process-manager-fanout"+                [ domainProcessManagerFanoutBench store runner fanout+                | fanout <- coordinatorFanouts+                ]+            ]+        ],     bgroup       "rebuild"-      [rebuildScenarioBench store rebuildRunCounter]+      [rebuildScenarioBench rebuildStore]   ]   where     hotKey =@@ -289,9 +316,16 @@  scenarioBench :: Store.KirokuStore -> OutboxScenario -> Benchmark scenarioBench store scenario =-  bench (Text.unpack scenario.scenarioName) $-    nfIO (runScenario store scenario)+  selected (warmAction store "outbox" (Text.unpack scenario.scenarioName) (runScenario store scenario)) $ \_ ->+    bench (Text.unpack scenario.scenarioName) $ nfIO (runScenario store scenario) +warmAction :: Store.KirokuStore -> String -> String -> IO () -> IO ()+warmAction store group name action = do+  setupTrace group name "checkpoint=before-warm-up"+  checkpointBenchmarkStore store+  setupTrace group name "warm-up invocations=3 timed_reset=TRUNCATE"+  replicateM_ 3 action+ runScenario :: Store.KirokuStore -> OutboxScenario -> IO () runScenario store scenario = do   runStoreChecked store do@@ -301,8 +335,8 @@  inboxScenarioBench :: Store.KirokuStore -> InboxScenario -> Benchmark inboxScenarioBench store scenario =-  bench (Text.unpack scenario.inboxScenarioName) $-    nfIO (runInboxScenario store scenario)+  selected (warmAction store "inbox" (Text.unpack scenario.inboxScenarioName) (runInboxScenario store scenario)) $ \_ ->+    bench (Text.unpack scenario.inboxScenarioName) $ nfIO (runInboxScenario store scenario)  runInboxScenario :: Store.KirokuStore -> InboxScenario -> IO () runInboxScenario store scenario = do@@ -614,24 +648,94 @@       upcasters = []     } +-- Seed a representative stream population before any prepared lookup is planned.+-- Empty-table statistics otherwise keep sequential scans as fresh samples grow.+prepareCommandStore :: Store.KirokuStore -> IO ()+prepareCommandStore store = do+  setupTrace "command" "shared" "schema seed_streams=5000 seed_events=5000 analyze=once-before-warm-up"+  for_ [1 .. 5000 :: Int] $ \n -> runStoreChecked store $ do+    void+      ( Store.appendToStream+          (StreamName ("benchseed-" <> Text.pack (show n)))+          NoStream+          [EventData Nothing (EventType "BenchSeed") Aeson.Null Nothing Nothing Nothing]+      )+  runStoreChecked store (Store.runTransaction (Tx.sql "ANALYZE streams; ANALYZE stream_events; ANALYZE events"))+ 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))+  selectedCounterWithCleanup label units (prepareCommand store label units (commandBudget command) action) checkCounts $ \counter ->+    bench benchmarkName $ nfIO (action counter)+  where+    label = show (streamName target) <> "/legacy/" <> benchmarkName+    checkCounts n = do+      maintenanceProbe store (Text.pack label <> "-finished")+      let StreamName prefix = streamName (tagTarget target "legacy")+      workCountProbe store (Text.pack label) (prefix <> "-iteration-") (if units == 0 then 0 else n) (n * units)+    units = commandUnits command+    action counter = do+      n <- nextBoundedIteration label (commandBudget command) counter+      let fresh = iterationTarget (tagTarget target "legacy") n+      runStoreChecked store do+        result <- runCommand defaultRunCommandOptions validatedStream fresh command+        case result of+          Right value | value ^. #eventsAppended == eventCount command -> pure ()+          other -> liftIO (fail ("unexpected command benchmark result: " <> show other))  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))+  selectedCounterWithCleanup label units (prepareCommand store label units (commandBudget command) action) checkCounts $ \counter ->+    bench benchmarkName $ nfIO (action counter)+  where+    label = show (streamName target) <> "/domain/" <> benchmarkName+    checkCounts n = do+      maintenanceProbe store (Text.pack label <> "-finished")+      let StreamName prefix = streamName (tagTarget target ("domain-" <> Text.pack benchmarkName))+      workCountProbe store (Text.pack label) (prefix <> "-iteration-") (if units == 0 then 0 else n) (n * units)+    units = commandUnits command+    action counter = do+      n <- nextBoundedIteration label (commandBudget command) counter+      let fresh = iterationTarget (tagTarget target ("domain-" <> Text.pack benchmarkName)) n+      runStoreChecked store do+        result <- runDomainCommand defaultRunCommandOptions handler fresh command+        case result of+          Right value | value ^. #result . #eventsAppended == eventCount command -> pure ()+          other -> liftIO (fail ("unexpected typed command benchmark result: " <> show other)) +commandUnits :: BenchCommand -> Int+commandUnits = eventCount++commandBudget :: BenchCommand -> Int+commandBudget EmitOne = 100000+commandBudget EmitLarge = 10000+commandBudget SelectNoOp = 1000000++eventCount :: BenchCommand -> Int+eventCount EmitOne = 1+eventCount EmitLarge = largeCommandBatchSize+eventCount SelectNoOp = 0++tagTarget :: Stream eventStream -> Text -> Stream eventStream+tagTarget target tag =+  let StreamName name = streamName target+   in stream (name <> "-" <> tag)++iterationTarget :: Stream eventStream -> Int -> Stream eventStream+iterationTarget target n =+  let StreamName name = streamName target+   in stream (name <> "-iteration-" <> Text.pack (show n))++prepareCommand :: Store.KirokuStore -> String -> Int -> Int -> (IORef Int -> IO ()) -> IO (IORef Int)+prepareCommand store label units budget action = do+  setupTrace "command" label ("counter budget_iterations=" <> show budget <> " events_per_iteration=" <> show units)+  counter <- newIORef 0+  setupTrace "command" label "checkpoint=before-warm-up"+  checkpointBenchmarkStore store+  setupTrace "command" label "warm-up invocations=3"+  replicateM_ 3 (action counter)+  maintenanceProbe store (Text.pack label <> "-ready")+  pure counter+ -- * Projection rebuild benchmark fixtures ---------------------------------  rebuildEventsPerCategory :: Int@@ -640,32 +744,44 @@ 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)))+rebuildScenarioBench :: Store.KirokuStore -> Benchmark+rebuildScenarioBench store =+  selectedCounterWithCleanup "rebuild" 600 setup (rebuildCountProbe store) $ \runCounter -> bench "three-categories-200" $ nfIO (runRebuild False runCounter)+  where+    setup = do+      setupTrace "rebuild" "three-categories-200" "schema source_events=600 bookkeeping_run_budget=10000"+      runStoreChecked store do+        Store.runTransaction (Tx.sql rebuildBenchSql)+        traverse_ seedRebuildStream rebuildStreams+        registerProjectionCatalog rebuildCatalog >>= \case+          Left err -> liftIO (fail (show err))+          Right _ -> pure ()+      counter <- newIORef 0+      checkpointBenchmarkStore store+      setupTrace "rebuild" "three-categories-200" "checkpoint=before-warm-up warm-up invocations=3 source_events_per_invocation=600"+      replicateM_ 3 (runRebuild True counter)+      probe <- lookupEnv "KEIRO_BENCH_REBUILD_PROBE"+      when (probe == Just "1") (replicateM_ 3 (runRebuild True counter))+      pure counter+    runRebuild probe runCounter = do+      runNumber <- nextBoundedIteration "rebuild" 10000 runCounter+      runStoreChecked store do+        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 -> do+                let sourceEvents = sum [sourceProgress ^. #eventCount | sourceProgress <- report ^. #sources]+                unless (sourceEvents == 600) (liftIO (fail ("rebuild source count changed: " <> show sourceEvents)))+                when probe (liftIO (setupTrace "rebuild" (show runNumber) ("source_events=" <> show sourceEvents)))+            | otherwise -> liftIO (fail ("rebuild benchmark did not promote: " <> show (report ^. #runStatus)))  seedRebuildStream :: (Store :> es) => StreamName -> Eff es () seedRebuildStream streamName' =@@ -909,33 +1025,55 @@ 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))+domainRouterFanoutBench :: Store.KirokuStore -> StoreRunner -> Int -> Benchmark+domainRouterFanoutBench store runner fanout =+  selectedCounterWithCleanup label fanout (prepareCommand store label fanout (100000 `div` fanout) action) checkCounts $ \counter -> bench (show fanout) $ nfIO (action counter)+  where+    label = "router-" <> show fanout+    checkCounts n = do+      maintenanceProbe store (Text.pack label <> "-finished")+      workCountProbe store (Text.pack label) ("bench-command-domain-router:" <> Text.pack label <> "-") (n * fanout) (n * fanout)+    action counter = do+      n <- nextIteration label fanout counter+      let correlationId = Text.pack label <> "-" <> Text.pack (show n)+          input = FanoutInput correlationId fanout+      runResourceStoreChecked runner do+        runDomainRouterWorker+          defaultRunCommandOptions+          fanoutDomainRouter+          (fanoutAdapter input)+          (\message -> Just (iterationSourceEvent n, message))+        when (n <= 3) (verifyFanout "router" correlationId fanout) -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))+domainProcessManagerFanoutBench :: Store.KirokuStore -> StoreRunner -> Int -> Benchmark+domainProcessManagerFanoutBench store runner fanout =+  selectedCounterWithCleanup label fanout (prepareCommand store label fanout (100000 `div` fanout) action) checkCounts $ \counter -> bench (show fanout) $ nfIO (action counter)+  where+    label = "process-manager-" <> show fanout+    checkCounts n = do+      maintenanceProbe store (Text.pack label <> "-finished")+      workCountProbe store (Text.pack label) ("bench-command-domain-process-manager:" <> Text.pack label <> "-") (n * fanout) (n * fanout)+    action counter = do+      n <- nextIteration label fanout counter+      let correlationId = Text.pack label <> "-" <> Text.pack (show n)+          input = FanoutInput correlationId fanout+      runResourceStoreChecked runner do+        runDomainProcessManagerWorker+          defaultRunCommandOptions+          fanoutDomainProcessManager+          (fanoutAdapter input)+          (\message -> Just (iterationSourceEvent n, message))+        when (n <= 3) (verifyFanout "process-manager" correlationId fanout) +verifyFanout :: (IOE :> es, Store :> es) => Text -> Text -> Int -> Eff es ()+verifyFanout coordinator correlationId fanout =+  for_ [0 .. fanout - 1] \targetIndex -> do+    rows <- Store.readStreamForward (StreamName (fanoutTargetName coordinator correlationId targetIndex)) (StreamVersion 0) 2+    unless (Vector.length rows == 1) (liftIO (fail "fan-out benchmark did not append exactly one event per fresh target"))++iterationSourceEvent :: Int -> RecordedEvent+iterationSourceEvent n = fanoutSourceEvent & #eventId .~ EventId (UUID.fromWords 0 0 1 (fromIntegral n))+ fanoutDomainRouter ::   DomainRouter     FanoutInput@@ -1005,12 +1143,6 @@     <> ":"     <> 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@@ -1047,7 +1179,8 @@                       payload = input                     },                 ack = AckHandle (\_ -> pure ()),-                lease = Nothing+                lease = Nothing,+                deliveryStatus = pure DeliveryCurrent               }           ],       shutdown = pure ()@@ -1066,3 +1199,54 @@   case result of     Left err -> fail (show err)     Right value -> pure value++-- A plain-IO diagnostic: it never contributes a tasty-bench timing or baseline.+runCommandGrowthProbe :: Store.KirokuStore -> IO ()+runCommandGrowthProbe store = do+  sample "empty" 0+  for_ [1 .. 5000 :: Int] $ \n -> append n+  sample "grown-5000-before-analyze" 5000+  runStoreChecked store (Store.runTransaction (Tx.sql "ANALYZE streams; ANALYZE stream_events; ANALYZE events"))+  sample "grown-5000-after-analyze" 5000+  for_ [5001 .. 10000 :: Int] $ \n -> append n+  sample "grown-10000" 10000+  for_ [10001 .. 100000 :: Int] $ \n -> append n+  sample "grown-100000" 100000+  for_ [100001 .. 3005000 :: Int] $ \n -> append n+  sample "grown-3005000" 3005000+  where+    append n = appendTarget (stream ("bench-probe-" <> Text.pack (show n)))+    appendTarget target = runStoreChecked store $ do+      result <- runDomainCommand defaultRunCommandOptions domainAcceptedOneHandler target EmitOne+      case result of+        Right value | value ^. #result . #eventsAppended == 1 -> pure ()+        other -> liftIO (fail (show other))+    sample :: Text -> Int -> IO ()+    sample label n = do+      commandPlanProbe store label+      for_ [1 .. 5 :: Int] $ \block -> do+        started <- getMonotonicTimeNSec+        replicateM_ 100 $ runStoreChecked store $ do+          result <- runDomainCommand defaultRunCommandOptions domainNoOpHandler (stream "bench-probe-absent") SelectNoOp+          case result of+            Right value | value ^. #result . #eventsAppended == 0 -> pure ()+            other -> liftIO (fail (show other))+        stopped <- getMonotonicTimeNSec+        freshStarted <- getMonotonicTimeNSec+        for_ [1 .. 100 :: Int] $ \sampleIndex ->+          appendTarget (stream (Text.intercalate "-" ["bench-probe-diagnostic", label, Text.pack (show block), Text.pack (show sampleIndex)]))+        freshStopped <- getMonotonicTimeNSec+        putStrLn ("FRESH_PROBE label=" <> Text.unpack label <> " block=" <> show block <> " mean_ns=" <> show ((freshStopped - freshStarted) `div` 100))+        putStrLn ("NOOP_PROBE label=" <> Text.unpack label <> " block=" <> show block <> " events=" <> show n <> " mean_ns=" <> show ((stopped - started) `div` 100))+      hFlush stdout++runResetProbes :: Store.KirokuStore -> Store.KirokuStore -> Store.KirokuStore -> IO ()+runResetProbes outboxStore inboxStore producerStore = do+  checkpointBenchmarkStore outboxStore+  let outboxSeed store count = do+        runStoreChecked store (Store.runTransaction (Tx.sql "TRUNCATE keiro.keiro_outbox"))+        seedOutbox store (take count (scenarioMessages (const Nothing)))+      inboxSeed = runInboxScenario inboxStore (InboxScenario "probe" Nothing PersistFullEnvelope Nothing inboxScenarioMessages)+  tableResetProbe outboxStore "keiro.keiro_outbox" 2000 (outboxSeed outboxStore 2000)+  tableResetProbe inboxStore "keiro.keiro_inbox" 2000 inboxSeed+  tableResetProbe producerStore "keiro.keiro_outbox" 1000 (outboxSeed producerStore 1000)
bench/ProducerIdentityBench.hs view
@@ -1,19 +1,30 @@ -- | Compare the pre-plan-164 fresh-ID path with deterministic producer writes.-module ProducerIdentityBench (producerIdentityBenchmarks) where+module ProducerIdentityBench (producerIdentityBenchmarks, runProducerIdentityProbe) where -import Control.Monad (forM, forM_)+import BenchSetup (selected, setupTrace)+import Control.Exception (evaluate)+import Control.Monad (forM, forM_, replicateM_) import Data.Aeson qualified as Aeson import Data.ByteString qualified as BS-import Data.IORef (IORef, atomicModifyIORef')+import Data.ByteString.Lazy.Char8 qualified as Lazy+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.List (sort, sortOn)+import Data.Text qualified as Text import Data.Time (UTCTime (..), secondsToDiffTime) import Data.Time.Calendar (Day (ModifiedJulianDay)) import Data.UUID qualified as UUID+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (preparable) import Keiro.Integration.Event import Keiro.Outbox+import Keiro.Outbox.Schema qualified as Schema import Keiro.Prelude+import Keiro.Test.Postgres (checkpointBenchmarkStore) import Kiroku.Store qualified as Store import Kiroku.Store.Types (EventId (..), EventType (..), GlobalPosition (..), RecordedEvent (..), StreamId (..), StreamVersion (..))-import Test.Tasty.Bench (Benchmark, bcompareWithin, bench, bgroup, nf, nfIO)+import Test.Tasty.Bench (Benchmark, bench, bgroup, nf, nfIO) import "hasql-transaction" Hasql.Transaction qualified as Tx  producerIdentityBenchmarks :: Store.KirokuStore -> IORef Int -> [Benchmark]@@ -21,50 +32,59 @@   [ bgroup       "producer-identity"       [ bench "derive-v1" $ nf identityText (ProducerEventKey (EventId (UUID.fromWords 0 0 0 1)) 0),-        bench "legacy-fresh-1000" $ nfIO (run True False False),-        bcompareWithin 0 1.10 "$0 == \"All.producer-identity.legacy-fresh-1000\"" $ bench "deterministic-fresh-1000" $ nfIO (run True True False),-        bench "deterministic-fresh-and-replay-1000" $ nfIO (run True True True),-        bench "legacy-single-tx-per-event-1000" $ nfIO (run False False False),-        bcompareWithin 0 1.10 "$0 == \"All.producer-identity.legacy-single-tx-per-event-1000\"" $ bench "deterministic-single-tx-per-event-1000" $ nfIO (run False True False)+        sqlBench "legacy-fresh-1000" (run True False False),+        freshBench True "deterministic-fresh-1000" (run True True False),+        sqlBench "deterministic-fresh-and-replay-1000" (run True True True),+        sqlBench "legacy-single-tx-per-event-1000" (run False False False),+        freshBench False "deterministic-single-tx-per-event-1000" (run False True False)       ]   ]   where-    run batch deterministic replay = do-      generation <- atomicModifyIORef' sequenceRef (\n -> (n + 1, n + 1))-      result <- Store.runStoreIO store $ do-        Store.runTransaction (Tx.sql "TRUNCATE keiro.keiro_outbox")-        let recorded = [sourceEvent generation i | i <- [1 .. 1000]]-        if deterministic+    sqlBench name action = selected (setupTrace "producer-identity" name "checkpoint=before-warm-up warm-up invocations=3 timed_reset=TRUNCATE" >> checkpointBenchmarkStore store >> replicateM_ 3 action) $ \_ -> bench name (nfIO action)+    freshBench batch name action =+      selected (checkFreshRatio store sequenceRef batch) $ \_ -> sqlBench name action+    run = runProducerWorkload store sequenceRef++-- The diagnostic pairs call precisely the normal benchmark workload, including+-- timed reset, preparation, insert-result checks and transaction boundaries.+runProducerWorkload :: Store.KirokuStore -> IORef Int -> Bool -> Bool -> Bool -> IO ()+runProducerWorkload store sequenceRef batch deterministic replay = do+  generation <- atomicModifyIORef' sequenceRef (\n -> (n + 1, n + 1))+  result <- Store.runStoreIO store $ do+    Store.runTransaction (Tx.sql "TRUNCATE keiro.keiro_outbox")+    let recorded = [sourceEvent generation i | i <- [1 .. 1000]]+    if deterministic+      then do+        let actions = fmap (\event -> enqueueProducerEventTx producer event 0 draft) recorded+        if batch           then do-            let actions = fmap (\event -> enqueueProducerEventTx producer event 0 draft) recorded-            if batch-              then do-                first <- Store.runTransaction (sequence actions)-                unless (all isInserted first) (error "producer benchmark failed to insert")-              else forM_ actions $ \action -> do-                outcome <- Store.runTransaction action-                unless (isInserted outcome) (error "producer benchmark failed to insert")-            when replay $ do-              second <- Store.runTransaction (sequence actions)-              unless (all isDuplicate second) (error "producer benchmark failed to replay")-          else do-            -- Match the old helper's preparation boundary: bulk preparation-            -- for bulk SQL, and preparation immediately before each individual-            -- transaction for the normal subscription path.-            let prepare event = do-                  oid <- freshOutboxId-                  envelope <- freshIntegrationEvent producer (draft & #sourceEventId ?~ event ^. #eventId & #sourceGlobalPosition ?~ event ^. #globalPosition)-                  pure (oid, envelope)-            if batch-              then do-                messages <- forM recorded prepare-                Store.runTransaction (traverse_ (uncurry enqueueIntegrationEventTx) messages)-              else forM_ recorded $ \event -> do-                message <- prepare event-                Store.runTransaction (uncurry enqueueIntegrationEventTx message)-      case result of-        Left err -> fail (show err)-        Right () -> pure ()+            first <- Store.runTransaction (sequence actions)+            unless (all isInserted first) (error "producer benchmark failed to insert")+          else forM_ actions $ \action -> do+            outcome <- Store.runTransaction action+            unless (isInserted outcome) (error "producer benchmark failed to insert")+        when replay $ do+          second <- Store.runTransaction (sequence actions)+          unless (all isDuplicate second) (error "producer benchmark failed to replay")+      else do+        -- Match the old helper's preparation boundary: bulk preparation+        -- for bulk SQL, and preparation immediately before each individual+        -- transaction for the normal subscription path.+        let prepare event = do+              oid <- freshOutboxId+              envelope <- freshIntegrationEvent producer (draft & #sourceEventId ?~ event ^. #eventId & #sourceGlobalPosition ?~ event ^. #globalPosition)+              pure (oid, envelope)+        if batch+          then do+            messages <- forM recorded prepare+            Store.runTransaction (traverse_ (uncurry enqueueIntegrationEventTx) messages)+          else forM_ recorded $ \event -> do+            message <- prepare event+            Store.runTransaction (uncurry enqueueIntegrationEventTx message)+  case result of+    Left err -> fail (show err)+    Right () -> pure ()+  where     isInserted ProducerInserted {} = True     isInserted _ = False     isDuplicate ProducerDuplicateIdentical {} = True@@ -113,3 +133,169 @@ identityText key =   let identity = deriveProducerIdentity producer key    in (UUID.toText (unOutboxId (identity ^. #outboxId)), identity ^. #messageId)++-- | Opt-in attribution on the existing isolated producer fixture. Normal+-- full workload and the 1.10 ceiling are shared with the paired guard.+runProducerIdentityProbe :: Store.KirokuStore -> IO ()+runProducerIdentityProbe store = do+  checkpointBenchmarkStore store+  let recorded = [sourceEvent 9001 i | i <- [1 .. 1000]]+      factory = traverse (\event -> enqueueProducerEventTx producer event 0 draft) recorded+      reset = probeTx store (Tx.sql "TRUNCATE keiro.keiro_outbox")+      emit label value = Lazy.putStrLn (Aeson.encode (Aeson.object ["phase" Aeson..= (label :: Text), "data" Aeson..= value]))+      countPhase label expected action = do+        observed <- probeTx store $ do+          before <- producerStatementCounts+          action+          after <- producerStatementCounts+          rows <- producerRows+          unless (rows == 1000) (error "producer probe row count differs")+          pure (subtractCounts after before)+        unless (observed == expected) (fail ("producer statement-count mismatch: " <> show observed))+        emit label (Aeson.toJSON observed)+  reset+  countPhase "fresh-factory-statements-legacy-producer-lookup" (0, 1000, 0) $ do+    outcomes <- factory+    unless (all inserted outcomes) (error "producer probe fresh insert failed")+  stored <- Store.runStoreIO store (Schema.listOutbox "bench.outbox") >>= either (fail . show) pure+  let ordered = sortOn (\row -> (row ^. #event) ^. #sourceGlobalPosition) stored+  template <- case ordered of+    row : _ -> pure (row ^. #event)+    [] -> fail "producer probe has no stored envelope"+  let prepare event =+        let identity = deriveProducerIdentity producer (ProducerEventKey (event ^. #eventId) 0)+            envelope = normalizeProducerEvent (template & #messageId .~ (identity ^. #messageId) & #sourceEventId ?~ (event ^. #eventId) & #sourceGlobalPosition ?~ (event ^. #globalPosition))+         in (identity, envelope)+      prepared = map prepare recorded+  unless (length ordered == 1000 && and [identity ^. #outboxId == row ^. #outboxId && envelope == row ^. #event | ((identity, envelope), row) <- zip prepared ordered]) $+    fail "producer probe equivalent preparation differs from the actual factory"+  _ <- evaluate (forceProducerInputs [(identity ^. #outboxId, event) | (identity, event) <- prepared])+  nativeLegacy <- probeLegacyInputs store recorded+  _ <- evaluate (forceProducerInputs nativeLegacy)+  countPhase "replay-factory-statements-legacy-producer-lookup" (0, 1000, 1000) $ do+    outcomes <- factory+    unless (all duplicate outcomes) (error "producer probe replay failed")+  reset+  countPhase "legacy-same-envelope-statements-legacy-producer-lookup" (1000, 0, 0) $+    traverse_ (\(identity, event) -> Schema.enqueueOutboxTx (OutboxMessage (identity ^. #outboxId) event)) prepared+  for_ [1 .. 5 :: Int] $ \sample -> do+    let inputs = [sourceEvent (9100 + sample) i | i <- [1 .. 1000]]+        deterministicPreparation = evaluate (forceProducerInputs [(identity ^. #outboxId, event) | (identity, event) <- map prepare inputs])+        legacyPreparation = do+          messages <- probeLegacyInputs store inputs+          evaluate (forceProducerInputs messages)+    for_ (if even sample then [("deterministic-equivalent-preparation", deterministicPreparation), ("legacy-actual-preparation", legacyPreparation)] else [("legacy-actual-preparation", legacyPreparation), ("deterministic-equivalent-preparation", deterministicPreparation)]) $ \(label, action) -> do+      started <- getMonotonicTimeNSec+      checksum <- action+      ended <- getMonotonicTimeNSec+      emit label (Aeson.object ["sample" Aeson..= sample, "events" Aeson..= (1000 :: Int), "elapsed_ns" Aeson..= (ended - started), "checksum" Aeson..= checksum])+    for_ [True, False] $ \identicalEnvelopes ->+      for_ [True, False] $ \batch ->+        for_ (if even sample then [True, False] else [False, True]) $ \deterministic -> do+          reset+          let actions+                | deterministic =+                    [ do+                        outcome <- Schema.enqueueProducerOutboxTx identity event+                        unless (inserted outcome) (error "prepared producer probe did not insert")+                    | (identity, event) <- prepared+                    ]+                | identicalEnvelopes = [Schema.enqueueOutboxTx (OutboxMessage (identity ^. #outboxId) event) | (identity, event) <- prepared]+                | otherwise = [Schema.enqueueOutboxTx (OutboxMessage oid event) | (oid, event) <- nativeLegacy]+          started <- getMonotonicTimeNSec+          if batch then probeTx store (sequence_ actions) else traverse_ (probeTx store) actions+          ended <- getMonotonicTimeNSec+          rows <- probeTx store producerRows+          unless (rows == 1000) (fail "prepared producer probe retained wrong rows")+          emit "prepared-enqueue" (Aeson.object ["sample" Aeson..= sample, "identical_envelopes" Aeson..= identicalEnvelopes, "batch" Aeson..= batch, "deterministic" Aeson..= deterministic, "events" Aeson..= rows, "elapsed_ns" Aeson..= (ended - started)])+  counter <- newIORef 0+  for_ [True, False] $ \batch -> do+    pairs <- measureFreshPairs store counter batch+    for_ (zip [1 .. freshPairSamples] pairs) $ \(sample, (legacy, deterministic)) ->+      for_ [(False, legacy), (True, deterministic)] $ \(flag, elapsed) ->+        emit "full-factory" (Aeson.object ["sample" Aeson..= sample, "batch" Aeson..= batch, "deterministic" Aeson..= flag, "events" Aeson..= (1000 :: Int), "elapsed_ns" Aeson..= elapsed])+  where+    inserted ProducerInserted {} = True+    inserted _ = False+    duplicate ProducerDuplicateIdentical {} = True+    duplicate _ = False++-- Compare adjacent full actions in alternating order. Both include their normal+-- reset/preparation/transaction boundaries; all fixed samples remain in the log.+measureFreshPairs :: Store.KirokuStore -> IORef Int -> Bool -> IO [(Double, Double)]+measureFreshPairs store counter batch = do+  checkpointBenchmarkStore store+  for_ [False, True] $ \deterministic ->+    replicateM_ 3 (runProducerWorkload store counter batch deterministic False)+  forM [1 .. freshPairSamples] $ \sample -> do+    values <- forM (if even sample then [True, False] else [False, True]) $ \deterministic -> do+      started <- getMonotonicTimeNSec+      runProducerWorkload store counter batch deterministic False+      ended <- getMonotonicTimeNSec+      rows <- probeTx store producerRows+      unless (rows == 1000 && ended > started) (fail "paired producer workload/count mismatch")+      pure (deterministic, fromIntegral (ended - started))+    case (lookup False values, lookup True values) of+      (Just legacy, Just deterministic) -> pure (legacy, deterministic)+      _ -> fail "paired producer measurement is incomplete"++checkFreshRatio :: Store.KirokuStore -> IORef Int -> Bool -> IO ()+checkFreshRatio store counter batch = do+  pairs <- measureFreshPairs store counter batch+  let ratios = [deterministic / legacy | (legacy, deterministic) <- pairs]+      medianRatio = sort ratios !! (freshPairSamples `div` 2)+  Lazy.putStrLn (Aeson.encode (Aeson.object ["phase" Aeson..= ("producer-fresh-ratio" :: Text), "batch" Aeson..= batch, "pairs_ns_legacy_deterministic" Aeson..= pairs, "median_ratio" Aeson..= medianRatio, "ceiling" Aeson..= freshRatioCeiling]))+  unless (medianRatio <= freshRatioCeiling) $+    fail ("producer fresh median paired ratio " <> show medianRatio <> " exceeds " <> show freshRatioCeiling)++freshPairSamples :: Int+freshPairSamples = 15++freshRatioCeiling :: Double+freshRatioCeiling = 1.10++probeLegacyInputs :: Store.KirokuStore -> [RecordedEvent] -> IO [(OutboxId, IntegrationEvent)]+probeLegacyInputs store inputs =+  Store.runStoreIO+    store+    ( forM inputs $ \event -> do+        oid <- freshOutboxId+        envelope <- freshIntegrationEvent producer (draft & #sourceEventId ?~ (event ^. #eventId) & #sourceGlobalPosition ?~ (event ^. #globalPosition))+        pure (oid, envelope)+    )+    >>= either (fail . show) pure++probeTx :: Store.KirokuStore -> Tx.Transaction a -> IO a+probeTx store action = Store.runStoreIO store (Store.runTransaction action) >>= either (fail . show) pure++producerRows :: Tx.Transaction Int64+producerRows = Tx.statement () (preparable "SELECT count(*) FROM keiro.keiro_outbox" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))++producerStatementCounts :: Tx.Transaction (Int64, Int64, Int64)+producerStatementCounts = do+  rows <- Tx.statement () (preparable "SELECT statement, generic_plans + custom_plans FROM pg_prepared_statements" E.noParams (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.int8))))+  let inserts = [(sql, n) | (sql, n) <- rows, "INSERT INTO keiro.keiro_outbox" `Text.isInfixOf` sql]+      count fragment = sum [n | (sql, n) <- inserts, fragment `Text.isInfixOf` sql]+      lookupCount = sum [n | (sql, n) <- rows, "WHERE outbox_id = $1 OR" `Text.isInfixOf` sql, "FOR UPDATE" `Text.isInfixOf` sql]+  pure (count "ON CONFLICT (source, message_id) DO NOTHING", count "ON CONFLICT DO NOTHING", lookupCount)++subtractCounts :: (Int64, Int64, Int64) -> (Int64, Int64, Int64) -> (Int64, Int64, Int64)+subtractCounts (a, b, c) (x, y, z) = (a - x, b - y, c - z)++-- Force the dynamic identity, provenance and timestamp fields without adding+-- envelope serialization to the preparation estimate.+{-# NOINLINE forceProducerInputs #-}+forceProducerInputs :: [(OutboxId, IntegrationEvent)] -> Int+forceProducerInputs = foldl' step 0+  where+    step total (oid, event) = case event ^. #occurredAt of+      UTCTime day time ->+        day `seq`+          time `seq`+            forceProvenance event `seq`+              (total + Text.length (UUID.toText (unOutboxId oid)) + Text.length (event ^. #messageId) + BS.length (event ^. #payloadBytes))++forceProvenance :: IntegrationEvent -> ()+forceProvenance event =+  (case event ^. #sourceEventId of Nothing -> (); Just (EventId uuid) -> uuid `seq` ()) `seq`+    (case event ^. #sourceGlobalPosition of Nothing -> (); Just (GlobalPosition position) -> position `seq` ())
bench/ReadModelBench.hs view
@@ -7,14 +7,18 @@     runReadModelExplainEvidenceIfRequested,     runReadModelLatencyEvidenceIfRequested,     setupReadModelBench,+    probeReadModelCounts,   ) where +import BenchSetup (selectedWithCleanup, setupTrace)+import CommandProbe (commandPlanProbe, workCountProbe) import Control.Monad (forM, replicateM_) import Data.Aeson qualified as Aeson import Data.ByteString qualified as ByteString+import Data.ByteString.Lazy.Char8 qualified as Lazy import Data.Foldable (toList)-import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.List qualified as List import Data.Map.Strict qualified as Map import Data.Maybe (listToMaybe)@@ -79,7 +83,7 @@     resumeVersionedRebuild,   ) import Keiro.Stream qualified as Keiro.Stream-import Keiro.Test.Postgres (StoreRunner (..))+import Keiro.Test.Postgres (StoreRunner (..), checkpointBenchmarkStore) import Kiroku.Store qualified as Store import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -102,6 +106,7 @@     StreamVersion (..),   ) import System.Environment (lookupEnv)+import System.IO (stderr) import Test.Tasty.Bench (Benchmark, bcompareWithin, bench, bgroup, nfIO) import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude@@ -185,6 +190,12 @@   runStoreChecked store (Store.runTransaction seedStatusScale)   seedStabilizingHistory store   seedRepairStreams store+  probe <- lookupEnv "KEIRO_BENCH_READ_MODEL_PLAN_PROBE"+  when (probe == Just "1") (commandPlanProbe store "read-model-before-analyze")+  setupTrace "read-model" "shared" "seed_streams=5000 seed_events=25000 analyze=once-before-warm-up"+  runStoreChecked store (Store.runTransaction (Tx.sql "ANALYZE"))+  when (probe == Just "1") (commandPlanProbe store "read-model-after-analyze")+  checkpointBenchmarkStore store   commandCounter <- newIORef 0   asyncCounter <- newIORef 0   runCounter <- newIORef 0@@ -207,23 +218,14 @@       "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"+          ( concat+              [ inlinePair "one-target" "one",+                inlinePair "three-target" "three",+                inlinePair "three-groups" "groups",+                inlinePair "many-revisions" "many-revisions",+                inlinePair "mixed-delivery" "mixed"               ]-          ],+          ),         bgroup           "async"           [ bgroup "legacy" [asyncScenarioBench fixture "mixed-delivery" "bench-legacy-mixed"],@@ -265,6 +267,12 @@       ]   ]   where+    -- Compare adjacent populations rather than five early legacy cases with+    -- five later versioned cases after the shared event store has grown.+    inlinePair benchmarkName suffix =+      [ bgroup "legacy" [inlineScenarioBench fixture benchmarkName ("bench-legacy-" <> suffix)],+        bgroup "versioned" [comparedInline benchmarkName ("bench-versioned-" <> suffix)]+      ]     comparedInline benchmarkName scenarioKey =       bcompareWithin 0 projectionDiagnosticTimeBudget (inlineLegacyPattern benchmarkName) $         inlineScenarioBench fixture benchmarkName scenarioKey@@ -328,12 +336,13 @@  inlineScenarioBench :: ReadModelBenchFixture -> String -> Text -> Benchmark inlineScenarioBench fixture benchmarkName scenarioKey =-  bench benchmarkName $ nfIO (runInlineScenario fixture scenarioKey)+  selectedWithCleanup (warmScenario fixture.store scenarioKey (runInlineScenario fixture scenarioKey)) (const (probeScenarioPlans fixture scenarioKey)) $ \_ ->+    bench benchmarkName $ nfIO (runInlineScenario fixture scenarioKey)  runInlineScenario :: ReadModelBenchFixture -> Text -> IO () runInlineScenario fixture scenarioKey = do   scenario <- requireScenario fixture scenarioKey-  invocation <- nextCounter fixture.commandCounter+  invocation <- nextCounter "read-model command events" 500000 fixture.commandCounter   let target = stream ("bench-projection-command-" <> scenarioKey <> "-" <> Text.pack (show invocation))   result <-     runResourceStoreChecked fixture.runner $@@ -350,14 +359,15 @@  asyncScenarioBench :: ReadModelBenchFixture -> String -> Text -> Benchmark asyncScenarioBench fixture benchmarkName scenarioKey =-  bench benchmarkName $ nfIO (runAsyncScenario fixture scenarioKey)+  selectedWithCleanup (warmScenario fixture.store (scenarioKey <> "-async") (runAsyncScenario fixture scenarioKey)) (const (probeScenarioPlans fixture 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+  invocation <- nextCounter "read-model async operations" 1000000 fixture.asyncCounter   outcome <-     runStoreChecked fixture.store $       Store.runTransaction@@ -395,7 +405,7 @@ runPromotionScenario :: ReadModelBenchFixture -> IO () runPromotionScenario fixture = do   scenario <- requireScenario fixture "bench-versioned-three"-  runNumber <- nextCounter fixture.runCounter+  runNumber <- nextCounter "read-model promotion runs" 10000 fixture.runCounter   promoteToV1 <- atomicModifyIORef' fixture.promotionRevision (\current -> (not current, current))   let group = firstOrError "promotion scenario group" scenario.scenarioGroups       revisionIds = map (^. #revisionId) group.revisions@@ -1128,21 +1138,22 @@  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]-        ]+  for_ [1 .. 5000 :: Int] $ \n ->+    runStoreChecked store $+      void $+        Store.appendToStream+          (StreamName ("benchseed-history-" <> Text.pack (show n)))+          NoStream+          [ EventData+              { eventId = Nothing,+                eventType = EventType "BenchSeedEvent",+                payload = Aeson.Null,+                metadata = Nothing,+                causationId = Nothing,+                correlationId = Nothing+              }+          | _ <- [1 .. stabilizingHistoryEventCount `div` 5000]+          ]  repairStreamName :: Int -> StreamName repairStreamName count = StreamName ("benchrepair-" <> Text.pack (show count))@@ -1427,9 +1438,27 @@ 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))+nextCounter :: String -> Int64 -> IORef Int64 -> IO Int64+nextCounter label budget counter = do+  next <- atomicModifyIORef' counter (\current -> let n = current + 1 in (n, n))+  when (next > budget) (fail ("benchmark fixture growth budget exceeded: " <> label <> " iterations=" <> show next))+  pure next +warmScenario :: Store.KirokuStore -> Text -> IO () -> IO ()+warmScenario store label action = do+  setupTrace "read-model" (Text.unpack label) "checkpoint=before-warm-up"+  checkpointBenchmarkStore store+  setupTrace "read-model" (Text.unpack label) "warm-up invocations=3"+  replicateM_ 3 action++probeReadModelCounts :: ReadModelBenchFixture -> IO ()+probeReadModelCounts fixture = do+  commands <- readIORef fixture.commandCounter+  asyncOperations <- readIORef fixture.asyncCounter+  promotionRuns <- readIORef fixture.runCounter+  setupTrace "counts" "read-model" ("command_events=" <> show commands <> " command_budget=500000 async_operations=" <> show asyncOperations <> " async_budget=1000000 promotion_runs=" <> show promotionRuns <> " promotion_budget=10000")+  workCountProbe fixture.store "read-model" "bench-projection-command-" (fromIntegral commands) (fromIntegral commands)+ fixedOccurredAt :: UTCTime fixedOccurredAt = UTCTime (ModifiedJulianDay 61000) (secondsToDiffTime 0) @@ -1439,4 +1468,31 @@  runStoreChecked :: Store.KirokuStore -> Eff '[Store, Error Store.StoreError, IOE] value -> IO value runStoreChecked store action =-  Store.runStoreIO store action >>= either (fail . show) pure+  store `seq` Store.runStoreIO store action >>= either (fail . show) pure++-- Inspect the same prepared group-admission query used by the completed case.+-- This is diagnostic only and never runs during accepted timing samples.+probeScenarioPlans :: ReadModelBenchFixture -> Text -> IO ()+probeScenarioPlans fixture scenarioKey = do+  enabled <- lookupEnv "KEIRO_BENCH_READ_MODEL_PLAN_PROBE"+  when (enabled == Just "1") $ do+    commandPlanProbe fixture.store (scenarioKey <> "-after-case")+    scenario <- requireScenario fixture scenarioKey+    for_ scenario.scenarioGroups $ \group -> do+      let groupText = rebuildGroupIdText group.groupId+      plan <- runStoreChecked fixture.store $ Store.runTransaction $ do+        names <-+          Tx.statement () $+            preparable+              "SELECT name FROM pg_prepared_statements WHERE statement LIKE 'SELECT locked_group.status,%'"+              E.noParams+              (D.rowList (D.column (D.nonNullable D.text)))+        name <- case names of+          [name] -> pure name+          _ -> error ("expected actual projection group query: " <> show names)+        Tx.statement () $+          preparable+            ("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) EXECUTE \"" <> Text.replace "\"" "\"\"" name <> "\"('" <> Text.replace "'" "''" groupText <> "')")+            E.noParams+            (D.singleRow (D.column (D.nonNullable D.json)))+      Lazy.hPutStrLn stderr (Aeson.encode (Aeson.object ["scenario" Aeson..= scenarioKey, "group" Aeson..= groupText, "plan" Aeson..= (plan :: Aeson.Value)]))
keiro.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiro-version: 0.21.0.0+version: 0.22.0.0 synopsis: Event sourcing framework and workflow engine description:   A library that composes kiroku, keiki, and shibuya into an@@ -20,8 +20,7 @@   CHANGELOG.md   README.md -extra-source-files:-  bench/GuardedCatalogBaselineMain.hs+extra-source-files: bench/GuardedCatalogBaselineMain.hs  source-repository head   type: git@@ -71,12 +70,15 @@     Keiro.Inbox.Kafka     Keiro.Inbox.Schema     Keiro.Inbox.Types+    Keiro.Inspection.Aggregate+    Keiro.Inspection.Cursor     Keiro.Outbox     Keiro.Outbox.Identity     Keiro.Outbox.Kafka     Keiro.Outbox.Schema     Keiro.Outbox.Types     Keiro.ProcessManager+    Keiro.ProcessManager.Inspect     Keiro.ProcessManager.Reaction     Keiro.Projection     Keiro.Projection.Catalog@@ -107,6 +109,7 @@     Keiro.Workflow.Child     Keiro.Workflow.Child.Schema     Keiro.Workflow.Gc+    Keiro.Workflow.Inspection     Keiro.Workflow.Instance     Keiro.Workflow.Resume     Keiro.Workflow.Schema@@ -119,6 +122,7 @@   other-modules:     Keiro.Command.Domain     Keiro.Outbox.Rejection+    Keiro.ProcessManager.Provenance     Keiro.Projection.Types     Keiro.ReadModel.Rebuild.Group     Keiro.ReadModel.Rebuild.Runner@@ -128,6 +132,7 @@     Keiro.ReplayDigest     Keiro.Workflow.Awakeable.Internal.Identity     Keiro.Workflow.Child.Cancel+    Keiro.Workflow.Inspection.Query     Keiro.Workflow.Instance.Schema     Keiro.Workflow.Journal     Paths_keiro@@ -154,6 +159,7 @@     aeson-casing >=0.2 && <0.3,     base >=4.21 && <5,     base16-bytestring >=1.0.2 && <1.1,+    base64-bytestring >=1.2 && <1.3,     bytestring >=0.11 && <0.13,     containers >=0.6 && <0.8,     contravariant-extras >=0.3 && <0.4,@@ -170,13 +176,13 @@     hs-opentelemetry-semantic-conventions >=1.40 && <2,     keiki >=0.9 && <0.10,     keiki-codec-json >=0.9 && <0.10,-    keiro-core ^>=0.21.0.0,-    kiroku-store ^>=0.11.0.0,+    keiro-core ^>=0.22.0.0,+    kiroku-store ^>=0.12.0.0,     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.10.0.1,+    shibuya-core ^>=0.12.0.0,     stm >=2.5 && <2.6,     streamly >=0.11 && <0.12,     streamly-core >=0.3 && <0.4,@@ -213,6 +219,7 @@     ReplayCompatibilitySpec     VersionedRebuildSpec     VersionedTargetPostgresSpec+    WorkflowInspectionSpec     WorkflowReplayCompatibilitySpec    ghc-options:@@ -240,11 +247,11 @@     keiki,     keiki-codec-json,     keiro,-    keiro-migrations ^>=0.21.0.0,-    keiro-test-support ^>=0.21.0.0,-    kiroku-store ^>=0.11.0.0,+    keiro-migrations ^>=0.22.0.0,+    keiro-test-support ^>=0.22.0.0,+    kiroku-store ^>=0.12.0.0,     process >=1.6 && <1.7,-    shibuya-core ^>=0.10.0.1,+    shibuya-core ^>=0.12.0.0,     stm >=2.5 && <2.6,     streamly-core >=0.3 && <0.4,     text >=2.1 && <2.2,@@ -277,11 +284,11 @@     keiki >=0.9 && <0.10,     keiki-codec-json >=0.9 && <0.10,     keiro,-    keiro-core ^>=0.21.0.0,-    keiro-test-support ^>=0.21.0.0,-    kiroku-store ^>=0.11.0.0,-    shibuya-core ^>=0.10.0.1,-    shibuya-kiroku-adapter ^>=0.6.0.1,+    keiro-core ^>=0.22.0.0,+    keiro-test-support ^>=0.22.0.0,+    kiroku-store ^>=0.12.0.0,+    shibuya-core ^>=0.12.0.0,+    shibuya-kiroku-adapter ^>=0.7.0.0,     stm >=2.5 && <2.6,     streamly-core >=0.3 && <0.4,     text >=2.1 && <2.2,@@ -297,6 +304,8 @@    main-is: Main.hs   other-modules:+    BenchSetup+    CommandProbe     GuardedCatalogBench     GuardedCatalogExample     GuardedCatalogFixture@@ -325,11 +334,11 @@     hs-opentelemetry-sdk >=1.0 && <1.1,     keiki >=0.9 && <0.10,     keiro,-    keiro-core ^>=0.21.0.0,-    keiro-test-support ^>=0.21.0.0,-    kiroku-store ^>=0.11.0.0,+    keiro-core ^>=0.22.0.0,+    keiro-test-support ^>=0.22.0.0,+    kiroku-store ^>=0.12.0.0,     process >=1.6 && <1.7,-    shibuya-core ^>=0.10.0.1,+    shibuya-core ^>=0.12.0.0,     streamly-core >=0.3 && <0.4,     tasty-bench >=0.4,     text >=2.1 && <2.2,
retention/Retention/Fixture.hs view
@@ -85,7 +85,7 @@ import Shibuya.Core.Ack (AckDecision (..)) import Shibuya.Core.Ack qualified as Ack import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..)) import Shibuya.Core.Types (Attempt (..), Cursor (..), Envelope (..), MessageId (..)) import Streamly.Data.Stream qualified as Streamly @@ -420,7 +420,8 @@       ack = AckHandle \case         AckHalt _ -> liftIO cancel         decision -> liftIO $ atomically $ void $ tryPutTMVar reply (toSubscriptionResult attempt decision),-      lease = Nothing+      lease = Nothing,+      deliveryStatus = pure DeliveryCurrent     }  toSubscriptionResult :: Word -> AckDecision -> Sub.SubscriptionResult
src/Keiro/Command.hs view
@@ -47,7 +47,8 @@ -- post-commit snapshot failures are likewise swallowed and counted. Every runner -- accepts a tracer for optional OpenTelemetry spans. ----- The additive hydration primitives are also consumed by "Keiro.ReplayAudit".+-- The source-reporting hydration variant supports inspection without command+-- side effects. The additive hydration primitives are also consumed by "Keiro.ReplayAudit". -- The audit deliberately calls the seeded and full variants separately so the -- public command-serving fallback cannot hide a stale or unreplayable seed. module Keiro.Command@@ -64,6 +65,7 @@      -- * Options     RunCommandOptions (..),+    InspectionProvenancePolicy (..),     defaultRunCommandOptions,      -- * Running commands@@ -82,6 +84,10 @@      -- * Hydration primitives (replay audit)     Hydrated (..),+    HydrationSource (..),+    FullReplayReason (..),+    inspectionRunCommandOptions,+    hydrateWithSource,     hydrate,     hydrateFull,     hydrateSeeded,@@ -330,6 +336,11 @@     HydrationTruncatedChain   deriving stock (Generic, Eq, Show) +-- | Legacy reaction source metadata is explicit application opt-in. Mounting+-- an inspection application never enables it.+data InspectionProvenancePolicy = NoInspectionProvenance | CaptureInspectionProvenance+  deriving stock (Eq, Show, Generic)+ -- | Knobs controlling a single command invocation. -- -- * 'retryLimit' — how many times to rehydrate-and-replay after an@@ -357,6 +368,8 @@ -- * 'seedVerifyInFlightLimit' — maximum concurrent sampled verifications in --   this process. A sampled verification is skipped when the limit is full; --   @0@ removes the limit.+-- * 'inspectionProvenance' — explicit bounded legacy reaction metadata;+--   disabled by default and independent of mounting an inspection server. data RunCommandOptions = RunCommandOptions   { retryLimit :: !Int,     pageSize :: !Int32,@@ -380,14 +393,16 @@     --     keys here are merged on top (see 'Keiro.Codec.metadataFor'). When     --     'Nothing', events carry only the schema-version marker, exactly as     --     before this field existed.-    metadata :: !(Maybe Value)+    metadata :: !(Maybe Value),+    -- | Bounded legacy process-manager provenance; disabled on ordinary writes.+    inspectionProvenance :: !InspectionProvenancePolicy   }   deriving stock (Generic)  -- | Sensible defaults: 3 retries, 256-event read pages, no caller-assigned -- event ids, a no-op pre-append hook, 5ms retry backoff, no metrics, post-append -- replay verification enabled, one sampled snapshot-seed verification per 1000--- snapshot hits, no tracer, and no extra metadata.+-- snapshot hits, no tracer, no extra metadata, and inspection provenance off. defaultRunCommandOptions :: RunCommandOptions defaultRunCommandOptions =   RunCommandOptions@@ -401,7 +416,8 @@       seedVerifySampleRate = 1000,       seedVerifyInFlightLimit = 1,       tracer = Nothing,-      metadata = Nothing+      metadata = Nothing,+      inspectionProvenance = NoInspectionProvenance     }  data Hydrated rs s = Hydrated@@ -411,6 +427,37 @@   }   deriving stock (Generic) +-- | How the ordinary hydration algorithm recovered this state.+data HydrationSource+  = FromSnapshot !StreamVersion+  | FullReplay !FullReplayReason+  deriving stock (Eq, Show, Generic)++data FullReplayReason+  = NoStateCodec+  | SnapshotMissed !SnapshotMissReason+  | SeededReplayFailed !CommandError+  deriving stock (Eq, Show, Generic)++-- | Read-only diagnostics never schedule seed verification or emit telemetry.+inspectionRunCommandOptions :: RunCommandOptions+inspectionRunCommandOptions =+  defaultRunCommandOptions+    { seedVerifySampleRate = 0,+      metrics = Nothing,+      tracer = Nothing,+      verifyReplayOnAppend = False+    }++hydrateWithSource ::+  forall phi rs s ci co es.+  (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Eff es (Either CommandError (Hydrated rs s, HydrationSource))+hydrateWithSource = hydrateUsing (\source -> fmap (\hydrated -> (hydrated, source)))+ data DomainCommandPlan target rs s co rejection noOp   = DomainCommandSilent !(SilentDomainDecision rejection noOp) !(CommandResult target)   | DomainCommandAppend !(Hydrated rs s) !(NonEmpty co) ![EventData]@@ -451,39 +498,47 @@   EventStream phi rs s ci co ->   Stream (EventStream phi rs s ci co) ->   Eff es (Either CommandError (Hydrated rs s))-hydrateAfterProbe options eventStream targetStream =-  snapshotSeed >>= \case-    Nothing -> hydrateFull options eventStream targetStream-    Just seed -> do-      replayed <--        hydrateSeeded-          options-          eventStream-          targetStream-          (seed ^. #state)-          (seed ^. #registers)-          (seed ^. #streamVersion)-      case replayed of-        Left _ -> hydrateFull options eventStream targetStream-        Right hydrated -> do-          for_ (eventStream ^. #stateCodec) $ \codec ->-            scheduleSeedVerification options eventStream targetStream codec seed-          pure (Right hydrated)+hydrateAfterProbe = hydrateUsing (\_ result -> result)++-- Inline at both entry points: the ordinary continuation ignores its lazy+-- source argument, so it constructs neither diagnostics nor a result pair.+{-# INLINE hydrateUsing #-}+hydrateUsing ::+  forall phi rs s ci co es result.+  (HasCallStack, IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>+  (HydrationSource -> Either CommandError (Hydrated rs s) -> result) ->+  RunCommandOptions ->+  EventStream phi rs s ci co ->+  Stream (EventStream phi rs s ci co) ->+  Eff es result+hydrateUsing finish options eventStream targetStream =+  case eventStream ^. #stateCodec of+    Nothing -> fullReplay NoStateCodec+    Just codec ->+      lookupSnapshotSeed ((eventStream ^. #resolveStreamName) targetStream) codec >>= \case+        SnapshotUnavailable reason -> do+          recordSnapshotReadMisses (options ^. #metrics) 1+          case reason of+            SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1+            _ -> pure ()+          fullReplay (SnapshotMissed reason)+        SnapshotHit seed -> do+          recordSnapshotReadHits (options ^. #metrics) 1+          replayed <-+            hydrateSeeded+              options+              eventStream+              targetStream+              (seed ^. #state)+              (seed ^. #registers)+              (seed ^. #streamVersion)+          case replayed of+            Left failure -> fullReplay (SeededReplayFailed failure)+            Right hydrated -> do+              scheduleSeedVerification options eventStream targetStream codec seed+              pure (finish (FromSnapshot (seed ^. #streamVersion)) (Right hydrated))   where-    snapshotSeed =-      case eventStream ^. #stateCodec of-        Nothing -> pure Nothing-        Just codec -> do-          lookupSnapshotSeed ((eventStream ^. #resolveStreamName) targetStream) codec >>= \case-            SnapshotHit seed -> do-              recordSnapshotReadHits (options ^. #metrics) 1-              pure (Just seed)-            SnapshotUnavailable reason -> do-              recordSnapshotReadMisses (options ^. #metrics) 1-              case reason of-                SnapshotDecodeFailed _ -> recordSnapshotDecodeFailures (options ^. #metrics) 1-                _ -> pure ()-              pure Nothing+    fullReplay reason = finish (FullReplay reason) <$> hydrateFull options eventStream targetStream  hydrateFull ::   forall phi rs s ci co es.
+ src/Keiro/Inspection/Aggregate.hs view
@@ -0,0 +1,424 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedRecordDot #-}++-- | Application-owned aggregate descriptions and read-only inspection.+-- Raw history belongs to Kiroku; these reads return folded state and references.+module Keiro.Inspection.Aggregate+  ( AggregateInspector (..),+    SomeAggregateInspector (..),+    AggregateInspectors,+    aggregateInspector,+    inspectorFromAuditTarget,+    displayViaStateCodec,+    aggregateInspectors,+    inspectorName,+    AggregateTypeSummary (..),+    listAggregateTypes,+    AggregateInspectionError (..),+    aggregateStream,+    aggregateIdOf,+    SnapshotCompatibility (..),+    SnapshotConfig (..),+    StoredSnapshot (..),+    SnapshotStatus (..),+    HistoryReference (..),+    AggregateView (..),+    inspectAggregate,+    snapshotStatusFor,+    AggregateListRequest (..),+    AggregateSummary (..),+    AggregateListPage (..),+    maxAggregatePageSize,+    aggregateListCursorKind,+    listAggregates,+    aggregateTypeJson,+    hydrationSourceJson,+    snapshotStatusJson,+    aggregateViewJson,+    aggregateSummaryJson,+    aggregateListPageJson,+    inspectionErrorText,+  )+where++import Data.Aeson (object, (.=))+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Text qualified as Text+import Data.Time (diffUTCTime)+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, (:>))+import Keiki.Core (BoolAlg, RegFile)+import Keiro.Command+import Keiro.EventStream (EventStream, SnapshotPolicy (..), StateCodec)+import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)+import Keiro.Inspection.Cursor+import Keiro.Prelude hiding ((.=))+import Keiro.ReplayAudit (AuditTarget, streamInCategory)+import Keiro.Snapshot (SnapshotMissReason (..))+import Keiro.Snapshot.Schema qualified as Snapshot+import Keiro.Stream (CategoryError, Stream, StreamCategory, categoryName, categoryText, entityStream)+import Keiro.Stream qualified as Stream+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Read (getStream, listStreams)+import Kiroku.Store.Types (StreamInfo (..), StreamName (..), StreamVersion (..), mkBrowsePageSize)++data AggregateInspector phi rs s ci co = AggregateInspector+  { name :: !Text,+    eventStream :: !(ValidatedEventStream phi rs s ci co),+    category :: !(StreamCategory (EventStream phi rs s ci co)),+    mkStream :: !(StreamName -> Maybe (Stream (EventStream phi rs s ci co))),+    displayState :: !(s -> RegFile rs -> Value),+    stateLabel :: !(s -> Text)+  }+  deriving stock (Generic)++aggregateInspector :: (Show s) => Text -> ValidatedEventStream phi rs s ci co -> StreamCategory (EventStream phi rs s ci co) -> (s -> RegFile rs -> Value) -> AggregateInspector phi rs s ci co+aggregateInspector name eventStream cat displayState =+  AggregateInspector name eventStream cat (streamInCategory (categoryText cat)) displayState (Text.pack . show)++inspectorFromAuditTarget :: (Show s) => Text -> AuditTarget phi rs s ci co -> (s -> RegFile rs -> Value) -> Either CategoryError (AggregateInspector phi rs s ci co)+inspectorFromAuditTarget name target display = do+  cat <- Stream.category (target ^. #category)+  pure ((aggregateInspector name (target ^. #eventStream) cat display) {mkStream = target ^. #mkStream})++displayViaStateCodec :: StateCodec (s, RegFile rs) -> s -> RegFile rs -> Value+displayViaStateCodec codec state registers = (codec ^. #encode) (state, registers)++data SomeAggregateInspector where+  SomeAggregateInspector :: (BoolAlg phi (RegFile rs, ci), Eq co) => AggregateInspector phi rs s ci co -> SomeAggregateInspector++type AggregateInspectors = Map Text SomeAggregateInspector++inspectorName :: SomeAggregateInspector -> Text+inspectorName (SomeAggregateInspector inspector) = inspector.name++aggregateInspectors :: [SomeAggregateInspector] -> Either Text AggregateInspectors+aggregateInspectors = go Map.empty+  where+    go acc [] = Right acc+    go acc (inspector : rest)+      | Map.member (inspectorName inspector) acc = Left ("duplicate aggregate inspector: " <> inspectorName inspector)+      | otherwise = go (Map.insert (inspectorName inspector) inspector acc) rest++data AggregateTypeSummary = AggregateTypeSummary+  {name :: !Text, category :: !Text, snapshotsConfigured :: !Bool, snapshotPolicy :: !Text}+  deriving stock (Eq, Show, Generic)++policyLabel :: SnapshotPolicy s -> Text+policyLabel = \case Never -> "never"; Every n -> "every:" <> Text.pack (show n); OnTerminal -> "on_terminal"; Custom _ -> "custom"++listAggregateTypes :: AggregateInspectors -> [AggregateTypeSummary]+listAggregateTypes = map describe . Map.elems+  where+    describe :: SomeAggregateInspector -> AggregateTypeSummary+    describe (SomeAggregateInspector inspector) =+      let definition = unvalidated inspector.eventStream+       in AggregateTypeSummary inspector.name (categoryText inspector.category) (isJust (definition ^. #stateCodec)) (policyLabel (definition ^. #snapshotPolicy))++data AggregateInspectionError+  = AggregateNotFound !StreamName+  | AggregateIdRejected !Text+  | AggregateHydrationFailed !StreamName !CommandError+  | InvalidPageSize !Int+  | InvalidCursor !CursorError+  | ResultTooLarge+  deriving stock (Eq, Show, Generic)++aggregateStream :: AggregateInspector phi rs s ci co -> Text -> Either AggregateInspectionError (Stream (EventStream phi rs s ci co))+aggregateStream inspector aggregateId+  | Text.null (Text.strip aggregateId) = Left (AggregateIdRejected aggregateId)+  | otherwise =+      let target = entityStream inspector.category aggregateId+       in case inspector.mkStream (Stream.streamName target) of+            Nothing -> Left (AggregateIdRejected aggregateId)+            Just accepted | inspectorStreamName inspector accepted == Stream.streamName target -> Right accepted+            _ -> Left (AggregateIdRejected aggregateId)++-- Metadata, membership and hydration must name the same physical stream even+-- when an audit target maps a physical name to a different typed handle.+inspectorStreamName :: AggregateInspector phi rs s ci co -> Stream (EventStream phi rs s ci co) -> StreamName+inspectorStreamName inspector target = (unvalidated inspector.eventStream ^. #resolveStreamName) target++aggregateIdOf :: AggregateInspector phi rs s ci co -> StreamName -> Maybe Text+aggregateIdOf inspector name@(StreamName raw) = do+  accepted <- inspector.mkStream name+  if inspectorStreamName inspector accepted /= name+    then Nothing+    else do+      suffix <- Text.stripPrefix (categoryText inspector.category <> "-") raw+      if Text.null (Text.strip suffix) then Nothing else Just suffix++data SnapshotCompatibility = Compatible | Incompatible ![Text] | Unused+  deriving stock (Eq, Show, Generic)++data SnapshotConfig = SnapshotConfig+  {policy :: !Text, stateCodecVersion :: !Int, regfileShapeHash :: !Text, stateShapeHash :: !Text}+  deriving stock (Eq, Show, Generic)++data StoredSnapshot = StoredSnapshot+  { aheadOfObservedHead :: !Bool,+    streamVersion :: !StreamVersion,+    createdAt :: !UTCTime,+    updatedAt :: !UTCTime,+    ageSeconds :: !Double,+    eventsSince :: !Int64,+    stateCodecVersion :: !Int,+    regfileShapeHash :: !Text,+    stateShapeHash :: !Text,+    compatibility :: !SnapshotCompatibility+  }+  deriving stock (Eq, Show, Generic)++data SnapshotStatus = SnapshotStatus+  {observedAt :: !UTCTime, configured :: !(Maybe SnapshotConfig), stored :: !(Maybe StoredSnapshot)}+  deriving stock (Eq, Show, Generic)++data HistoryReference = HistoryReference+  {owner :: !Text, streamName :: !StreamName, headVersion :: !StreamVersion}+  deriving stock (Eq, Show, Generic)++data AggregateView = AggregateView+  { name :: !Text,+    category :: !Text,+    aggregateId :: !Text,+    streamName :: !StreamName,+    streamVersion :: !StreamVersion,+    createdAt :: !UTCTime,+    deletedAt :: !(Maybe UTCTime),+    truncateBefore :: !StreamVersion,+    hydration :: !(Maybe HydrationSource),+    stateLabel :: !(Maybe Text),+    state :: !(Maybe Value),+    snapshot :: !SnapshotStatus,+    history :: !HistoryReference+  }+  deriving stock (Eq, Show, Generic)++snapshotStatusOf :: (Store :> es) => AggregateInspector phi rs s ci co -> StreamInfo -> Eff es SnapshotStatus+snapshotStatusOf inspector info = do+  observation <- Snapshot.observeSnapshotRow info.id+  let definition = unvalidated inspector.eventStream+      configured = fmap (\codec -> SnapshotConfig (policyLabel (definition ^. #snapshotPolicy)) (codec ^. #stateCodecVersion) (codec ^. #shapeHash) (codec ^. #stateShapeHash)) (definition ^. #stateCodec)+      stored =+        fmap+          ( \row ->+              StoredSnapshot+                (row.streamVersion > info.version)+                row.streamVersion+                row.createdAt+                row.updatedAt+                (realToFrac (diffUTCTime observation.observedAt row.updatedAt))+                (max 0 (versionInt info.version - versionInt row.streamVersion))+                row.stateCodecVersion+                row.regfileShapeHash+                row.stateShapeHash+                (compatibilityOf configured row)+          )+          observation.row+  pure SnapshotStatus {observedAt = observation.observedAt, configured, stored}+  where+    compatibilityOf :: Maybe SnapshotConfig -> Snapshot.SnapshotRow -> SnapshotCompatibility+    compatibilityOf Nothing _ = Unused+    compatibilityOf (Just config) row =+      case [key | (key, differs) <- [("state_codec_version", config.stateCodecVersion /= row.stateCodecVersion), ("regfile_shape_hash", config.regfileShapeHash /= row.regfileShapeHash), ("state_shape_hash", config.stateShapeHash /= row.stateShapeHash)], differs] of+        [] -> Compatible+        keys -> Incompatible keys++snapshotStatusFor :: (Store :> es) => AggregateInspector phi rs s ci co -> Text -> Eff es (Either AggregateInspectionError (StreamName, StreamVersion, SnapshotStatus))+snapshotStatusFor inspector aggregateId =+  case aggregateStream inspector aggregateId of+    Left err -> pure (Left err)+    Right target -> do+      info <- getStream (inspectorStreamName inspector target)+      case info of+        Nothing -> pure (Left (AggregateNotFound (inspectorStreamName inspector target)))+        Just found -> do+          status <- snapshotStatusOf inspector found+          pure (Right (found.name, found.version, status))++inspectAggregate :: (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => AggregateInspector phi rs s ci co -> Text -> Eff es (Either AggregateInspectionError AggregateView)+inspectAggregate inspector aggregateId =+  case aggregateStream inspector aggregateId of+    Left err -> pure (Left err)+    Right target ->+      getStream (inspectorStreamName inspector target) >>= \case+        Nothing -> pure (Left (AggregateNotFound (inspectorStreamName inspector target)))+        Just info -> do+          folded <- if isJust info.deletedAt then pure (Right Nothing) else fmap (fmap Just) (hydrateWithSource inspectionRunCommandOptions (unvalidated inspector.eventStream) target)+          case folded of+            Left err -> pure (Left (AggregateHydrationFailed info.name err))+            Right result -> do+              snapshot <- snapshotStatusOf inspector info+              pure+                ( Right+                    AggregateView+                      { name = inspector.name,+                        category = categoryText inspector.category,+                        aggregateId,+                        streamName = info.name,+                        streamVersion = maybe info.version (\(hydrated, _) -> hydrated ^. #streamVersion) result,+                        createdAt = info.createdAt,+                        deletedAt = info.deletedAt,+                        truncateBefore = info.truncateBefore,+                        hydration = fmap snd result,+                        stateLabel = fmap (\(hydrated, _) -> inspector.stateLabel (hydrated ^. #state)) result,+                        state = fmap (\(hydrated, _) -> inspector.displayState (hydrated ^. #state) (hydrated ^. #registers)) result,+                        snapshot,+                        history = HistoryReference "kiroku" info.name info.version+                      }+                )++data AggregateListRequest = AggregateListRequest {pageSize :: !Int, after :: !(Maybe InspectionCursor)}+  deriving stock (Eq, Show, Generic)++data AggregateSummary = AggregateSummary+  { aggregateId :: !Text,+    streamName :: !StreamName,+    streamVersion :: !StreamVersion,+    createdAt :: !UTCTime,+    deletedAt :: !(Maybe UTCTime),+    truncateBefore :: !StreamVersion+  }+  deriving stock (Eq, Show, Generic)++data AggregateListPage = AggregateListPage+  {name :: !Text, category :: !Text, items :: ![AggregateSummary], skipped :: !Int, next :: !(Maybe InspectionCursor)}+  deriving stock (Eq, Show, Generic)++maxAggregatePageSize :: Int+maxAggregatePageSize = 500++aggregateListCursorKind :: Text+aggregateListCursorKind = "aggregate-list"++listAggregates :: (Store :> es) => AggregateInspector phi rs s ci co -> AggregateListRequest -> Eff es (Either AggregateInspectionError AggregateListPage)+listAggregates inspector request =+  case parameters of+    Left err -> pure (Left err)+    Right (after, limit) -> do+      raw <- listStreams (Just (categoryName inspector.category)) (Just (categoryText inspector.category <> "-")) after limit+      let examined = Vector.toList (Vector.take request.pageSize raw)+          items = mapMaybe summary examined+          continuation =+            if Vector.length raw > request.pageSize+              then case reverse examined of+                lastRow : _ -> case lastRow.name of StreamName lastName -> maybe (Left ResultTooLarge) (Right . Just) (encodeCursorBounded aggregateListCursorKind (inspector.name, categoryText inspector.category, lastName))+                [] -> Right Nothing+              else Right Nothing+      pure $ fmap (\next -> AggregateListPage inspector.name (categoryText inspector.category) items (length examined - length items) next) continuation+  where+    parameters+      | request.pageSize < 1 || request.pageSize > maxAggregatePageSize = Left (InvalidPageSize request.pageSize)+      | otherwise = do+          limit <- either (const (Left (InvalidPageSize request.pageSize))) Right (mkBrowsePageSize (request.pageSize + 1))+          after <- traverse decodeAfter request.after+          pure (after, limit)+    decodeAfter :: InspectionCursor -> Either AggregateInspectionError StreamName+    decodeAfter cursor = do+      (name, cat, lastName) <- either (Left . InvalidCursor) Right (decodeCursor aggregateListCursorKind cursor)+      if name == inspector.name && cat == categoryText inspector.category && Text.isPrefixOf (cat <> "-") lastName+        then Right (StreamName lastName)+        else Left (InvalidCursor (MalformedCursor "cursor belongs to another aggregate inspector"))+    summary :: StreamInfo -> Maybe AggregateSummary+    summary info = do+      aggregateId <- aggregateIdOf inspector info.name+      pure (AggregateSummary aggregateId info.name info.version info.createdAt info.deletedAt info.truncateBefore)++versionInt :: StreamVersion -> Int64+versionInt (StreamVersion n) = n++nameText :: StreamName -> Text+nameText (StreamName name) = name++aggregateTypeJson :: AggregateTypeSummary -> Value+aggregateTypeJson summary = object ["name" .= summary.name, "category" .= summary.category, "snapshots_configured" .= summary.snapshotsConfigured, "snapshot_policy" .= summary.snapshotPolicy]++hydrationSourceJson :: HydrationSource -> Value+hydrationSourceJson = \case+  FromSnapshot version -> object ["source" .= ("snapshot" :: Text), "snapshot_version" .= versionInt version]+  FullReplay reason ->+    object+      ( ["source" .= ("full_replay" :: Text)] <> case reason of+          NoStateCodec -> ["reason" .= ("no_state_codec" :: Text)]+          SnapshotMissed miss ->+            ["reason" .= ("snapshot_missed" :: Text)] <> case miss of+              SnapshotNoStream -> ["miss" .= ("no_stream" :: Text)]+              SnapshotNotFound -> ["miss" .= ("not_found" :: Text)]+              SnapshotDecodeFailed detail -> ["miss" .= ("decode_failed" :: Text), "detail" .= detail]+          SeededReplayFailed err -> ["reason" .= ("seeded_replay_failed" :: Text), "error" .= commandErrorClass err, "detail" .= Text.pack (show err)]+      )++snapshotStatusJson :: SnapshotStatus -> Value+snapshotStatusJson status =+  object+    [ "status" .= (if isJust status.stored then "present" else "absent" :: Text),+      "observed_at" .= status.observedAt,+      "configured" .= fmap configJson status.configured,+      "stored" .= fmap storedJson status.stored+    ]+  where+    configJson :: SnapshotConfig -> Value+    configJson config = object ["policy" .= config.policy, "state_codec_version" .= config.stateCodecVersion, "regfile_shape_hash" .= config.regfileShapeHash, "state_shape_hash" .= config.stateShapeHash]+    storedJson :: StoredSnapshot -> Value+    storedJson stored =+      object+        [ "stream_version" .= versionInt stored.streamVersion,+          "created_at" .= stored.createdAt,+          "updated_at" .= stored.updatedAt,+          "age_seconds" .= stored.ageSeconds,+          "events_since" .= stored.eventsSince,+          "ahead_of_observed_head" .= stored.aheadOfObservedHead,+          "state_codec_version" .= stored.stateCodecVersion,+          "regfile_shape_hash" .= stored.regfileShapeHash,+          "state_shape_hash" .= stored.stateShapeHash,+          "compatibility" .= (case stored.compatibility of Compatible -> "compatible"; Incompatible _ -> "incompatible"; Unused -> "unused" :: Text),+          "mismatched" .= (case stored.compatibility of Incompatible fields -> fields; _ -> [])+        ]++aggregateViewJson :: AggregateView -> Value+aggregateViewJson view =+  object+    [ "name" .= view.name,+      "category" .= view.category,+      "aggregate_id" .= view.aggregateId,+      "stream_name" .= nameText view.streamName,+      "stream_version" .= versionInt view.streamVersion,+      "state_version" .= (if isJust view.state then Just (versionInt view.streamVersion) else Nothing),+      "created_at" .= view.createdAt,+      "deleted_at" .= view.deletedAt,+      "truncate_before" .= versionInt view.truncateBefore,+      "hydration" .= fmap hydrationSourceJson view.hydration,+      "state_label" .= view.stateLabel,+      "state" .= view.state,+      "snapshot" .= snapshotStatusJson view.snapshot,+      "history" .= object ["owner" .= view.history.owner, "stream_name" .= nameText view.history.streamName, "head_version" .= versionInt view.history.headVersion]+    ]++aggregateSummaryJson :: AggregateSummary -> Value+aggregateSummaryJson summary =+  object+    [ "aggregate_id" .= summary.aggregateId,+      "stream_name" .= nameText summary.streamName,+      "stream_version" .= versionInt summary.streamVersion,+      "created_at" .= summary.createdAt,+      "deleted_at" .= summary.deletedAt,+      "truncate_before" .= versionInt summary.truncateBefore+    ]++aggregateListPageJson :: AggregateListPage -> Value+aggregateListPageJson page =+  object+    ( ["name" .= page.name, "category" .= page.category, "items" .= map aggregateSummaryJson page.items, "skipped" .= page.skipped]+        <> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next+    )++inspectionErrorText :: AggregateInspectionError -> Text+inspectionErrorText = \case+  AggregateNotFound name -> "aggregate not found: " <> nameText name+  AggregateIdRejected aggregateId -> "aggregate id rejected: " <> aggregateId+  AggregateHydrationFailed name err -> "aggregate hydration failed: " <> nameText name <> ": " <> Text.pack (show err)+  InvalidPageSize n -> "page size must be 1..500: " <> Text.pack (show n)+  InvalidCursor err -> "invalid aggregate cursor: " <> Text.pack (show err)+  ResultTooLarge -> "aggregate continuation exceeds the cursor byte limit"
+ src/Keiro/Inspection/Cursor.hs view
@@ -0,0 +1,61 @@+module Keiro.Inspection.Cursor+  ( InspectionCursor (..),+    CursorError (..),+    maxCursorBytes,+    encodeCursor,+    encodeCursorBounded,+    decodeCursor,+  )+where++import Data.Aeson (FromJSON, ToJSON, eitherDecodeStrict', encode, object, parseJSON, withObject, (.:), (.=))+import Data.Aeson.Types (parseEither)+import Data.ByteString qualified as ByteString+import Data.ByteString.Base64.URL qualified as Base64+import Data.ByteString.Lazy qualified as LazyByteString+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import GHC.Generics (Generic)++-- | An opaque, kind-bound continuation. Clients only echo it to the same read.+newtype InspectionCursor = InspectionCursor {cursorText :: Text}+  deriving stock (Eq, Show, Generic)++data CursorError = MalformedCursor !Text | CursorKindMismatch !Text !Text+  deriving stock (Eq, Show, Generic)++maxCursorBytes :: Int+maxCursorBytes = 4096++encodeCursor :: (ToJSON payload) => Text -> payload -> InspectionCursor+encodeCursor kind payload = encodedCursor (cursorJson kind payload)++-- | Page producers must not emit a continuation the decoder would reject.+-- Demand only the maximum representable JSON prefix before base64 encoding.+encodeCursorBounded :: (ToJSON payload) => Text -> payload -> Maybe InspectionCursor+encodeCursorBounded kind payload =+  let decodedLimit = maxCursorBytes * 3 `div` 4+      prefix = LazyByteString.take (fromIntegral decodedLimit + 1) (cursorJson kind payload)+   in if LazyByteString.length prefix > fromIntegral decodedLimit then Nothing else Just (encodedCursor prefix)++cursorJson :: (ToJSON payload) => Text -> payload -> LazyByteString.ByteString+cursorJson kind payload = encode (object ["k" .= kind, "v" .= payload])++encodedCursor :: LazyByteString.ByteString -> InspectionCursor+encodedCursor = InspectionCursor . Text.decodeUtf8 . Base64.encodeUnpadded . LazyByteString.toStrict++decodeCursor :: (FromJSON payload) => Text -> InspectionCursor -> Either CursorError payload+decodeCursor expected (InspectionCursor token)+  -- Check character count before UTF-8 encoding, then the actual encoded bytes.+  | Text.length token > maxCursorBytes = Left (MalformedCursor "cursor exceeds 4096 encoded bytes")+  | ByteString.length bytes > maxCursorBytes = Left (MalformedCursor "cursor exceeds 4096 encoded bytes")+  | otherwise = do+      decoded <- malformed (Base64.decodeUnpadded bytes)+      value <- malformed (eitherDecodeStrict' decoded)+      (kind, payload) <- malformed (parseEither (withObject "inspection cursor" (\obj -> (,) <$> obj .: "k" <*> obj .: "v")) value)+      if kind /= expected then Left (CursorKindMismatch expected kind) else malformed (parseEither parseJSONValue payload)+  where+    bytes = Text.encodeUtf8 token+    malformed = either (Left . MalformedCursor . Text.pack) Right+    parseJSONValue = parseJSON
src/Keiro/ProcessManager.hs view
@@ -156,6 +156,7 @@     CommandResult,     DomainCommandHandler,     DomainCommandOutcome,+    InspectionProvenancePolicy (..),     RunCommandOptions,     commandErrorClass,     runCommandWithSql,@@ -165,6 +166,7 @@ import Keiro.EventStream (EventStream) import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated) import Keiro.Prelude+import Keiro.ProcessManager.Provenance import Keiro.Projection (InlineProjection, runCommandWithProjections, runDomainCommandWithProjections) import Keiro.Stream (Stream) import Keiro.Telemetry (KeiroMetrics, recordDispatchDeadLettered, recordDispatchDuplicate, recordDispatchFailed, recordDispatchPoison)@@ -520,6 +522,30 @@       Text.pack (show emitIndex)     ] +-- Inline so the disabled branch retains the original options update without+-- constructing provenance, traversing lists or resolving any target names.+{-# INLINE managerCommandOptions #-}+managerCommandOptions :: RunCommandOptions -> EventId -> RecordedEvent -> ProcessManagerAction ci targetCi -> (Stream targetCi -> StoreTypes.StreamName) -> RunCommandOptions+managerCommandOptions options managerEventId source action resolveTarget =+  case options ^. #inspectionProvenance of+    NoInspectionProvenance -> original+    CaptureInspectionProvenance ->+      withProvenanceMetadata+        ( encodeReactionProvenance+            ReactionProvenance+              { sourceEventId = source ^. #eventId,+                sourceGlobalPosition = source ^. #globalPosition,+                sourceStreamId = source ^. #originalStreamId,+                dispatched = [(index, resolveTarget (command ^. #target)) | (index, command) <- zip [0 ..] (action ^. #commands)],+                timerIds = fmap (^. #timerId) (action ^. #timers),+                family = legacyProvenanceFamily,+                truncated = False+              }+        )+        original+  where+    original = options & #eventIds .~ [managerEventId]+ -- | 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.@@ -554,7 +580,7 @@       managerStream = (manager ^. #streamFor) correlationId       managerProbes = deterministicCommandIdProbes (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)       managerEventId = NonEmpty.head managerProbes-      managerOptions = options & #eventIds .~ [managerEventId]+      managerOptions = managerCommandOptions options managerEventId sourceEvent action (\target -> ((unvalidated (manager ^. #targetEventStream)) ^. #resolveStreamName) (coerce target))       managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream   existingManagerId <- firstExistingEventId options managerStreamName managerProbes   case existingManagerId of@@ -694,7 +720,7 @@       managerStream = (manager ^. #streamFor) correlationId       managerProbes = deterministicCommandIdProbes (manager ^. #name) correlationId (sourceEvent ^. #eventId) (-1)       managerEventId = NonEmpty.head managerProbes-      managerOptions = options & #eventIds .~ [managerEventId]+      managerOptions = managerCommandOptions options managerEventId sourceEvent action (\target -> ((unvalidated ((manager ^. #targetHandler) ^. #eventStream)) ^. #resolveStreamName) (coerce target))       managerStreamName = ((unvalidated (manager ^. #eventStream)) ^. #resolveStreamName) managerStream       finish managerResult = pure (Right (correlationId, managerResult, action))   existingManagerId <- firstExistingEventId options managerStreamName managerProbes
+ src/Keiro/ProcessManager/Inspect.hs view
@@ -0,0 +1,441 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedRecordDot #-}++-- | Application-owned descriptors and observations of persisted manager state.+-- Inspection uses the runtime hydration algorithm; raw history remains in Kiroku.+module Keiro.ProcessManager.Inspect+  ( ProcessManagerInspector (..),+    SomeProcessManagerInspector (..),+    ProcessManagerInspectors,+    ProcessManagerTypeSummary (..),+    listProcessManagerTypes,+    processManagerTypeJson,+    processManagerInspector,+    domainProcessManagerInspector,+    reactiveProcessManagerInspector,+    ProcessManagerProvenanceMode (..),+    EnabledInput (..),+    ReactionProvenance (..),+    encodeReactionProvenance,+    decodeReactionProvenance,+    DispatchWitness (..),+    DispatchedCommandStatus (..),+    LastReaction (..),+    InstanceViewRequest (..),+    ProcessManagerInspectionError (..),+    ProcessManagerInstanceView (..),+    inspectProcessManagerInstance,+    processManagerViewJson,+    InstanceListRequest (..),+    InstanceListError (..),+    InstanceSummary (..),+    InstancePage (..),+    instanceListCursorKind,+    listProcessManagerInstances,+    instancePageJson,+  )+where++import Data.Aeson (object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Encoding qualified as Encoding+import Data.Aeson.KeyMap qualified as KeyMap+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.Text qualified as Text+import Data.UUID qualified as UUID+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, (:>))+import Keiki.Core (BoolAlg, HsPred, RegFile)+import Keiki.Core qualified as Keiki+import Keiki.Render.Mermaid (edgeInputName)+import Keiro.Command+import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))+import Keiro.EventStream.Validate (ValidatedEventStream, unvalidated)+import Keiro.Inspection.Aggregate (hydrationSourceJson)+import Keiro.Inspection.Cursor+import Keiro.Prelude hiding ((.=))+import Keiro.ProcessManager (DomainProcessManager, ProcessManager, deterministicCommandIdProbes, firstExistingEventId)+import Keiro.ProcessManager.Provenance+import Keiro.ProcessManager.Reaction (ReactiveProcessManager)+import Keiro.Stream (Stream, StreamCategory, categoryName, categoryText)+import Keiro.Timer+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Read (getStream, listStreams, lookupStreamName, readStreamForward)+import Kiroku.Store.Types hiding (categoryName)++-- | Select the persisted evidence family explicitly; state inspection alone+-- does not authorize interpreting legacy metadata as modern reaction evidence.+data ProcessManagerProvenanceMode = ReadLegacyProvenance | NoReactionProvenance+  deriving stock (Generic, Eq, Show)++-- | Encoders and membership mappings belong to the application. A custom+-- mapping must round-trip through streamFor and resolveStreamName.+data ProcessManagerInspector phi rs s ci co = ProcessManagerInspector+  { name :: !Text,+    eventStream :: !(ValidatedEventStream phi rs s ci co),+    streamFor :: !(Text -> Stream (EventStream phi rs s ci co)),+    category :: !(StreamCategory (EventStream phi rs s ci co)),+    correlationOf :: !(StreamName -> Maybe Text),+    displayState :: !(s -> RegFile rs -> Value),+    stateLabel :: !(s -> Text),+    enabledInputs :: !(s -> [EnabledInput]),+    provenanceMode :: !ProcessManagerProvenanceMode+  }+  deriving stock (Generic)++data EnabledInput = EnabledInput {input :: !(Maybe Text), targetState :: !Text}+  deriving stock (Generic, Eq, Show)++mkInspector :: (Show s) => ProcessManagerProvenanceMode -> Text -> ValidatedEventStream (HsPred rs ci) rs s ci co -> (Text -> Stream (EventStream (HsPred rs ci) rs s ci co)) -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co+mkInspector provenanceMode name definition streamFor category displayState =+  ProcessManagerInspector+    name+    definition+    streamFor+    category+    (\(StreamName raw) -> Text.stripPrefix (categoryText category <> "-") raw)+    displayState+    (Text.pack . show)+    ( \state ->+        [ EnabledInput (edgeInputName edge) (Text.pack (show (Keiki.target edge)))+        | edge <- Keiki.edgesOut (unvalidated definition ^. #transducer) state,+          Keiki.mode edge == Keiki.Live+        ]+    )+    provenanceMode++processManagerInspector :: (Show s) => ProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co+processManagerInspector manager = mkInspector ReadLegacyProvenance (manager ^. #name) (manager ^. #eventStream) (manager ^. #streamFor)++domainProcessManagerInspector :: (Show s) => DomainProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo rej noOp -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co+domainProcessManagerInspector manager = mkInspector ReadLegacyProvenance (manager ^. #name) (manager ^. #eventStream) (manager ^. #streamFor)++-- | State inspection for the surviving reaction API. Persisted events do not+-- retain enough input/source information to infer modern dispatch provenance.+reactiveProcessManagerInspector :: (Show s) => ReactiveProcessManager input (HsPred rs ci) rs s ci co tPhi tRs tS tCi tCo rej noOp -> StreamCategory (EventStream (HsPred rs ci) rs s ci co) -> (s -> RegFile rs -> Value) -> ProcessManagerInspector (HsPred rs ci) rs s ci co+reactiveProcessManagerInspector manager = mkInspector NoReactionProvenance (manager ^. #name) (manager ^. #sagaHandler . #eventStream) (manager ^. #streamFor)++data SomeProcessManagerInspector where+  SomeProcessManagerInspector :: (BoolAlg phi (RegFile rs, ci), Eq co) => ProcessManagerInspector phi rs s ci co -> SomeProcessManagerInspector++type ProcessManagerInspectors = Map Text SomeProcessManagerInspector++-- | Mounted metadata, independent of persisted instances. Names are catalog+-- lookup keys, including aliases. Timer capability is not currently declared.+data ProcessManagerTypeSummary = ProcessManagerTypeSummary+  { name :: !Text,+    category :: !Text,+    snapshotsConfigured :: !Bool,+    snapshotPolicy :: !Text,+    timersConfigured :: !(Maybe Bool)+  }+  deriving stock (Eq, Show, Generic)++-- | Enumerate only descriptor fields; never evaluate a policy predicate.+-- Lookup names retain the map's ascending order.+listProcessManagerTypes :: ProcessManagerInspectors -> [ProcessManagerTypeSummary]+listProcessManagerTypes = map describe . Map.toAscList+  where+    describe (lookupName, SomeProcessManagerInspector inspector) =+      case unvalidated inspector.eventStream of+        EventStream {stateCodec = codec, snapshotPolicy = policy} ->+          ProcessManagerTypeSummary lookupName (categoryText inspector.category) (isJust codec) (policyLabel policy) Nothing++-- Keep the constructor match separate from the existential map traversal.+-- GHC 9.12.4 otherwise mislabels Custom as Never in the optimized callback+-- sentinel test; OPAQUE also prevents worker/wrapper from removing this boundary.+{-# OPAQUE policyLabel #-}+policyLabel :: SnapshotPolicy s -> Text+policyLabel = \case+  Never -> "never"+  Every n -> "every:" <> Text.pack (show n)+  OnTerminal -> "on_terminal"+  Custom _ -> "custom"++processManagerTypeJson :: ProcessManagerTypeSummary -> Value+processManagerTypeJson summary =+  object+    [ "name" .= summary.name,+      "category" .= summary.category,+      "snapshots_configured" .= summary.snapshotsConfigured,+      "snapshot_policy" .= summary.snapshotPolicy,+      "timers_configured" .= summary.timersConfigured+    ]++-- Direct encoding avoids building an intermediate JSON tree on polling reads.+-- Keep its meaning equal to processManagerTypeJson (covered by parity tests).+instance Aeson.ToJSON ProcessManagerTypeSummary where+  toJSON = processManagerTypeJson+  toEncoding summary =+    Encoding.pairs+      ( "category" .= summary.category+          <> "name" .= summary.name+          <> "snapshot_policy" .= summary.snapshotPolicy+          <> "snapshots_configured" .= summary.snapshotsConfigured+          <> "timers_configured" .= summary.timersConfigured+      )++data DispatchWitness = EventPresent | NotObserved deriving stock (Generic, Eq, Show)++data DispatchedCommandStatus = DispatchedCommandStatus+  {emitIndex :: !Int, targetStreamName :: !StreamName, eventId :: !EventId, witness :: !DispatchWitness}+  deriving stock (Generic, Eq, Show)++data LastReaction = LastReaction+  { eventId :: !EventId,+    eventType :: !EventType,+    streamVersion :: !StreamVersion,+    globalPosition :: !GlobalPosition,+    recordedAt :: !UTCTime,+    provenance :: !(Maybe ReactionProvenance),+    sourceStreamName :: !(Maybe StreamName),+    dispatchedCommands :: ![DispatchedCommandStatus]+  }+  deriving stock (Generic, Eq, Show)++data InstanceViewRequest = InstanceViewRequest {timerPageSize :: !Int}+  deriving stock (Generic, Eq, Show)++data ProcessManagerInspectionError+  = ProcessManagerHydrationFailed !CommandError+  | InvalidInstanceViewRequest !Text+  | ProcessManagerTimerReadFailed !PendingTimerReadError+  deriving stock (Generic, Eq, Show)++data ProcessManagerInstanceView = ProcessManagerInstanceView+  { name :: !Text,+    correlationId :: !Text,+    journalStreamName :: !StreamName,+    streamVersion :: !StreamVersion,+    observedHeadVersion :: !StreamVersion,+    createdAt :: !UTCTime,+    deletedAt :: !(Maybe UTCTime),+    truncateBefore :: !StreamVersion,+    hydration :: !(Maybe HydrationSource),+    stateLabel :: !(Maybe Text),+    state :: !(Maybe Value),+    lastReaction :: !(Maybe LastReaction),+    waitingFor :: ![EnabledInput],+    pendingTimers :: !PendingTimerPage,+    observedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show)++physicalName :: ProcessManagerInspector phi rs s ci co -> Text -> StreamName+physicalName inspector correlation = (unvalidated inspector.eventStream ^. #resolveStreamName) (inspector.streamFor correlation)++inspectProcessManagerInstance :: (IOE :> es, Store :> es, BoolAlg phi (RegFile rs, ci), Eq co) => ProcessManagerInspector phi rs s ci co -> InstanceViewRequest -> Text -> Eff es (Either ProcessManagerInspectionError (Maybe ProcessManagerInstanceView))+inspectProcessManagerInstance inspector request correlation+  | Text.null (Text.strip correlation) = pure (Left (InvalidInstanceViewRequest "correlation id must not be blank"))+  | Left err <- validatePendingTimerRequest timerFilter timerRequest = pure (Left (ProcessManagerTimerReadFailed err))+  | otherwise = do+      metadata <- getStream streamName+      case metadata of+        Nothing -> pure (Right Nothing)+        Just info -> do+          result <-+            if isJust info.deletedAt+              then pure (Right Nothing)+              else fmap (fmap Just) (hydrateWithSource inspectionRunCommandOptions (unvalidated inspector.eventStream) (inspector.streamFor correlation))+          case result of+            Left err -> pure (Left (ProcessManagerHydrationFailed err))+            Right hydrated -> do+              let version = maybe info.version (\(value, _) -> value ^. #streamVersion) hydrated+              lastReaction <- case hydrated of+                Just _ | version > StreamVersion 0 -> do+                  let StreamVersion n = version+                  events <- readStreamForward streamName (StreamVersion (n - 1)) 1+                  case Vector.toList events of+                    event : _ | event.streamVersion == version -> Just <$> observeReaction event+                    _ -> pure Nothing+                _ -> pure Nothing+              timers <- findPendingTimers timerFilter timerRequest+              pure $ case timers of+                Left err -> Left (ProcessManagerTimerReadFailed err)+                Right page ->+                  Right+                    ( Just+                        ( ProcessManagerInstanceView+                            inspector.name+                            correlation+                            streamName+                            version+                            info.version+                            info.createdAt+                            info.deletedAt+                            info.truncateBefore+                            (snd <$> hydrated)+                            (fmap (\(value, _) -> inspector.stateLabel (value ^. #state)) hydrated)+                            (fmap (\(value, _) -> inspector.displayState (value ^. #state) (value ^. #registers)) hydrated)+                            lastReaction+                            (maybe [] (\(value, _) -> inspector.enabledInputs (value ^. #state)) hydrated)+                            page+                            page.observedAt+                        )+                    )+  where+    streamName = physicalName inspector correlation+    timerFilter = PendingTimerFilter (Just inspector.name) (Just correlation)+    timerRequest = PendingTimerPageRequest request.timerPageSize Nothing+    observeReaction event = case inspector.provenanceMode of+      NoReactionProvenance -> pure (LastReaction event.eventId event.eventType event.streamVersion event.globalPosition event.createdAt Nothing Nothing [])+      ReadLegacyProvenance -> observeLegacyReaction event+    observeLegacyReaction event = do+      let provenance = event.metadata >>= (\case Aeson.Object fields -> KeyMap.lookup "keiro.pm" fields; _ -> Nothing) >>= decodeReactionProvenance+      sourceName <- maybe (pure Nothing) (lookupStreamName . (.sourceStreamId)) provenance+      statuses <- maybe (pure []) (\record -> traverse (probe record) record.dispatched) provenance+      pure (LastReaction event.eventId event.eventType event.streamVersion event.globalPosition event.createdAt provenance sourceName statuses)+    probe record (index, target) = do+      let probes = deterministicCommandIdProbes inspector.name correlation record.sourceEventId index+      existing <- firstExistingEventId inspectionRunCommandOptions target probes+      pure (DispatchedCommandStatus index target (fromMaybe (NonEmpty.head probes) existing) (maybe NotObserved (const EventPresent) existing))++data InstanceListRequest = InstanceListRequest {pageSize :: !Int, after :: !(Maybe InspectionCursor)}+  deriving stock (Generic, Eq, Show)++data InstanceListError = InvalidInstancePageSize !Int | InvalidInstanceCursor !CursorError | InstanceResultTooLarge+  deriving stock (Generic, Eq, Show)++data InstanceSummary = InstanceSummary+  { correlationId :: !Text,+    streamName :: !StreamName,+    streamVersion :: !StreamVersion,+    createdAt :: !UTCTime,+    deletedAt :: !(Maybe UTCTime),+    truncateBefore :: !StreamVersion+  }+  deriving stock (Generic, Eq, Show)++data InstancePage = InstancePage {items :: ![InstanceSummary], skipped :: !Int, next :: !(Maybe InspectionCursor)}+  deriving stock (Generic, Eq, Show)++instanceListCursorKind :: Text+instanceListCursorKind = "pm-list"++-- | Exactly one raw store page. Rejected membership rows count as examined;+-- even an empty displayed page advances from the last examined raw row.+listProcessManagerInstances :: (Store :> es) => ProcessManagerInspector phi rs s ci co -> InstanceListRequest -> Eff es (Either InstanceListError InstancePage)+listProcessManagerInstances inspector request = case parameters of+  Left err -> pure (Left err)+  Right (after, limit) -> do+    raw <- listStreams (Just (categoryName inspector.category)) (Just (categoryText inspector.category <> "-")) after limit+    let examined = Vector.toList (Vector.take request.pageSize raw)+        items = mapMaybe summary examined+        continuation =+          if Vector.length raw > request.pageSize+            then case reverse examined of+              row : _ -> maybe (Left InstanceResultTooLarge) (Right . Just) (encodeCursorBounded instanceListCursorKind (inspector.name, categoryText inspector.category, nameText row.name))+              [] -> Right Nothing+            else Right Nothing+    pure (InstancePage items (length examined - length items) <$> continuation)+  where+    parameters+      | request.pageSize < 1 || request.pageSize > 500 = Left (InvalidInstancePageSize request.pageSize)+      | otherwise = do+          limit <- either (const (Left (InvalidInstancePageSize request.pageSize))) Right (mkBrowsePageSize (request.pageSize + 1))+          after <- traverse decodeAfter request.after+          pure (after, limit)+    decodeAfter cursor = do+      (name, category, lastName) <- either (Left . InvalidInstanceCursor) Right (decodeCursor instanceListCursorKind cursor)+      if name == inspector.name && category == categoryText inspector.category && Text.isPrefixOf (category <> "-") lastName+        then Right (StreamName lastName)+        else Left (InvalidInstanceCursor (MalformedCursor "cursor belongs to another process-manager inspector"))+    summary info = do+      correlation <- inspector.correlationOf info.name+      if Text.null (Text.strip correlation) || physicalName inspector correlation /= info.name+        then Nothing+        else Just (InstanceSummary correlation info.name info.version info.createdAt info.deletedAt info.truncateBefore)++-- | Library-owned representation shared by transport adapters.+processManagerViewJson :: ProcessManagerInstanceView -> Value+processManagerViewJson view =+  object+    [ "name" .= view.name,+      "correlation_id" .= view.correlationId,+      "journal_stream_name" .= nameText view.journalStreamName,+      "stream_version" .= versionNumber view.streamVersion,+      "state_version" .= fmap (const (versionNumber view.streamVersion)) view.state,+      "observed_head_version" .= versionNumber view.observedHeadVersion,+      "created_at" .= view.createdAt,+      "deleted_at" .= view.deletedAt,+      "truncate_before" .= versionNumber view.truncateBefore,+      "observed_at" .= view.observedAt,+      "hydration" .= fmap hydrationSourceJson view.hydration,+      "state_label" .= view.stateLabel,+      "state" .= view.state,+      "last_reaction" .= fmap reactionJson view.lastReaction,+      "pending_timers" .= pendingTimerPageJson view.pendingTimers,+      "waiting_for" .= [object ["input" .= candidate.input, "target_state" .= candidate.targetState] | candidate <- view.waitingFor]+    ]++reactionJson :: LastReaction -> Value+reactionJson reaction =+  object+    [ "event_id" .= eventText reaction.eventId,+      "event_type" .= eventTypeText reaction.eventType,+      "stream_version" .= versionNumber reaction.streamVersion,+      "global_position" .= positionNumber reaction.globalPosition,+      "recorded_at" .= reaction.recordedAt,+      "provenance_status" .= (case reaction.provenance of Nothing -> "unavailable"; Just p | p.truncated -> "truncated"; _ -> "available" :: Text),+      "provenance" .= fmap provenanceJson reaction.provenance+    ]+  where+    provenanceJson record =+      object+        [ "family" .= record.family,+          "truncated" .= record.truncated,+          "source_event_id" .= eventText record.sourceEventId,+          "source_global_position" .= positionNumber record.sourceGlobalPosition,+          "source_stream_id" .= streamNumber record.sourceStreamId,+          "source_stream_name" .= fmap nameText reaction.sourceStreamName,+          "dispatched_commands"+            .= [ object+                   [ "emit_index" .= status.emitIndex,+                     "target_stream_name" .= nameText status.targetStreamName,+                     "event_id" .= eventText status.eventId,+                     "witness" .= (case status.witness of EventPresent -> "event_present"; NotObserved -> "not_observed" :: Text)+                   ]+               | status <- reaction.dispatchedCommands+               ],+          "timer_ids" .= [UUID.toText uuid | TimerId uuid <- record.timerIds]+        ]++instancePageJson :: InstancePage -> Value+instancePageJson page =+  object+    ( [ "items"+          .= [ object+                 [ "correlation_id" .= item.correlationId,+                   "stream_name" .= nameText item.streamName,+                   "stream_version" .= versionNumber item.streamVersion,+                   "created_at" .= item.createdAt,+                   "deleted_at" .= item.deletedAt,+                   "truncate_before" .= versionNumber item.truncateBefore+                 ]+             | item <- page.items+             ],+        "skipped" .= page.skipped+      ]+        <> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next+    )++nameText :: StreamName -> Text+nameText (StreamName value) = value++versionNumber :: StreamVersion -> Int64+versionNumber (StreamVersion value) = value++positionNumber :: GlobalPosition -> Int64+positionNumber (GlobalPosition value) = value++streamNumber :: StreamId -> Int64+streamNumber (StreamId value) = value++eventText :: EventId -> Text+eventText (EventId value) = UUID.toText value++eventTypeText :: EventType -> Text+eventTypeText (EventType value) = value
+ src/Keiro/ProcessManager/Provenance.hs view
@@ -0,0 +1,130 @@+{-# LANGUAGE OverloadedRecordDot #-}++-- | The bounded, explicitly enabled legacy reaction metadata contract.+-- Kept below the runner and inspector to avoid a dependency cycle.+module Keiro.ProcessManager.Provenance+  ( ReactionProvenance (..),+    legacyProvenanceFamily,+    encodeReactionProvenance,+    decodeReactionProvenance,+    withProvenanceMetadata,+  )+where++import Control.Monad (unless)+import Data.Aeson (Value (..), object, withObject, withText, (.:), (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (Parser, parseEither)+import Data.ByteString.Lazy qualified as Lazy+import Data.Text qualified as Text+import Data.UUID qualified as UUID+import Data.Vector qualified as Vector+import Keiro.Command (RunCommandOptions)+import Keiro.Prelude hiding ((.=))+import Keiro.Timer.Types (TimerId (..))+import Kiroku.Store.Types (EventId (..), GlobalPosition (..), StreamId (..), StreamName (..))++data ReactionProvenance = ReactionProvenance+  { sourceEventId :: !EventId,+    sourceGlobalPosition :: !GlobalPosition,+    sourceStreamId :: !StreamId,+    dispatched :: ![(Int, StreamName)],+    timerIds :: ![TimerId],+    family :: !Text,+    truncated :: !Bool+  }+  deriving stock (Eq, Show, Generic)++legacyProvenanceFamily :: Text+legacyProvenanceFamily = "legacy-positional-v1"++maxEntries, maxBytes :: Int+maxEntries = 64+maxBytes = 16 * 1024++-- | Encode the inner @keiro.pm@ value. Only the supported identity family can+-- be written. Oversized identifiers or frames retain source-only evidence with+-- @truncated=true@; the prefix walks never demand the full follow-up lists.+encodeReactionProvenance :: ReactionProvenance -> Value+encodeReactionProvenance original+  | original.family /= legacyProvenanceFamily = Null+  | any oversized targets = frame sourceOnly+  | not (withinByteBudget value) = frame sourceOnly+  | otherwise = value+  where+    (targets, moreTargets) = prefix original.dispatched+    (timers, moreTimers) = prefix original.timerIds+    bounded = original {dispatched = targets, timerIds = timers, truncated = original.truncated || moreTargets || moreTimers}+    sourceOnly = original {dispatched = [], timerIds = [], truncated = True}+    value = frame bounded+    oversized (_, StreamName name) = Text.length (Text.take (maxBytes + 1) name) > maxBytes+    prefix xs = let (selected, rest) = splitAt maxEntries xs in (selected, not (null rest))++withinByteBudget :: Value -> Bool+withinByteBudget value = Lazy.length (Lazy.take (fromIntegral maxBytes + 1) (Aeson.encode value)) <= fromIntegral maxBytes++frame :: ReactionProvenance -> Value+frame provenance =+  object+    [ "v" .= (1 :: Int),+      "family" .= provenance.family,+      "truncated" .= provenance.truncated,+      "source"+        .= object+          [ "eventId" .= eventText provenance.sourceEventId,+            "globalPosition" .= positionNumber provenance.sourceGlobalPosition,+            "streamId" .= streamNumber provenance.sourceStreamId+          ],+      "dispatched" .= [object ["i" .= index, "target" .= name] | (index, StreamName name) <- provenance.dispatched],+      "timers" .= [UUID.toText timer | TimerId timer <- provenance.timerIds]+    ]+  where+    eventText (EventId uuid) = UUID.toText uuid+    positionNumber (GlobalPosition n) = n+    streamNumber (StreamId n) = n++-- | Decode an inner frame. Missing/unknown versions or identity families are+-- unavailable, never interpreted through another family's deterministic IDs.+decodeReactionProvenance :: Value -> Maybe ReactionProvenance+decodeReactionProvenance = either (const Nothing) Just . parseEither parseFrame+  where+    parseFrame = withObject "legacy reaction provenance" $ \value -> do+      version <- value .: "v" :: Parser Int+      family <- value .: "family"+      unless (version == 1 && family == legacyProvenanceFamily) (fail "unsupported reaction provenance")+      unless (withinByteBudget (Object value)) (fail "provenance exceeds its byte bound")+      (eventId, position, streamId) <-+        value .: "source"+          >>= withObject+            "source"+            ( \source ->+                (,,)+                  <$> (EventId <$> (source .: "eventId" >>= uuidValue))+                  <*> (GlobalPosition <$> source .: "globalPosition")+                  <*> (StreamId <$> source .: "streamId")+            )+      dispatched <-+        value .: "dispatched"+          >>= boundedArray+            ( withObject "dispatch" $ \entry -> do+                index <- entry .: "i"+                unless (index >= (0 :: Int)) (fail "negative emit index")+                (index,) . StreamName <$> entry .: "target"+            )+      timerIds <- value .: "timers" >>= boundedArray (fmap TimerId . uuidValue)+      truncated <- value .: "truncated"+      pure (ReactionProvenance eventId position streamId dispatched timerIds family truncated)+    uuidValue = withText "UUID" (maybe (fail "invalid UUID") pure . UUID.fromText)+    boundedArray parse = \case+      Array values | Vector.length values <= maxEntries -> traverse parse (Vector.toList values)+      _ -> fail "provenance array exceeds its bound"++-- Keep non-object caller values intact for the existing codec rejection. The+-- inner merge remains lazy until encoding, so silent/rejected decisions do not+-- demand provenance or follow-ups merely because capture is enabled.+withProvenanceMetadata :: Value -> RunCommandOptions -> RunCommandOptions+withProvenanceMetadata provenance options =+  options & #metadata .~ case options ^. #metadata of+    Nothing -> Just (object ["keiro.pm" .= provenance])+    Just value -> Just (case value of Object fields -> Object (KeyMap.insert "keiro.pm" provenance fields); _ -> value)
src/Keiro/ReadModel/Rebuild.hs view
@@ -75,6 +75,12 @@     ProjectionGroupStatusV1 (..),     listProjectionGroupStatuses,     lookupProjectionGroupStatus,+    ProjectionStatusPageRequest (..),+    ProjectionStatusReadError (..),+    ProjectionStatusPage (..),+    projectionGroupsCursorKind,+    validateProjectionStatusRequest,+    listProjectionGroupStatusesPage,     beginGroupRebuild,     resetDeclaredSubscriptions,     insertProjectionDedupBatchStmt,
src/Keiro/ReadModel/Rebuild/Status.hs view
@@ -7,14 +7,22 @@     ProjectionGroupStatusV1 (..),     listProjectionGroupStatuses,     lookupProjectionGroupStatus,+    ProjectionStatusPageRequest (..),+    ProjectionStatusReadError (..),+    ProjectionStatusPage (..),+    projectionGroupsCursorKind,+    validateProjectionStatusRequest,+    listProjectionGroupStatusesPage,   ) where +import Contravariant.Extras (contrazip2) 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.Inspection.Cursor import Keiro.Prelude import Keiro.Projection.Catalog   ( ProjectionRevisionId,@@ -63,6 +71,74 @@     failureDetail :: !(Maybe Text)   }   deriving stock (Eq, Show, Generic)++-- | A bounded observation of the frozen v1 relation. The existing unbounded+-- compatibility API below retains its behavior.+data ProjectionStatusPageRequest = ProjectionStatusPageRequest+  {pageSize :: !Int, after :: !(Maybe InspectionCursor)}+  deriving stock (Eq, Show, Generic)++data ProjectionStatusReadError+  = InvalidProjectionStatusPageSize !Int+  | InvalidProjectionStatusCursor !CursorError+  | ProjectionStatusResultTooLarge+  deriving stock (Eq, Show, Generic)++data ProjectionStatusPage = ProjectionStatusPage+  {observedAt :: !UTCTime, items :: ![ProjectionGroupStatusV1], next :: !(Maybe InspectionCursor)}+  deriving stock (Eq, Show, Generic)++projectionGroupsCursorKind :: Text+projectionGroupsCursorKind = "projection-groups"++validateProjectionStatusRequest :: ProjectionStatusPageRequest -> Either ProjectionStatusReadError (Maybe Text)+validateProjectionStatusRequest request+  | request ^. #pageSize < 1 || request ^. #pageSize > 500 = Left (InvalidProjectionStatusPageSize (request ^. #pageSize))+  | otherwise = traverse decode (request ^. #after)+  where+    decode cursor = do+      (contract, groupId) <- either (Left . InvalidProjectionStatusCursor) Right (decodeCursor projectionGroupsCursorKind cursor)+      if contract /= ("projection_group_status_v1" :: Text)+        then invalid "cursor belongs to another status contract"+        else either (const (invalid "invalid group id")) (Right . rebuildGroupIdText) (mkRebuildGroupId groupId)+    invalid = Left . InvalidProjectionStatusCursor . MalformedCursor++-- | Page in database group-id order, with exclusive continuation and one+-- look-ahead row. Select Keiro-owned group keys first, then use correlated+-- lookups through the frozen owner relation. The OFFSET 0 boundary preserves+-- parameterized lookups instead of flattening the view into fleet-wide joins.+-- A group's own model/checkpoint cardinality remains part of its read cost.+listProjectionGroupStatusesPage :: (Store :> es) => ProjectionStatusPageRequest -> Eff es (Either ProjectionStatusReadError ProjectionStatusPage)+listProjectionGroupStatusesPage request = case validateProjectionStatusRequest request of+  Left err -> pure (Left err)+  Right after -> do+    (observed, rows) <- runTransaction $ do+      observed <- Tx.statement () (preparable "SELECT now()" mempty (D.singleRow (D.column (D.nonNullable D.timestamptz))))+      rows <- Tx.statement (after, fromIntegral size + 1) (pageProjectionGroupStatusesStmt (isJust after))+      pure (observed, rows)+    let selected = take size rows+        continuation = case (drop size rows, reverse selected) of+          (_ : _, row : _) ->+            maybe+              (Left ProjectionStatusResultTooLarge)+              (Right . Just)+              (encodeCursorBounded projectionGroupsCursorKind ("projection_group_status_v1" :: Text, rebuildGroupIdText (row ^. #groupId)))+          _ -> Right Nothing+    pure (ProjectionStatusPage observed selected <$> continuation)+  where+    size = request ^. #pageSize++pageProjectionGroupStatusesStmt :: Bool -> Statement (Maybe Text, Int64) [ProjectionGroupStatusV1]+pageProjectionGroupStatusesStmt after =+  preparable+    ( "WITH inspection_groups AS MATERIALIZED (SELECT group_id FROM keiro.keiro_projection_rebuild_groups"+        <> (if after then " WHERE group_id > $1" else " WHERE $1::text IS NULL")+        <> " ORDER BY group_id LIMIT $2) SELECT status.* FROM inspection_groups page CROSS JOIN LATERAL ("+        <> statusSelect+        <> " WHERE group_id = page.group_id OFFSET 0) status ORDER BY status.group_id"+    )+    (contrazip2 (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int8)))+    (D.rowList projectionGroupStatusDecoder)  -- | List the complete public status relation in stable group-id order. listProjectionGroupStatuses ::
src/Keiro/Snapshot/Schema.hs view
@@ -21,11 +21,13 @@ module Keiro.Snapshot.Schema   ( -- * Rows     SnapshotRow (..),+    SnapshotObservation (..),     SnapshotWrite (..),      -- * Storage     lookupSnapshot,     lookupSnapshotRow,+    observeSnapshotRow,     deleteSnapshotRow,     writeSnapshotRow,   )@@ -59,6 +61,14 @@   }   deriving stock (Generic, Eq, Show) +-- | A snapshot and its database observation clock. This is an inspection read;+-- it does not change the lookup used by ordinary hydration.+data SnapshotObservation = SnapshotObservation+  { row :: !(Maybe SnapshotRow),+    observedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show)+ -- | The fields needed to write a snapshot — 'SnapshotRow' minus the -- database-managed timestamps. data SnapshotWrite = SnapshotWrite@@ -99,6 +109,18 @@     $ Tx.statement       (streamIdToInt streamId)       lookupSnapshotRowStmt++-- | Observe a row and database time in one transaction. This is observational:+-- ReadCommitted does not freeze concurrent updates between statements.+observeSnapshotRow :: (Store :> es) => StreamId -> Eff es SnapshotObservation+observeSnapshotRow streamId = runTransaction $ do+  row <- Tx.statement (streamIdToInt streamId) lookupSnapshotRowStmt+  observedAt <- Tx.statement () observationTimeStmt+  pure SnapshotObservation {row, observedAt}++observationTimeStmt :: Statement () UTCTime+observationTimeStmt =+  preparable "SELECT now()" E.noParams (D.singleRow (D.column (D.nonNullable D.timestamptz)))  -- | 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.
src/Keiro/Subscription/Shard.hs view
@@ -49,6 +49,15 @@     ownershipSnapshot,     ownershipSnapshotFor,     shardCountSnapshot,+    LeaseState (..),+    ShardOwnershipRow (..),+    ShardOwnershipView (..),+    ShardedSubscriptionSummary (..),+    ShardedSubscriptionList (..),+    leaseStateAt,+    leaseAgeAt,+    shardOwnershipView,+    listShardedSubscriptions,      -- * Fair-share helper     fairShareTarget,@@ -57,18 +66,23 @@  import Data.Set (Set) import Data.Set qualified as Set-import Data.Time (NominalDiffTime)+import Data.Time (NominalDiffTime, diffUTCTime) import Data.Typeable (cast) import Data.UUID.V4 qualified as UUIDv4 import Effectful (Eff, IOE, (:>)) import Effectful.Exception (Exception (..), throwIO) import Keiro.Prelude import Keiro.Subscription.Shard.Schema-  ( WorkerId (..),+  ( ShardOwnershipRow (..),+    ShardedSubscriptionSummary (..),+    WorkerId (..),     claimShardsTx,+    currentDatabaseTime,     ensureShardRows,     listShardCounts,     listShardOwnership,+    listShardOwnershipDetail,+    listShardedSubscriptionSummaries,     lockShardNameTx,     lockShardRowsTx,     releaseShardsTx,@@ -81,6 +95,37 @@ import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx +-- | Interpret lease state at the database observation time. Equality remains+-- live, matching the runtime's strict-less-than expiry predicate.+data LeaseState = LeaseLive | LeaseExpired | LeaseUnowned | LeaseInvalid+  deriving stock (Generic, Eq, Show)++data ShardOwnershipView = ShardOwnershipView+  {subscriptionName :: !SubscriptionName, observedAt :: !UTCTime, buckets :: ![ShardOwnershipRow]}+  deriving stock (Generic, Eq, Show)++data ShardedSubscriptionList = ShardedSubscriptionList+  {observedAt :: !UTCTime, subscriptions :: ![ShardedSubscriptionSummary]}+  deriving stock (Generic, Eq, Show)++leaseStateAt :: UTCTime -> ShardOwnershipRow -> LeaseState+leaseStateAt now row = case (row ^. #owner, row ^. #leaseExpiresAt) of+  (Nothing, _) -> LeaseUnowned+  (Just _, Nothing) -> LeaseInvalid+  (Just _, Just expires) | expires < now -> LeaseExpired+  _ -> LeaseLive++leaseAgeAt :: UTCTime -> ShardOwnershipRow -> Maybe NominalDiffTime+leaseAgeAt now row = case row ^. #owner of+  Nothing -> Nothing+  Just _ -> diffUTCTime now <$> row ^. #heartbeatAt++shardOwnershipView :: (Store :> es) => SubscriptionName -> Eff es ShardOwnershipView+shardOwnershipView name = runTransaction (ShardOwnershipView name <$> currentDatabaseTime <*> listShardOwnershipDetail name)++listShardedSubscriptions :: (Store :> es) => Eff es ShardedSubscriptionList+listShardedSubscriptions = runTransaction (ShardedSubscriptionList <$> currentDatabaseTime <*> listShardedSubscriptionSummaries)+ -- | Mint a fresh per-process 'WorkerId' (a random UUID). freshWorkerId :: (IOE :> es) => Eff es WorkerId freshWorkerId = WorkerId <$> liftIO UUIDv4.nextRandom@@ -125,17 +170,17 @@ ensureShards :: (Store :> es) => ShardLease -> Eff es () ensureShards lease = do   outcome <- runTransaction $ do-    lockShardNameTx (subscriptionName lease)-    counts <- listShardCounts (subscriptionName lease)-    let configured = shardCount lease+    lockShardNameTx (lease ^. #subscriptionName)+    counts <- listShardCounts (lease ^. #subscriptionName)+    let configured = lease ^. #shardCount         found = [n | (n, _) <- counts, n /= configured]     if null found-      then ensureShardRows (subscriptionName lease) configured >> pure (Right ())+      then ensureShardRows (lease ^. #subscriptionName) configured >> pure (Right ())       else         pure           ( Left               ShardCountMismatch-                { mismatchSubscriptionName = case subscriptionName lease of SubscriptionName name -> name,+                { mismatchSubscriptionName = case lease ^. #subscriptionName of SubscriptionName name -> name,                   mismatchConfigured = configured,                   mismatchFound = found                 }@@ -214,13 +259,13 @@ acquireOwnedBuckets :: (IOE :> es, Store :> es) => ShardLease -> Int -> Eff es (Set Int) acquireOwnedBuckets lease liveWorkers = do   now <- liftIO getCurrentTime-  let target = fairShareTarget (shardCount lease) liveWorkers+  let target = fairShareTarget (lease ^. #shardCount) liveWorkers   runTransaction $ do-    held <- renewLeaseTx (subscriptionName lease) (workerId lease) now (leaseTtl lease)+    held <- renewLeaseTx (lease ^. #subscriptionName) (workerId lease) now (leaseTtl lease)     -- Claim at most one bucket per pass (see the note above on convergence).     claimed <-       if length held < target-        then claimShardsTx (subscriptionName lease) (workerId lease) 1 now (leaseTtl lease)+        then claimShardsTx (lease ^. #subscriptionName) (workerId lease) 1 now (leaseTtl lease)         else pure []     pure (Set.fromList held <> Set.fromList claimed) @@ -229,14 +274,14 @@ renewOwnedBuckets :: (IOE :> es, Store :> es) => ShardLease -> Eff es (Set Int) renewOwnedBuckets lease = do   now <- liftIO getCurrentTime-  held <- runTransaction (renewLeaseTx (subscriptionName lease) (workerId lease) now (leaseTtl lease))+  held <- runTransaction (renewLeaseTx (lease ^. #subscriptionName) (workerId lease) now (leaseTtl lease))   pure (Set.fromList held)  -- | Graceful release of the given buckets (clean shutdown), so they are -- claimable immediately instead of after lease expiry. relinquish :: (Store :> es) => ShardLease -> Set Int -> Eff es () relinquish lease buckets =-  runTransaction (releaseShardsTx (subscriptionName lease) (workerId lease) (Set.toList buckets))+  runTransaction (releaseShardsTx (lease ^. #subscriptionName) (workerId lease) (Set.toList buckets))  -- | Read every bucket's @(bucket, owner, lease_expires_at)@ for this -- subscription. The worker uses it to estimate @liveWorkers@ (count of distinct@@ -244,7 +289,7 @@ ownershipSnapshot ::   (Store :> es) => ShardLease -> Eff es [(Int, Maybe WorkerId, Maybe UTCTime)] ownershipSnapshot lease =-  runTransaction (listShardOwnership (subscriptionName lease))+  runTransaction (listShardOwnership (lease ^. #subscriptionName))  -- | Read ownership for a subscription without constructing a worker lease. -- Intended for operator and observability surfaces.
src/Keiro/Subscription/Shard/Schema.hs view
@@ -23,6 +23,11 @@ module Keiro.Subscription.Shard.Schema   ( -- * Worker identity     WorkerId (..),+    ShardOwnershipRow (..),+    ShardedSubscriptionSummary (..),+    listShardOwnershipDetail,+    listShardedSubscriptionSummaries,+    currentDatabaseTime,      -- * Lease statements (composable within a transaction)     ensureShardRows,@@ -39,6 +44,7 @@  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5) import Data.Int (Int32)+import Data.Map.Strict qualified as Map import Data.Time (NominalDiffTime, addUTCTime) import Data.UUID (UUID) import Hasql.Decoders qualified as D@@ -54,6 +60,93 @@ -- dead process's leases — it claims afresh once the old leases expire. newtype WorkerId = WorkerId UUID   deriving stock (Eq, Ord, Show)++-- | Detailed lease observation. No ownership or lock is acquired by reads.+data ShardOwnershipRow = ShardOwnershipRow+  { bucket :: !Int,+    shardCount :: !Int,+    owner :: !(Maybe WorkerId),+    leaseExpiresAt :: !(Maybe UTCTime),+    heartbeatAt :: !(Maybe UTCTime),+    updatedAt :: !UTCTime+  }+  deriving stock (Generic, Eq, Show)++data ShardedSubscriptionSummary = ShardedSubscriptionSummary+  { subscriptionName :: !SubscriptionName,+    shardCounts :: ![(Int, Int)],+    bucketsTotal :: !Int,+    bucketsLive :: !Int,+    bucketsExpired :: !Int,+    bucketsUnowned :: !Int,+    liveOwners :: !Int+  }+  deriving stock (Generic, Eq, Show)++currentDatabaseTime :: Tx.Transaction UTCTime+currentDatabaseTime = Tx.statement () (preparable "SELECT now()" mempty (D.singleRow (D.column (D.nonNullable D.timestamptz))))++listShardOwnershipDetail :: SubscriptionName -> Tx.Transaction [ShardOwnershipRow]+listShardOwnershipDetail (SubscriptionName name) =+  Tx.statement name $+    preparable+      "SELECT bucket,shard_count,owner_worker_id,lease_expires_at,heartbeat_at,updated_at FROM keiro.keiro_subscription_shards WHERE subscription_name=$1 ORDER BY bucket"+      (E.param (E.nonNullable E.text))+      ( D.rowList+          ( ShardOwnershipRow+              <$> (fromIntegral <$> D.column (D.nonNullable D.int4))+              <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+              <*> (fmap WorkerId <$> D.column (D.nullable D.uuid))+              <*> D.column (D.nullable D.timestamptz)+              <*> D.column (D.nullable D.timestamptz)+              <*> D.column (D.nonNullable D.timestamptz)+          )+      )++listShardedSubscriptionSummaries :: Tx.Transaction [ShardedSubscriptionSummary]+listShardedSubscriptionSummaries = do+  rows <-+    Tx.statement () $+      preparable+        """+        WITH owners AS (+          SELECT subscription_name, count(DISTINCT owner_worker_id) AS live_owners+          FROM keiro.keiro_subscription_shards+          WHERE owner_worker_id IS NOT NULL AND lease_expires_at >= now()+          GROUP BY subscription_name+        )+        SELECT s.subscription_name,s.shard_count,count(*),+          count(*) FILTER (WHERE owner_worker_id IS NOT NULL AND lease_expires_at >= now()),+          count(*) FILTER (WHERE owner_worker_id IS NOT NULL AND lease_expires_at < now()),+          count(*) FILTER (WHERE owner_worker_id IS NULL),coalesce(o.live_owners,0)+        FROM keiro.keiro_subscription_shards s LEFT JOIN owners o USING (subscription_name)+        GROUP BY s.subscription_name,s.shard_count,o.live_owners+        ORDER BY s.subscription_name COLLATE "C",s.shard_count+        """+        mempty+        ( D.rowList+            ( (\name count total live expired unowned owners -> ShardedSubscriptionSummary (SubscriptionName name) [(count, total)] total live expired unowned owners)+                <$> D.column (D.nonNullable D.text)+                <*> (fromIntegral <$> D.column (D.nonNullable D.int4))+                <*> number+                <*> number+                <*> number+                <*> number+                <*> number+            )+        )+  pure (Map.elems (Map.fromListWith merge [(row ^. #subscriptionName, row) | row <- rows]))+  where+    number = fromIntegral <$> D.column (D.nonNullable D.int8)+    merge a b =+      ShardedSubscriptionSummary+        (a ^. #subscriptionName)+        (b ^. #shardCounts <> a ^. #shardCounts)+        (a ^. #bucketsTotal + b ^. #bucketsTotal)+        (a ^. #bucketsLive + b ^. #bucketsLive)+        (a ^. #bucketsExpired + b ^. #bucketsExpired)+        (a ^. #bucketsUnowned + b ^. #bucketsUnowned)+        (a ^. #liveOwners)  -- | Idempotently insert the @N@ rows @(name, bucket = 0..N-1, shard_count = N)@ -- with @owner_worker_id@ left @NULL@. @ON CONFLICT DO NOTHING@ makes calling it
src/Keiro/Timer.hs view
@@ -37,6 +37,17 @@     DeadTimerPage (..),     lookupTimerInspection,     findDeadTimers,+    PendingTimerFilter (..),+    anyPendingTimer,+    PendingTimerPageRequest (..),+    PendingTimerReadError (..),+    PendingTimerPage (..),+    pendingTimerStatuses,+    pendingTimerCursorKind,+    validatePendingTimerRequest,+    findPendingTimers,+    pendingTimerItemJson,+    pendingTimerPageJson,      -- * Guarded foreground resume     DeadTimerClaimRequest (..),
src/Keiro/Timer/Schema.hs view
@@ -28,6 +28,17 @@     DeadTimerPage (..),     lookupTimerInspection,     findDeadTimers,+    PendingTimerFilter (..),+    anyPendingTimer,+    PendingTimerPageRequest (..),+    PendingTimerReadError (..),+    PendingTimerPage (..),+    pendingTimerStatuses,+    pendingTimerCursorKind,+    validatePendingTimerRequest,+    findPendingTimers,+    pendingTimerItemJson,+    pendingTimerPageJson,      -- * Guarded foreground resume     DeadTimerClaimRequest (..),@@ -66,15 +77,19 @@ where  import Contravariant.Extras (contrazip2, contrazip5, contrazip6)+import Data.Aeson (object, (.=)) import Data.Int (Int32)-import Data.Time (NominalDiffTime, addUTCTime)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Time (NominalDiffTime, addUTCTime, diffUTCTime) import Data.UUID (UUID)+import Data.UUID qualified as UUID import Data.UUID.V4 qualified as UUIDv4 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.Inspection.Cursor+import Keiro.Prelude hiding ((.=)) import Keiro.Timer.Types (TimerId (..), TimerRequest (..)) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction)@@ -205,6 +220,133 @@       ReasonAbsent -> (1, Nothing)       ReasonExact value -> (2, Just value)       ReasonPrefix value -> (3, Just value)++-- | Exact optional owner/correlation filters. These reads reserve no work.+data PendingTimerFilter = PendingTimerFilter+  { processManagerName :: !(Maybe Text),+    correlationId :: !(Maybe Text)+  }+  deriving stock (Generic, Eq, Show)++anyPendingTimer :: PendingTimerFilter+anyPendingTimer = PendingTimerFilter Nothing Nothing++data PendingTimerPageRequest = PendingTimerPageRequest+  { pageSize :: !Int,+    after :: !(Maybe InspectionCursor)+  }+  deriving stock (Generic, Eq, Show)++data PendingTimerReadError+  = InvalidPendingTimerPageSize !Int+  | InvalidPendingTimerCursor !CursorError+  | PendingTimerResultTooLarge+  deriving stock (Generic, Eq, Show)++-- | Live eligibility, ordered by (fire_at,timer_id), not a frozen traversal.+-- Limits bound returned rows, not search work. In particular filtered reads+-- can examine the pending ledger; automatic callers must apply their budget.+data PendingTimerPage = PendingTimerPage+  { observedAt :: !UTCTime,+    timers :: ![TimerInspection],+    next :: !(Maybe InspectionCursor)+  }+  deriving stock (Generic, Eq, Show)++pendingTimerStatuses :: NonEmpty TimerStatus+pendingTimerStatuses = Scheduled :| [Firing]++pendingTimerCursorKind :: Text+pendingTimerCursorKind = "timer-pending"++-- | Pure validation, before borrowing a connection or hydrating an instance.+-- Continuations bind both filters as well as the exclusive database key.+validatePendingTimerRequest :: PendingTimerFilter -> PendingTimerPageRequest -> Either PendingTimerReadError (Maybe (UTCTime, UUID))+validatePendingTimerRequest timerFilter request+  | request ^. #pageSize < 1 || request ^. #pageSize > 500 = Left (InvalidPendingTimerPageSize (request ^. #pageSize))+  | otherwise = traverse decode (request ^. #after)+  where+    invalid = Left . InvalidPendingTimerCursor+    decode cursor = case decodeCursor pendingTimerCursorKind cursor of+      Left err -> invalid err+      Right (manager, correlation, fireAt, timerText)+        | manager /= timerFilter ^. #processManagerName || correlation /= timerFilter ^. #correlationId -> invalid (MalformedCursor "cursor belongs to another timer filter")+        | otherwise -> case UUID.fromText timerText of+            Nothing -> invalid (MalformedCursor "invalid timer UUID")+            Just timerId -> Right (fireAt, timerId)++-- | Read pending timers and database time in one observational transaction.+-- No locks, claims, cleanup or mutation. Statement shapes avoid nullable OR+-- filters so custom and generic plans can use the available ordered indexes.+findPendingTimers :: (Store :> es) => PendingTimerFilter -> PendingTimerPageRequest -> Eff es (Either PendingTimerReadError PendingTimerPage)+findPendingTimers timerFilter request = case validatePendingTimerRequest timerFilter request of+  Left err -> pure (Left err)+  Right position -> do+    (observed, rows) <- runTransaction $ do+      observed <- Tx.statement () (preparable "SELECT now()" mempty (D.singleRow (D.column (D.nonNullable D.timestamptz))))+      rows <-+        Tx.statement+          (timerFilter ^. #processManagerName, timerFilter ^. #correlationId, fst <$> position, snd <$> position, fromIntegral size + 1)+          (findPendingTimersStmt (isJust (timerFilter ^. #processManagerName)) (isJust (timerFilter ^. #correlationId)) (isJust position))+      pure (observed, rows)+    let selected = take size rows+        continuation = case (drop size rows, reverse selected) of+          (_ : _, lastRow : _) ->+            let timer = lastRow ^. #timer+             in Just+                  <$> maybe+                    (Left PendingTimerResultTooLarge)+                    Right+                    ( encodeCursorBounded+                        pendingTimerCursorKind+                        (timerFilter ^. #processManagerName, timerFilter ^. #correlationId, timer ^. #fireAt, UUID.toText (timerIdToUuid (timer ^. #timerId)))+                    )+          _ -> Right Nothing+    pure (PendingTimerPage observed selected <$> continuation)+  where+    size = request ^. #pageSize++-- | Shared representation for pending pages and process-manager details.+pendingTimerItemJson :: UTCTime -> TimerInspection -> Value+pendingTimerItemJson observed inspection =+  let timer = inspection ^. #timer+   in object+        [ "timer_id" .= UUID.toText (timerIdToUuid (timer ^. #timerId)),+          "process_manager_name" .= (timer ^. #processManagerName),+          "correlation_id" .= (timer ^. #correlationId),+          "fire_at" .= (timer ^. #fireAt),+          "payload" .= (timer ^. #payload),+          "status" .= statusToText (timer ^. #status),+          "attempts" .= (timer ^. #attempts),+          "fired_event_id" .= fmap (UUID.toText . eventIdToUuid) (timer ^. #firedEventId),+          "last_error" .= (inspection ^. #lastError),+          "due_in_seconds" .= (realToFrac (diffUTCTime (timer ^. #fireAt) observed) :: Double)+        ]++pendingTimerPageJson :: PendingTimerPage -> Value+pendingTimerPageJson page =+  object+    ( ["observed_at" .= (page ^. #observedAt), "items" .= map (pendingTimerItemJson (page ^. #observedAt)) (page ^. #timers)]+        <> maybe [] (\cursor -> ["next_cursor" .= cursorText cursor]) (page ^. #next)+    )++findPendingTimersStmt :: Bool -> Bool -> Bool -> Statement (Maybe Text, Maybe Text, Maybe UTCTime, Maybe UUID, Int64) [TimerInspection]+findPendingTimersStmt manager correlation cursor =+  preparable+    ( "SELECT timer_id,process_manager_name,correlation_id,fire_at,payload,status,attempts,fired_event_id,last_error FROM keiro.keiro_timers WHERE status IN ('scheduled','firing')"+        <> (if manager then " AND process_manager_name=$1" else " AND $1::text IS NULL")+        <> (if correlation then " AND correlation_id=$2" else " AND $2::text IS NULL")+        <> (if cursor then " AND (fire_at,timer_id)>($3,$4)" else " AND $3::timestamptz IS NULL AND $4::uuid IS NULL")+        <> " ORDER BY fire_at,timer_id LIMIT $5"+    )+    ( contrazip5+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.timestamptz))+        (E.param (E.nullable E.uuid))+        (E.param (E.nonNullable E.int8))+    )+    (D.rowList timerInspectionDecoder)  -- | Exact dead-row guards and an explicit total attempt ceiling. Lease seconds -- must be between 1 and 2147483647, avoiding interval conversion overflow.
src/Keiro/Workflow/Awakeable/Schema.hs view
@@ -32,11 +32,14 @@      -- * Read-only lookups     lookupAwakeable,+    lookupAwakeablesOfOwnerPage,     countPendingAwakeables,   ) where  import Contravariant.Extras (contrazip3)+import Data.Functor.Contravariant ((>$<))+import Data.Int (Int32) import Data.UUID (UUID) import Effectful (Eff, (:>)) import Hasql.Decoders qualified as D@@ -46,6 +49,20 @@ import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | One exclusive creation-key page, including allocations not yet journaled.+lookupAwakeablesOfOwnerPage :: (Store :> es) => Text -> Text -> Maybe (UTCTime, UUID) -> Int -> Eff es [AwakeableRow]+lookupAwakeablesOfOwnerPage name wid after count = runTransaction (Tx.statement () statement)+  where+    prefix = (const name >$< E.param (E.nonNullable E.text)) <> (const wid >$< E.param (E.nonNullable E.text))+    (cursorSql, cursorParams, limitParam) = case after of+      Nothing -> ("", mempty, "$3")+      Just (created, ident) -> (" AND (created_at,awakeable_id)>($3,$4)", (const created >$< E.param (E.nonNullable E.timestamptz)) <> (const ident >$< E.param (E.nonNullable E.uuid)), "$5")+    statement =+      preparable+        ("SELECT awakeable_id, owner_workflow_name, owner_workflow_id, status, payload, created_at, updated_at, completed_at FROM keiro.keiro_awakeables WHERE owner_workflow_name=$1 AND owner_workflow_id=$2" <> cursorSql <> " ORDER BY created_at,awakeable_id LIMIT " <> limitParam)+        (prefix <> cursorParams <> (const (fromIntegral count :: Int32) >$< E.param (E.nonNullable E.int4)))+        (D.rowList awakeableRowDecoder)  -- | An awakeable's lifecycle state. --
src/Keiro/Workflow/Child/Schema.hs view
@@ -40,6 +40,7 @@     -- * Read-only lookups     lookupChild,     lookupChildrenOfParent,+    lookupChildrenOfParentPage,     childStatus,     countActiveChildren,     findRunningChildIds,@@ -47,6 +48,8 @@ where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)+import Data.Functor.Contravariant ((>$<))+import Data.Int (Int32) import Effectful (Eff, (:>)) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E@@ -55,6 +58,24 @@ import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | One exclusive creation-key page; size validation belongs to inspection.+lookupChildrenOfParentPage :: (Store :> es) => Text -> Text -> Maybe (UTCTime, Text, Text) -> Int -> Eff es [ChildRow]+lookupChildrenOfParentPage pid pname after count = runTransaction (Tx.statement () statement)+  where+    prefix = (const pid >$< E.param (E.nonNullable E.text)) <> (const pname >$< E.param (E.nonNullable E.text))+    (cursorSql, cursorParams, limitParam) = case after of+      Nothing -> ("", mempty, "$3")+      Just (created, cid, cname) ->+        ( " AND (created_at,child_id,child_name)>($3,$4,$5)",+          (const created >$< E.param (E.nonNullable E.timestamptz)) <> (const cid >$< E.param (E.nonNullable E.text)) <> (const cname >$< E.param (E.nonNullable E.text)),+          "$6"+        )+    statement =+      preparable+        ("SELECT child_id, child_name, parent_id, parent_name, await_step, status, result, failure_reason, created_at, updated_at, completed_at FROM keiro.keiro_workflow_children WHERE parent_id=$1 AND parent_name=$2" <> cursorSql <> " ORDER BY created_at,child_id,child_name LIMIT " <> limitParam)+        (prefix <> cursorParams <> (const (fromIntegral count :: Int32) >$< E.param (E.nonNullable E.int4)))+        (D.rowList childRowDecoder)  -- | A child workflow's lifecycle, as seen from its parent. --
+ src/Keiro/Workflow/Inspection.hs view
@@ -0,0 +1,353 @@+{-# LANGUAGE OverloadedRecordDot #-}++-- | Bounded observations of workflow state. Cursors bind immutable ordering+-- keys, read kinds, owners, and (for journal/steps) generations. Each request+-- observes then-current rows; it does not hold a snapshot across pages.+module Keiro.Workflow.Inspection+  ( WorkflowStatusClass (..),+    statusClassToText,+    statusClassFromText,+    statusClassOf,+    classStatus,+    InspectionError (..),+    maxInspectionPageSize,+    WorkflowListOrdering (..),+    WorkflowListQuery (..),+    defaultWorkflowListQuery,+    WorkflowListPage (..),+    listWorkflowInstancesPage,+    DetailPageQuery (..),+    OwnerPageQuery (..),+    WorkflowJournalEntry (..),+    WorkflowJournalPage (..),+    WorkflowStepPage (..),+    WorkflowChildPage (..),+    WorkflowAwakeablePage (..),+    readWorkflowJournalPage,+    loadStepIndexPage,+    listWorkflowChildrenPage,+    listWorkflowAwakeablesPage,+    validateWorkflowListQuery,+    validateJournalQuery,+    validateStepQuery,+    validateChildQuery,+    validateAwakeableQuery,+    instanceToJson,+    childToJson,+    awakeableToJson,+    stepToJson,+    journalEntryToJson,+  )+where++import Data.Aeson (FromJSON, ToJSON (..), Value, object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Types (Pair)+import Data.Int (Int32, Int64)+import Data.Maybe (isJust)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Data.UUID (UUID)+import Data.UUID qualified as UUID+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, (:>))+import Effectful.Exception (throwIO)+import GHC.Generics (Generic)+import Keiro.Codec (decodeRecorded)+import Keiro.Inspection.Cursor+import Keiro.Workflow.Awakeable.Schema qualified as Awakeable+import Keiro.Workflow.Child.Schema qualified as Child+import Keiro.Workflow.Inspection.Query+import Keiro.Workflow.Instance (WorkflowInstanceRow, WorkflowStatus (..))+import Keiro.Workflow.Instance qualified as Instance+import Keiro.Workflow.Schema (WorkflowStepRow)+import Keiro.Workflow.Schema qualified as Schema+import Keiro.Workflow.Types+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Read (readStreamForward)+import Kiroku.Store.Types++maxInspectionPageSize :: Int+maxInspectionPageSize = 500++data InspectionError = InvalidPageSize !Int | InvalidCursor !CursorError | CursorGenerationMismatch !Int !Int | CursorResultTooLarge+  deriving stock (Eq, Show, Generic)++statusClassOf :: WorkflowInstanceRow -> WorkflowStatusClass+statusClassOf row = case row.status of+  WfRunning | row.attempts == 0 -> ClassRunning+  WfRunning -> ClassRetrying+  WfSuspended | isJust row.wakeAfter -> ClassSleeping+  WfSuspended -> ClassAwaiting+  WfCompleted -> ClassCompleted+  WfCancelled -> ClassCancelled+  WfFailed -> ClassFailed++data WorkflowListPage = WorkflowListPage+  {items :: ![WorkflowInstanceRow], nextCursor :: !(Maybe InspectionCursor), observedAt :: !UTCTime}+  deriving stock (Eq, Show, Generic)++validateSize :: Int -> Either InspectionError ()+validateSize size+  | size < 1 || size > maxInspectionPageSize = Left (InvalidPageSize size)+  | otherwise = Right ()++decode :: (FromJSON a) => Text -> InspectionCursor -> Either InspectionError a+decode kind token = either (Left . InvalidCursor) Right (decodeCursor kind token)++invalid :: Text -> Either InspectionError a+invalid = Left . InvalidCursor . MalformedCursor++nextFor :: (ToJSON key) => Text -> Int -> [a] -> (a -> key) -> Either InspectionError (Maybe InspectionCursor)+nextFor kind count rows key+  | length rows <= count = Right Nothing+  | otherwise = case reverse (take count rows) of+      row : _ ->+        maybe (Left CursorResultTooLarge) (Right . Just) (encodeCursorBounded kind (key row))+      [] -> Right Nothing++listWorkflowInstancesPage :: (IOE :> es, Store :> es) => WorkflowListQuery -> Eff es (Either InspectionError WorkflowListPage)+listWorkflowInstancesPage query = case validated of+  Left err -> pure (Left err)+  Right position -> do+    (observedAt, rows) <- Instance.inspectionInstanceRows query position (query.pageSize + 1)+    pure $ do+      nextCursor <- case query.ordering of+        ByKey -> nextFor "wf-key2" query.pageSize rows (\r -> (r.workflowId, r.workflowName))+        ByCreatedDesc -> nextFor "wf-created" query.pageSize rows (\r -> (r.createdAt, r.workflowName, r.workflowId))+      pure (WorkflowListPage (take query.pageSize rows) nextCursor observedAt)+  where+    validated = do+      validateSize query.pageSize+      traverse+        ( \token -> case query.ordering of+            ByKey -> uncurry AfterWorkflowKey <$> decode "wf-key2" token+            ByCreatedDesc -> (\(created, name, wid) -> AfterWorkflowCreated created name wid) <$> decode "wf-created" token+        )+        query.cursor++-- | Pure validation for front ends before they admit work or verify schemas.+-- The reads repeat validation so direct library callers retain the same guard.+validateWorkflowListQuery :: WorkflowListQuery -> Either InspectionError ()+validateWorkflowListQuery query = do+  validateSize query.pageSize+  case query.cursor of+    Nothing -> Right ()+    Just token -> case query.ordering of+      ByKey -> () <$ (decode "wf-key2" token :: Either InspectionError (Text, Text))+      ByCreatedDesc -> () <$ (decode "wf-created" token :: Either InspectionError (UTCTime, Text, Text))++data DetailPageQuery = DetailPageQuery+  {generation :: !(Maybe Int), pageSize :: !Int, cursor :: !(Maybe InspectionCursor)}+  deriving stock (Eq, Show, Generic)++data OwnerPageQuery = OwnerPageQuery+  {pageSize :: !Int, cursor :: !(Maybe InspectionCursor)}+  deriving stock (Eq, Show, Generic)++data WorkflowJournalEntry = WorkflowJournalEntry+  { eventId :: !UUID,+    eventType :: !Text,+    streamVersion :: !Int64,+    globalPosition :: !Int64,+    stepName :: !Text,+    recordedAt :: !UTCTime,+    payload :: !Value,+    event :: !WorkflowJournalEvent+  }+  deriving stock (Eq, Show, Generic)++data WorkflowJournalPage = WorkflowJournalPage+  {generation :: !Int, items :: ![WorkflowJournalEntry], nextCursor :: !(Maybe InspectionCursor), observedAt :: !UTCTime}+  deriving stock (Eq, Show, Generic)++data WorkflowStepPage = WorkflowStepPage+  {generation :: !Int, items :: ![WorkflowStepRow], nextCursor :: !(Maybe InspectionCursor), observedAt :: !UTCTime}+  deriving stock (Eq, Show, Generic)++data WorkflowChildPage = WorkflowChildPage+  {items :: ![Child.ChildRow], nextCursor :: !(Maybe InspectionCursor), observedAt :: !UTCTime}+  deriving stock (Eq, Show, Generic)++data WorkflowAwakeablePage = WorkflowAwakeablePage+  {items :: ![Awakeable.AwakeableRow], nextCursor :: !(Maybe InspectionCursor), observedAt :: !UTCTime}+  deriving stock (Eq, Show, Generic)++validGeneration :: Int -> Bool+validGeneration gen = gen >= 0 && toInteger gen <= toInteger (maxBound :: Int32)++prepareDetail :: forall key es. (FromJSON key, Store :> es) => Text -> WorkflowName -> WorkflowId -> DetailPageQuery -> (key -> Bool) -> Eff es (Either InspectionError (Int, Maybe key))+prepareDetail kind name@(WorkflowName ownerName) wid@(WorkflowId ownerId) query validKey =+  case validateDetail kind ownerName ownerId query validKey of+    Left err -> pure (Left err)+    Right position -> do+      gen <- maybe (Schema.currentGeneration name wid) pure query.generation+      pure $ case position of+        Just (cursorGen, _) | gen /= cursorGen -> Left (CursorGenerationMismatch gen cursorGen)+        _ -> Right (gen, snd <$> position)++validateDetail :: forall key. (FromJSON key) => Text -> Text -> Text -> DetailPageQuery -> (key -> Bool) -> Either InspectionError (Maybe (Int, key))+validateDetail kind ownerName ownerId query validKey = do+  validateSize query.pageSize+  case query.generation of Just gen | not (validGeneration gen) -> invalid "generation is outside the supported range"; _ -> Right ()+  traverse+    ( \token -> do+        (cursorOwner, cursorId, gen, key) <- decode kind token :: Either InspectionError (Text, Text, Int, key)+        if cursorOwner /= ownerName || cursorId /= ownerId+          then invalid "cursor belongs to another workflow"+          else+            if not (validGeneration gen) || not (validKey key)+              then invalid "cursor key is outside the supported range"+              else Right (gen, key)+    )+    query.cursor++validateJournalQuery :: WorkflowName -> WorkflowId -> DetailPageQuery -> Either InspectionError ()+validateJournalQuery (WorkflowName name) (WorkflowId wid) query = () <$ validateDetail "wf-journal" name wid query (> (0 :: Int64))++validateStepQuery :: WorkflowName -> WorkflowId -> DetailPageQuery -> Either InspectionError ()+validateStepQuery (WorkflowName name) (WorkflowId wid) query = () <$ validateDetail "wf-steps" name wid query (const True :: Text -> Bool)++validateChildQuery :: WorkflowName -> WorkflowId -> OwnerPageQuery -> Either InspectionError ()+validateChildQuery name wid query = () <$ (prepareOwner "wf-children" name wid query :: Either InspectionError (Maybe (UTCTime, Text, Text)))++validateAwakeableQuery :: WorkflowName -> WorkflowId -> OwnerPageQuery -> Either InspectionError ()+validateAwakeableQuery name wid query = () <$ (prepareOwner "wf-awakeables" name wid query :: Either InspectionError (Maybe (UTCTime, UUID)))++prepareOwner :: forall key. (FromJSON key) => Text -> WorkflowName -> WorkflowId -> OwnerPageQuery -> Either InspectionError (Maybe key)+prepareOwner kind (WorkflowName ownerName) (WorkflowId ownerId) query = do+  validateSize query.pageSize+  traverse+    ( \token -> do+        (cursorOwner, cursorId, key) <- decode kind token :: Either InspectionError (Text, Text, key)+        if cursorOwner == ownerName && cursorId == ownerId then Right key else invalid "cursor belongs to another workflow"+    )+    query.cursor++readWorkflowJournalPage :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> DetailPageQuery -> Eff es (Either InspectionError WorkflowJournalPage)+readWorkflowJournalPage name@(WorkflowName ownerName) wid@(WorkflowId ownerId) query = do+  prepared <- prepareDetail "wf-journal" name wid query (> (0 :: Int64))+  case prepared of+    Left err -> pure (Left err)+    Right (gen, after) -> do+      observedAt <- Schema.inspectionObservedAt+      recorded <- Vector.toList <$> readStreamForward (workflowGenerationStreamName name wid gen) (StreamVersion (maybe 0 (\value -> value) after)) (fromIntegral (query.pageSize + 1))+      entries <- either (throwIO . WorkflowJournalDecodeError) pure (traverse decodeJournalEntry (take query.pageSize recorded))+      pure $ (\nextCursor -> WorkflowJournalPage gen entries nextCursor observedAt) <$> nextFor "wf-journal" query.pageSize recorded (\r -> (ownerName, ownerId, gen, case r.streamVersion of StreamVersion v -> v))++loadStepIndexPage :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> DetailPageQuery -> Eff es (Either InspectionError WorkflowStepPage)+loadStepIndexPage name@(WorkflowName ownerName) wid@(WorkflowId ownerId) query = do+  prepared <- prepareDetail "wf-steps" name wid query (const True)+  case prepared of+    Left err -> pure (Left err)+    Right (gen, after) -> do+      observedAt <- Schema.inspectionObservedAt+      rows <- Schema.loadStepIndexPage name wid gen after (query.pageSize + 1)+      pure $ (\nextCursor -> WorkflowStepPage gen (take query.pageSize rows) nextCursor observedAt) <$> nextFor "wf-steps" query.pageSize rows (\r -> (ownerName, ownerId, gen, r.stepName))++listWorkflowChildrenPage :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> OwnerPageQuery -> Eff es (Either InspectionError WorkflowChildPage)+listWorkflowChildrenPage name@(WorkflowName ownerName) wid@(WorkflowId ownerId) query =+  case prepareOwner "wf-children" name wid query of+    Left err -> pure (Left err)+    Right after -> do+      observedAt <- Schema.inspectionObservedAt+      rows <- Child.lookupChildrenOfParentPage ownerId ownerName after (query.pageSize + 1)+      pure $ (\nextCursor -> WorkflowChildPage (take query.pageSize rows) nextCursor observedAt) <$> nextFor "wf-children" query.pageSize rows (\r -> (ownerName, ownerId, (r.createdAt, r.childId, r.childName)))++listWorkflowAwakeablesPage :: (IOE :> es, Store :> es) => WorkflowName -> WorkflowId -> OwnerPageQuery -> Eff es (Either InspectionError WorkflowAwakeablePage)+listWorkflowAwakeablesPage name@(WorkflowName ownerName) wid@(WorkflowId ownerId) query =+  case prepareOwner "wf-awakeables" name wid query of+    Left err -> pure (Left err)+    Right after -> do+      observedAt <- Schema.inspectionObservedAt+      rows <- Awakeable.lookupAwakeablesOfOwnerPage ownerName ownerId after (query.pageSize + 1)+      pure $ (\nextCursor -> WorkflowAwakeablePage (take query.pageSize rows) nextCursor observedAt) <$> nextFor "wf-awakeables" query.pageSize rows (\r -> (ownerName, ownerId, (r.createdAt, r.awakeableId)))++decodeJournalEntry :: RecordedEvent -> Either Text WorkflowJournalEntry+decodeJournalEntry recorded = do+  event <- either (Left . Text.pack . show) Right (decodeRecorded workflowJournalCodec recorded)+  let (stepName, recordedAt, payload) = case event of+        StepRecorded name value timestamp -> (name, timestamp, value)+        WorkflowCompleted timestamp -> (completedStepName, timestamp, Aeson.Null)+        WorkflowCancelled timestamp -> (cancelledStepName, timestamp, Aeson.Null)+        WorkflowFailed reason timestamp -> (failedStepName, timestamp, Aeson.toJSON reason)+        WorkflowContinuedAsNew gen timestamp -> (continuedAsNewStepName, timestamp, Aeson.toJSON gen)+  pure+    WorkflowJournalEntry+      { eventId = case recorded.eventId of EventId value -> value,+        eventType = case recorded.eventType of EventType value -> value,+        streamVersion = case recorded.streamVersion of StreamVersion value -> value,+        globalPosition = case recorded.globalPosition of GlobalPosition value -> value,+        stepName,+        recordedAt,+        payload,+        event+      }++stepToJson :: WorkflowStepRow -> Value+stepToJson row = object ["step" .= row.stepName, "result" .= row.result, "recorded_at" .= row.recordedAt]++journalEntryToJson :: WorkflowJournalEntry -> Value+journalEntryToJson row = object ["event_id" .= UUID.toText row.eventId, "event_type" .= row.eventType, "stream_version" .= row.streamVersion, "global_position" .= row.globalPosition, "step_name" .= row.stepName, "recorded_at" .= row.recordedAt, "payload" .= row.payload]++pageFields :: UTCTime -> Maybe InspectionCursor -> [Value] -> [Pair]+pageFields clock next items = ["observed_at" .= clock, "items" .= items] <> maybe [] (\token -> ["next_cursor" .= token.cursorText]) next++instance ToJSON WorkflowListPage where toJSON page = object (pageFields page.observedAt page.nextCursor (map instanceToJson page.items))++instance ToJSON WorkflowJournalPage where toJSON page = object ("generation" .= page.generation : pageFields page.observedAt page.nextCursor (map journalEntryToJson page.items))++instance ToJSON WorkflowStepPage where toJSON page = object ("generation" .= page.generation : pageFields page.observedAt page.nextCursor (map stepToJson page.items))++instance ToJSON WorkflowChildPage where toJSON page = object (pageFields page.observedAt page.nextCursor (map childToJson page.items))++instance ToJSON WorkflowAwakeablePage where toJSON page = object (pageFields page.observedAt page.nextCursor (map awakeableToJson page.items))++instanceToJson :: Instance.WorkflowInstanceRow -> Value+instanceToJson row =+  object+    [ "workflow_id" .= row.workflowId,+      "workflow_name" .= row.workflowName,+      "generation" .= row.generation,+      "status" .= Instance.statusToText row.status,+      "status_class" .= statusClassToText (statusClassOf row),+      "attempts" .= row.attempts,+      "last_error" .= row.lastError,+      "next_attempt_at" .= row.nextAttemptAt,+      "wake_after" .= row.wakeAfter,+      "leased_by" .= row.leasedBy,+      "lease_expires_at" .= row.leaseExpiresAt,+      "created_at" .= row.createdAt,+      "updated_at" .= row.updatedAt,+      "completed_at" .= row.completedAt+    ]++childToJson :: Child.ChildRow -> Value+childToJson row =+  object+    [ "child_id" .= row.childId,+      "child_name" .= row.childName,+      "parent_id" .= row.parentId,+      "parent_name" .= row.parentName,+      "await_step" .= row.awaitStep,+      "status" .= Child.statusToText row.status,+      "result" .= row.result,+      "failure_reason" .= row.failureReason,+      "created_at" .= row.createdAt,+      "updated_at" .= row.updatedAt,+      "completed_at" .= row.completedAt+    ]++awakeableToJson :: Awakeable.AwakeableRow -> Value+awakeableToJson row =+  object+    [ "awakeable_id" .= UUID.toText row.awakeableId,+      "owner_workflow_name" .= row.ownerWorkflowName,+      "owner_workflow_id" .= row.ownerWorkflowId,+      "status" .= Awakeable.statusToText row.status,+      "payload" .= row.payload,+      "created_at" .= row.createdAt,+      "updated_at" .= row.updatedAt,+      "completed_at" .= row.completedAt+    ]
+ src/Keiro/Workflow/Inspection/Query.hs view
@@ -0,0 +1,67 @@+-- | Inspection-only query types. Kept separate to let the owning instance+-- schema consume them without depending on the rendering/page facade.+module Keiro.Workflow.Inspection.Query where++import Data.List.NonEmpty (NonEmpty)+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Keiro.Inspection.Cursor+import Keiro.Workflow.Instance.Schema (WorkflowStatus (..))++data WorkflowStatusClass+  = ClassRunning+  | ClassRetrying+  | ClassSleeping+  | ClassAwaiting+  | ClassCompleted+  | ClassCancelled+  | ClassFailed+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++statusClassToText :: WorkflowStatusClass -> Text+statusClassToText = \case+  ClassRunning -> "running"+  ClassRetrying -> "retrying"+  ClassSleeping -> "sleeping"+  ClassAwaiting -> "awaiting"+  ClassCompleted -> "completed"+  ClassCancelled -> "cancelled"+  ClassFailed -> "failed"++statusClassFromText :: Text -> Maybe WorkflowStatusClass+statusClassFromText value = lookup value [(statusClassToText c, c) | c <- [minBound .. maxBound]]++classStatus :: WorkflowStatusClass -> WorkflowStatus+classStatus = \case+  ClassRunning -> WfRunning+  ClassRetrying -> WfRunning+  ClassSleeping -> WfSuspended+  ClassAwaiting -> WfSuspended+  ClassCompleted -> WfCompleted+  ClassCancelled -> WfCancelled+  ClassFailed -> WfFailed++data WorkflowListOrdering = ByKey | ByCreatedDesc+  deriving stock (Eq, Show, Generic)++data WorkflowListQuery = WorkflowListQuery+  { classes :: !(Maybe (NonEmpty WorkflowStatusClass)),+    workflowName :: !(Maybe Text),+    createdSince :: !(Maybe UTCTime),+    createdBefore :: !(Maybe UTCTime),+    updatedSince :: !(Maybe UTCTime),+    updatedBefore :: !(Maybe UTCTime),+    ordering :: !WorkflowListOrdering,+    pageSize :: !Int,+    cursor :: !(Maybe InspectionCursor)+  }+  deriving stock (Eq, Show, Generic)++defaultWorkflowListQuery :: WorkflowListQuery+defaultWorkflowListQuery = WorkflowListQuery Nothing Nothing Nothing Nothing Nothing Nothing ByKey 100 Nothing++-- | Validated exclusive database keys, decoded by the inspection facade.+-- The default key tuple is (workflow_id, workflow_name).+data WorkflowListPosition = AfterWorkflowKey !Text !Text | AfterWorkflowCreated !UTCTime !Text !Text+  deriving stock (Eq, Show)
src/Keiro/Workflow/Instance.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE OverloadedRecordDot #-}+ -- | Durable workflow instance summaries. -- -- The journal stream and @keiro_workflow_steps@ index remain the source of truth@@ -17,7 +19,13 @@     upsertInstanceTx,     markInstanceSuspendedAwaiting,     lookupInstance,+    lookupInstanceSession,+    WorkflowHeaderKey,+    workflowHeaderKeyBytes,+    WorkflowHeaderObservation (..),+    lookupInstanceConditionalSession,     listWorkflowInstances,+    inspectionInstanceRows,     cancelWorkflow,     claimInstance,     renewInstanceLeaseTx,@@ -32,17 +40,27 @@ where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)+import Data.Aeson ((.:))+import Data.Aeson qualified as Aeson+import Data.Aeson.Types (parseEither)+import Data.ByteString qualified as ByteString+import Data.ByteString.Lazy qualified as LazyByteString+import Data.Functor.Compose (Compose (..))+import Data.Functor.Contravariant ((>$<)) import Data.Int (Int32)+import Data.List (nub) import Data.List.NonEmpty qualified as NonEmpty import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, mapMaybe) import Data.Text qualified as Text-import Data.Time (NominalDiffTime, addUTCTime)+import Data.Time (NominalDiffTime, UTCTime (..), addDays, addUTCTime, fromGregorian) 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.Session qualified as Session import Hasql.Statement (Statement, preparable) import Keiro.Prelude import Keiro.Workflow.Awakeable.Schema@@ -55,6 +73,7 @@     lookupChild,     reviveFailedChildTx,   )+import Keiro.Workflow.Inspection.Query import Keiro.Workflow.Instance.Schema   ( WorkflowStatus (..),     statusFromText,@@ -213,6 +232,47 @@ lookupInstance (WorkflowName name) (WorkflowId wid) =   runTransaction (Tx.statement (wid, name) lookupInstanceStmt) +-- | Read one header in the caller's session. A single SELECT already observes+-- one database snapshot; inspection hosts need no transaction-control round+-- trips around it. This reuses the ordinary lookup's statement and decoder.+lookupInstanceSession :: WorkflowName -> WorkflowId -> Session.Session (Maybe WorkflowInstanceRow)+lookupInstanceSession (WorkflowName name) (WorkflowId wid) =+  Session.statement (wid, name) lookupInstanceStmt++-- | An exact image of all header fields, produced only by the owning query.+-- It is a representation key, never a timestamp, hash or transaction id.+data WorkflowHeaderKey = WorkflowHeaderKey !ByteString.ByteString !Text !Text+  deriving stock (Eq)++instance Show WorkflowHeaderKey where+  show _ = "<workflow header key>"++workflowHeaderKeyBytes :: WorkflowHeaderKey -> Int+workflowHeaderKeyBytes (WorkflowHeaderKey bytes _ _) = ByteString.length bytes++data WorkflowHeaderObservation+  = HeaderUnchanged+  | HeaderChanged !WorkflowHeaderKey !WorkflowInstanceRow+  | HeaderImageTooLarge+  deriving stock (Eq, Show)++-- | Recheck the complete current row in one SELECT. An equal image permits+-- reuse of its pure rendering; every request still observes the database.+-- The byte limit bounds the returned image before client decoding. Missing+-- rows, changed rows and oversized images remain distinct.+lookupInstanceConditionalSession :: Int -> Maybe WorkflowHeaderKey -> WorkflowName -> WorkflowId -> Session.Session (Maybe WorkflowHeaderObservation)+lookupInstanceConditionalSession limit known (WorkflowName name) (WorkflowId wid) =+  Session.statement (image, fromIntegral limit) lookupInstanceConditionalStmt+  where+    -- A complete prior image already contains the exact lookup identity. Reuse+    -- its encoded bytes, avoiding separate serialization of both key strings.+    -- A foreign identity starts with an incomplete image and cannot compare+    -- equal to the complete database header.+    image = case known of+      Just (WorkflowHeaderKey bytes previousName previousId)+        | name == previousName && wid == previousId -> bytes+      _ -> LazyByteString.toStrict (Aeson.encode (Aeson.object ["workflow_name" Aeson..= name, "workflow_id" Aeson..= wid]))+ -- | Enumerate workflow instance summaries using stable keyset pagination. -- -- Status and name filters are exact. Rows inserted before the supplied cursor@@ -464,6 +524,95 @@     )     (D.rowMaybe instanceRowDecoder) +lookupInstanceConditionalStmt :: Statement (ByteString.ByteString, Int64) (Maybe WorkflowHeaderObservation)+{-# NOINLINE lookupInstanceConditionalStmt #-}+lookupInstanceConditionalStmt =+  preparable+    """+    WITH header AS MATERIALIZED (+      SELECT workflow_id, workflow_name, generation, status, attempts,+             last_error,+             CASE WHEN next_attempt_at IS NOT NULL THEN CASE WHEN isfinite(next_attempt_at) THEN jsonb_build_array(+               (next_attempt_at AT TIME ZONE 'UTC')::date - DATE '2000-01-01',+               extract(epoch FROM (next_attempt_at AT TIME ZONE 'UTC')::time)) ELSE to_jsonb(next_attempt_at::text) END END AS next_attempt_at,+             CASE WHEN wake_after IS NOT NULL THEN CASE WHEN isfinite(wake_after) THEN jsonb_build_array(+               (wake_after AT TIME ZONE 'UTC')::date - DATE '2000-01-01',+               extract(epoch FROM (wake_after AT TIME ZONE 'UTC')::time)) ELSE to_jsonb(wake_after::text) END END AS wake_after,+             leased_by,+             CASE WHEN lease_expires_at IS NOT NULL THEN CASE WHEN isfinite(lease_expires_at) THEN jsonb_build_array(+               (lease_expires_at AT TIME ZONE 'UTC')::date - DATE '2000-01-01',+               extract(epoch FROM (lease_expires_at AT TIME ZONE 'UTC')::time)) ELSE to_jsonb(lease_expires_at::text) END END AS lease_expires_at,+             CASE WHEN isfinite(created_at) THEN jsonb_build_array((created_at AT TIME ZONE 'UTC')::date - DATE '2000-01-01',+               extract(epoch FROM (created_at AT TIME ZONE 'UTC')::time)) ELSE to_jsonb(created_at::text) END AS created_at,+             CASE WHEN isfinite(updated_at) THEN jsonb_build_array((updated_at AT TIME ZONE 'UTC')::date - DATE '2000-01-01',+               extract(epoch FROM (updated_at AT TIME ZONE 'UTC')::time)) ELSE to_jsonb(updated_at::text) END AS updated_at,+             CASE WHEN completed_at IS NOT NULL THEN CASE WHEN isfinite(completed_at) THEN jsonb_build_array(+               (completed_at AT TIME ZONE 'UTC')::date - DATE '2000-01-01',+               extract(epoch FROM (completed_at AT TIME ZONE 'UTC')::time)) ELSE to_jsonb(completed_at::text) END END AS completed_at+      FROM keiro.keiro_workflows+      WHERE workflow_id = ($1::jsonb ->> 'workflow_id')+        AND workflow_name = ($1::jsonb ->> 'workflow_name')+    ), image AS MATERIALIZED (+      SELECT CASE+        WHEN $2 <= 0 OR octet_length(workflow_id)::bigint+             + octet_length(workflow_name)::bigint+             + coalesce(octet_length(last_error)::bigint,0)+             + coalesce(octet_length(leased_by)::bigint,0) > $2+          THEN NULL+        ELSE to_jsonb(header)+      END AS value FROM header+    ), checked AS MATERIALIZED (+      SELECT value, value IS NULL OR octet_length(value::text) > $2 AS oversized+      FROM image+    )+    SELECT oversized,+           CASE WHEN oversized OR value = $1::jsonb THEN NULL ELSE value END+    FROM checked+    """+    ( contrazip2+        (E.param (E.nonNullable E.jsonbBytes))+        (E.param (E.nonNullable E.int8))+    )+    (D.rowMaybe (observation <$> D.column (D.nonNullable D.bool) <*> D.column (D.nullable (D.jsonbBytes decodeImage))))+  where+    observation True _ = HeaderImageTooLarge+    observation False Nothing = HeaderUnchanged+    observation False (Just (key, row)) = HeaderChanged key row+    decodeImage bytes = do+      value <- either (Left . Text.pack) Right (Aeson.eitherDecodeStrict' bytes)+      row <- either (Left . Text.pack) Right (parseEither parseHeader value)+      pure (WorkflowHeaderKey bytes (row ^. #workflowName) (row ^. #workflowId), row)+    parseHeader = Aeson.withObject "workflow header image" $ \row ->+      WorkflowInstanceRow+        <$> row .: "workflow_id"+        <*> row .: "workflow_name"+        <*> row .: "generation"+        <*> (statusFromText <$> row .: "status")+        <*> row .: "attempts"+        <*> row .: "last_error"+        <*> (row .: "next_attempt_at" >>= traverse timestampImage)+        <*> (row .: "wake_after" >>= traverse timestampImage)+        <*> row .: "leased_by"+        <*> (row .: "lease_expires_at" >>= traverse timestampImage)+        <*> (row .: "created_at" >>= timestampImage)+        <*> (row .: "updated_at" >>= timestampImage)+        <*> (row .: "completed_at" >>= traverse timestampImage)+    -- Separate days from seconds so epoch division cannot round a distant+    -- timestamp's microseconds. BC dates also avoid textual JSON date parsing.+    -- Match the existing binary decoder's Int64 microsecond interpretation+    -- of PostgreSQL's infinity sentinels, rather than changing legacy JSON.+    timestampImage (Aeson.String "infinity") = pure (sentinelTime (maxBound :: Int64))+    timestampImage (Aeson.String "-infinity") = pure (sentinelTime (minBound :: Int64))+    timestampImage value = do+      (days, seconds) <- Aeson.parseJSON value+      Aeson.withScientific+        "timestamp seconds"+        (\s -> pure (UTCTime (addDays days (fromGregorian 2000 1 1)) (fromRational (toRational s))))+        seconds+    sentinelTime microseconds =+      let (days, remainder) = divMod (toInteger microseconds) (86400 * 1000000)+       in UTCTime (addDays days (fromGregorian 2000 1 1)) (fromRational (toRational remainder / 1000000))+ listWorkflowInstancesStmt :: Statement (Maybe [Text], Maybe Text, Maybe Text, Maybe Text, Int32) [WorkflowInstanceRow] listWorkflowInstancesStmt =   preparable@@ -678,18 +827,93 @@     ((> 0) <$> D.rowsAffected)  instanceRowDecoder :: D.Row WorkflowInstanceRow-instanceRowDecoder =+instanceRowDecoder = instanceFields (D.column . D.nonNullable) (D.column . D.nullable)++-- Shared by ordinary reads and the nullable row of an inspection outer join.+-- The ordinary decoder retains the exact same columns and nullability.+instanceFields :: (Applicative f) => (forall a. D.Value a -> f a) -> (forall a. D.Value a -> f (Maybe a)) -> f WorkflowInstanceRow+instanceFields required optional =   WorkflowInstanceRow-    <$> D.column (D.nonNullable D.text)-    <*> D.column (D.nonNullable D.text)-    <*> D.column (D.nonNullable D.int4)-    <*> (statusFromText <$> D.column (D.nonNullable D.text))-    <*> D.column (D.nonNullable D.int4)-    <*> D.column (D.nullable D.text)-    <*> D.column (D.nullable D.timestamptz)-    <*> D.column (D.nullable D.timestamptz)-    <*> D.column (D.nullable D.text)-    <*> D.column (D.nullable D.timestamptz)-    <*> D.column (D.nonNullable D.timestamptz)-    <*> D.column (D.nonNullable D.timestamptz)-    <*> D.column (D.nullable D.timestamptz)+    <$> required D.text+    <*> required D.text+    <*> required D.int4+    <*> (statusFromText <$> required D.text)+    <*> required D.int4+    <*> optional D.text+    <*> optional D.timestamptz+    <*> optional D.timestamptz+    <*> optional D.text+    <*> optional D.timestamptz+    <*> required D.timestamptz+    <*> required D.timestamptz+    <*> optional D.timestamptz++-- | Inspection-only parameterized statement shapes. Absent filters are omitted+-- from SQL rather than hidden behind nullable ORs that defeat generic plans.+-- The facade validates the size and cursor before calling this primitive.+-- The outer join returns the database observation clock even for an empty page.+inspectionInstanceRows :: (Store :> es) => WorkflowListQuery -> Maybe WorkflowListPosition -> Int -> Eff es (UTCTime, [WorkflowInstanceRow])+inspectionInstanceRows query position count = do+  rows <- runTransaction (Tx.statement () (inspectionInstanceStatement query position count))+  case rows of+    (clock, _) : _ -> pure (clock, mapMaybe snd rows)+    [] -> error "inspection clock outer join returned no row"++inspectionInstanceStatement :: WorkflowListQuery -> Maybe WorkflowListPosition -> Int -> Statement () [(UTCTime, Maybe WorkflowInstanceRow)]+inspectionInstanceStatement query position count = preparable sql params decoder+  where+    number = Text.pack . show+    parameter :: Text -> E.Value a -> a -> (Int -> Text, Int, E.Params ())+    parameter template value argument = (\n -> Text.replace "?" ("$" <> number n) template, 1, const argument >$< E.param (E.nonNullable value))+    optional template value = fmap (parameter template value)+    selectedClasses = nub (maybe [] NonEmpty.toList query.classes)+    filters =+      [(const ("(" <> Text.intercalate " OR " ["(" <> classPredicate status <> ")" | status <- selectedClasses] <> ")"), 0, mempty) | query.classes /= Nothing]+        <> catMaybes+          [ optional "workflow_name = ?" E.text query.workflowName,+            optional "created_at >= ?" E.timestamptz query.createdSince,+            optional "created_at < ?" E.timestamptz query.createdBefore,+            optional "updated_at >= ?" E.timestamptz query.updatedSince,+            optional "updated_at < ?" E.timestamptz query.updatedBefore+          ]+        <> cursorParts+    cursorParts = case position of+      Nothing -> []+      Just (AfterWorkflowKey wid name) ->+        [ ( \n -> "(workflow_id, workflow_name) > ($" <> number n <> ",$" <> number (n + 1) <> ")",+            2,+            (const wid >$< E.param (E.nonNullable E.text)) <> (const name >$< E.param (E.nonNullable E.text))+          )+        ]+      Just (AfterWorkflowCreated created name wid) ->+        [ ( \n -> "(created_at, workflow_name, workflow_id) < ($" <> number n <> ",$" <> number (n + 1) <> ",$" <> number (n + 2) <> ")",+            3,+            (const created >$< E.param (E.nonNullable E.timestamptz)) <> (const name >$< E.param (E.nonNullable E.text)) <> (const wid >$< E.param (E.nonNullable E.text))+          )+        ]+    (next, predicates, filterParams) = foldl' (\(index, clauses, encoders) (render, width, encoder) -> (index + width, clauses <> [render index], encoders <> encoder)) (1, [], mempty) filters+    order = case query.ordering of ByKey -> "workflow_id, workflow_name"; ByCreatedDesc -> "created_at DESC, workflow_name DESC, workflow_id DESC"+    columns = "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"+    -- Classification is inspection-only. Reuse existing status/key access;+    -- no generated column or inspection index adds work to ordinary writes.+    classPredicate = \case+      ClassRunning -> "status = 'running' AND attempts = 0"+      ClassRetrying -> "status = 'running' AND attempts <> 0"+      ClassSleeping -> "status = 'suspended' AND wake_after IS NOT NULL"+      ClassAwaiting -> "status = 'suspended' AND wake_after IS NULL"+      ClassCompleted -> "status = 'completed'"+      ClassCancelled -> "status = 'cancelled'"+      ClassFailed -> "status = 'failed'"+    sql =+      "/* keiro-inspection-page-v2 */ WITH inspection_page AS MATERIALIZED (SELECT "+        <> columns+        <> " FROM keiro.keiro_workflows WHERE "+        <> Text.intercalate " AND " ("true" : predicates)+        <> " ORDER BY "+        <> order+        <> " LIMIT $"+        <> number next+        <> ") SELECT now(), inspection_page.* FROM (VALUES (1)) AS clock(x) LEFT JOIN inspection_page ON true ORDER BY "+        <> order+    params = filterParams <> (const (fromIntegral count :: Int32) >$< E.param (E.nonNullable E.int4))+    decoder = D.rowList ((,) <$> D.column (D.nonNullable D.timestamptz) <*> getCompose (instanceFields (Compose . D.column . D.nullable) (Compose . fmap Just . D.column . D.nullable)))
src/Keiro/Workflow/Schema.hs view
@@ -27,6 +27,8 @@      -- * Read-only lookups     loadStepIndex,+    loadStepIndexPage,+    inspectionObservedAt,     lookupStepResult,     stepExists,     terminalMarkers,@@ -36,6 +38,7 @@ where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)+import Data.Functor.Contravariant ((>$<)) import Data.Int (Int32) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map@@ -56,6 +59,25 @@ import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import "hasql-transaction" Hasql.Transaction qualified as Tx++-- | Database clock for an inspection page. This is an observation timestamp,+-- not a claim that separate ReadCommitted statements share a row snapshot.+inspectionObservedAt :: (Store :> es) => Eff es UTCTime+inspectionObservedAt = runTransaction (Tx.statement () (preparable "SELECT now()" E.noParams (D.singleRow (D.column (D.nonNullable D.timestamptz)))))++-- | Bounded step rows in immutable name order. The caller validates the size.+loadStepIndexPage :: (Store :> es) => WorkflowName -> WorkflowId -> Int -> Maybe Text -> Int -> Eff es [WorkflowStepRow]+loadStepIndexPage (WorkflowName name) (WorkflowId wid) gen after count = runTransaction (Tx.statement () statement)+  where+    prefix = (const wid >$< E.param (E.nonNullable E.text)) <> (const name >$< E.param (E.nonNullable E.text)) <> (const (fromIntegral gen :: Int32) >$< E.param (E.nonNullable E.int4))+    (cursorSql, cursorParams, limitParam) = case after of+      Nothing -> ("", mempty, "$4")+      Just key -> (" AND step_name > $4", const key >$< E.param (E.nonNullable E.text), "$5")+    statement =+      preparable+        ("SELECT workflow_id, workflow_name, generation, step_name, result, recorded_at FROM keiro.keiro_workflow_steps WHERE workflow_id=$1 AND workflow_name=$2 AND generation=$3" <> cursorSql <> " ORDER BY step_name LIMIT " <> limitParam)+        (prefix <> cursorParams <> (const (fromIntegral count :: Int32) >$< E.param (E.nonNullable E.int4)))+        (D.rowList (WorkflowStepRow <$> 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) <*> D.column (D.nonNullable D.jsonb) <*> D.column (D.nonNullable D.timestamptz)))  -- | A row of the @keiro_workflow_steps@ index: the workflow instance and -- name, the step name, the step's JSON result, and when it was recorded. The
test/GroupRebuildSpec.hs view
@@ -20,6 +20,7 @@ import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable)+import Keiro.Inspection.Cursor qualified as Cursor import Keiro.Prelude import Keiro.Projection   ( CatalogAsyncApplyOutcome (..),@@ -187,6 +188,17 @@      statuses <- expectStore store listProjectionGroupStatuses     length statuses `shouldBe` 251+    Right first <- expectStore store (listProjectionGroupStatusesPage (ProjectionStatusPageRequest 100 Nothing))+    Right second <- expectStore store (listProjectionGroupStatusesPage (ProjectionStatusPageRequest 100 (first ^. #next)))+    Right third <- expectStore store (listProjectionGroupStatusesPage (ProjectionStatusPageRequest 100 (second ^. #next)))+    (first ^. #items) <> (second ^. #items) <> (third ^. #items) `shouldBe` statuses+    third ^. #next `shouldBe` Nothing+    expectStore store listProjectionGroupStatuses `shouldReturn` statuses+    for_ [0, -1, 501] $ \size ->+      validateProjectionStatusRequest (ProjectionStatusPageRequest size Nothing) `shouldBe` Left (InvalidProjectionStatusPageSize size)+    for_ [Cursor.InspectionCursor "broken", Cursor.encodeCursor "timer-pending" ("other" :: Text)] $ \cursor ->+      validateProjectionStatusRequest (ProjectionStatusPageRequest 1 (Just cursor)) `shouldSatisfy` isLeft+     map (^. #groupId) statuses `shouldSatisfy` strictlyAscending    it "registers an additive catalog without disturbing the existing group slice" $ \store -> do
test/Main.hs view
@@ -18,6 +18,7 @@ import Data.Aeson.Types (parseEither) import Data.ByteString (ByteString) import Data.ByteString qualified as ByteString+import Data.ByteString.Lazy qualified as LazyByteString import Data.Char (isDigit) import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef) import Data.Int (Int32)@@ -102,6 +103,7 @@   ) import Keiro.DeterministicId (deterministicIdProbes, identitySeedBytes, legacySeedBytes) import Keiro.EventStream (Terminality (..))+import Keiro.EventStream qualified as EventStream import Keiro.EventStream.Validate   ( EventStreamWarning (..),     ValidatedEventStream,@@ -141,6 +143,8 @@     delegatedFromPMCommand,   ) import Keiro.Inbox.Kafka qualified as InboxKafka+import Keiro.Inspection.Aggregate qualified as Aggregate+import Keiro.Inspection.Cursor qualified as Cursor import Keiro.Integration.Event   ( IntegrationContentType (..),     IntegrationEvent (..),@@ -197,6 +201,7 @@ import Keiro.Outbox.Schema (markOutboxFailedTx, markOutboxRejectedTx) import Keiro.Prelude import Keiro.ProcessManager+import Keiro.ProcessManager.Inspect qualified as PMInspection import Keiro.ProcessManager.Reaction qualified as Reaction import Keiro.Projection import Keiro.ReadModel@@ -205,15 +210,20 @@ import Keiro.Snapshot.Policy (shouldSnapshot, shouldSnapshotSpan) import Keiro.Stream qualified as Stream import Keiro.Subscription.Shard-  ( ShardCountMismatch (..),+  ( LeaseState (..),+    ShardCountMismatch (..),     ShardCountResizeReport (..),     ShardLease (..),     ShardResizeActiveLeases (..),     WorkerId (..),     ensureShards,     fairShareTarget,+    leaseAgeAt,+    leaseStateAt,+    listShardedSubscriptions,     resizeShardCount,     resizeShardCountTx,+    shardOwnershipView,   ) import Keiro.Subscription.Shard.Schema   ( claimShardsTx,@@ -426,7 +436,7 @@ 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.Ingested (DeliveryStatus (..), Ingested (..)) import Shibuya.Core.Types (Envelope (..)) import Streamly.Data.Stream qualified as Streamly import System.Exit (ExitCode (..))@@ -435,11 +445,13 @@ import Test.Hspec import VersionedRebuildSpec qualified import VersionedTargetPostgresSpec qualified+import WorkflowInspectionSpec qualified import WorkflowReplayCompatibilitySpec qualified import "hasql-transaction" Hasql.Transaction qualified as Tx  main :: IO () main = withMigratedSuite $ \fixture -> hspec $ do+  WorkflowInspectionSpec.spec fixture   CatalogSpec.spec   PreimageSpec.spec   CatalogEvolutionSpec.spec fixture@@ -2438,6 +2450,57 @@         for_ callbackRecordeds $ \recorded ->           recorded ^. #metadata `shouldSatisfy` hasMarker +  describe "Keiro.Inspection.Aggregate" $ around (withFreshStore fixture) $ do+    it "inspects the command fold and snapshot, while deleted streams have no folded state" $ \store -> do+      let codec = defaultStateCodec @SnapshotCounterRegs @CounterState 1+          inspector = Aggregate.aggregateInspector "counter" snapshotCounterEventStream (Stream.categoryUnsafe "inspectioncounter") (Aggregate.displayViaStateCodec codec)+          target = Stream.entityStream (Stream.categoryUnsafe "inspectioncounter") "one"+      forM_ [2, 3, 4] $ \amount -> do+        Right (Right _) <- Store.runStoreIO store (runCommand inspectionRunCommandOptions snapshotCounterEventStream target (Add amount))+        pure ()+      Right (Right view) <- Store.runStoreIO store (Aggregate.inspectAggregate inspector "one")+      Right (Right full) <- Store.runStoreIO store (hydrateFull inspectionRunCommandOptions (unvalidated snapshotCounterEventStream) target)+      (view ^. #state) `shouldBe` Just ((codec ^. #encode) (full ^. #state, full ^. #registers))+      (view ^. #streamVersion) `shouldBe` (full ^. #streamVersion)+      let mappedDefinition :: SnapshotCounterEventStream+          mappedDefinition = (unvalidated snapshotCounterEventStream) & #resolveStreamName .~ (\typed -> case Stream.streamName typed of StreamName raw -> StreamName ("inspectioncounter-" <> raw))+          mappedStream = mkEventStreamOrThrow "inspection-mapped-counter" mappedDefinition+          auditTarget = ReplayAudit.AuditTarget mappedStream "inspectioncounter" (\(StreamName physical) -> stream <$> Text.stripPrefix "inspectioncounter-" physical)+      Right mappedInspector <- pure (Aggregate.inspectorFromAuditTarget "counter" auditTarget (Aggregate.displayViaStateCodec codec))+      Right (Right mappedView) <- Store.runStoreIO store (Aggregate.inspectAggregate mappedInspector "one")+      (mappedView ^. #state) `shouldBe` (view ^. #state)+      (mappedView ^. #streamName) `shouldBe` StreamName "inspectioncounter-one"+      Aggregate.aggregateIdOf mappedInspector (StreamName "inspectioncounter-one") `shouldBe` Just "one"+      (view ^. #hydration) `shouldBe` Just (FromSnapshot (StreamVersion 2))+      fmap (^. #compatibility) (view ^. #snapshot . #stored) `shouldBe` Just Aggregate.Compatible+      Right _ <- Store.runStoreIO store (Store.softDeleteStream (StreamName "inspectioncounter-one"))+      Right (Right deleted) <- Store.runStoreIO store (Aggregate.inspectAggregate inspector "one")+      (deleted ^. #state) `shouldBe` Nothing+      (deleted ^. #hydration) `shouldBe` Nothing+      (deleted ^. #streamVersion) `shouldBe` StreamVersion 3+      Aggregate.aggregateStream inspector " " `shouldBe` Left (Aggregate.AggregateIdRejected " ")+      missing <- Store.runStoreIO store (Aggregate.inspectAggregate inspector "missing")+      missing `shouldBe` Right (Left (Aggregate.AggregateNotFound (StreamName "inspectioncounter-missing")))++    it "advances one raw page through rejected rows without losing the look-ahead row" $ \store -> do+      let base = Aggregate.aggregateInspector "counter" counterEventStream (Stream.categoryUnsafe "inspectionpage") (\state _ -> Aeson.String (Text.pack (show state)))+          inspector = base & #mkStream .~ (\name@(StreamName raw) -> if Text.isSuffixOf "keep" raw then (base ^. #mkStream) name else Nothing)+      forM_ ["a-reject", "b-reject", "c-keep"] $ \suffix -> do+        Right (Right _) <- Store.runStoreIO store (runCommand inspectionRunCommandOptions counterEventStream (Stream.entityStream (Stream.categoryUnsafe "inspectionpage") suffix) (Add 1))+        pure ()+      Right (Right first) <- Store.runStoreIO store (Aggregate.listAggregates inspector (Aggregate.AggregateListRequest 2 Nothing))+      (first ^. #items) `shouldBe` []+      (first ^. #skipped) `shouldBe` 2+      (first ^. #next) `shouldSatisfy` isJust+      Right (Right second) <- Store.runStoreIO store (Aggregate.listAggregates inspector (Aggregate.AggregateListRequest 2 (first ^. #next)))+      map (^. #aggregateId) (second ^. #items) `shouldBe` ["c-keep"]+      (second ^. #next) `shouldBe` Nothing+      let other = inspector & #name .~ ("another-counter" :: Text)+      Right foreignPage <- Store.runStoreIO store (Aggregate.listAggregates other (Aggregate.AggregateListRequest 2 (first ^. #next)))+      foreignPage `shouldSatisfy` (\case Left (Aggregate.InvalidCursor _) -> True; _ -> False)+      Right malformed <- Store.runStoreIO store (Aggregate.listAggregates inspector (Aggregate.AggregateListRequest 2 (Just (Cursor.InspectionCursor "bad"))))+      malformed `shouldSatisfy` (\case Left (Aggregate.InvalidCursor _) -> True; _ -> False)+   describe "Keiro.Snapshot" $ around (withFreshStore fixture) $ do     it "reports an ErrorCall when strict encoding reaches an empty register slot" $ \_storeHandle -> do       result <-@@ -2550,6 +2613,55 @@       lookup "keiro.snapshot.encode.failures" scalars `shouldBe` Just (IntNumber 1)       lookup "keiro.snapshot.write.failures" scalars `shouldBe` Nothing +    it "reports snapshot and corrupt-seed sources without changing the folded state" $ \storeHandle -> do+      let target = stream "inspection-hydration-source" :: Stream SnapshotCounterEventStream+          options = inspectionRunCommandOptions+          definition = unvalidated snapshotCounterEventStream+          codec = defaultStateCodec @SnapshotCounterRegs @CounterState 1+          image hydrated = (hydrated ^. #streamVersion, (codec ^. #encode) (hydrated ^. #state, hydrated ^. #registers))+      forM_ [2, 3, 4] $ \amount -> do+        Right (Right _) <- Store.runStoreIO storeHandle (runCommand options snapshotCounterEventStream target (Add amount))+        pure ()+      Right (Right (fromSeed, source)) <- Store.runStoreIO storeHandle (hydrateWithSource options definition target)+      Right (Right ordinary) <- Store.runStoreIO storeHandle (hydrate options definition target)+      Right (Right full) <- Store.runStoreIO storeHandle (hydrateFull options definition target)+      source `shouldBe` FromSnapshot (StreamVersion 2)+      image fromSeed `shouldBe` image full+      image ordinary `shouldBe` image full+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.statement ("inspection-hydration-source", Aeson.String "bad") corruptSnapshotStateStmt+      Right (Right (fallback, fallbackSource)) <- Store.runStoreIO storeHandle (hydrateWithSource options definition target)+      fallbackSource `shouldSatisfy` (\case FullReplay (SnapshotMissed (SnapshotDecodeFailed _)) -> True; _ -> False)+      image fallback `shouldBe` image full++    it "distinguishes absent snapshot streams and no codec during inspection hydration" $ \storeHandle -> do+      Right (Right (_, noCodec)) <-+        Store.runStoreIO storeHandle $+          hydrateWithSource inspectionRunCommandOptions (unvalidated counterEventStream) (stream "inspection-no-codec")+      noCodec `shouldBe` FullReplay NoStateCodec+      Right (Right (_, absent)) <-+        Store.runStoreIO storeHandle $+          hydrateWithSource inspectionRunCommandOptions (unvalidated snapshotCounterEventStream) (stream "inspection-absent-seed")+      absent `shouldBe` FullReplay (SnapshotMissed SnapshotNoStream)+      (inspectionRunCommandOptions ^. #seedVerifySampleRate) `shouldBe` 0+      (inspectionRunCommandOptions ^. #verifyReplayOnAppend) `shouldBe` False++    it "observes absent and present snapshots with a database clock" $ \storeHandle -> do+      let target = stream "inspection-snapshot-clock" :: Stream SnapshotCounterEventStream+      Right (Right _) <- Store.runStoreIO storeHandle (runCommand inspectionRunCommandOptions snapshotCounterEventStream target (Add 2))+      Right (Just sid) <- Store.runStoreIO storeHandle (Store.lookupStreamId (StreamName "inspection-snapshot-clock"))+      Right absent <- Store.runStoreIO storeHandle (observeSnapshotRow sid)+      (absent ^. #row) `shouldBe` Nothing+      Right (Right _) <- Store.runStoreIO storeHandle (runCommand inspectionRunCommandOptions snapshotCounterEventStream target (Add 3))+      Right observed <- Store.runStoreIO storeHandle (observeSnapshotRow sid)+      case (observed ^. #row) of+        Nothing -> expectationFailure "expected snapshot observation"+        Just row -> do+          (row ^. #streamVersion) `shouldBe` StreamVersion 2+          (observed ^. #observedAt) `shouldSatisfy` (>= (row ^. #updatedAt))+     it "hydrates from snapshot and replays only the tail" $ \storeHandle -> do       (exporter, metricsRef) <- inMemoryMetricExporter       (provider, _env) <-@@ -5205,6 +5317,198 @@           Prelude.sum persistedCounts `shouldBe` fanOut    describe "Keiro.ProcessManager" $ around (withFreshResourceStore fixture) $ do+    it "discovers sorted lookup names without evaluating instance callbacks" $ \_ -> do+      let unused = error "discovery invoked an instance callback"+          legacy =+            (PMInspection.processManagerInspector pmSnapshotProcessManager (categoryUnsafe "pm:counter") (\_ _ -> unused))+              { PMInspection.streamFor = \_ -> unused,+                PMInspection.correlationOf = \_ -> unused,+                PMInspection.stateLabel = \_ -> unused,+                PMInspection.enabledInputs = \_ -> unused+              }+          definition :: SnapshotPolicy (CounterState, RegFile SnapshotCounterRegs) -> ValidatedSnapshotCounterEventStream+          definition policy = mkEventStreamOrThrow "discovery" (pmSnapshotCounterEventStreamDef {EventStream.snapshotPolicy = policy, EventStream.resolveStreamName = \_ -> unused})+          domain = PMInspection.domainProcessManagerInspector domainProcessManager (categoryUnsafe "domain") (\_ _ -> unused)+          reactive = PMInspection.reactiveProcessManagerInspector counterReactionManager (categoryUnsafe "reactive") (\_ _ -> unused)+          catalog =+            Map.fromList+              [ ("z-alias", PMInspection.SomeProcessManagerInspector legacy),+                ("a-custom", PMInspection.SomeProcessManagerInspector (legacy {PMInspection.eventStream = definition (Custom (\_ _ _ -> unused))})),+                ("b-never", PMInspection.SomeProcessManagerInspector (legacy {PMInspection.eventStream = definition Never})),+                ("c-terminal", PMInspection.SomeProcessManagerInspector (legacy {PMInspection.eventStream = definition OnTerminal})),+                ("domain", PMInspection.SomeProcessManagerInspector domain),+                ("reactive", PMInspection.SomeProcessManagerInspector reactive)+              ]+          summaries = PMInspection.listProcessManagerTypes catalog+      PMInspection.listProcessManagerTypes Map.empty `shouldBe` []+      map (^. #name) summaries `shouldBe` ["a-custom", "b-never", "c-terminal", "domain", "reactive", "z-alias"]+      map (^. #snapshotPolicy) summaries `shouldBe` ["custom", "never", "on_terminal", "never", "never", "every:2"]+      map (^. #snapshotsConfigured) summaries `shouldBe` [True, True, True, False, False, True]+      map (^. #timersConfigured) summaries `shouldBe` replicate 6 Nothing+      map (^. #category) summaries `shouldBe` ["pm:counter", "pm:counter", "pm:counter", "domain", "reactive", "pm:counter"]+      encoded <- evaluate (LazyByteString.length (Aeson.encode (map PMInspection.processManagerTypeJson summaries)))+      encoded `shouldSatisfy` (> 0)+      forM_ (summaries <> [PMInspection.ProcessManagerTypeSummary "known-timers" "timer-metadata" False "never" (Just True), PMInspection.ProcessManagerTypeSummary "no-timers" "timer-metadata" False "never" (Just False), PMInspection.ProcessManagerTypeSummary "escaped-\"\\\n界" "category-界" True "custom" Nothing]) $ \summary ->+        Aeson.eitherDecode (Aeson.encode summary) `shouldBe` Right (PMInspection.processManagerTypeJson summary)+      Aeson.eitherDecode (Aeson.encode summaries) `shouldBe` Right (Aeson.toJSON (map PMInspection.processManagerTypeJson summaries))+    it "instance view equals the runtime's own rebuild" $ \(storeHandle, StoreRunner runner) -> do+      let options = defaultRunCommandOptions & #inspectionProvenance .~ CaptureInspectionProvenance+          definition = unvalidated pmSnapshotCounterEventStream+          Just codec = definition ^. #stateCodec+          display state registers = codec ^. #encode $ (state, registers)+          inspector = PMInspection.processManagerInspector pmSnapshotProcessManager (categoryUnsafe "pm:counter") display+          inspect value correlation = runner (PMInspection.inspectProcessManagerInstance value (PMInspection.InstanceViewRequest 20) correlation)+      forM_ [1 .. 3] $ \n -> do+        let source = recordedFromEventId (EventId (UUID.fromWords 0 0 0 n)) (CounterAdded (fromIntegral n))+        Right (Right _) <- runner (runProcessManagerOnce options pmSnapshotProcessManager source (CounterAdded (fromIntegral n)))+        pure ()+      Right (Right rebuilt) <- runner (hydrateFull inspectionRunCommandOptions definition ((pmSnapshotProcessManager ^. #streamFor) "order-1"))+      Right (Right (Just view)) <- inspect inspector "order-1"+      view ^. #state `shouldBe` Just (display (rebuilt ^. #state) (rebuilt ^. #registers))+      view ^. #streamVersion `shouldBe` rebuilt ^. #streamVersion+      view ^. #hydration `shouldBe` Just (FromSnapshot (StreamVersion 2))+      view ^. #waitingFor `shouldSatisfy` (not . null)+      let noCodec = inspector & #eventStream .~ mkEventStreamOrThrow "inspection-no-codec" definition {stateCodec = Nothing, snapshotPolicy = Never}+      Right (Right (Just replayed)) <- inspect noCodec "order-1"+      replayed ^. #state `shouldBe` view ^. #state+      replayed ^. #hydration `shouldBe` Just (FullReplay NoStateCodec)+      Right absent <- inspect inspector "missing"+      absent `shouldBe` Right Nothing+      Right _ <- Store.runStoreIO storeHandle (Store.softDeleteStream (StreamName "pm:counter-snap-order-1"))+      Right (Right (Just deleted)) <- inspect inspector "order-1"+      deleted ^. #state `shouldBe` Nothing+      deleted ^. #hydration `shouldBe` Nothing+      deleted ^. #deletedAt `shouldSatisfy` isJust++    it "advances manager listings across rejected raw rows" $ \(_, StoreRunner runner) -> do+      let inspector =+            (PMInspection.processManagerInspector counterProcessManager (categoryUnsafe "pm:counter") (\state _ -> Aeson.toJSON (show state)))+              & #correlationOf+              .~ (\(StreamName raw) -> if raw == "pm:counter-c" then Just ("c" :: Text) else Nothing)+          readPage after = runner (PMInspection.listProcessManagerInstances inspector (PMInspection.InstanceListRequest 2 after))+      forM_ ["a", "b", "c"] $ \key -> do+        Right (Right _) <- runner (runCommand defaultRunCommandOptions counterEventStream (stream ("pm:counter-" <> key)) (Add 1))+        pure ()+      Right (Right first) <- readPage Nothing+      first ^. #items `shouldBe` []+      first ^. #skipped `shouldBe` 2+      first ^. #next `shouldSatisfy` isJust+      Right (Right second) <- readPage (first ^. #next)+      map (^. #correlationId) (second ^. #items) `shouldBe` ["c"]+      second ^. #next `shouldBe` Nothing++    it "inspects modern reaction state without inventing input-dependent dispatch provenance" $ \(storeHandle, StoreRunner runner) -> do+      let manager = counterReactionManager {Reaction.name = "inspection-reaction", Reaction.streamFor = \cid -> stream ("inspectionreaction-" <> cid)}+          inspector = PMInspection.reactiveProcessManagerInspector manager (categoryUnsafe "inspectionreaction") (\state _ -> Aeson.toJSON (show state))+          source = recordedFromEventId (EventId sampleUuid) (CounterAdded 5)+          target = stream "inspection-target-dependent" :: Stream CounterCommand+          timer = counterTimerRequest & #processManagerName .~ "inspection-reaction" & #correlationId .~ "one"+          legacy = PMInspection.ReactionProvenance (source ^. #eventId) (source ^. #globalPosition) (source ^. #originalStreamId) [(0, StreamName "fictional-target")] [] "legacy-positional-v1" False+          options = defaultRunCommandOptions & #metadata ?~ object ["keiro.pm" Aeson..= PMInspection.encodeReactionProvenance legacy]+          plan = Reaction.AdvanceReaction (Add 5) [] [Reaction.FollowDispatch (PMCommand target (Add 2)), Reaction.FollowDispatch (PMCommand target (Add 3)), Reaction.FollowSchedule Reaction.Once timer]+          inspect cid = runner (PMInspection.inspectProcessManagerInstance inspector (PMInspection.InstanceViewRequest 20) cid)+      Right (Right _) <- runner (Reaction.runReactiveProcessManagerOnce options manager source ("one", plan))+      Right targetEvents <- runner (Store.readStreamForward (StreamName "inspection-target-dependent") (StreamVersion 0) 10)+      Vector.length targetEvents `shouldBe` 2+      Right (Right rebuilt) <- runner (hydrateFull inspectionRunCommandOptions (unvalidated multiCounterEventStream) ((manager ^. #streamFor) "one"))+      Right (Right (Just view)) <- inspect "one"+      view ^. #state `shouldBe` Just (Aeson.toJSON (show (rebuilt ^. #state)))+      view ^. #streamVersion `shouldBe` StreamVersion 2+      view ^. #waitingFor `shouldSatisfy` (not . null)+      length (view ^. #pendingTimers . #timers) `shouldBe` 1+      Just last <- pure (view ^. #lastReaction)+      last ^. #streamVersion `shouldBe` StreamVersion 2+      last ^. #provenance `shouldBe` Nothing+      last ^. #sourceStreamName `shouldBe` Nothing+      last ^. #dispatchedCommands `shouldBe` []+      -- Even a valid legacy-looking frame on the latest persisted event is ignored.+      Right events <- runner (Store.readStreamForward (StreamName "inspectionreaction-one") (StreamVersion 0) 10)+      (Vector.last events ^. #metadata) `shouldSatisfy` isJust+      let json = PMInspection.processManagerViewJson view+      case json of+        Aeson.Object fields -> case KeyMap.lookup "last_reaction" fields of+          Just (Aeson.Object reaction) -> do+            KeyMap.lookup "provenance_status" reaction `shouldBe` Just (Aeson.String "unavailable")+            KeyMap.lookup "provenance" reaction `shouldBe` Just Aeson.Null+          other -> expectationFailure (show other)+        other -> expectationFailure (show other)+      Right (Right _) <- runner (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions manager (recordedFromEventId (EventId sampleUuid2) (CounterAdded 1)) ("one", Reaction.NoAdvance []))+      Right (Right (Just unchanged)) <- inspect "one"+      unchanged ^. #lastReaction `shouldBe` view ^. #lastReaction+      Right (Right _) <- runner (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions manager (recordedFromEventId (EventId sampleUuid3) (CounterAdded 1)) ("two", Reaction.AdvanceReaction (Add 1) [] []))+      Right (Right page) <- runner (PMInspection.listProcessManagerInstances inspector (PMInspection.InstanceListRequest 1 Nothing))+      map (^. #correlationId) (page ^. #items) `shouldBe` ["one"]+      Right (Right next) <- runner (PMInspection.listProcessManagerInstances inspector (PMInspection.InstanceListRequest 1 (page ^. #next)))+      map (^. #correlationId) (next ^. #items) `shouldBe` ["two"]+      let silentManager = silentReactionManager {Reaction.name = "inspection-silent", Reaction.streamFor = \cid -> stream ("inspectionsilent-" <> cid)}+          silentInspector = PMInspection.reactiveProcessManagerInspector silentManager (categoryUnsafe "inspectionsilent") (\state _ -> Aeson.toJSON (show state))+      Right (Right _) <- runner (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions silentManager source ("one", Reaction.AdvanceReaction NoOpSilently [] []))+      Right silent <- runner (PMInspection.inspectProcessManagerInstance silentInspector (PMInspection.InstanceViewRequest 20) "one")+      silent `shouldBe` Right Nothing+      Right absent <- inspect "missing"+      absent `shouldBe` Right Nothing+      Right _ <- Store.runStoreIO storeHandle (Store.softDeleteStream (StreamName "inspectionreaction-one"))+      Right (Right (Just deleted)) <- inspect "one"+      deleted ^. #state `shouldBe` Nothing+      deleted ^. #lastReaction `shouldBe` Nothing++    it "records source provenance for explicit inspection capture" $ \(_, StoreRunner runner) -> do+      let source = recordedFromEventId (EventId sampleUuid) (CounterAdded 9)+      let options = defaultRunCommandOptions & #inspectionProvenance .~ CaptureInspectionProvenance & #metadata ?~ Aeson.object ["actor" Aeson..= ("ops" :: Text)]+      Right (Right _) <- runner (runProcessManagerOnce options counterProcessManager source (CounterAdded 9))+      Right events <- runner (Store.readStreamForward (StreamName "pm:counter-order-1") (StreamVersion 0) 1)+      Vector.length events `shouldBe` 1+      case Vector.head events ^. #metadata of+        Just (Aeson.Object fields) -> do+          KeyMap.lookup "actor" fields `shouldBe` Just (Aeson.String "ops")+          Just frame <- pure (KeyMap.lookup "keiro.pm" fields)+          Just provenance <- pure (PMInspection.decodeReactionProvenance frame)+          (provenance ^. #sourceEventId) `shouldBe` (source ^. #eventId)+          (provenance ^. #sourceGlobalPosition) `shouldBe` (source ^. #globalPosition)+          (provenance ^. #sourceStreamId) `shouldBe` (source ^. #originalStreamId)+          (provenance ^. #dispatched) `shouldBe` [(0, StreamName "counter-target-order-1")]+          (provenance ^. #truncated) `shouldBe` False+          LazyByteString.length (Aeson.encode frame) `shouldSatisfy` (<= 16384)+          let many = provenance & #dispatched .~ [(i, StreamName ("target-" <> Text.pack (show i))) | i <- [0 .. 99]] & #timerIds .~ replicate 100 (TimerId sampleUuid)+          Just prefix <- pure (PMInspection.decodeReactionProvenance (PMInspection.encodeReactionProvenance many))+          length (prefix ^. #dispatched) `shouldBe` 64+          length (prefix ^. #timerIds) `shouldBe` 64+          (prefix ^. #truncated) `shouldBe` True+          let escaped = many & #dispatched .~ [(i, StreamName (Text.replicate 400 "\"")) | i <- [0 .. 63]]+              limited = PMInspection.encodeReactionProvenance escaped+          Just sourceOnly <- pure (PMInspection.decodeReactionProvenance limited)+          (sourceOnly ^. #dispatched) `shouldBe` []+          (sourceOnly ^. #truncated) `shouldBe` True+          LazyByteString.length (Aeson.encode limited) `shouldSatisfy` (<= 16384)+          case frame of+            Aeson.Object body -> do+              PMInspection.decodeReactionProvenance (Aeson.Object (KeyMap.insert "family" (Aeson.String "other-family") body)) `shouldBe` Nothing+              let tooLarge = Aeson.toJSON [Aeson.object ["i" Aeson..= (0 :: Int), "target" Aeson..= Text.replicate 20000 "x"]]+              PMInspection.decodeReactionProvenance (Aeson.Object (KeyMap.insert "dispatched" tooLarge body)) `shouldBe` Nothing+            _ -> expectationFailure "expected provenance object"+        _ -> expectationFailure "expected source provenance metadata"+      (Vector.head events ^. #eventId) `shouldBe` deterministicCommandId "counter-pm" "order-1" (source ^. #eventId) (-1)+      let inspector = PMInspection.processManagerInspector counterProcessManager (categoryUnsafe "pm:counter") (\state _ -> Aeson.toJSON (show state))+      Right (Right (Just observed)) <- runner (PMInspection.inspectProcessManagerInstance inspector (PMInspection.InstanceViewRequest 20) "order-1")+      Just last <- pure (observed ^. #lastReaction)+      map (^. #witness) (last ^. #dispatchedCommands) `shouldBe` [PMInspection.EventPresent]+      map (^. #eventId) (last ^. #dispatchedCommands) `shouldBe` [deterministicCommandId "counter-pm" "order-1" (source ^. #eventId) 0]+      Right _ <- runner (Store.softDeleteStream (StreamName "counter-target-order-1"))+      Right (Right (Just afterDelete)) <- runner (PMInspection.inspectProcessManagerInstance inspector (PMInspection.InstanceViewRequest 20) "order-1")+      Just missingTarget <- pure (afterDelete ^. #lastReaction)+      map (^. #witness) (missingTarget ^. #dispatchedCommands) `shouldBe` [PMInspection.NotObserved]+      let another = recordedFromEventId (EventId sampleUuid2) (CounterAdded 9)+      Right invalid <- runner (runProcessManagerOnce (options & #metadata ?~ Aeson.String "invalid") counterProcessManager another (CounterAdded 9))+      invalid `shouldSatisfy` (\case Left (EncodeFailed (NonObjectCallerMetadata _)) -> True; _ -> False)+      Right (Right _) <- runner (runDomainProcessManagerOnce options domainProcessManager source (DomainDispatchInput "capture" [CoordinatorAccept 9]))+      Right domainEvents <- runner (Store.readStreamForward (StreamName "domain-pm:capture") (StreamVersion 0) 1)+      case Vector.head domainEvents ^. #metadata of+        Just (Aeson.Object fields) -> do+          Just domainFrame <- pure (KeyMap.lookup "keiro.pm" fields)+          Just provenance <- pure (PMInspection.decodeReactionProvenance domainFrame)+          (provenance ^. #dispatched) `shouldBe` [(0, StreamName "domain-pm-target:capture:0")]+        _ -> expectationFailure "expected domain manager provenance"+     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 <-@@ -5229,6 +5533,7 @@         _runner $           Store.readStreamForward (StreamName "counter-target-order-1") (StreamVersion 0) 10       Vector.length managerEvents `shouldBe` 1+      (Vector.head managerEvents ^. #metadata) `shouldBe` Just (Aeson.object ["schemaVersion" Aeson..= (1 :: Int)])       Vector.length targetEvents `shouldBe` 1       timer <-         _runner $@@ -6625,6 +6930,47 @@         noRecovery `shouldBe` Right 0    describe "Keiro.Timer" $ around (withFreshStore fixture) $ do+    it "pages pending timers in due order without mutating rows" $ \storeHandle -> do+      let requests =+            [ counterTimerRequest+                & #timerId+                .~ TimerId (UUID.fromWords 0 0 0 n)+                & #processManagerName+                .~ manager+                & #correlationId+                .~ correlation+            | (n, manager, correlation) <- [(1, "a", "x"), (2, "b", "x"), (3, "a", "y"), (4, "b", "y")]+            ]+          readPage timerFilter size after = Store.runStoreIO storeHandle (Timer.findPendingTimers timerFilter (Timer.PendingTimerPageRequest size after))+          ids page = map (^. #timer . #timerId) (page ^. #timers)+          snapshot = Store.runStoreIO storeHandle (Store.runTransaction (Tx.statement () timerReadSnapshotStmt))+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ traverse_ scheduleTimerTx requests+      Right before <- snapshot+      Right (Right first) <- readPage Timer.anyPendingTimer 2 Nothing+      ids first `shouldBe` map (^. #timerId) (take 2 requests)+      Right (Right second) <- readPage Timer.anyPendingTimer 2 (first ^. #next)+      ids second `shouldBe` map (^. #timerId) (drop 2 requests)+      second ^. #next `shouldBe` Nothing+      forM_ [(Timer.PendingTimerFilter (Just "a") Nothing, [0, 2]), (Timer.PendingTimerFilter Nothing (Just "y"), [2, 3]), (Timer.PendingTimerFilter (Just "b") (Just "x"), [1])] $ \(timerFilter, indices) -> do+        Right (Right page) <- readPage timerFilter 10 Nothing+        ids page `shouldBe` [requests !! index ^. #timerId | index <- indices]+      snapshot `shouldReturn` Right before+      Right (Left foreignFilter) <- readPage (Timer.PendingTimerFilter (Just "a") Nothing) 2 (first ^. #next)+      foreignFilter `shouldSatisfy` (\case Timer.InvalidPendingTimerCursor _ -> True; _ -> False)+      Right (Just claimed) <- Store.runStoreIO storeHandle (claimDueTimer dueTimerTime)+      Right True <- Store.runStoreIO storeHandle (markTimerFired (claimed ^. #timerId) (EventId sampleUuid2))+      Right True <- Store.runStoreIO storeHandle (deadLetterTimer (requests !! 1 ^. #timerId) "excluded")+      Right (Right remaining) <- readPage Timer.anyPendingTimer 10 Nothing+      ids remaining `shouldBe` map (^. #timerId) (drop 2 requests)++    it "rejects invalid pending timer requests before database work" $ \_ -> do+      forM_ [0, -1, 501] $ \size ->+        Timer.validatePendingTimerRequest Timer.anyPendingTimer (Timer.PendingTimerPageRequest size Nothing)+          `shouldBe` Left (Timer.InvalidPendingTimerPageSize size)+      forM_ [Cursor.InspectionCursor "not-a-cursor", Cursor.encodeCursor "wf-key" ("key" :: Text)] $ \cursor ->+        Timer.validatePendingTimerRequest Timer.anyPendingTimer (Timer.PendingTimerPageRequest 1 (Just cursor))+          `shouldSatisfy` (\case Left (Timer.InvalidPendingTimerCursor _) -> True; _ -> False)+     it "guards dead resume ownership and retains attempts when parked" $ \storeHandle -> do       let tid = counterTimerRequest ^. #timerId           request = DeadTimerClaimRequest tid (counterTimerRequest ^. #processManagerName) "deferred" 2 60@@ -11857,6 +12203,28 @@         t0 = UTCTime (ModifiedJulianDay 60000) (secondsToDiffTime 0)         tExpired = addUTCTime 60 t0 -- past A's 30 s lease         shardOpts = defaultShardedWorkerOptions (Category (CategoryName "orders")) 4+    it "observes ownership failover and lease ages without changing workers" $ \store -> do+      now <- getCurrentTime+      Right _ <- Store.runStoreIO store $ Store.runTransaction $ do+        ensureShardRows subName 4+        ensureShardRows (SubscriptionName "other-shard") 2+        claimShardsTx subName wA 4 now ttl+      Right first <- Store.runStoreIO store (shardOwnershipView subName)+      map (^. #owner) (first ^. #buckets) `shouldBe` replicate 4 (Just wA)+      map (leaseStateAt (first ^. #observedAt)) (first ^. #buckets) `shouldBe` replicate 4 LeaseLive+      map (leaseAgeAt (first ^. #observedAt)) (first ^. #buckets) `shouldBe` replicate 4 (Just (diffUTCTime (first ^. #observedAt) now))+      Right _ <- Store.runStoreIO store $ Store.runTransaction $ claimShardsTx subName wB 4 (addUTCTime 60 now) ttl+      Right second <- Store.runStoreIO store (shardOwnershipView subName)+      map (^. #owner) (second ^. #buckets) `shouldBe` replicate 4 (Just wB)+      Right listing <- Store.runStoreIO store listShardedSubscriptions+      let summaries = listing ^. #subscriptions+      map (^. #bucketsTotal) summaries `shouldBe` [4, 2]+      map (^. #bucketsLive) summaries `shouldBe` [4, 0]+      map (^. #liveOwners) summaries `shouldBe` [1, 0]+      Right () <- Store.runStoreIO store $ Store.runTransaction $ releaseShardsTx subName wB [0]+      Right released <- Store.runStoreIO store (shardOwnershipView subName)+      map (leaseAgeAt (released ^. #observedAt)) (take 1 (released ^. #buckets)) `shouldBe` [Nothing]+     it "validates sharded worker options before startup" $ \_store -> do       shouldBeRight_ (mkShardedWorkerOptions shardOpts)       mkShardedWorkerOptions (shardOpts & #shardCount .~ (fromIntegral (maxBound :: Int32) + 1))@@ -18492,7 +18860,8 @@       Ingested         { envelope = routerTestEnvelope message,           ack = AckHandle (\decision -> liftIO (modifyIORef' decisionsRef (<> [decision]))),-          lease = Nothing+          lease = Nothing,+          deliveryStatus = pure DeliveryCurrent         }  routerTestEnvelope :: msg -> Envelope msg
+ test/WorkflowInspectionSpec.hs view
@@ -0,0 +1,259 @@+{-# LANGUAGE OverloadedRecordDot #-}++module WorkflowInspectionSpec (spec) where++import Control.Monad (forM, forM_)+import Data.Aeson (Value (..))+import Data.List (nub, sort)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding (encodeUtf8)+import Data.Time (UTCTime)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Hasql.Pool qualified as Pool+import Keiro.Inspection.Cursor+import Keiro.Test.Postgres+import Keiro.Workflow (appendJournalEntry, continueAsNew, runWorkflow)+import Keiro.Workflow.Awakeable.Schema qualified as Awakeable+import Keiro.Workflow.Child.Schema qualified as Child+import Keiro.Workflow.Inspection+import Keiro.Workflow.Instance qualified as Instance+import Keiro.Workflow.Schema qualified as Schema+import Keiro.Workflow.Types+import Kiroku.Store.Append (appendToStream)+import Kiroku.Store.Connection (KirokuStore (..))+import Kiroku.Store.Effect (Store, runStoreIO)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction)+import Kiroku.Store.Types (EventData (..), EventType (..), ExpectedVersion (..))+import Test.Hspec+import "hasql-transaction" Hasql.Transaction qualified as Tx++spec :: Fixture -> Spec+spec fixture = describe "Keiro.Workflow.Inspection" $ do+  describe "opaque cursors" $ do+    it "round-trips Unicode and exact timestamps, rejects foreign kinds and oversized tokens" $ do+      let payload = ("承認" :: Text, "workflow/1" :: Text, read "2026-10-20 01:02:03.123456 UTC" :: UTCTime)+          token = encodeCursor "wf-key2" payload+      decodeCursor "wf-key2" token `shouldBe` Right payload+      encodeCursorBounded "wf-key2" payload `shouldBe` Just token+      encodeCursorBounded "wf-key2" (Text.replicate 100000 "界") `shouldBe` Nothing+      (decodeCursor "wf-created" token :: Either CursorError Value) `shouldBe` Left (CursorKindMismatch "wf-created" "wf-key2")+      forM_ ["not-a-cursor", "", Text.replicate 4097 "a", "===="] $ \bad ->+        (decodeCursor "wf-key2" (InspectionCursor bad) :: Either CursorError Value) `shouldSatisfy` (\case Left (MalformedCursor _) -> True; _ -> False)+  around (withFreshStore fixture) $ do+    it "rechecks every header field against its exact image, including old and far-future dates" $ \store -> do+      let fields =+            [ ("workflow_id", "'two'"),+              ("workflow_name", "'other'"),+              ("generation", "1"),+              ("status", "'failed'"),+              ("attempts", "0"),+              ("last_error", "'revised'"),+              ("next_attempt_at", "NULL"),+              ("wake_after", "NULL"),+              ("leased_by", "NULL"),+              ("lease_expires_at", "NULL"),+              ("created_at", "'0002-01-01 00:00:00 BC'"),+              ("updated_at", "'294276-01-01 00:00:00.123456+00'"),+              ("completed_at", "NULL"),+              ("created_at", "'infinity'"),+              ("updated_at", "'-infinity'"),+              ("next_attempt_at", "'infinity'"),+              ("wake_after", "'-infinity'"),+              ("lease_expires_at", "'infinity'"),+              ("completed_at", "'-infinity'")+            ]+          readHeader known name wid =+            Pool.use store.pool (Instance.lookupInstanceConditionalSession 4096 known (WorkflowName name) (WorkflowId wid)) >>= either (fail . show) pure+      forM_ fields $ \(field, value) -> do+        sql store "DELETE FROM keiro.keiro_workflows; INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status,attempts,last_error,next_attempt_at,wake_after,leased_by,lease_expires_at,created_at,updated_at,completed_at) VALUES ('one','header','running',2,'問題','1910-01-01 01:02:03.123456+00','2050-01-01+00','worker','2070-01-01+00','0001-01-01 00:00:00 BC','1970-01-01 00:00:00.123456+00','2026-01-01+00')"+        initial <- readHeader Nothing "header" "one"+        (key, original) <- case initial of+          Just (Instance.HeaderChanged key row) -> pure (key, row)+          other -> fail (show other)+        shouldBe (Just original) =<< expectStore store (Instance.lookupInstance (WorkflowName "header") (WorkflowId "one"))+        shouldReturn (readHeader (Just key) "header" "one") (Just Instance.HeaderUnchanged)+        sql store ("UPDATE keiro.keiro_workflows SET " <> field <> "=" <> value)+        let name = if field == "workflow_name" then "other" else "header"+            wid = if field == "workflow_id" then "two" else "one"+        changed <- readHeader (Just key) name wid+        case changed of+          Just (Instance.HeaderChanged next row) -> do+            shouldBe (Just row) =<< expectStore store (Instance.lookupInstance (WorkflowName name) (WorkflowId wid))+            shouldReturn (readHeader (Just next) name wid) (Just Instance.HeaderUnchanged)+          other -> fail ("did not observe changed " <> Text.unpack field <> ": " <> show other)+        shouldReturn (readHeader (Just key) "absent" "one") Nothing+        sql store "DELETE FROM keiro.keiro_workflows"+        shouldReturn (readHeader (Just key) name wid) Nothing+    it "rejects header images before returning oversized text or escaped JSON" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status,last_error) VALUES ('one','header','running',repeat('x',5000))"+      let readHeader = Pool.use store.pool (Instance.lookupInstanceConditionalSession 512 Nothing (WorkflowName "header") (WorkflowId "one"))+      shouldReturn readHeader (Right (Just Instance.HeaderImageTooLarge))+      sql store "UPDATE keiro.keiro_workflows SET last_error=repeat(chr(1),100)"+      shouldReturn readHeader (Right (Just Instance.HeaderImageTooLarge))+    it "reads fresh, owner-scoped headers through a reused session action" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) VALUES ('one','header','running')"+      let readHeader = Pool.use store.pool (Instance.lookupInstanceSession (WorkflowName "header") (WorkflowId "one")) >>= either (fail . show) pure+      initial <- readHeader+      shouldBe initial =<< expectStore store (Instance.lookupInstance (WorkflowName "header") (WorkflowId "one"))+      sql store "UPDATE keiro.keiro_workflows SET generation=1,status='failed',last_error='new' WHERE workflow_id='one' AND workflow_name='header'"+      refreshed <- readHeader+      shouldBe (fmap (\row -> (row.generation, row.status, row.lastError)) refreshed) (Just (1, Instance.WfFailed, Just "new"))+      shouldReturn (Pool.use store.pool (Instance.lookupInstanceSession (WorkflowName "other") (WorkflowId "one"))) (Right Nothing)+    it "rejects an outgoing cursor that clients could not resume within the cursor limit" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) VALUES ('a',repeat('界',2000),'running'),('b',repeat('界',2000),'running')"+      result <- expectStore store (listWorkflowInstancesPage defaultWorkflowListQuery {pageSize = 1})+      result `shouldBe` Left CursorResultTooLarge+    it "preserves large ordinary keys and includes them in both list orderings" $ \store -> do+      sql store "WITH bytes AS (SELECT string_agg(md5(n::text),'') value FROM generate_series(1,120) n) INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) SELECT left(value,2680),'a','running' FROM bytes UNION ALL SELECT 'b',left(value,2680),'completed' FROM bytes"+      original <- expectStore store (Instance.listWorkflowInstances Instance.defaultWorkflowInstanceFilter)+      length original `shouldBe` 2+      forM_ [ByKey, ByCreatedDesc] $ \ordering -> do+        pages <- drain store defaultWorkflowListQuery {pageSize = 1, ordering = ordering}+        sort (map (.workflowId) (concatMap (.items) pages)) `shouldBe` sort (map (.workflowId) original)+        map (length . (.items)) pages `shouldBe` [1, 1]+      running <- inspect store defaultWorkflowListQuery {classes = Just (ClassRunning :| [])}+      map statusClassOf running.items `shouldBe` [ClassRunning]+    it "pages wide keys without an obsolete exceptional-population limit" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) SELECT 'wide-'||lpad(n::text,3,'0'),repeat('x',2700),'running' FROM generate_series(1,65) n; INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) VALUES ('normal','normal','running')"+      forM_ [ByKey, ByCreatedDesc] $ \ordering -> do+        pages <- drain store defaultWorkflowListQuery {pageSize = 10, ordering}+        length (concatMap (.items) pages) `shouldBe` 66+      absent <- inspect store defaultWorkflowListQuery {workflowName = Just "absent"}+      absent.items `shouldBe` []+      normal <- inspect store defaultWorkflowListQuery {workflowName = Just "normal"}+      map (.workflowId) normal.items `shouldBe` ["normal"]+      sql store "DELETE FROM keiro.keiro_workflows; INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) SELECT 'bytes-'||n,repeat('x',131072),'running' FROM generate_series(1,2) n"+      expectStore store (listWorkflowInstancesPage defaultWorkflowListQuery {pageSize = 1}) `shouldReturn` Left CursorResultTooLarge+    it "orders by id before name and refuses the old key cursor kind" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) VALUES ('a','z','running'),('z','a','running')"+      pages <- drain store defaultWorkflowListQuery {pageSize = 1}+      map (\row -> (row.workflowId, row.workflowName)) (concatMap (.items) pages) `shouldBe` [("a", "z"), ("z", "a")]+      legacy <- expectStore store (Instance.listWorkflowInstances Instance.defaultWorkflowInstanceFilter)+      map (.workflowId) legacy `shouldBe` ["z", "a"]+      let old = encodeCursor "wf-key" ("a" :: Text, "z" :: Text)+      validateWorkflowListQuery defaultWorkflowListQuery {cursor = Just old} `shouldBe` Left (InvalidCursor (CursorKindMismatch "wf-key2" "wf-key"))+      expectStore store (listWorkflowInstancesPage defaultWorkflowListQuery {cursor = Just old}) `shouldReturn` Left (InvalidCursor (CursorKindMismatch "wf-key2" "wf-key"))+    it "classifies every stored row identically in Haskell and SQL without modifying rows" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id, workflow_name, status, attempts, wake_after) VALUES ('running','test','running',0,NULL),('retrying','test','running',2,NULL),('sleeping','test','suspended',0,now()+interval '1 hour'),('awaiting','test','suspended',0,NULL),('completed','test','completed',0,NULL),('cancelled','test','cancelled',0,NULL),('failed','test','failed',0,NULL)"+      sql store "UPDATE keiro.keiro_workflows SET status='running' WHERE workflow_id='failed'"+      _ <- expectStore store (Instance.claimInstance "stuck-worker" 30 (WorkflowName "test") (WorkflowId "failed"))+      expectStore store (runTransaction (Instance.upsertInstanceTx "failed" "test" 0 Instance.WfFailed (Just "exhausted")))+      original <- expectStore store (Instance.listWorkflowInstances Instance.defaultWorkflowInstanceFilter)+      pages <- forM [minBound .. maxBound] $ \status -> do+        page <- inspect store defaultWorkflowListQuery {classes = Just (status :| [])}+        map statusClassOf page.items `shouldBe` [status]+        if status == ClassFailed then map (.leasedBy) page.items `shouldBe` [Just "stuck-worker"] else pure ()+        pure page.items+      sort (map (.workflowId) (concat pages)) `shouldBe` sort (map (.workflowId) original)+      afterRows <- expectStore store (Instance.listWorkflowInstances Instance.defaultWorkflowInstanceFilter)+      afterRows `shouldBe` original+    it "pages key order completely, and omits continuation on a full final page" $ \store -> do+      seed store 23+      sql store "UPDATE keiro.keiro_workflows SET status=CASE WHEN right(workflow_id,1) IN ('1','4','7') THEN 'failed' WHEN right(workflow_id,1) IN ('2','5','8') THEN 'completed' ELSE status END"+      pages <- drain store defaultWorkflowListQuery {pageSize = 5}+      map (length . (.items)) pages `shouldBe` [5, 5, 5, 5, 3]+      let keys = map (\row -> (row.workflowId, row.workflowName)) (concatMap (.items) pages)+      length (nub keys) `shouldBe` 23+      keys `shouldBe` sort keys+      filtered <- drain store defaultWorkflowListQuery {pageSize = 3, classes = Just (ClassFailed :| [ClassCompleted, ClassFailed])}+      concatMap (.items) filtered `shouldBe` filter (\row -> statusClassOf row `elem` [ClassFailed, ClassCompleted]) (concatMap (.items) pages)+      sql store "DELETE FROM keiro.keiro_workflows WHERE workflow_id > 'w-020'"+      full <- drain store defaultWorkflowListQuery {pageSize = 5}+      map (length . (.items)) full `shouldBe` [5, 5, 5, 5]+      map (.nextCursor) full `shouldSatisfy` (\tokens -> case reverse tokens of Nothing : _ -> True; _ -> False)+    it "pages newest creation first including tied timestamps and observes an empty page clock" $ \store -> do+      seed store 12+      pages <- drain store defaultWorkflowListQuery {pageSize = 5, ordering = ByCreatedDesc}+      map (length . (.items)) pages `shouldBe` [5, 5, 2]+      let keys = map (\row -> (row.createdAt, row.workflowName, row.workflowId)) (concatMap (.items) pages)+      keys `shouldBe` reverse (sort keys)+      empty <- inspect store defaultWorkflowListQuery {workflowName = Just "absent"}+      empty.items `shouldBe` []+      empty.nextCursor `shouldBe` Nothing+      empty.observedAt `shouldSatisfy` (> read "2026-01-01 00:00:00 UTC")+    it "rejects sizes and malformed or cross-order cursors before reading" $ \store -> do+      forM_ [0, 501, maxBound] $ \size ->+        expectStore store (listWorkflowInstancesPage defaultWorkflowListQuery {pageSize = size}) `shouldReturn` Left (InvalidPageSize size)+      malformed <- expectStore store (listWorkflowInstancesPage defaultWorkflowListQuery {cursor = Just (InspectionCursor "not-a-cursor")})+      malformed `shouldSatisfy` (\case Left (InvalidCursor (MalformedCursor _)) -> True; _ -> False)+      seed store 2+      first <- inspect store defaultWorkflowListQuery {pageSize = 1}+      crossOrder <- expectStore store (listWorkflowInstancesPage defaultWorkflowListQuery {ordering = ByCreatedDesc, cursor = first.nextCursor})+      crossOrder `shouldSatisfy` (\case Left (InvalidCursor (CursorKindMismatch "wf-created" "wf-key2")) -> True; _ -> False)+    it "applies half-open creation and update windows" $ \store -> do+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status,created_at,updated_at) SELECT 'window-'||n,'window','running','2026-10-01 00:00:00+00'::timestamptz+n*interval '1 hour','2026-10-01 00:00:00+00'::timestamptz+n*interval '1 hour' FROM generate_series(0,2) n"+      let boundary = read "2026-10-01 01:00:00 UTC" :: UTCTime+      forM_ [defaultWorkflowListQuery {createdSince = Just boundary}, defaultWorkflowListQuery {updatedSince = Just boundary}] $ \query -> do+        page <- inspect store query+        length page.items `shouldBe` 2+      forM_ [defaultWorkflowListQuery {createdBefore = Just boundary}, defaultWorkflowListQuery {updatedBefore = Just boundary}] $ \query -> do+        page <- inspect store query+        length page.items `shouldBe` 1+    it "observes then-current inserts strictly beyond the cursor" $ \store -> do+      seed store 3+      first <- inspect store defaultWorkflowListQuery {pageSize = 1}+      sql store "INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status) VALUES ('a','a','running'),('z','z','running')"+      second <- inspect store defaultWorkflowListQuery {cursor = first.nextCursor}+      map (.workflowId) second.items `shouldSatisfy` elem "z"+      map (.workflowId) second.items `shouldSatisfy` not . elem "a"+    it "independently pages journal and steps and rejects cross-owner and rotated generation cursors" $ \store -> do+      clock <- expectStore store Schema.inspectionObservedAt+      forM_ ["a", "b", "c"] $ \step -> expectStore store (appendJournalEntry (WorkflowName "test") (WorkflowId "owner") (StepRecorded step Null clock))+      first <- expectStore store (readWorkflowJournalPage (WorkflowName "test") (WorkflowId "owner") (DetailPageQuery Nothing 2 Nothing)) >>= right+      length first.items `shouldBe` 2+      second <- expectStore store (readWorkflowJournalPage (WorkflowName "test") (WorkflowId "owner") (DetailPageQuery Nothing 2 first.nextCursor)) >>= right+      length second.items `shouldBe` 1+      second.nextCursor `shouldBe` Nothing+      steps <- expectStore store (loadStepIndexPage (WorkflowName "test") (WorkflowId "owner") (DetailPageQuery Nothing 2 Nothing)) >>= right+      length steps.items `shouldBe` 2+      other <- expectStore store (loadStepIndexPage (WorkflowName "test") (WorkflowId "different") (DetailPageQuery Nothing 2 steps.nextCursor))+      other `shouldSatisfy` (\case Left (InvalidCursor _) -> True; _ -> False)+      _ <- expectStore store (runWorkflow (WorkflowName "test") (WorkflowId "owner") (continueAsNew () >> pure ()))+      rotated <- expectStore store (loadStepIndexPage (WorkflowName "test") (WorkflowId "owner") (DetailPageQuery Nothing 2 steps.nextCursor))+      rotated `shouldBe` Left (CursorGenerationMismatch 1 0)+    it "pages children and directly allocated awakeables through their owning tables" $ \store -> do+      expectStore store $ runTransaction $ do+        Child.registerChildTx "child-a" "child" "owner" "test" "await-a"+        Child.registerChildTx "child-b" "child" "owner" "test" "await-b"+        Awakeable.registerAwakeableTx UUID.nil "test" "owner"+        Awakeable.registerAwakeableTx (maybe (error "UUID fixture") (\x -> x) (UUID.fromString "00000000-0000-0000-0000-000000000001")) "test" "owner"+      first <- expectStore store (listWorkflowChildrenPage (WorkflowName "test") (WorkflowId "owner") (OwnerPageQuery 1 Nothing)) >>= right+      second <- expectStore store (listWorkflowChildrenPage (WorkflowName "test") (WorkflowId "owner") (OwnerPageQuery 1 first.nextCursor)) >>= right+      map (.childId) (first.items <> second.items) `shouldBe` ["child-a", "child-b"]+      second.nextCursor `shouldBe` Nothing+      awake <- expectStore store (listWorkflowAwakeablesPage (WorkflowName "test") (WorkflowId "owner") (OwnerPageQuery 1 Nothing)) >>= right+      following <- expectStore store (listWorkflowAwakeablesPage (WorkflowName "test") (WorkflowId "owner") (OwnerPageQuery 1 awake.nextCursor)) >>= right+      length (awake.items <> following.items) `shouldBe` 2+      following.nextCursor `shouldBe` Nothing+      foreignPage <- expectStore store (listWorkflowAwakeablesPage (WorkflowName "test") (WorkflowId "different") (OwnerPageQuery 1 awake.nextCursor))+      foreignPage `shouldSatisfy` (\case Left (InvalidCursor _) -> True; _ -> False)+    it "raises the runtime's decode error instead of returning a partial corrupt journal" $ \store -> do+      _ <- expectStore store (appendToStream (workflowGenerationStreamName (WorkflowName "corrupt") (WorkflowId "owner") 0) AnyVersion [EventData Nothing (EventType "StepRecorded") (String "invalid") Nothing Nothing Nothing])+      expectStore store (readWorkflowJournalPage (WorkflowName "corrupt") (WorkflowId "owner") (DetailPageQuery Nothing 2 Nothing))+        `shouldThrow` (\case WorkflowJournalDecodeError _ -> True; _ -> False)++seed :: KirokuStore -> Int -> IO ()+seed store count = sql store ("INSERT INTO keiro.keiro_workflows(workflow_id,workflow_name,status,created_at,updated_at) SELECT 'w-'||lpad(n::text,3,'0'),'name-'||(n%3),'running','2026-10-01 00:00:00+00'::timestamptz+(n/2)*interval '1 minute','2026-10-01 00:00:00+00'::timestamptz FROM generate_series(1," <> Text.pack (show count) <> ") n")++sql :: KirokuStore -> Text -> IO ()+sql store text = expectStore store (runTransaction (Tx.sql (encodeUtf8 text)))++expectStore :: KirokuStore -> Eff '[Store, Error StoreError, IOE] a -> IO a+expectStore store action = runStoreIO store action >>= either (fail . show) pure++right :: (Show err) => Either err a -> IO a+right = either (fail . show) pure++inspect :: KirokuStore -> WorkflowListQuery -> IO WorkflowListPage+inspect store query = expectStore store (listWorkflowInstancesPage query) >>= right++drain :: KirokuStore -> WorkflowListQuery -> IO [WorkflowListPage]+drain store query = do+  page <- inspect store query+  rest <- maybe (pure []) (\token -> drain store query {cursor = Just token}) page.nextCursor+  pure (page : rest)