packages feed

kioku-core 0.3.0.0 → 0.4.0.0

raw patch · 43 files changed

+8098/−1543 lines, 43 filesdep ~filepathdep ~keirodep ~keiro-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: filepath, keiro, keiro-core, kioku-api, kioku-core, kioku-migrations, kiroku-store, shibuya-core, shibuya-kiroku-adapter, unordered-containers

API changes (from Hackage documentation)

- Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryArchivedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ()))
- Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryConfidenceUpdatedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Types.Confidence, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))
- Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryMergedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))
- Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryRecordedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Kioku.Id.SessionId, (Kioku.Api.Scope.MemoryScope, (Kioku.Api.Types.MemoryType, (Data.Text.Internal.Text, (GHC.Types.Int, (Kioku.Api.Types.Confidence, (Data.Set.Internal.Set Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))))))
- Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemorySupersededTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))
- Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryTagsUpdatedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Data.Set.Internal.Set Data.Text.Internal.Text, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))
- Kioku.Memory.ReadModel: newtype MemoriesByNamespaceQuery
- Kioku.Memory.ReadModel: newtype MemoriesBySessionQuery
- Kioku.Memory.ReadModel: newtype MemoryByIdQuery
- Kioku.Memory.ReadModel: newtype MemorySupersessionChainQuery
- Kioku.Recall: data VectorCandidateQuery
- Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.RecallCandidateQuery
- Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.RecallStrategy
- Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.VectorCandidateQuery
- Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.RecallCandidateQuery
- Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.RecallStrategy
- Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.VectorCandidateQuery
- Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.RecallCandidateQuery
- Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.RecallStrategy
- Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.VectorCandidateQuery
- Kioku.Recall: memoryRecordColumns :: Text
- Kioku.Recall: selectVectorCandidatesExactStmt :: Statement VectorCandidateQuery [MemoryRecord]
- Kioku.Recall: selectVectorCandidatesStmt :: Statement VectorCandidateQuery [MemoryRecord]
- Kioku.Recall: vectorCandidateQuery :: RecallRequest -> Vector Double -> VectorCandidateQuery
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.InteractiveSessionRecordedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Data.Text.Internal.Text, (Data.Text.Internal.Text, (Kioku.Api.Scope.MemoryScope, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (Data.Time.Clock.Internal.UTCTime.UTCTime, ()))))))
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionAwaitingTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Time.Clock.Internal.UTCTime.UTCTime, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionCompletedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Data.Time.Clock.Internal.UTCTime.UTCTime, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, ()))))
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionFailedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Data.Time.Clock.Internal.UTCTime.UTCTime, (Data.Text.Internal.Text, ())))
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionResumedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Types.Bool, (Data.Text.Internal.Text, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionStartedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Data.Text.Internal.Text, (Data.Text.Internal.Text, (Kioku.Api.Scope.MemoryScope, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Kioku.Id.SessionId, (GHC.Internal.Maybe.Maybe Kioku.Id.SessionId, (GHC.Types.Int, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))))
- Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.TurnRecordedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Data.Text.Internal.Text, (GHC.Types.Int, (Data.Text.Internal.Text, (Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe GHC.Types.Int, (GHC.Internal.Maybe.Maybe GHC.Types.Int, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))))
- Kioku.Session.ReadModel: newtype SessionByIdQuery
- Kioku.Session.ReadModel: newtype SessionChainQuery
- Kioku.Session.ReadModel: newtype SessionDelegationChildrenQuery
- Kioku.Session.ReadModel: newtype TurnsBySessionQuery
+ Kioku.Distill.L1: L1NotPermitted :: !MemoryPermission -> L1Error
+ Kioku.Distill.L1: L1RecallRefused :: !RecallError -> L1Error
+ Kioku.Distill.L2: SceneTimerPayload :: !MemorySpaceId -> !MemoryScope -> SceneTimerPayload
+ Kioku.Distill.L2: [memorySpaceId] :: SceneTimerPayload -> !MemorySpaceId
+ Kioku.Distill.L2: [scope] :: SceneTimerPayload -> !MemoryScope
+ Kioku.Distill.L2: data SceneTimerPayload
+ Kioku.Distill.L3: PersonaTimerPayload :: !MemorySpaceId -> !MemoryScope -> PersonaTimerPayload
+ Kioku.Distill.L3: [memorySpaceId] :: PersonaTimerPayload -> !MemorySpaceId
+ Kioku.Distill.L3: [scope] :: PersonaTimerPayload -> !MemoryScope
+ Kioku.Distill.L3: data PersonaTimerPayload
+ Kioku.Distill.L3: partitionedCorrelationId :: MemorySpaceId -> MemoryScope -> Text
+ Kioku.Distill.Timer: L1TimerPayload :: !Text -> !Maybe Int -> !MemorySpaceId -> L1TimerPayload
+ Kioku.Distill.Timer: [kind] :: L1TimerPayload -> !Text
+ Kioku.Distill.Timer: [memorySpaceId] :: L1TimerPayload -> !MemorySpaceId
+ Kioku.Distill.Timer: [turnCount] :: L1TimerPayload -> !Maybe Int
+ Kioku.Distill.Timer: data L1TimerPayload
+ Kioku.Distill.Timer: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Distill.Timer.L1TimerPayload
+ Kioku.Distill.Timer: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Distill.Timer.L1TimerPayload
+ Kioku.Distill.Timer: instance GHC.Classes.Eq Kioku.Distill.Timer.L1TimerPayload
+ Kioku.Distill.Timer: instance GHC.Internal.Generics.Generic Kioku.Distill.Timer.L1TimerPayload
+ Kioku.Distill.Timer: instance GHC.Internal.Show.Show Kioku.Distill.Timer.L1TimerPayload
+ Kioku.Memory: MemoryActorMismatch :: !RecordedPrincipal -> !RecordedPrincipal -> MemoryWriteError
+ Kioku.Memory: MemoryNotPermitted :: !MemoryPermission -> MemoryWriteError
+ Kioku.Memory: MemorySpaceMismatch :: !MemorySpaceId -> !MemorySpaceId -> MemoryWriteError
+ Kioku.Memory: archiveWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> ArchiveMemoryData -> Eff es (Either MemoryWriteError MemoryId)
+ Kioku.Memory: mergeWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> MemoryId -> MemoryId -> Eff es (Either MemoryWriteError MemoryId)
+ Kioku.Memory: recordWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> RecordMemoryData -> Eff es (Either MemoryWriteError MemoryId)
+ Kioku.Memory: supersedeWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> SupersedeMemoryData -> Eff es (Either MemoryWriteError MemoryId)
+ Kioku.Memory: updateConfidenceWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> UpdateMemoryConfidenceData -> Eff es (Either MemoryWriteError MemoryId)
+ Kioku.Memory: updateTagsWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> UpdateMemoryTagsData -> Eff es (Either MemoryWriteError MemoryId)
+ Kioku.Memory.Domain: [actorPrincipal] :: MemoryMergedData -> !RecordedPrincipal
+ Kioku.Memory.Domain: [memorySpaceId] :: MemoryMergedData -> !MemorySpaceId
+ Kioku.Memory.Domain: [ownerPrincipal] :: MemoryRecordedData -> !Maybe PrincipalRef
+ Kioku.Memory.Domain: commandMemorySpaceId :: MemoryCommand -> MemorySpaceId
+ Kioku.Memory.Domain: eventMemorySpaceId :: MemoryEvent -> MemorySpaceId
+ Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryArchivedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Data.Time.Clock.Internal.UTCTime.UTCTime, ()))))
+ Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryConfidenceUpdatedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Kioku.Api.Types.Confidence, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))
+ Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryMergedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))
+ Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryRecordedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (GHC.Internal.Maybe.Maybe Kioku.Api.Access.Internal.PrincipalRef, (Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Kioku.Id.SessionId, (Kioku.Api.Scope.MemoryScope, (Kioku.Api.Types.MemoryType, (Data.Text.Internal.Text, (GHC.Types.Int, (Kioku.Api.Types.Confidence, (Data.Set.Internal.Set Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ()))))))))))))))
+ Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemorySupersededTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Kioku.Id.MemoryId, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))
+ Kioku.Memory.Domain: instance Keiki.Builder.ToOutFields (Kioku.Memory.Domain.MemoryTagsUpdatedTermFields rs ci ifs) rs ci ifs (Kioku.Id.MemoryId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Data.Set.Internal.Set Data.Text.Internal.Text, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))
+ Kioku.Memory.Embedding.Worker: BackfillEverySpace :: EmbeddingBackfillScope
+ Kioku.Memory.Embedding.Worker: BackfillOneSpace :: !MemorySpaceId -> EmbeddingBackfillScope
+ Kioku.Memory.Embedding.Worker: EmbedSpaceMismatch :: !MemorySpaceId -> !MemorySpaceId -> EmbedOutcome
+ Kioku.Memory.Embedding.Worker: data EmbeddingBackfillScope
+ Kioku.Memory.Embedding.Worker: instance GHC.Classes.Eq Kioku.Memory.Embedding.Worker.EmbeddingBackfillScope
+ Kioku.Memory.Embedding.Worker: instance GHC.Internal.Generics.Generic Kioku.Memory.Embedding.Worker.EmbeddingBackfillScope
+ Kioku.Memory.Embedding.Worker: instance GHC.Internal.Show.Show Kioku.Memory.Embedding.Worker.EmbeddingBackfillScope
+ Kioku.Memory.ReadModel: [memorySpaceId] :: MemorySupersessionChainQuery -> !MemorySpaceId
+ Kioku.Memory.ReadModel: data MemoriesByNamespaceQuery
+ Kioku.Memory.ReadModel: data MemoriesBySessionQuery
+ Kioku.Memory.ReadModel: data MemoryByIdQuery
+ Kioku.Memory.ReadModel: data MemorySupersessionChainQuery
+ Kioku.Partition: memorySpaceColumn :: Row MemorySpaceId
+ Kioku.Partition: memorySpaceParam :: Params MemorySpaceId
+ Kioku.Partition: parsePartitionSpace :: Object -> Parser MemorySpaceId
+ Kioku.Partition: parseRecordedActor :: Object -> Parser RecordedPrincipal
+ Kioku.Partition: parseRecordedActorFromAgent :: Object -> Parser RecordedPrincipal
+ Kioku.Partition: parseRecordedOwner :: Object -> Parser (Maybe PrincipalRef)
+ Kioku.Recall: ExactScope :: !MemoryScope -> RecallTarget
+ Kioku.Recall: NamespaceWide :: !Namespace -> RecallTarget
+ Kioku.Recall: RecallQuery :: !RecallTarget -> !Text -> !RecallStrategy -> !RecallLimit -> RecallQuery
+ Kioku.Recall: RecallSpaceMismatch :: !MemorySpaceId -> !MemorySpaceId -> RecallError
+ Kioku.Recall: [memorySpaceId] :: RecallRequest -> !MemorySpaceId
+ Kioku.Recall: [target] :: RecallQuery -> !RecallTarget
+ Kioku.Recall: allRecallStrategies :: [RecallStrategy]
+ Kioku.Recall: data FtsCandidateSql
+ Kioku.Recall: data RecallError
+ Kioku.Recall: data RecallLimit
+ Kioku.Recall: data RecallQuery
+ Kioku.Recall: data RecallTarget
+ Kioku.Recall: data ResolvedRecall
+ Kioku.Recall: data VectorCandidateSql
+ Kioku.Recall: defaultRecallLimit :: RecallLimit
+ Kioku.Recall: explainFtsCandidates :: FtsCandidateSql -> Transaction [Text]
+ Kioku.Recall: explainVectorAnnCandidates :: VectorCandidateSql -> Transaction [Text]
+ Kioku.Recall: explainVectorExactCandidates :: VectorCandidateSql -> Transaction [Text]
+ Kioku.Recall: ftsCandidateSql :: ResolvedRecall -> FtsCandidateSql
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.BoundedCandidateParams
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.EntityCandidateParams
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.FtsCandidateSql
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.RecallError
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.ResolvedRecall
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.ScopeBound
+ Kioku.Recall: instance GHC.Classes.Eq Kioku.Recall.VectorCandidateSql
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.BoundedCandidateParams
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.EntityCandidateParams
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.FtsCandidateSql
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.RecallError
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.ResolvedRecall
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.ScopeBound
+ Kioku.Recall: instance GHC.Internal.Generics.Generic Kioku.Recall.VectorCandidateSql
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.BoundedCandidateParams
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.EntityCandidateParams
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.FtsCandidateSql
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.RecallError
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.ResolvedRecall
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.ScopeBound
+ Kioku.Recall: instance GHC.Internal.Show.Show Kioku.Recall.VectorCandidateSql
+ Kioku.Recall: legacyRecall :: forall (es :: [Effect]). (IOE :> es, Store :> es) => EmbeddingModel -> VectorCapability -> MemoryAccessContext -> RecallRequest -> Eff es (Either RecallError [RecallHit])
+ Kioku.Recall: legacyRecallTarget :: MemoryScope -> RecallTarget
+ Kioku.Recall: maxRecallLimit :: Int
+ Kioku.Recall: mkRecallLimit :: Int -> Either Text RecallLimit
+ Kioku.Recall: mkRecallQuery :: RecallTarget -> Text -> RecallStrategy -> Int -> Either Text RecallQuery
+ Kioku.Recall: parseRecallStrategy :: Text -> Either Text RecallStrategy
+ Kioku.Recall: recallLimitInt :: RecallLimit -> Int
+ Kioku.Recall: recallStrategyText :: RecallStrategy -> Text
+ Kioku.Recall: recallTargetExactScope :: RecallTarget -> Maybe MemoryScope
+ Kioku.Recall: recallTargetIsNamespaceWide :: RecallTarget -> Bool
+ Kioku.Recall: recallTargetNamespace :: RecallTarget -> Namespace
+ Kioku.Recall: resolveRecall :: MemorySpaceId -> RecallQuery -> ResolvedRecall
+ Kioku.Recall: runVectorAnnCandidates :: VectorCandidateSql -> Transaction [MemoryRecord]
+ Kioku.Recall: vectorCandidateSql :: ResolvedRecall -> Vector Double -> VectorCandidateSql
+ Kioku.Session: SessionActorMismatch :: !RecordedPrincipal -> !RecordedPrincipal -> SessionWriteError
+ Kioku.Session: SessionNotPermitted :: !MemoryPermission -> SessionWriteError
+ Kioku.Session: SessionSpaceMismatch :: !MemorySpaceId -> !MemorySpaceId -> SessionWriteError
+ Kioku.Session: [memorySpaceId] :: SessionRow -> !MemorySpaceId
+ Kioku.Session: awaitInputWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> AwaitInputData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: completeWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> CompleteSessionData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: failSessionWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> FailSessionData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: forceResumeWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> SessionId -> Text -> UTCTime -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: recordInteractiveWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> RecordInteractiveSessionData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: recordTurnWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> RecordTurnData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: resumeWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> ResumeSessionData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session: startWithContext :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> StartSessionData -> Eff es (Either SessionWriteError SessionId)
+ Kioku.Session.Domain: [actorPrincipal] :: TurnRecordedData -> !RecordedPrincipal
+ Kioku.Session.Domain: [memorySpaceId] :: TurnRecordedData -> !MemorySpaceId
+ Kioku.Session.Domain: [ownerPrincipal] :: InteractiveSessionRecordedData -> !Maybe PrincipalRef
+ Kioku.Session.Domain: commandMemorySpaceId :: SessionCommand -> MemorySpaceId
+ Kioku.Session.Domain: eventMemorySpaceId :: SessionEvent -> MemorySpaceId
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.InteractiveSessionRecordedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (GHC.Internal.Maybe.Maybe Kioku.Api.Access.Internal.PrincipalRef, (Data.Text.Internal.Text, (Data.Text.Internal.Text, (Kioku.Api.Scope.MemoryScope, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))))
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionAwaitingTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Time.Clock.Internal.UTCTime.UTCTime, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionCompletedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Data.Time.Clock.Internal.UTCTime.UTCTime, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, ()))))))
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionFailedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Data.Time.Clock.Internal.UTCTime.UTCTime, (Data.Text.Internal.Text, ())))))
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionResumedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Types.Bool, (Data.Text.Internal.Text, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.SessionStartedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (GHC.Internal.Maybe.Maybe Kioku.Api.Access.Internal.PrincipalRef, (Data.Text.Internal.Text, (Data.Text.Internal.Text, (Kioku.Api.Scope.MemoryScope, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Kioku.Id.SessionId, (GHC.Internal.Maybe.Maybe Kioku.Id.SessionId, (GHC.Types.Int, (Data.Time.Clock.Internal.UTCTime.UTCTime, ()))))))))))))
+ Kioku.Session.Domain: instance Keiki.Builder.ToOutFields (Kioku.Session.Domain.TurnRecordedTermFields rs ci ifs) rs ci ifs (Kioku.Id.SessionId, (Kioku.Api.Access.Internal.MemorySpaceId, (Kioku.Api.Access.Internal.RecordedPrincipal, (Data.Text.Internal.Text, (GHC.Types.Int, (Data.Text.Internal.Text, (Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe Data.Text.Internal.Text, (GHC.Internal.Maybe.Maybe GHC.Types.Int, (GHC.Internal.Maybe.Maybe GHC.Types.Int, (Data.Time.Clock.Internal.UTCTime.UTCTime, ())))))))))))
+ Kioku.Session.ReadModel: [correlationKey] :: AwaitingSessionsByCorrelationKeyQuery -> !Text
+ Kioku.Session.ReadModel: [limit] :: SessionsByNamespaceQuery -> !Int
+ Kioku.Session.ReadModel: [memorySpaceId] :: TurnsBySessionQuery -> !MemorySpaceId
+ Kioku.Session.ReadModel: [startedAfter] :: SessionsByStartedRangeQuery -> !UTCTime
+ Kioku.Session.ReadModel: [startedBefore] :: SessionsByStartedRangeQuery -> !UTCTime
+ Kioku.Session.ReadModel: data SessionByIdQuery
+ Kioku.Session.ReadModel: data SessionChainQuery
+ Kioku.Session.ReadModel: data SessionDelegationChildrenQuery
+ Kioku.Session.ReadModel: data TurnsBySessionQuery
+ Kioku.Workspace: ArtifactMove :: !FilePath -> !FilePath -> !MoveVerdict -> ArtifactMove
+ Kioku.Workspace: MoveAlreadyMigrated :: MoveVerdict
+ Kioku.Workspace: MoveCollision :: MoveVerdict
+ Kioku.Workspace: MoveReady :: MoveVerdict
+ Kioku.Workspace: [destination] :: ArtifactMove -> !FilePath
+ Kioku.Workspace: [source] :: ArtifactMove -> !FilePath
+ Kioku.Workspace: [verdict] :: ArtifactMove -> !MoveVerdict
+ Kioku.Workspace: applyArtifactMigration :: [ArtifactMove] -> IO ()
+ Kioku.Workspace: data ArtifactMove
+ Kioku.Workspace: data MoveVerdict
+ Kioku.Workspace: instance GHC.Classes.Eq Kioku.Workspace.ArtifactMove
+ Kioku.Workspace: instance GHC.Classes.Eq Kioku.Workspace.MoveVerdict
+ Kioku.Workspace: instance GHC.Internal.Generics.Generic Kioku.Workspace.ArtifactMove
+ Kioku.Workspace: instance GHC.Internal.Generics.Generic Kioku.Workspace.MoveVerdict
+ Kioku.Workspace: instance GHC.Internal.Show.Show Kioku.Workspace.ArtifactMove
+ Kioku.Workspace: instance GHC.Internal.Show.Show Kioku.Workspace.MoveVerdict
+ Kioku.Workspace: legacyPersonaArtifactDir :: FilePath -> FilePath
+ Kioku.Workspace: legacySceneArtifactDir :: FilePath -> FilePath
+ Kioku.Workspace: personaArtifactDir :: FilePath -> MemorySpaceId -> FilePath
+ Kioku.Workspace: planArtifactMigration :: FilePath -> MemorySpaceId -> IO [ArtifactMove]
+ Kioku.Workspace: sceneArtifactDir :: FilePath -> MemorySpaceId -> FilePath
+ Kioku.Workspace: spaceArtifactRoot :: FilePath -> MemorySpaceId -> FilePath
+ Kioku.Workspace: spaceDirectoryName :: MemorySpaceId -> Text
- Kioku.Distill.L1: FindMergeCandidates :: (MemoryScope -> Text -> Eff es (Either ReadModelError [MemoryRecord])) -> FindMergeCandidates (es :: [Effect])
+ Kioku.Distill.L1: FindMergeCandidates :: (MemoryAccessContext -> MemoryScope -> Text -> Eff es (Either L1Error [MemoryRecord])) -> FindMergeCandidates (es :: [Effect])
- Kioku.Distill.L1: [runFindMergeCandidates] :: FindMergeCandidates (es :: [Effect]) -> MemoryScope -> Text -> Eff es (Either ReadModelError [MemoryRecord])
+ Kioku.Distill.L1: [runFindMergeCandidates] :: FindMergeCandidates (es :: [Effect]) -> MemoryAccessContext -> MemoryScope -> Text -> Eff es (Either L1Error [MemoryRecord])
- Kioku.Distill.L1: distillSessionL1 :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => L1RunMode -> DistillRuntime -> FindMergeCandidates es -> SessionId -> Eff es (Either L1Error L1Outcome)
+ Kioku.Distill.L1: distillSessionL1 :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> L1RunMode -> DistillRuntime -> FindMergeCandidates es -> SessionId -> Eff es (Either L1Error L1Outcome)
- Kioku.Distill.L2: SceneRow :: !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Text -> !Text -> ![Text] -> !Text -> !UTCTime -> !UTCTime -> SceneRow
+ Kioku.Distill.L2: SceneRow :: !MemorySpaceId -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Text -> !Text -> ![Text] -> !Text -> !UTCTime -> !UTCTime -> SceneRow
- Kioku.Distill.L2: fireL2SceneTimer :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DistillRuntime -> TimerRow -> Eff es FireOutcome
+ Kioku.Distill.L2: fireL2SceneTimer :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryContextProvider (Eff es) -> DistillRuntime -> TimerRow -> Eff es FireOutcome
- Kioku.Distill.L2: getScenesByScope :: forall (es :: [Effect]). Store :> es => MemoryScope -> Eff es [SceneRow]
+ Kioku.Distill.L2: getScenesByScope :: forall (es :: [Effect]). Store :> es => MemorySpaceId -> MemoryScope -> Eff es [SceneRow]
- Kioku.Distill.L2: l2SceneTimerId :: MemoryScope -> Text -> TimerId
+ Kioku.Distill.L2: l2SceneTimerId :: MemorySpaceId -> MemoryScope -> Text -> TimerId
- Kioku.Distill.L2: regenerateScene :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DistillRuntime -> MemoryScope -> Eff es (Either L2Error (Maybe SceneRow))
+ Kioku.Distill.L2: regenerateScene :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DistillRuntime -> MemorySpaceId -> MemoryScope -> Eff es (Either L2Error (Maybe SceneRow))
- Kioku.Distill.L3: PersonaRow :: !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Int -> !Text -> !UTCTime -> !UTCTime -> PersonaRow
+ Kioku.Distill.L3: PersonaRow :: !MemorySpaceId -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Int -> !Text -> !UTCTime -> !UTCTime -> PersonaRow
- Kioku.Distill.L3: fireL3PersonaTimer :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DistillRuntime -> TimerRow -> Eff es FireOutcome
+ Kioku.Distill.L3: fireL3PersonaTimer :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryContextProvider (Eff es) -> DistillRuntime -> TimerRow -> Eff es FireOutcome
- Kioku.Distill.L3: getPersonaByScope :: forall (es :: [Effect]). Store :> es => MemoryScope -> Eff es (Maybe PersonaRow)
+ Kioku.Distill.L3: getPersonaByScope :: forall (es :: [Effect]). Store :> es => MemorySpaceId -> MemoryScope -> Eff es (Maybe PersonaRow)
- Kioku.Distill.L3: l3PersonaTimerId :: MemoryScope -> UTCTime -> TimerId
+ Kioku.Distill.L3: l3PersonaTimerId :: MemorySpaceId -> MemoryScope -> UTCTime -> TimerId
- Kioku.Distill.L3: regeneratePersona :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DistillRuntime -> MemoryScope -> Eff es (Either L3Error (Maybe PersonaRow))
+ Kioku.Distill.L3: regeneratePersona :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DistillRuntime -> MemorySpaceId -> MemoryScope -> Eff es (Either L3Error (Maybe PersonaRow))
- Kioku.Distill.L3: scheduleL3PersonaTimerTx :: MemoryScope -> UTCTime -> Transaction ()
+ Kioku.Distill.L3: scheduleL3PersonaTimerTx :: MemorySpaceId -> MemoryScope -> UTCTime -> Transaction ()
- Kioku.Distill.Timer.Worker: drainKiokuTimers :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => Maybe KeiroMetrics -> DistillRuntime -> FindMergeCandidates es -> Eff es Int
+ Kioku.Distill.Timer.Worker: drainKiokuTimers :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) => Maybe KeiroMetrics -> MemoryContextProvider (Eff es) -> DistillRuntime -> FindMergeCandidates es -> Eff es Int
- Kioku.Distill.Timer.Worker: fireKiokuTimer :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => DistillRuntime -> FindMergeCandidates es -> TimerRow -> Eff es FireOutcome
+ Kioku.Distill.Timer.Worker: fireKiokuTimer :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryContextProvider (Eff es) -> DistillRuntime -> FindMergeCandidates es -> TimerRow -> Eff es FireOutcome
- Kioku.Distill.Timer.Worker: fireL1Timer :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => DistillRuntime -> FindMergeCandidates es -> TimerRow -> Eff es FireOutcome
+ Kioku.Distill.Timer.Worker: fireL1Timer :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryContextProvider (Eff es) -> DistillRuntime -> FindMergeCandidates es -> TimerRow -> Eff es FireOutcome
- Kioku.Distill.Timer.Worker: runKiokuTimerWorkerOnce :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => Maybe KeiroMetrics -> DistillRuntime -> FindMergeCandidates es -> UTCTime -> Eff es (Maybe TimerRow)
+ Kioku.Distill.Timer.Worker: runKiokuTimerWorkerOnce :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) => Maybe KeiroMetrics -> MemoryContextProvider (Eff es) -> DistillRuntime -> FindMergeCandidates es -> UTCTime -> Eff es (Maybe TimerRow)
- Kioku.Memory: getActiveRowsByScope :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryScope -> Eff es (Either ReadModelError [MemoryRow])
+ Kioku.Memory: getActiveRowsByScope :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> MemoryScope -> Eff es (Either ReadModelError [MemoryRow])
- Kioku.Memory: getActiveRowsByType :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> MemoryType -> Eff es (Either ReadModelError [MemoryRow])
+ Kioku.Memory: getActiveRowsByType :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> MemoryType -> Eff es (Either ReadModelError [MemoryRow])
- Kioku.Memory: getActiveRowsInNamespace :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> Eff es (Either ReadModelError [MemoryRow])
+ Kioku.Memory: getActiveRowsInNamespace :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> Eff es (Either ReadModelError [MemoryRow])
- Kioku.Memory: getMemoryRowById :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryId -> Eff es (Either ReadModelError (Maybe MemoryRow))
+ Kioku.Memory: getMemoryRowById :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> MemoryId -> Eff es (Either ReadModelError (Maybe MemoryRow))
- Kioku.Memory: getRowsBySession :: forall (es :: [Effect]). (IOE :> es, Store :> es) => SessionId -> Eff es (Either ReadModelError [MemoryRow])
+ Kioku.Memory: getRowsBySession :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> SessionId -> Eff es (Either ReadModelError [MemoryRow])
- Kioku.Memory: getSupersessionChain :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryId -> Eff es (Either ReadModelError [MemoryRow])
+ Kioku.Memory: getSupersessionChain :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> MemoryId -> Eff es (Either ReadModelError [MemoryRow])
- Kioku.Memory.Domain: ArchiveMemoryData :: !MemoryId -> !UTCTime -> ArchiveMemoryData
+ Kioku.Memory.Domain: ArchiveMemoryData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !UTCTime -> ArchiveMemoryData
- Kioku.Memory.Domain: MemoryArchivedData :: !MemoryId -> !UTCTime -> MemoryArchivedData
+ Kioku.Memory.Domain: MemoryArchivedData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !UTCTime -> MemoryArchivedData
- Kioku.Memory.Domain: MemoryConfidenceUpdatedData :: !MemoryId -> !Confidence -> !UTCTime -> MemoryConfidenceUpdatedData
+ Kioku.Memory.Domain: MemoryConfidenceUpdatedData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !Confidence -> !UTCTime -> MemoryConfidenceUpdatedData
- Kioku.Memory.Domain: MemoryMergedData :: !MemoryId -> !MemoryId -> !UTCTime -> MemoryMergedData
+ Kioku.Memory.Domain: MemoryMergedData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !MemoryId -> !UTCTime -> MemoryMergedData
- Kioku.Memory.Domain: MemoryRecordedData :: !MemoryId -> !Text -> !Maybe SessionId -> !MemoryScope -> !MemoryType -> !Text -> !Int -> !Confidence -> !Set Text -> !Maybe MemoryId -> !UTCTime -> MemoryRecordedData
+ Kioku.Memory.Domain: MemoryRecordedData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe PrincipalRef -> !Text -> !Maybe SessionId -> !MemoryScope -> !MemoryType -> !Text -> !Int -> !Confidence -> !Set Text -> !Maybe MemoryId -> !UTCTime -> MemoryRecordedData
- Kioku.Memory.Domain: MemorySupersededData :: !MemoryId -> !MemoryId -> !UTCTime -> MemorySupersededData
+ Kioku.Memory.Domain: MemorySupersededData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !MemoryId -> !UTCTime -> MemorySupersededData
- Kioku.Memory.Domain: MemoryTagsUpdatedData :: !MemoryId -> !Set Text -> !UTCTime -> MemoryTagsUpdatedData
+ Kioku.Memory.Domain: MemoryTagsUpdatedData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !Set Text -> !UTCTime -> MemoryTagsUpdatedData
- Kioku.Memory.Domain: MergeMemoryData :: !MemoryId -> !MemoryId -> !UTCTime -> MergeMemoryData
+ Kioku.Memory.Domain: MergeMemoryData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !MemoryId -> !UTCTime -> MergeMemoryData
- Kioku.Memory.Domain: RecordMemoryData :: !MemoryId -> !Text -> !Maybe SessionId -> !MemoryScope -> !MemoryType -> !Text -> !Int -> !Confidence -> !Set Text -> !Maybe MemoryId -> !UTCTime -> RecordMemoryData
+ Kioku.Memory.Domain: RecordMemoryData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe PrincipalRef -> !Text -> !Maybe SessionId -> !MemoryScope -> !MemoryType -> !Text -> !Int -> !Confidence -> !Set Text -> !Maybe MemoryId -> !UTCTime -> RecordMemoryData
- Kioku.Memory.Domain: SupersedeMemoryData :: !MemoryId -> !MemoryId -> !UTCTime -> SupersedeMemoryData
+ Kioku.Memory.Domain: SupersedeMemoryData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !MemoryId -> !UTCTime -> SupersedeMemoryData
- Kioku.Memory.Domain: UpdateMemoryConfidenceData :: !MemoryId -> !Confidence -> !UTCTime -> UpdateMemoryConfidenceData
+ Kioku.Memory.Domain: UpdateMemoryConfidenceData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !Confidence -> !UTCTime -> UpdateMemoryConfidenceData
- Kioku.Memory.Domain: UpdateMemoryTagsData :: !MemoryId -> !Set Text -> !UTCTime -> UpdateMemoryTagsData
+ Kioku.Memory.Domain: UpdateMemoryTagsData :: !MemoryId -> !MemorySpaceId -> !RecordedPrincipal -> !Set Text -> !UTCTime -> UpdateMemoryTagsData
- Kioku.Memory.Domain: memoryTransducer :: SymTransducer (HsPred (MemoryRegs :: [Slot]) MemoryCommand) (MemoryRegs :: [Slot]) MemoryVertex MemoryCommand MemoryEvent
+ Kioku.Memory.Domain: memoryTransducer :: SymTransducer (HsPred MemoryRegs MemoryCommand) MemoryRegs MemoryVertex MemoryCommand MemoryEvent
- Kioku.Memory.Domain: type MemoryRegs = '[] :: [a]
+ Kioku.Memory.Domain: type MemoryRegs = '[ '("memorySpaceId", MemorySpaceId)]
- Kioku.Memory.Embedding.Worker: backfillMissingEmbeddings :: forall (es :: [Effect]). (IOE :> es, Store :> es) => VectorCapability -> EmbeddingModel -> Int -> Eff es Int
+ Kioku.Memory.Embedding.Worker: backfillMissingEmbeddings :: forall (es :: [Effect]). (IOE :> es, Store :> es) => VectorCapability -> EmbeddingWorkerEnv -> EmbeddingBackfillScope -> Eff es Int
- Kioku.Memory.Embedding.Worker: embeddingHandler :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => VectorCapability -> EmbeddingWorkerEnv -> Ingested es RecordedEvent -> Eff es AckDecision
+ Kioku.Memory.Embedding.Worker: embeddingHandler :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => MemoryContextProvider (Eff es) -> VectorCapability -> EmbeddingWorkerEnv -> Ingested es RecordedEvent -> Eff es AckDecision
- Kioku.Memory.Embedding.Worker: embeddingWorkerProcessor :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => VectorCapability -> EmbeddingModel -> Int -> KirokuStore -> Eff es (ProcessorId, QueueProcessor es)
+ Kioku.Memory.Embedding.Worker: embeddingWorkerProcessor :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es) => MemoryContextProvider (Eff es) -> VectorCapability -> EmbeddingModel -> Int -> KirokuStore -> Eff es (ProcessorId, QueueProcessor es)
- Kioku.Memory.Embedding.Worker: runEmbeddingWorkerHost :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es, Tracing :> es) => KirokuStore -> VectorCapability -> EmbeddingModel -> Int -> Eff es ()
+ Kioku.Memory.Embedding.Worker: runEmbeddingWorkerHost :: forall (es :: [Effect]). (IOE :> es, Store :> es, Error StoreError :> es, Tracing :> es) => KirokuStore -> MemoryContextProvider (Eff es) -> VectorCapability -> EmbeddingModel -> Int -> Eff es ()
- Kioku.Memory.EventStream: memoryEventStream :: ValidatedEventStream (HsPred (MemoryRegs :: [Slot]) MemoryCommand) (MemoryRegs :: [Slot]) MemoryVertex MemoryCommand MemoryEvent
+ Kioku.Memory.EventStream: memoryEventStream :: ValidatedEventStream (HsPred MemoryRegs MemoryCommand) MemoryRegs MemoryVertex MemoryCommand MemoryEvent
- Kioku.Memory.EventStream: type MemoryEventStream = EventStream HsPred MemoryRegs :: [Slot] MemoryCommand MemoryRegs :: [Slot] MemoryVertex MemoryCommand MemoryEvent
+ Kioku.Memory.EventStream: type MemoryEventStream = EventStream HsPred MemoryRegs MemoryCommand MemoryRegs MemoryVertex MemoryCommand MemoryEvent
- Kioku.Memory.ReadModel: MemoriesByNamespaceQuery :: Text -> MemoriesByNamespaceQuery
+ Kioku.Memory.ReadModel: MemoriesByNamespaceQuery :: !MemorySpaceId -> !Text -> MemoriesByNamespaceQuery
- Kioku.Memory.ReadModel: MemoriesByScopeQuery :: Text -> Maybe Text -> Maybe Text -> MemoriesByScopeQuery
+ Kioku.Memory.ReadModel: MemoriesByScopeQuery :: !MemorySpaceId -> !Text -> !Maybe Text -> !Maybe Text -> MemoriesByScopeQuery
- Kioku.Memory.ReadModel: MemoriesBySessionQuery :: Text -> MemoriesBySessionQuery
+ Kioku.Memory.ReadModel: MemoriesBySessionQuery :: !MemorySpaceId -> !Text -> MemoriesBySessionQuery
- Kioku.Memory.ReadModel: MemoriesByTypeQuery :: Text -> Text -> MemoriesByTypeQuery
+ Kioku.Memory.ReadModel: MemoriesByTypeQuery :: !MemorySpaceId -> !Text -> !Text -> MemoriesByTypeQuery
- Kioku.Memory.ReadModel: MemoryByIdQuery :: Text -> MemoryByIdQuery
+ Kioku.Memory.ReadModel: MemoryByIdQuery :: !MemorySpaceId -> !Text -> MemoryByIdQuery
- Kioku.Memory.ReadModel: MemoryRow :: !Text -> !Text -> !Maybe Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Text -> !Int -> !Text -> !Set Text -> !Text -> !Maybe Text -> !Maybe Text -> !UTCTime -> !UTCTime -> MemoryRow
+ Kioku.Memory.ReadModel: MemoryRow :: !MemorySpaceId -> !Text -> !Text -> !Maybe Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Text -> !Int -> !Text -> !Set Text -> !Text -> !Maybe Text -> !Maybe Text -> !UTCTime -> !UTCTime -> MemoryRow
- Kioku.Memory.ReadModel: MemorySupersessionChainQuery :: Text -> MemorySupersessionChainQuery
+ Kioku.Memory.ReadModel: MemorySupersessionChainQuery :: !MemorySpaceId -> !Text -> MemorySupersessionChainQuery
- Kioku.Memory.ReadModel: [memoryId] :: MemoryRow -> !Text
+ Kioku.Memory.ReadModel: [memoryId] :: MemorySupersessionChainQuery -> !Text
- Kioku.Memory.ReadModel: [memoryType] :: MemoryRow -> !Text
+ Kioku.Memory.ReadModel: [memoryType] :: MemoriesByTypeQuery -> !Text
- Kioku.Memory.ReadModel: [namespace] :: MemoryRow -> !Text
+ Kioku.Memory.ReadModel: [namespace] :: MemoriesByTypeQuery -> !Text
- Kioku.Memory.ReadModel: [scopeKind] :: MemoryRow -> !Maybe Text
+ Kioku.Memory.ReadModel: [scopeKind] :: MemoriesByScopeQuery -> !Maybe Text
- Kioku.Memory.ReadModel: [scopeRef] :: MemoryRow -> !Maybe Text
+ Kioku.Memory.ReadModel: [scopeRef] :: MemoriesByScopeQuery -> !Maybe Text
- Kioku.Memory.ReadModel: [sessionId] :: MemoryRow -> !Maybe Text
+ Kioku.Memory.ReadModel: [sessionId] :: MemoriesBySessionQuery -> !Text
- Kioku.Recall: RecallRequest :: !MemoryScope -> !Text -> !RecallStrategy -> !Int -> RecallRequest
+ Kioku.Recall: RecallRequest :: !MemorySpaceId -> !MemoryScope -> !Text -> !RecallStrategy -> !Int -> RecallRequest
- Kioku.Recall: getActiveByScope :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryScope -> Eff es (Either ReadModelError [MemoryRecord])
+ Kioku.Recall: getActiveByScope :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> MemoryScope -> Eff es (Either ReadModelError [MemoryRecord])
- Kioku.Recall: getActiveInNamespace :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> Eff es (Either ReadModelError [MemoryRecord])
+ Kioku.Recall: getActiveInNamespace :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> Eff es (Either ReadModelError [MemoryRecord])
- Kioku.Recall: getById :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryId -> Eff es (Either ReadModelError (Maybe MemoryRecord))
+ Kioku.Recall: getById :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> MemoryId -> Eff es (Either ReadModelError (Maybe MemoryRecord))
- Kioku.Recall: getBySession :: forall (es :: [Effect]). (IOE :> es, Store :> es) => SessionId -> Eff es (Either ReadModelError [MemoryRecord])
+ Kioku.Recall: getBySession :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> SessionId -> Eff es (Either ReadModelError [MemoryRecord])
- Kioku.Recall: getByType :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> MemoryType -> Eff es (Either ReadModelError [MemoryRecord])
+ Kioku.Recall: getByType :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> MemoryType -> Eff es (Either ReadModelError [MemoryRecord])
- Kioku.Recall: getGlobal :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> Eff es (Either ReadModelError [MemoryRecord])
+ Kioku.Recall: getGlobal :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> Eff es (Either ReadModelError [MemoryRecord])
- Kioku.Recall: recall :: forall (es :: [Effect]). (IOE :> es, Store :> es) => EmbeddingModel -> VectorCapability -> RecallRequest -> Eff es [RecallHit]
+ Kioku.Recall: recall :: forall (es :: [Effect]). (IOE :> es, Store :> es) => EmbeddingModel -> VectorCapability -> MemoryAccessContext -> RecallQuery -> Eff es (Either RecallError [RecallHit])
- Kioku.Recall: selectFtsCandidates :: forall (es :: [Effect]). Store :> es => RecallRequest -> Eff es [MemoryRecord]
+ Kioku.Recall: selectFtsCandidates :: forall (es :: [Effect]). Store :> es => ResolvedRecall -> Eff es [MemoryRecord]
- Kioku.Recall: selectVectorCandidates :: forall (es :: [Effect]). Store :> es => RecallRequest -> Vector Double -> Eff es [MemoryRecord]
+ Kioku.Recall: selectVectorCandidates :: forall (es :: [Effect]). Store :> es => ResolvedRecall -> Vector Double -> Eff es [MemoryRecord]
- Kioku.Recall: selectVectorCandidatesDiagnosed :: forall (es :: [Effect]). Store :> es => RecallRequest -> Vector Double -> Eff es (VectorChannelOutcome, [MemoryRecord])
+ Kioku.Recall: selectVectorCandidatesDiagnosed :: forall (es :: [Effect]). Store :> es => ResolvedRecall -> Vector Double -> Eff es (VectorChannelOutcome, [MemoryRecord])
- Kioku.Session: SessionRow :: !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Int -> !Text -> !UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe UTCTime -> !Maybe Text -> SessionRow
+ Kioku.Session: SessionRow :: !MemorySpaceId -> !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Int -> !Text -> !UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe UTCTime -> !Maybe Text -> SessionRow
- Kioku.Session: getAwaitingByCorrelationKey :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> Text -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getAwaitingByCorrelationKey :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> Text -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getByFocus :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> Text -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getByFocus :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> Text -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getById :: forall (es :: [Effect]). (IOE :> es, Store :> es) => SessionId -> Eff es (Either ReadModelError (Maybe SessionRow))
+ Kioku.Session: getById :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> SessionId -> Eff es (Either ReadModelError (Maybe SessionRow))
- Kioku.Session: getByScope :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryScope -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getByScope :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> MemoryScope -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getByStartedRange :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> UTCTime -> UTCTime -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getByStartedRange :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> UTCTime -> UTCTime -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getChain :: forall (es :: [Effect]). (IOE :> es, Store :> es) => SessionId -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getChain :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> SessionId -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getDelegationChildren :: forall (es :: [Effect]). (IOE :> es, Store :> es) => SessionId -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getDelegationChildren :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> SessionId -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getRecentInNamespace :: forall (es :: [Effect]). (IOE :> es, Store :> es) => Namespace -> Int -> Eff es (Either ReadModelError [SessionRow])
+ Kioku.Session: getRecentInNamespace :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> Namespace -> Int -> Eff es (Either ReadModelError [SessionRow])
- Kioku.Session: getTurns :: forall (es :: [Effect]). (IOE :> es, Store :> es) => SessionId -> Eff es (Either ReadModelError [TurnRow])
+ Kioku.Session: getTurns :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemorySpaceId -> SessionId -> Eff es (Either ReadModelError [TurnRow])
- Kioku.Session.Domain: AwaitInputData :: !SessionId -> !Text -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> AwaitInputData
+ Kioku.Session.Domain: AwaitInputData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Text -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> AwaitInputData
- Kioku.Session.Domain: CompleteSessionData :: !SessionId -> !UTCTime -> !Maybe Text -> !Maybe Text -> CompleteSessionData
+ Kioku.Session.Domain: CompleteSessionData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !UTCTime -> !Maybe Text -> !Maybe Text -> CompleteSessionData
- Kioku.Session.Domain: FailSessionData :: !SessionId -> !UTCTime -> !Text -> FailSessionData
+ Kioku.Session.Domain: FailSessionData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !UTCTime -> !Text -> FailSessionData
- Kioku.Session.Domain: InteractiveSessionRecordedData :: !SessionId -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !UTCTime -> InteractiveSessionRecordedData
+ Kioku.Session.Domain: InteractiveSessionRecordedData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe PrincipalRef -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !UTCTime -> InteractiveSessionRecordedData
- Kioku.Session.Domain: RecordInteractiveSessionData :: !SessionId -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !UTCTime -> RecordInteractiveSessionData
+ Kioku.Session.Domain: RecordInteractiveSessionData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe PrincipalRef -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !UTCTime -> RecordInteractiveSessionData
- Kioku.Session.Domain: RecordTurnData :: !SessionId -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> RecordTurnData
+ Kioku.Session.Domain: RecordTurnData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> RecordTurnData
- Kioku.Session.Domain: ResumeSessionData :: !SessionId -> !Maybe Text -> !Bool -> !Text -> !UTCTime -> ResumeSessionData
+ Kioku.Session.Domain: ResumeSessionData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe Text -> !Bool -> !Text -> !UTCTime -> ResumeSessionData
- Kioku.Session.Domain: SessionAwaitingData :: !SessionId -> !Text -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> SessionAwaitingData
+ Kioku.Session.Domain: SessionAwaitingData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Text -> !Maybe Text -> !Maybe UTCTime -> !UTCTime -> SessionAwaitingData
- Kioku.Session.Domain: SessionCompletedData :: !SessionId -> !UTCTime -> !Maybe Text -> !Maybe Text -> SessionCompletedData
+ Kioku.Session.Domain: SessionCompletedData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !UTCTime -> !Maybe Text -> !Maybe Text -> SessionCompletedData
- Kioku.Session.Domain: SessionFailedData :: !SessionId -> !UTCTime -> !Text -> SessionFailedData
+ Kioku.Session.Domain: SessionFailedData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !UTCTime -> !Text -> SessionFailedData
- Kioku.Session.Domain: SessionResumedData :: !SessionId -> !Maybe Text -> !Bool -> !Text -> !UTCTime -> SessionResumedData
+ Kioku.Session.Domain: SessionResumedData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe Text -> !Bool -> !Text -> !UTCTime -> SessionResumedData
- Kioku.Session.Domain: SessionStartedData :: !SessionId -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !Maybe SessionId -> !Maybe SessionId -> !Int -> !UTCTime -> SessionStartedData
+ Kioku.Session.Domain: SessionStartedData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe PrincipalRef -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !Maybe SessionId -> !Maybe SessionId -> !Int -> !UTCTime -> SessionStartedData
- Kioku.Session.Domain: StartSessionData :: !SessionId -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !Maybe SessionId -> !Maybe SessionId -> !Int -> !UTCTime -> StartSessionData
+ Kioku.Session.Domain: StartSessionData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Maybe PrincipalRef -> !Text -> !Text -> !MemoryScope -> !Maybe Text -> !Maybe SessionId -> !Maybe SessionId -> !Int -> !UTCTime -> StartSessionData
- Kioku.Session.Domain: TurnRecordedData :: !SessionId -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> TurnRecordedData
+ Kioku.Session.Domain: TurnRecordedData :: !SessionId -> !MemorySpaceId -> !RecordedPrincipal -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> TurnRecordedData
- Kioku.Session.Domain: type SessionRegs = '[ '("awaitedCorrelationKey", Maybe Text), '("lastTurnIndex", Int)]
+ Kioku.Session.Domain: type SessionRegs = '[ '("awaitedCorrelationKey", Maybe Text), '("lastTurnIndex", Int), '("memorySpaceId", MemorySpaceId)]
- Kioku.Session.ReadModel: AwaitingSessionsByCorrelationKeyQuery :: Text -> Text -> AwaitingSessionsByCorrelationKeyQuery
+ Kioku.Session.ReadModel: AwaitingSessionsByCorrelationKeyQuery :: !MemorySpaceId -> !Text -> !Text -> AwaitingSessionsByCorrelationKeyQuery
- Kioku.Session.ReadModel: SessionByIdQuery :: Text -> SessionByIdQuery
+ Kioku.Session.ReadModel: SessionByIdQuery :: !MemorySpaceId -> !Text -> SessionByIdQuery
- Kioku.Session.ReadModel: SessionChainQuery :: Text -> SessionChainQuery
+ Kioku.Session.ReadModel: SessionChainQuery :: !MemorySpaceId -> !Text -> SessionChainQuery
- Kioku.Session.ReadModel: SessionDelegationChildrenQuery :: Text -> SessionDelegationChildrenQuery
+ Kioku.Session.ReadModel: SessionDelegationChildrenQuery :: !MemorySpaceId -> !Text -> SessionDelegationChildrenQuery
- Kioku.Session.ReadModel: SessionRow :: !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Int -> !Text -> !UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe UTCTime -> !Maybe Text -> SessionRow
+ Kioku.Session.ReadModel: SessionRow :: !MemorySpaceId -> !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Int -> !Text -> !UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe UTCTime -> !Maybe Text -> SessionRow
- Kioku.Session.ReadModel: SessionsByFocusQuery :: Text -> Text -> SessionsByFocusQuery
+ Kioku.Session.ReadModel: SessionsByFocusQuery :: !MemorySpaceId -> !Text -> !Text -> SessionsByFocusQuery
- Kioku.Session.ReadModel: SessionsByNamespaceQuery :: Text -> Int -> SessionsByNamespaceQuery
+ Kioku.Session.ReadModel: SessionsByNamespaceQuery :: !MemorySpaceId -> !Text -> !Int -> SessionsByNamespaceQuery
- Kioku.Session.ReadModel: SessionsByScopeQuery :: Text -> Maybe Text -> Maybe Text -> SessionsByScopeQuery
+ Kioku.Session.ReadModel: SessionsByScopeQuery :: !MemorySpaceId -> !Text -> !Maybe Text -> !Maybe Text -> SessionsByScopeQuery
- Kioku.Session.ReadModel: SessionsByStartedRangeQuery :: Text -> UTCTime -> UTCTime -> SessionsByStartedRangeQuery
+ Kioku.Session.ReadModel: SessionsByStartedRangeQuery :: !MemorySpaceId -> !Text -> !UTCTime -> !UTCTime -> SessionsByStartedRangeQuery
- Kioku.Session.ReadModel: TurnRow :: !Text -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> TurnRow
+ Kioku.Session.ReadModel: TurnRow :: !MemorySpaceId -> !Text -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> TurnRow
- Kioku.Session.ReadModel: TurnsBySessionQuery :: Text -> TurnsBySessionQuery
+ Kioku.Session.ReadModel: TurnsBySessionQuery :: !MemorySpaceId -> !Text -> TurnsBySessionQuery
- Kioku.Session.ReadModel: [focus] :: SessionRow -> !Text
+ Kioku.Session.ReadModel: [focus] :: SessionsByFocusQuery -> !Text
- Kioku.Session.ReadModel: [namespace] :: SessionRow -> !Text
+ Kioku.Session.ReadModel: [namespace] :: AwaitingSessionsByCorrelationKeyQuery -> !Text
- Kioku.Session.ReadModel: [parentSessionId] :: SessionRow -> !Maybe Text
+ Kioku.Session.ReadModel: [parentSessionId] :: SessionDelegationChildrenQuery -> !Text
- Kioku.Session.ReadModel: [scopeKind] :: SessionRow -> !Maybe Text
+ Kioku.Session.ReadModel: [scopeKind] :: SessionsByScopeQuery -> !Maybe Text
- Kioku.Session.ReadModel: [scopeRef] :: SessionRow -> !Maybe Text
+ Kioku.Session.ReadModel: [scopeRef] :: SessionsByScopeQuery -> !Maybe Text
- Kioku.Session.ReadModel: [sessionId] :: TurnRow -> !Text
+ Kioku.Session.ReadModel: [sessionId] :: TurnsBySessionQuery -> !Text

Files

CHANGELOG.md view
@@ -1,5 +1,184 @@ # Changelog +## 0.4.0.0 — 2026-08-17++### Breaking Changes++- Moved onto the Keiro 0.13 / Kiroku 0.8 / Shibuya 0.9 cohort: `keiro ^>=0.13.0.0`,+  `keiro-core ^>=0.13.0.0`, `kiroku-store ^>=0.8.0.0`, `shibuya-core ^>=0.9.0.0`, and+  `shibuya-kiroku-adapter ^>=0.5.1.1`. A consumer that writes its own `Store` interpreter must+  handle Kiroku's new retention, checkpoint-initialization and visible-head constructors; one that+  matches exhaustively on `Kiroku.Store.Error.StoreError` must add arms for+  `TransientTransactionFailure` and `HistoryRetentionActive`; and one that matches on+  `Shibuya.Core.Ack.DeadLetterReason` must handle the new `ApplicationFailure` arm.+- The library now builds with `-Werror=incomplete-patterns`. This is breaking only for a fork+  carrying its own partial matches; `kioku-core` itself was already clean.++- Kioku's projections now live in the `kioku` PostgreSQL schema instead of `kiroku`, and every+  statement names them explicitly through the new internal `Kioku.Database.Schema` rather than+  resolving them through `search_path`. The event store is unchanged; Kioku still appends to the+  host's Kiroku streams. The runtime role needs `USAGE ON SCHEMA kioku` in addition to its+  existing table privileges.+- Read-model identities advance with the relations they name: memory v2 -> v3+  (`kioku-memory-v3`), session v4 -> v5 (`kioku-session-v5`), turn v2 -> v3 (`kioku-turn-v3`).+  Keiro's registry stores no physical relation name, so the version bump is what makes a binary+  from the wrong side of `kioku/0012-relocate-projections-to-kioku-schema` fail closed with+  `ReadModelStaleSchema` instead of querying tables that have moved. `kioku-migrate up` reconciles+  the registry; a library embedder must call `Kioku.ReadModel.reconcileReadModelRegistry` itself+  before serving traffic.+- pgvector capability detection now probes schema `kioku`, table `memories`. The `to_regtype`+  check for the `vector` type still resolves against the connection's search path, because the+  extension was deliberately not moved.++### Fixed++- `Kioku.Worker.Failure.isTransientStoreError` now classifies `TransientTransactionFailure` as+  transient and `HistoryRetentionActive` as permanent. Both arms were absent: the first because+  the constructor is new in `kiroku-store` 0.8.0.0, the second since 0.7.0.0. Before 0.8.0.0+  PostgreSQL's `40001`/`40P01` reached this function as `UnexpectedServerError` and were called+  permanent, so a serialization failure or deadlock halted the embedding worker instead of+  retrying it.+++### Breaking Changes++- `recall` takes a `MemoryAccessContext` and a `RecallQuery` instead of a `RecallRequest`, and+  returns `Either RecallError [RecallHit]`. The target says what to search; the memory space comes+  from the context and nothing in the request can change it, so widening a target can never widen+  the tenancy. It is the one read that takes a whole context rather than a `MemorySpaceId`,+  because it is the only one that can be asked to widen.+- `RecallRequest` and the new `legacyRecall` are **deprecated** wrappers for one release.+  `legacyRecall` maps the request's scope through `legacyRecallTarget` — so `ScopeGlobal` stays+  namespace-wide — refuses a request naming a space other than the one its context authorizes,+  and preserves the old `maxResults` edges (zero returns nothing; anything above 100 is clamped,+  which is unobservable because a fused result set holds at most 100 memories). See+  `docs/adr/an-explicit-recall-target-replaces-the-overloaded-scope.md` for the removal+  conditions.+- `ExactScope (ScopeGlobal ns)` — ranked recall of the exact global bucket — **executes**, and+  returns the rows recorded with no entity scope rather than the whole namespace. Each of the+  three targets now compiles to its own SQL: the exact global bucket asks for+  `scope_kind IS NULL AND scope_ref IS NULL`, an exact entity compares both columns, and a+  namespace-wide target carries no scope clause at all. The single parameterised predicate they+  replace read NULL scope columns as "no scope filter", which is why the exact global bucket+  previously had to be refused. See+  `docs/adr/each-recall-target-gets-its-own-statement.md`.+- `RecallError` lost `RecallExactGlobalUnsupported`. Its only remaining constructor is+  `RecallSpaceMismatch`, which only the deprecated `legacyRecall` can produce; `recall` keeps+  returning `Either` so that adding a refusal later is not a breaking change at every call site.+- `RecallStrategy` moved to `kioku-api`'s `Kioku.Api.Recall`. `Kioku.Recall` re-exports it, so an+  existing import keeps working.+- The recall test seams (`selectFtsCandidates`, `selectVectorCandidates`,+  `selectVectorCandidatesDiagnosed`) take a `ResolvedRecall` — a query bound to one authorized+  space with its target already compiled to a scope predicate. `resolveRecall` is the only way to+  build one, and it is total. `selectVectorCandidatesStmt`, `selectVectorCandidatesExactStmt`,+  `vectorCandidateQuery` and `memoryRecordColumns` are replaced by `vectorCandidateSql` /+  `ftsCandidateSql` and the `runVectorAnnCandidates` / `explainVectorAnnCandidates` /+  `explainVectorExactCandidates` / `explainFtsCandidates` transaction seams, which is how the+  recall harness stopped keeping its own copy of the vector SQL to `EXPLAIN`.+- `Kioku.Distill.L1.FindMergeCandidates` receives the pass's `MemoryAccessContext` rather than its+  `MemorySpaceId`, and its error channel is `L1Error` rather than `ReadModelError`. `L1Error`+  gained `L1RecallRefused`, so a recall refusal cannot arrive at the consolidator as an empty+  candidate list.+- **Behavior change:** `Kioku.Distill.L1.recallCandidates` draws merge candidates from the+  session's exact scope rather than from its whole namespace, so it agrees with+  `scopedScanCandidates` about which memories a session's atoms may merge into. A globally-scoped+  session used to draw candidates from every entity scope beside it — and could merge an atom into+  one, rewriting a memory that feeds a scene the session has nothing to do with. Entity-scoped+  sessions are unaffected; globally-scoped ones will report more `stored` and fewer `merged`.+- Every memory and session command payload gained required fields: `memorySpaceId` and+  `actorPrincipal` on all of them, plus `ownerPrincipal` on `RecordMemoryData`,+  `StartSessionData`, and `RecordInteractiveSessionData`. Every construction site is a compile+  error until updated. The corresponding event payloads gained the same fields.+- Every write function takes a `MemoryAccessContext` first and is named `*WithContext`:+  `recordWithContext`, `supersedeWithContext`, `archiveWithContext`, `updateTagsWithContext`,+  `updateConfidenceWithContext`, `mergeWithContext`, `startWithContext`, `awaitInputWithContext`,+  `resumeWithContext`, `forceResumeWithContext`, `completeWithContext`, `failSessionWithContext`,+  `recordInteractiveWithContext`, and `recordTurnWithContext`. The unsuffixed names remain as+  **deprecated** wrappers for one release; they take no context and refuse any payload naming a+  space other than `legacyMemorySpaceId`.+- `distillSessionL1` takes a `MemoryAccessContext` first and demands the `MemoryDistill`+  permission before any LLM call.+- `fireL1Timer`, `fireKiokuTimer`, `runKiokuTimerWorkerOnce`, and `drainKiokuTimers` take a+  `MemoryContextProvider`, because a worker discovers its own work and cannot arrive holding a+  context.+- `MemoryWriteError` gained `MemoryNotPermitted`, `MemorySpaceMismatch`, and+  `MemoryActorMismatch`; `SessionWriteError` gained the three equivalents; `L1Error` gained+  `L1NotPermitted`.+- An L1 timer payload that cannot be parsed now dead-letters instead of being ignored. Payloads+  written before this release parse fine and fire in the legacy space.+- Every read function takes a `MemorySpaceId` first and returns nothing outside it:+  `Kioku.Memory.getMemoryRowById`, `getActiveRowsInNamespace`, `getActiveRowsByScope`,+  `getRowsBySession`, `getActiveRowsByType`, `getSupersessionChain`; `Kioku.Session.getById`,+  `getRecentInNamespace`, `getByScope`, `getByFocus`, `getByStartedRange`, `getChain`,+  `getDelegationChildren`, `getAwaitingByCorrelationKey`, `getTurns`; `Kioku.Recall.getById`,+  `getActiveByScope`, `getActiveInNamespace`, `getGlobal`, `getBySession`, `getByType`;+  `Kioku.Distill.L2.getScenesByScope` and `regenerateScene`; `Kioku.Distill.L3.getPersonaByScope`+  and `regeneratePersona`. Pass `memoryContextSpace` of the context that authorized the read.+- `RecallRequest` gained a required `memorySpaceId` field.+- Every read-model query record is now a record with named fields rather than a positional+  constructor, with `memorySpaceId` first: `MemoryByIdQuery`, `MemoriesByNamespaceQuery`,+  `MemoriesByScopeQuery`, `MemoriesBySessionQuery`, `MemoriesByTypeQuery`,+  `MemorySupersessionChainQuery`, and the nine session equivalents.+- `MemoryRow`, `SessionRow`, `TurnRow`, `SceneRow`, and `PersonaRow` gained a leading+  `memorySpaceId` field.+- `fireL2SceneTimer` and `fireL3PersonaTimer` take a `MemoryContextProvider`, like `fireL1Timer`.+- Scene and persona timer ids and correlation ids now include the memory space, so two spaces+  sharing a namespace and scope no longer share one timer. Timers already scheduled keep their+  old ids and fire in the legacy space.+- `runEmbeddingWorkerHost`, `embeddingWorkerProcessor`, and `embeddingHandler` take a+  `MemoryContextProvider`, like the timer handlers, because the embedding worker also discovers+  its own work.+- `backfillMissingEmbeddings` takes an `EmbeddingWorkerEnv` and an `EmbeddingBackfillScope` in+  place of an `EmbeddingModel` and a dimension count.+- `EmbedOutcome` gained `EmbedSpaceMismatch`.+- `runKiokuTimerWorkerOnce` and `drainKiokuTimers` require `Tracing :> es`.+- Scene and persona mirrors moved from `.kioku/{scenes,persona}/<slug>.md` to+  `.kioku/spaces/<space-dir>/{scenes,persona}/<slug>.md`. `sceneMirrorPath` and+  `personaMirrorPath` keep their signatures — the space comes from the row — but return different+  paths. Run `kioku migrate-artifacts` to relocate existing files.+- A cross-space id on a write path now returns `MemoryNotFound` / `SessionNotFound` rather than+  reaching the aggregate and returning `MemoryCommandRejected`. That closes the existence oracle+  the previous release documented: an id in another space is now indistinguishable from one that+  was never written.+- Read-model registry identities advanced: memory models to v2 / `kioku-memory-v2`, session models+  to v4 / `kioku-session-v4`, turns to v2 / `kioku-turn-v2`. `kioku-migrate` reconciles them; a+  host applying migrations as a library must call `reconcileReadModelRegistry` itself.++### Added++- `Kioku.Partition` — the single place that decides what a payload written before memory spaces+  means: the legacy space, and a legacy-marked agent label. No codec invents its own default.+- `Kioku.Distill.Timer.L1TimerPayload`, which carries the memory space a scheduled distillation+  pass belongs to.+- `Kioku.Workspace` — the per-space artifact layout and the migration of the pre-partition tree.+  A memory space id is validated for a database column rather than for a path, so the directory+  component is a sanitised prefix plus a digest of the exact id: `..` cannot escape+  `.kioku/spaces`, and two ids that differ only in case cannot share a directory.+- `kioku migrate-artifacts` — a dry run by default; `--apply` copies, never moves, and refuses a+  destination whose content differs.+- `kioku worker --backfill --space ID` — an embedding backfill bounded to one memory space. The+  default stays every space.+- A `kioku.timer.fire` span per fire attempt, carrying `kioku.memory_space_id`, the timer id, the+  attempt count, a bounded `kioku.timer.outcome`, and the failure reason. Metrics gained no space+  or principal label and must not: a space is caller-supplied text.+- Aggregate state carries the memory space, and every non-creation edge guards on it, so a command+  naming a different space is refused by the state machine rather than by a read-model precheck.++### Compatibility++- Events already on disk keep decoding. They land in `legacyMemorySpaceId`; an old free-text+  `agentId` is recorded as a legacy-marked label and never rewritten into a directory principal;+  an event that recorded no agent is `UnattributedPrincipal`. Encoders emit only the new form.+- Reads are partitioned by the schema. Every read-model table carries a non-null+  `memory_space_id`, backfilled to `kioku_legacy`, and every statement names it. An upgraded+  single-space deployment sees exactly what it saw before. See+  `docs/user/upgrading-to-memory-spaces.md`.+- Workspace mirrors are partitioned. Files written before this release stay at `.kioku/scenes`+  and `.kioku/persona`, which nothing writes to any more; `kioku migrate-artifacts` reports and+  relocates them. The one exception is deletion: emptying a scope in the legacy space unlinks its+  historical mirror too, because forgotten content surviving on disk is a retention failure+  rather than a stale cache.+ ## 0.3.0.0 — 2026-08-05  ### Breaking Changes
kioku-core.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kioku-core-version:         0.3.0.0+version:         0.4.0.0 synopsis:        Reusable agent memory runtime description:   Core runtime for kioku. M1 establishes the application effect stack; later@@ -27,6 +27,7 @@   ghc-options:     -Wall -Wcompat -Widentities -Wincomplete-record-updates     -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints+    -Werror=incomplete-patterns  common shared   default-language:   GHC2024@@ -42,8 +43,8 @@     TemplateHaskell  library-  import:          warnings, shared-  hs-source-dirs:  src+  import:           warnings, shared+  hs-source-dirs:   src   exposed-modules:     Kioku.App     Kioku.Distill.Consolidate@@ -64,6 +65,7 @@     Kioku.Memory.Embedding.Worker     Kioku.Memory.EventStream     Kioku.Memory.ReadModel+    Kioku.Partition     Kioku.ReadModel     Kioku.Recall     Kioku.Recall.Capability@@ -72,7 +74,32 @@     Kioku.Session.EventStream     Kioku.Session.ReadModel     Kioku.Worker.Failure+    Kioku.Workspace +  -- Where Kioku's projections physically live is Kioku's business, so the+  -- qualified relation names stay internal rather than becoming a public API a+  -- consumer could pin its own SQL to.+  other-modules:    Kioku.Database.Schema++  -- Works around a GHC 9.12.4 coercion-optimiser bug that makes the *profiled*+  -- build of Kioku.Distill.L1 panic:+  --+  --     panic! (the 'impossible' happened) / coercionKind / ConsSymbolDef+  --     pprPanic, called at compiler/GHC/Core/Coercion.hs:2550:17+  --+  -- `ConsSymbolDef` is the built-in rewrite axiom behind the type-level Symbol+  -- machinery that mmzk-typeid's `Data.KindID.V7` prefix validation produces.+  -- With -prof, optCoercion builds an AxiomRuleCo whose argument coercions no+  -- longer let the built-in rule re-prove itself, and coercionKind crashes.+  --+  -- This lives in ghc-prof-options, not ghc-options, so ordinary builds keep the+  -- coercion optimiser; only the profiled way pays for the workaround. Profiling+  -- detail is NOT the trigger -- `profiling-detail: none` and `late-toplevel`+  -- both still panic -- so do not "simplify" this to a -fprof-auto tweak.+  -- Retest and drop once the toolchain moves off GHC 9.12.4. Full write-up:+  -- mori://MMZK1526/mmzk-typeid/upstream-issues/mmzk-typeid-kindid-ghc-9-12-4-profiling-coercionkind-panic+  ghc-prof-options: -fno-opt-coercion+   -- Two bounds below look wrong but are not. baikai-effectful has no 0.4   -- release; 0.3.0.2 is its newest and is itself built against baikai ^>=0.4.0,   -- so its version lags the rest of the Baikai cohort by design. And shikumi is@@ -100,18 +127,19 @@     , hasql-transaction       >=1.0       && <1.3     , hs-opentelemetry-api    >=1.0       && <1.1     , keiki                   ^>=0.9.0.0-    , keiro                   ^>=0.11.0.0-    , keiro-core              ^>=0.11.0.0-    , kioku-api               ^>=0.3.0.0-    , kiroku-store            ^>=0.3.0.1+    , keiro                   ^>=0.13.0.0+    , keiro-core              ^>=0.13.0.0+    , kioku-api               ^>=0.4.0.0+    , kiroku-store            ^>=0.8.0.0     , lens                    >=5.2       && <5.4     , mmzk-typeid             >=0.7       && <0.8-    , shibuya-core            >=0.8.0.1   && <0.9-    , shibuya-kiroku-adapter  ^>=0.4.0.0+    , shibuya-core            ^>=0.9.0.0+    , shibuya-kiroku-adapter  ^>=0.5.1.1     , shikumi                 ^>=0.3.0.1     , shikumi-trace           ^>=0.2.0.1     , text                    >=2.1       && <2.2     , time                    >=1.12      && <1.15+    , unordered-containers    >=0.2       && <0.3     , uuid                    >=1.3       && <1.4     , vector                  ^>=0.13.2.0 @@ -125,16 +153,23 @@     Kioku.DistillSpec     Kioku.EmbeddingWorkerSpec     Kioku.IdempotencySpec+    Kioku.MemorySpaceSpec+    Kioku.PortfolioAccessSpec     Kioku.ReadModelReconcileSpec+    Kioku.RecallCompatSpec     Kioku.RecallHarness     Kioku.RecallSpec     Kioku.RecallSqlSpec+    Kioku.RecallTargetSpec     Kioku.ReiCompatSpec     Kioku.SchemaSpec     Kioku.ScopeIdentitySpec     Kioku.SessionInvariantsSpec     Kioku.SessionLineageSpec+    Kioku.SpaceFixtures+    Kioku.SpaceIsolationSpec     Kioku.TimerWorkerSpec+    Kioku.WorkspaceSpec    hs-source-dirs: test   ghc-options:    -threaded -rtsopts -with-rtsopts=-N@@ -148,16 +183,17 @@     , directory                      >=1.3     , effectful                      >=2.5     , effectful-core                 >=2.5+    , filepath                       >=1.4     , hasql                          >=1.6     , hasql-transaction              >=1.0-    , keiro                          ^>=0.11.0.0-    , keiro-core                     ^>=0.11.0.0-    , kioku-api                      ^>=0.3.0.0-    , kioku-core                     ^>=0.3.0.0-    , kioku-migrations:test-support  ^>=0.3.0.0-    , kiroku-store                   ^>=0.3.0.1+    , keiro                          ^>=0.13.0.0+    , keiro-core                     ^>=0.13.0.0+    , kioku-api                      ^>=0.4.0.0+    , kioku-core                     ^>=0.4.0.0+    , kioku-migrations:test-support  ^>=0.4.0.0+    , kiroku-store                   ^>=0.8.0.0     , lens                           >=5.2-    , shibuya-core                   >=0.8.0.1   && <0.9+    , shibuya-core                   ^>=0.9.0.0     , shikumi                        ^>=0.3.0.1     , shikumi-trace                  ^>=0.2.0.1     , tasty                          >=1.5
+ src/Kioku/Database/Schema.hs view
@@ -0,0 +1,89 @@+-- | Where Kioku's own relations live, spelled once.+--+-- Kioku shares the host application's Kiroku event store but owns its projections, and since+-- migration @0012-relocate-projections-to-kioku-schema.sql@ those projections live in a+-- dedicated @kioku@ PostgreSQL schema rather than beside the event tables in @kiroku@. See+-- @docs\/adr\/projections-live-in-the-kioku-schema.md@.+--+-- Every statement Kioku issues names its relation through one of the constants below instead+-- of relying on @search_path@. That is deliberate. The Kiroku connection puts its own primary+-- schema first and then whatever @extraSearchPath@ entries the host configured, on a pool the+-- host shares — so an unqualified @memories@ would resolve to whichever relation the host's+-- settings happened to reach first. Naming the schema in the SQL makes ownership a property of+-- the query rather than of the connection.+--+-- 'Keiro.Connection.qualifyTable' produces the double-quoted @\"kioku\".\"memories\"@ form, so+-- these values are safe to concatenate into static SQL. They are trusted constants and nothing+-- user-supplied reaches them; every value a caller provides still travels as a parameter.+--+-- This module stays in @other-modules@: the physical layout is Kioku's business, not a public+-- API its consumers should be able to depend on.+module Kioku.Database.Schema+  ( kiokuSchema,+    memoriesTable,+    sessionsTable,+    turnsTable,+    l1WatermarksTable,+    consolidationDecisionsTable,+    scenesTable,+    personasTable,+    memoriesRelation,+    sessionsRelation,+    turnsRelation,+  )+where++import Data.Text (Text)+import Keiro.Connection (qualifyTable)++-- | The PostgreSQL schema every Kioku-owned relation lives in.+--+-- Not to be confused with a Keiro read-model schema identity (a logical name, version, and+-- shape hash) or with a Kioku memory space (a @memory_space_id@ column value). Moving the+-- tables into one schema changes neither; see+-- @docs\/adr\/the-partition-is-a-column-not-a-schema.md@.+kiokuSchema :: Text+kiokuSchema = "kioku"++-- | The unqualified names Keiro's 'Keiro.ReadModel.ReadModel' metadata records, which it keeps+-- separate from the schema. Exported so a read-model declaration and the SQL it runs cannot+-- drift apart: both are built from these.+memoriesRelation, sessionsRelation, turnsRelation :: Text+memoriesRelation = "memories"+sessionsRelation = "sessions"+turnsRelation = "turns"++l1WatermarksRelation, consolidationDecisionsRelation, scenesRelation, personasRelation :: Text+l1WatermarksRelation = "l1_watermarks"+consolidationDecisionsRelation = "consolidation_decisions"+scenesRelation = "scenes"+personasRelation = "personas"++-- | The memory projection: one row per memory, with its full-text vector and its optional+-- embedding.+memoriesTable :: Text+memoriesTable = qualifyTable kiokuSchema memoriesRelation++-- | The session projection, including continuation and awaiting state.+sessionsTable :: Text+sessionsTable = qualifyTable kiokuSchema sessionsRelation++-- | The ordered turns of a session.+turnsTable :: Text+turnsTable = qualifyTable kiokuSchema turnsRelation++-- | L1 distillation's idempotency watermarks.+l1WatermarksTable :: Text+l1WatermarksTable = qualifyTable kiokuSchema l1WatermarksRelation++-- | L2 consolidation's decision audit trail.+consolidationDecisionsTable :: Text+consolidationDecisionsTable = qualifyTable kiokuSchema consolidationDecisionsRelation++-- | L3 scene projections.+scenesTable :: Text+scenesTable = qualifyTable kiokuSchema scenesRelation++-- | L3 persona projections.+personasTable :: Text+personasTable = qualifyTable kiokuSchema personasRelation
src/Kioku/Distill/L1.hs view
@@ -36,8 +36,17 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.ReadModel (ReadModelError)+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryPermission (..),+    MemorySpaceId,+    memoryContextAllows,+    memoryContextRecordedActor,+    memoryContextSpace,+  ) import Kioku.Api.Scope (MemoryScope, scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Api.Types (Confidence (..), MemoryRecord (..), MemoryType (..), confidenceFromText, memoryTypeFromText)+import Kioku.Database.Schema (consolidationDecisionsTable, l1WatermarksTable) import Kioku.Distill.Consolidate   ( ConsolidateInput (..),     ConsolidationAction (..),@@ -50,6 +59,7 @@ import Kioku.Memory qualified as Memory import Kioku.Memory.Domain (RecordMemoryData (..)) import Kioku.Memory.ReadModel (MemoryRow (..))+import Kioku.Partition (memorySpaceParam) import Kioku.Prelude import Kioku.Recall qualified as Recall import Kioku.Recall.Capability (VectorCapability)@@ -61,8 +71,21 @@ import Kiroku.Store.Transaction (runTransaction) import Shikumi.Schema.Types (field, unField) +-- | How a pass finds memories that a newly extracted atom might merge into.+--+-- The authorizing context is an argument rather than something the finder closes over, so a+-- finder value can be built once at startup and still be used by passes running in different+-- memory spaces. It is the whole context rather than a bare 'MemorySpaceId' because+-- 'Kioku.Recall.recall' takes one: a finder that searches must be handed the decision that+-- authorized the pass, so that widening what it searches can never widen whose memories it+-- reaches.+-- The error channel is 'L1Error' rather than 'ReadModelError' because a finder that runs recall+-- can fail in a way a read model cannot — see 'L1RecallRefused'. Flattening such a refusal into+-- an empty candidate list would tell the consolidator there is nothing to merge into, which is+-- the "a denial became an empty result" mistake 'Kioku.Api.Access' exists to prevent. newtype FindMergeCandidates es = FindMergeCandidates-  { runFindMergeCandidates :: MemoryScope -> Text -> Eff es (Either ReadModelError [MemoryRecord])+  { runFindMergeCandidates ::+      MemoryAccessContext -> MemoryScope -> Text -> Eff es (Either L1Error [MemoryRecord])   }  data L1Error@@ -73,6 +96,10 @@   | L1ExtractionFailed !Text   | L1ConsolidationFailed !Text   | L1MemoryWriteFailed !Memory.MemoryWriteError+  | -- | the context does not authorize distillation in this space+    L1NotPermitted !MemoryPermission+  | -- | the merge-candidate finder asked recall for something it would not run+    L1RecallRefused !Recall.RecallError   deriving stock (Generic, Show)  data L1Summary = L1Summary@@ -116,14 +143,16 @@   }  data WatermarkRow = WatermarkRow-  { sessionId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text,     lastTurnIndex :: !Int32,     distilledAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)  data AuditRow = AuditRow-  { decisionId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    decisionId :: !Text,     sessionId :: !Text,     namespace :: !Text,     scopeKind :: !(Maybe Text),@@ -142,94 +171,125 @@ -- skipped before any LLM call, which is what makes keiro's at-least-once timer -- re-fires cheap. The watermark advances only when the whole fold succeeds, so -- a failed pass is retried in full.+--+-- The pass writes memories, so it needs a 'MemoryAccessContext' — the one for the memory space+-- the session belongs to. It demands 'MemoryDistill' before anything else, so an unauthorized+-- pass fails before it spends a single LLM token rather than after, at the first write. distillSessionL1 ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->   L1RunMode ->   DistillRuntime ->   FindMergeCandidates es ->   SessionId ->   Eff es (Either L1Error L1Outcome)-distillSessionL1 mode rt finder sid = do-  sessionResult <- Session.getById sid-  case sessionResult of-    Left err -> pure (Left (L1SessionReadFailed err))-    Right Nothing -> pure (Left (L1SessionNotFound sid))-    Right (Just session) -> do-      turnsResult <- Session.getTurns sid-      case turnsResult of-        Left err -> pure (Left (L1TurnReadFailed err))-        Right turns -> do-          let maxTurnIndex = maximum (0 : fmap (.turnIndex) turns)-          upToDate <- watermarkCovers mode sid maxTurnIndex-          if upToDate-            then pure (Right L1SkippedUpToDate)-            else do-              inputResult <- buildExtractInput sid session turns-              case inputResult of-                Left err -> pure (Left err)-                Right input -> do-                  extractedResult <- liftIO (runExtraction rt input)-                  case extractedResult of-                    Left err -> pure (Left (L1ExtractionFailed (Text.pack (show err))))-                    Right output -> do-                      foldResult <--                        foldM-                          (stepAtom maxTurnIndex session)-                          (Right emptySummary {extracted = length output.atoms})-                          output.atoms-                      case foldResult of-                        Left err -> pure (Left err)-                        Right summary -> do-                          writeWatermark sid maxTurnIndex-                          pure (Right (L1Distilled summary))+distillSessionL1 context mode rt finder sid+  | not (memoryContextAllows MemoryDistill context) =+      pure (Left (L1NotPermitted MemoryDistill))+  | otherwise = do+      sessionResult <- Session.getById space sid+      case sessionResult of+        Left err -> pure (Left (L1SessionReadFailed err))+        Right Nothing -> pure (Left (L1SessionNotFound sid))+        Right (Just session) -> do+          turnsResult <- Session.getTurns space sid+          case turnsResult of+            Left err -> pure (Left (L1TurnReadFailed err))+            Right turns -> do+              let maxTurnIndex = maximum (0 : fmap (.turnIndex) turns)+              upToDate <- watermarkCovers mode space sid maxTurnIndex+              if upToDate+                then pure (Right L1SkippedUpToDate)+                else do+                  inputResult <- buildExtractInput space sid session turns+                  case inputResult of+                    Left err -> pure (Left err)+                    Right input -> do+                      extractedResult <- liftIO (runExtraction rt input)+                      case extractedResult of+                        Left err -> pure (Left (L1ExtractionFailed (Text.pack (show err))))+                        Right output -> do+                          foldResult <-+                            foldM+                              (stepAtom maxTurnIndex session)+                              (Right emptySummary {extracted = length output.atoms})+                              output.atoms+                          case foldResult of+                            Left err -> pure (Left err)+                            Right summary -> do+                              writeWatermark space sid maxTurnIndex+                              pure (Right (L1Distilled summary))   where+    space = memoryContextSpace context+     stepAtom _ _ (Left err) _ = pure (Left err)     stepAtom maxTurnIndex session (Right summary) atom =-      applyAtom rt finder sid session maxTurnIndex summary atom+      applyAtom context rt finder sid session maxTurnIndex summary atom  watermarkCovers ::   (Store :> es) =>   L1RunMode ->+  MemorySpaceId ->   SessionId ->   Int ->   Eff es Bool-watermarkCovers IgnoreWatermark _ _ = pure False-watermarkCovers RespectWatermark sid maxTurnIndex = do-  stored <- readWatermark sid+watermarkCovers IgnoreWatermark _ _ _ = pure False+watermarkCovers RespectWatermark space sid maxTurnIndex = do+  stored <- readWatermark space sid   pure (maybe False (>= maxTurnIndex) stored)  readWatermark ::   (Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Maybe Int)-readWatermark sid =+readWatermark space sid =   runTransaction $-    fmap fromIntegral <$> Tx.statement (idText sid) selectWatermarkStmt+    fmap fromIntegral <$> Tx.statement (WatermarkKey space (idText sid)) selectWatermarkStmt  writeWatermark ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Int ->   Eff es ()-writeWatermark sid maxTurnIndex = do+writeWatermark space sid maxTurnIndex = do   now <- liftIO getCurrentTime   runTransaction $     Tx.statement       WatermarkRow-        { sessionId = idText sid,+        { memorySpaceId = space,+          sessionId = idText sid,           lastTurnIndex = fromIntegral maxTurnIndex,           distilledAt = now         }       upsertWatermarkStmt +data WatermarkKey = WatermarkKey !MemorySpaceId !Text+ scopedScanCandidates ::   (IOE :> es, Store :> es) =>   Int ->   FindMergeCandidates es scopedScanCandidates limit =-  FindMergeCandidates \scope _query ->-    fmap (take (max 0 limit)) <$> Recall.getActiveByScope scope+  FindMergeCandidates \context scope _query ->+    bimap L1MemoryReadFailed (take (max 0 limit))+      <$> Recall.getActiveByScope (memoryContextSpace context) scope +-- | Merge candidates from ranked hybrid recall over the atom's own text.+--+-- The target is @'Recall.ExactScope' scope@, the same population 'scopedScanCandidates' draws+-- from. The two finders differ in how they /rank/ and bound that population — one by hybrid+-- relevance to the atom, the other by a priority-ordered scan — and a finder that also changed+-- which memories exist would not be a substitutable alternative to the other.+--+-- It was not always so. Until recall targets were explicit this line read+-- @'Recall.legacyRecallTarget' scope@, which maps a global scope to /namespace-wide/, so a+-- session scoped @mori@ drew candidates from @mori:repo:web@ as well — and could merge an atom+-- into that memory, rewriting content that feeds a scene the session has nothing to do with.+-- Nobody chose that; it is what @ScopeGlobal@ meaning two things looked like from inside one+-- module. See @docs\/plans\/30-migrate-recall-consumers-to-explicit-targets.md@ for the decision+-- and what it costs: a globally-scoped session now stores where it used to merge across scopes. recallCandidates ::   (IOE :> es, Store :> es) =>   EmbeddingModel ->@@ -237,29 +297,37 @@   Int ->   FindMergeCandidates es recallCandidates model capability limit =-  FindMergeCandidates \scope query -> do-    hits <--      Recall.recall-        model-        capability-        Recall.RecallRequest-          { scope,-            query,-            strategy = Recall.Hybrid,-            maxResults = max 0 limit-          }-    pure (Right (fmap (.memory) hits))+  FindMergeCandidates \context scope query ->+    -- A limit of zero or less asks for no candidates, which is what the pre-'RecallLimit'+    -- @take (max 0 limit)@ produced. Anything above the bound is clamped rather than refused:+    -- a fused result set holds at most 100 memories, so it was never reachable anyway.+    case Recall.mkRecallLimit (min Recall.maxRecallLimit limit) of+      Left _ -> pure (Right [])+      Right maxResults -> do+        hits <-+          Recall.recall+            model+            capability+            context+            Recall.RecallQuery+              { target = Recall.ExactScope scope,+                query,+                strategy = Recall.Hybrid,+                maxResults+              }+        pure (bimap L1RecallRefused (fmap (.memory)) hits)  buildExtractInput ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   SessionRow ->   [TurnRow] ->   Eff es (Either L1Error ExtractInput)-buildExtractInput sid session turns = do+buildExtractInput space sid session turns = do   memoryTextResult <-     if null turns-      then fallbackMemoryText sid (sessionScope session)+      then fallbackMemoryText space sid (sessionScope session)       else pure (Right (renderTurns turns))   pure do     memoryText <- memoryTextResult@@ -272,17 +340,18 @@  fallbackMemoryText ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   MemoryScope ->   Eff es (Either L1Error Text)-fallbackMemoryText sid scope = do-  bySession <- Recall.getBySession sid+fallbackMemoryText space sid scope = do+  bySession <- Recall.getBySession space sid   case bySession of     Left err -> pure (Left (L1MemoryReadFailed err))     Right rows       | not (null rows) -> pure (Right (renderMemories rows))       | otherwise -> do-          byScope <- Recall.getActiveByScope scope+          byScope <- Recall.getActiveByScope space scope           pure $             case byScope of               Left err -> Left (L1MemoryReadFailed err)@@ -290,6 +359,7 @@  applyAtom ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->   DistillRuntime ->   FindMergeCandidates es ->   SessionId ->@@ -298,10 +368,14 @@   L1Summary ->   ExtractedAtom ->   Eff es (Either L1Error L1Summary)-applyAtom rt finder sid session maxTurnIndex summary atom = do-  candidatesResult <- finder.runFindMergeCandidates (sessionScope session) (unField atom.content)+applyAtom context rt finder sid session maxTurnIndex summary atom = do+  candidatesResult <-+    finder.runFindMergeCandidates+      context+      (sessionScope session)+      (unField atom.content)   case candidatesResult of-    Left err -> pure (Left (L1MemoryReadFailed err))+    Left err -> pure (Left err)     Right candidates -> do       decisionResult <-         liftIO $@@ -315,11 +389,11 @@       case decisionResult of         Left err -> pure (Left (L1ConsolidationFailed (Text.pack (show err))))         Right decision -> do-          appliedResult <- applyDecision sid session atom decision+          appliedResult <- applyDecision context sid session atom decision           case appliedResult of             Left err -> pure (Left err)             Right applied -> do-              writeAudit sid session atom maxTurnIndex decision applied+              writeAudit (memoryContextSpace context) sid session atom maxTurnIndex decision applied               pure (Right (addAppliedDecision summary applied))  -- | Apply a consolidation decision, writing nothing until the whole plan for@@ -328,12 +402,13 @@ -- keiro's at-least-once timer contract. applyDecision ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->   SessionId ->   SessionRow ->   ExtractedAtom ->   ConsolidationDecision ->   Eff es (Either L1Error AppliedDecision)-applyDecision sid session atom decision =+applyDecision context sid session atom decision =   case decision.action of     SkipAtom -> pure (Right (appliedSkip Nothing))     StoreAtom -> storeWinner Nothing Nothing@@ -360,7 +435,7 @@                 appliedNote = note               }         )-        <$> recordAtom sid session atom decision winner supersedes+        <$> recordAtom context sid session atom decision winner supersedes      mergeInto action = do       let requested = nub (parsedTargetIds decision)@@ -371,23 +446,26 @@       if not (null requested) && null nonSelf         then pure (Right (appliedSkip (Just selfTargetNote)))         else do-          winnerRow <- Memory.getMemoryRowById winner+          winnerRow <- Memory.getMemoryRowById (memoryContextSpace context) winner           case winnerRow of             Left err -> pure (Left (L1MemoryReadFailed err))             Right (Just row)               | row.status /= "active" ->                   pure (Right (appliedSkip (Just (retiredWinnerNote row.status))))             _ -> do-              resolved <- resolveExistingTargets nonSelf+              resolved <- resolveExistingTargets (memoryContextSpace context) nonSelf               case resolved of                 Left err -> pure (Left err)                 Right [] -> storeWinner (Just degradeNote) Nothing                 Right targets@(firstTarget : _) -> do-                  winnerResult <- recordAtom sid session atom decision winner (Just firstTarget)+                  winnerResult <- recordAtom context sid session atom decision winner (Just firstTarget)                   case winnerResult of                     Left err -> pure (Left err)                     Right stored -> do-                      mergeResults <- traverse (\target -> requireMemoryWrite =<< Memory.merge target stored) targets+                      mergeResults <-+                        traverse+                          (\target -> requireMemoryWrite =<< Memory.mergeWithContext context target stored)+                          targets                       pure $                         case lefts mergeResults of                           err : _ -> Left err@@ -406,14 +484,15 @@ -- wedging the timer and leaking one memory per retry. resolveExistingTargets ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   [MemoryId] ->   Eff es (Either L1Error [MemoryId])-resolveExistingTargets =+resolveExistingTargets space =   foldM step (Right [])   where     step (Left err) _ = pure (Left err)     step (Right acc) mid = do-      row <- Memory.getMemoryRowById mid+      row <- Memory.getMemoryRowById space mid       pure $         case row of           Left err -> Left (L1MemoryReadFailed err)@@ -428,8 +507,13 @@ retiredWinnerNote status =   "this atom was already distilled and is now " <> status <> "; already represented" +-- | The distilled memory belongs to the space the pass was authorized for, and is attributed to+-- that context's principal — the thing that ran the distillation, not the agent whose session+-- produced the evidence. @agentId@ still carries that agent, unchanged, as the organizing label+-- it always was. recordAtom ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->   SessionId ->   SessionRow ->   ExtractedAtom ->@@ -437,12 +521,16 @@   MemoryId ->   Maybe MemoryId ->   Eff es (Either L1Error MemoryId)-recordAtom sid session atom decision memoryId supersedes = do+recordAtom context sid session atom decision memoryId supersedes = do   now <- liftIO getCurrentTime   requireMemoryWrite-    =<< Memory.record+    =<< Memory.recordWithContext+      context       RecordMemoryData         { memoryId,+          memorySpaceId = memoryContextSpace context,+          actorPrincipal = memoryContextRecordedActor context,+          ownerPrincipal = Nothing,           agentId = session.agentId,           sessionId = Just sid,           scope = sessionScope session,@@ -466,6 +554,7 @@ -- over new turns still writes its own. writeAudit ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   SessionRow ->   ExtractedAtom ->@@ -473,12 +562,13 @@   ConsolidationDecision ->   AppliedDecision ->   Eff es ()-writeAudit sid session atom maxTurnIndex decision applied = do+writeAudit space sid session atom maxTurnIndex decision applied = do   now <- liftIO getCurrentTime   runTransaction $     Tx.statement       AuditRow-        { decisionId = l1AuditKey sid maxTurnIndex (unField atom.content),+        { memorySpaceId = space,+          decisionId = l1AuditKey sid maxTurnIndex (unField atom.content),           sessionId = idText sid,           namespace = scopeNamespaceText (sessionScope session),           scopeKind = scopeKindText (sessionScope session),@@ -604,15 +694,24 @@ encodeTargetIds =   TE.decodeUtf8 . BL.toStrict . Aeson.encode -selectWatermarkStmt :: Statement Text (Maybe Int32)+selectWatermarkStmt :: Statement WatermarkKey (Maybe Int32) selectWatermarkStmt =   preparable-    """-    SELECT last_turn_index-    FROM kioku_l1_watermarks-    WHERE session_id = $1-    """-    (E.param (E.nonNullable E.text))+    ( """+      SELECT last_turn_index+      FROM+      """+        <> " "+        <> l1WatermarksTable+        <> " "+        <> """+           WHERE memory_space_id = $1+             AND session_id = $2+           """+    )+    ( ((\(WatermarkKey space _) -> space) >$< memorySpaceParam)+        <> ((\(WatermarkKey _ sessionId) -> sessionId) >$< E.param (E.nonNullable E.text))+    )     (D.rowMaybe (D.column (D.nonNullable D.int4)))  -- | @GREATEST@ keeps the watermark monotonic: a slow pass over turns 1-3 that@@ -620,39 +719,48 @@ upsertWatermarkStmt :: Statement WatermarkRow () upsertWatermarkStmt =   preparable-    """-    INSERT INTO kioku_l1_watermarks (session_id, last_turn_index, distilled_at)-    VALUES ($1, $2, $3)-    ON CONFLICT (session_id) DO UPDATE-      SET last_turn_index =-            GREATEST(kioku_l1_watermarks.last_turn_index, EXCLUDED.last_turn_index),-          distilled_at = EXCLUDED.distilled_at-    """+    ( "INSERT INTO "+        <> l1WatermarksTable+        <> " AS watermark\n"+        <> """+             (memory_space_id, session_id, last_turn_index, distilled_at)+           VALUES ($1, $2, $3, $4)+           ON CONFLICT (session_id) DO UPDATE+             SET last_turn_index =+                   GREATEST(watermark.last_turn_index, EXCLUDED.last_turn_index),+                 distilled_at = EXCLUDED.distilled_at+           """+    )     watermarkRowEncoder     D.noResult  watermarkRowEncoder :: E.Params WatermarkRow watermarkRowEncoder =-  ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.lastTurnIndex) >$< E.param (E.nonNullable E.int4))     <> ((\row -> row.distilledAt) >$< E.param (E.nonNullable E.timestamptz))  insertAuditStmt :: Statement AuditRow () insertAuditStmt =   preparable-    """-    INSERT INTO kioku_consolidation_decisions-      (decision_id, session_id, namespace, scope_kind, scope_ref, candidate_content,-       decision, target_ids, result_memory_id, rationale, decided_at)-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)-    ON CONFLICT (decision_id) DO NOTHING-    """+    ( "INSERT INTO "+        <> consolidationDecisionsTable+        <> "\n"+        <> """+             (memory_space_id, decision_id, session_id, namespace, scope_kind, scope_ref,+              candidate_content, decision, target_ids, result_memory_id, rationale, decided_at)+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)+           ON CONFLICT (decision_id) DO NOTHING+           """+    )     auditRowEncoder     D.noResult  auditRowEncoder :: E.Params AuditRow auditRowEncoder =-  ((\row -> row.decisionId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.decisionId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.scopeKind) >$< E.param (E.nullable E.text))
src/Kioku/Distill/L2.hs view
@@ -8,6 +8,7 @@     l2SceneProcessManagerName,     l2SceneTimerId,     l2SceneTimerScheduleProjection,+    SceneTimerPayload (..),     mirrorSceneToCurrentWorkspace,     mirrorSceneToWorkspace,     regenerateScene,@@ -16,16 +17,16 @@   ) where -import Contravariant.Extras (contrazip3, contrazip4) import Control.Exception (IOException, try) import Crypto.Hash (Digest, SHA256) import Crypto.Hash qualified as Hash import Data.Aeson qualified as Aeson+import Data.Aeson.Types (withObject, (.:)) import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as BL-import Data.Foldable (for_)+import Data.Foldable (for_, traverse_) import Data.Functor.Contravariant ((>$<))-import Data.Maybe (catMaybes)+import Data.Maybe (catMaybes, maybeToList) import Data.Text qualified as Text import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as TextIO@@ -41,9 +42,11 @@ import Keiro.Projection (InlineProjection (..)) import Keiro.ReadModel (ReadModelError) import Keiro.Timer (TimerId (..), TimerRequest (..), TimerRow (..), scheduleTimerTx)+import Kioku.Api.Access (MemoryContextProvider (..), MemorySpaceId, legacyMemorySpaceId, memoryContextSpace) import Kioku.Api.Scope (MemoryScope, scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Api.Types (MemoryRecord (..))-import Kioku.Distill.L3 (scheduleL3PersonaTimerTx)+import Kioku.Database.Schema (memoriesTable, scenesTable)+import Kioku.Distill.L3 (partitionedCorrelationId, scheduleL3PersonaTimerTx) import Kioku.Distill.Runtime (DistillRuntime, distillWorkspaceRoot, runSceneDistillation) import Kioku.Distill.Scene (SceneInput (..), SceneOutput (..)) import Kioku.Distill.ScopeIdentity (escapeScopeComponent, scopeIdentity, scopeSlugFromColumns)@@ -57,8 +60,10 @@     MemoryRecordedData (..),     MemorySupersededData (..),   )+import Kioku.Partition (memorySpaceColumn, memorySpaceParam, parsePartitionSpace) import Kioku.Prelude import Kioku.Recall qualified as Recall+import Kioku.Workspace (legacySceneArtifactDir, sceneArtifactDir) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (EventId (..), RecordedEvent (..))@@ -73,7 +78,8 @@   deriving stock (Generic, Show)  data SceneRow = SceneRow-  { sceneId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    sceneId :: !Text,     namespace :: !Text,     scopeKind :: !(Maybe Text),     scopeRef :: !(Maybe Text),@@ -87,12 +93,23 @@   }   deriving stock (Generic, Eq, Show) -newtype SceneTimerPayload = SceneTimerPayload-  { scope :: MemoryScope+-- | What a scheduled scene regeneration needs to know.+--+-- @memorySpaceId@ is what keeps two spaces that happen to share a namespace and scope from+-- regenerating each other's scene. Timers scheduled before the field existed decode into+-- 'Kioku.Api.Access.legacyMemorySpaceId', the same rule stored events follow.+data SceneTimerPayload = SceneTimerPayload+  { memorySpaceId :: !MemorySpaceId,+    scope :: !MemoryScope   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON SceneTimerPayload where+  parseJSON =+    withObject "SceneTimerPayload" \o ->+      SceneTimerPayload <$> parsePartitionSpace o <*> o .: "scope"+ l2SceneProcessManagerName :: Text l2SceneProcessManagerName = "kioku-l2-scene" @@ -119,15 +136,16 @@   MemoryRecorded d ->     scheduleTimerTx $       l2SceneTimerRequest+        d.memorySpaceId         d.scope         (idText (d.memoryId :: MemoryId))         (addUTCTime sceneDebounceSeconds d.recordedAt)   MemoryArchived d ->-    scheduleScopedSceneTimerTx d.memoryId (kindSourceId d.memoryId "archived") d.archivedAt+    scheduleScopedSceneTimerTx d.memorySpaceId d.memoryId (kindSourceId d.memoryId "archived") d.archivedAt   MemorySuperseded d ->-    scheduleScopedSceneTimerTx d.memoryId (kindSourceId d.memoryId "superseded") d.supersededAt+    scheduleScopedSceneTimerTx d.memorySpaceId d.memoryId (kindSourceId d.memoryId "superseded") d.supersededAt   MemoryMerged d ->-    scheduleScopedSceneTimerTx d.memoryId (kindSourceId d.memoryId "merged") d.mergedAt+    scheduleScopedSceneTimerTx d.memorySpaceId d.memoryId (kindSourceId d.memoryId "merged") d.mergedAt   -- Confidence is in the scene's source hash ('atomSource') and in the LLM prompt   -- ('renderAtom'), so changing it makes the scene stale exactly as forgetting does.   --@@ -139,6 +157,7 @@   -- event id is stable across replay, so it cannot double-schedule either.   MemoryConfidenceUpdated d ->     scheduleScopedSceneTimerTx+      d.memorySpaceId       d.memoryId       (idText d.memoryId <> ":confidence:" <> eventIdText recorded.eventId)       d.updatedAt@@ -157,12 +176,13 @@ -- memory id, because keiro's 'scheduleTimerTx' re-arms a conflicting timer only -- while it is still @scheduled@ — reusing the record-time id would be silently -- dropped once that timer has fired, which by then it almost always has.-scheduleScopedSceneTimerTx :: MemoryId -> Text -> UTCTime -> Tx.Transaction ()-scheduleScopedSceneTimerTx memoryId sourceId occurredAt = do-  scopeCols <- Tx.statement (idText memoryId) selectMemoryScopeColumnsStmt+scheduleScopedSceneTimerTx :: MemorySpaceId -> MemoryId -> Text -> UTCTime -> Tx.Transaction ()+scheduleScopedSceneTimerTx memorySpaceId memoryId sourceId occurredAt = do+  scopeCols <- Tx.statement (MemoryScopeLookup memorySpaceId (idText memoryId)) selectMemoryScopeColumnsStmt   for_ scopeCols \(ns, sk, sr) ->     scheduleTimerTx $       l2SceneTimerRequest+        memorySpaceId         (scopeFromColumns ns sk sr)         sourceId         (addUTCTime sceneDebounceSeconds occurredAt)@@ -175,18 +195,21 @@ eventIdText :: EventId -> Text eventIdText (EventId uuid) = UUID.toText uuid -l2SceneTimerRequest :: MemoryScope -> Text -> UTCTime -> TimerRequest-l2SceneTimerRequest scope sourceId fireAt =+l2SceneTimerRequest :: MemorySpaceId -> MemoryScope -> Text -> UTCTime -> TimerRequest+l2SceneTimerRequest memorySpaceId scope sourceId fireAt =   TimerRequest-    { timerId = l2SceneTimerId scope sourceId,+    { timerId = l2SceneTimerId memorySpaceId scope sourceId,       processManagerName = l2SceneProcessManagerName,-      correlationId = scopeIdentity scope,+      correlationId = partitionedCorrelationId memorySpaceId scope,       fireAt,-      payload = Aeson.toJSON (SceneTimerPayload scope)+      payload = Aeson.toJSON SceneTimerPayload {memorySpaceId, scope}     } -l2SceneTimerId :: MemoryScope -> Text -> TimerId-l2SceneTimerId scope sourceId =+-- | The timer id and correlation id both carry the memory space, for the reason given at+-- 'Kioku.Distill.L3.l3PersonaTimerId': these timers are keyed by a scope, and two spaces are+-- allowed to use the same one.+l2SceneTimerId :: MemorySpaceId -> MemoryScope -> Text -> TimerId+l2SceneTimerId memorySpaceId scope sourceId =   TimerId $     UUIDv5.generateNamed       l2SceneTimerNamespace@@ -195,17 +218,18 @@     raw =       l2SceneProcessManagerName         <> ":"-        <> scopeIdentity scope+        <> partitionedCorrelationId memorySpaceId scope         <> ":"         <> sourceId  regenerateScene ::   (IOE :> es, Store :> es) =>   DistillRuntime ->+  MemorySpaceId ->   MemoryScope ->   Eff es (Either L2Error (Maybe SceneRow))-regenerateScene rt scope = do-  memoryResult <- Recall.getActiveByScope scope+regenerateScene rt memorySpaceId scope = do+  memoryResult <- Recall.getActiveByScope memorySpaceId scope   case memoryResult of     Left err -> pure (Left (L2MemoryReadFailed err))     -- Every memory in this scope has been forgotten. Delete the scene outright@@ -214,21 +238,21 @@     -- memories, and a blank mirror file is just a confusing way to still be     -- there. No LLM runs on this path -- there is nothing left to summarize.     Right [] -> do-      existing <- lookupScene scope defaultSceneKey+      existing <- lookupScene memorySpaceId scope defaultSceneKey       case existing of         Left err -> pure (Left err)         Right Nothing -> pure (Right Nothing)         Right (Just row) -> do           now <- liftIO getCurrentTime           runTransaction do-            Tx.statement row.sceneId deleteSceneStmt-            scheduleL3PersonaTimerTx scope now+            Tx.statement (SceneKey memorySpaceId row.sceneId) deleteSceneStmt+            scheduleL3PersonaTimerTx memorySpaceId scope now           liftIO (bestEffortRemoveSceneMirror rt row)           pure (Right Nothing)     Right atoms -> do       let sourceHash = sceneSourceHash atoms           sceneId = sceneRowId scope-      existing <- lookupScene scope defaultSceneKey+      existing <- lookupScene memorySpaceId scope defaultSceneKey       case existing of         Left err -> pure (Left err)         Right (Just row)@@ -250,7 +274,8 @@               now <- liftIO getCurrentTime               let row =                     SceneRow-                      { sceneId,+                      { memorySpaceId,+                        sceneId,                         namespace = scopeNamespaceText scope,                         scopeKind = scopeKindText scope,                         scopeRef = scopeRefText scope,@@ -264,16 +289,22 @@                       }               runTransaction do                 Tx.statement row upsertSceneStmt-                scheduleL3PersonaTimerTx scope now+                scheduleL3PersonaTimerTx memorySpaceId scope now               liftIO (bestEffortMirrorScene rt row)               pure (Right (Just row)) +-- | Fire one L2 scene timer.+--+-- Like the L1 handler, a background pass cannot arrive holding an authorization context: it+-- reads the memory space out of the payload and asks the provider for a decision about /that/+-- space. A refusal is a configuration fact, so it dead-letters rather than retrying forever. fireL2SceneTimer ::   (IOE :> es, Store :> es) =>+  MemoryContextProvider (Eff es) ->   DistillRuntime ->   TimerRow ->   Eff es FireOutcome-fireL2SceneTimer rt row+fireL2SceneTimer contexts rt row   | row.processManagerName /= l2SceneProcessManagerName =       pure FireNotMine   | otherwise =@@ -283,35 +314,72 @@         Aeson.Error err ->           pure (FireFailedPermanently ("L2 scene timer payload is malformed: " <> Text.pack err))         Aeson.Success payload -> do-          result <- regenerateScene rt payload.scope-          pure $-            case result of-              Right _ -> FireCompleted (timerMarkerEventId row.timerId)-              Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))+          decision <- contexts.contextForSpace payload.memorySpaceId+          case decision of+            Left denial ->+              pure+                ( FireFailedPermanently+                    ("L2 scene timer is not authorized for its memory space: " <> Text.pack (show denial))+                )+            Right context -> do+              result <- regenerateScene rt (memoryContextSpace context) payload.scope+              pure $+                case result of+                  Right _ -> FireCompleted (timerMarkerEventId row.timerId)+                  Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))  lookupScene ::   (Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Text ->   Eff es (Either L2Error (Maybe SceneRow))-lookupScene scope sceneKey = do+lookupScene memorySpaceId scope sceneKey = do   result <-     runTransaction $-      Tx.statement-        (scopeNamespaceText scope, scopeKindText scope, scopeRefText scope, sceneKey)-        selectSceneByScopeKeyStmt+      Tx.statement (SceneScopeKey (scopeKey memorySpaceId scope) sceneKey) selectSceneByScopeKeyStmt   pure (Right result)  getScenesByScope ::   (Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Eff es [SceneRow]-getScenesByScope scope =+getScenesByScope memorySpaceId scope =   runTransaction $-    Tx.statement-      (scopeNamespaceText scope, scopeKindText scope, scopeRefText scope)-      selectScenesByScopeStmt+    Tx.statement (scopeKey memorySpaceId scope) selectScenesByScopeStmt +-- | A scope lookup inside one memory space, as a record rather than a tuple so that the+-- partition cannot be transposed with the namespace it sits beside.+data PartitionedScope = PartitionedScope+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    scopeKind :: !(Maybe Text),+    scopeRef :: !(Maybe Text)+  }++data SceneScopeKey = SceneScopeKey !PartitionedScope !Text++data SceneKey = SceneKey !MemorySpaceId !Text++data MemoryScopeLookup = MemoryScopeLookup !MemorySpaceId !Text++scopeKey :: MemorySpaceId -> MemoryScope -> PartitionedScope+scopeKey memorySpaceId scope =+  PartitionedScope+    { memorySpaceId,+      namespace = scopeNamespaceText scope,+      scopeKind = scopeKindText scope,+      scopeRef = scopeRefText scope+    }++partitionedScopeEncoder :: E.Params PartitionedScope+partitionedScopeEncoder =+  ((\q -> q.memorySpaceId) >$< memorySpaceParam)+    <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))+    <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))+ mirrorSceneToCurrentWorkspace :: SceneRow -> IO FilePath mirrorSceneToCurrentWorkspace row = do   workspace <- getCurrentDirectory@@ -320,14 +388,30 @@ mirrorSceneToWorkspace :: FilePath -> SceneRow -> IO FilePath mirrorSceneToWorkspace workspace row = do   let path = sceneMirrorPath workspace row-  createDirectoryIfMissing True (workspace </> ".kioku" </> "scenes")+  createDirectoryIfMissing True (sceneArtifactDir workspace row.memorySpaceId)   TextIO.writeFile path (renderSceneFile row)   pure path +-- | The mirror path is derived from the row's own memory space as well as its scope.+--+-- The filename alone cannot separate two spaces: the slug comes from the namespace, kind, and+-- ref, and two spaces are allowed to hold all three. The space is the directory. See+-- "Kioku.Workspace" for why that directory is not simply the space id. sceneMirrorPath :: FilePath -> SceneRow -> FilePath sceneMirrorPath workspace row =-  workspace </> ".kioku" </> "scenes" </> Text.unpack (sceneScopeSlug row <> ".md")+  sceneArtifactDir workspace row.memorySpaceId </> Text.unpack (sceneScopeSlug row <> ".md") +-- | Where this scene's mirror lived before memory spaces existed.+--+-- Only meaningful for the legacy space, which is the only space that can have one: the+-- historical tree predates the partition, so every file in it was written by the deployment that+-- became @kioku_legacy@.+legacySceneMirrorPath :: FilePath -> SceneRow -> Maybe FilePath+legacySceneMirrorPath workspace row+  | row.memorySpaceId == legacyMemorySpaceId =+      Just (legacySceneArtifactDir workspace </> Text.unpack (sceneScopeSlug row <> ".md"))+  | otherwise = Nothing+ bestEffortMirrorScene :: DistillRuntime -> SceneRow -> IO () bestEffortMirrorScene rt row = do   let write = do@@ -340,16 +424,24 @@ -- The durable artifact is the database row, and it is already gone by the time -- this runs; a failure to unlink the file must not fail the timer, and the next -- regeneration in this workspace rewrites or removes it anyway.+--+-- The legacy space's pre-partition mirror goes too, and it is the one place anything still+-- touches the historical tree. Every memory in this scope has been forgotten, so a surviving+-- @.kioku\/scenes\/…@ file is not merely stale — it is forgotten content that a host agent would+-- keep reading until somebody ran the artifact migration. bestEffortRemoveSceneMirror :: DistillRuntime -> SceneRow -> IO () bestEffortRemoveSceneMirror rt row = do   let remove = do         workspace <- distillWorkspaceRoot rt-        let path = sceneMirrorPath workspace row-        exists <- doesFileExist path-        when exists (removeFile path)+        traverse_ removeIfPresent (sceneMirrorPath workspace row : maybeToList (legacySceneMirrorPath workspace row))   _ <- try remove :: IO (Either IOException ())   pure () +removeIfPresent :: FilePath -> IO ()+removeIfPresent path = do+  exists <- doesFileExist path+  when exists (removeFile path)+ renderSceneFile :: SceneRow -> Text renderSceneFile row =   "# " <> row.title <> "\n\n" <> row.bodyMd <> "\n"@@ -412,8 +504,9 @@ sceneRowDecoder :: D.Row SceneRow sceneRowDecoder =   SceneRow-    <$> D.column (D.nonNullable D.text)+    <$> memorySpaceColumn     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nonNullable D.text)@@ -426,7 +519,8 @@  sceneRowEncoder :: E.Params SceneRow sceneRowEncoder =-  ((\row -> row.sceneId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.sceneId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.scopeKind) >$< E.param (E.nullable E.text))     <> ((\row -> row.scopeRef) >$< E.param (E.nullable E.text))@@ -438,11 +532,13 @@     <> ((\row -> row.createdAt) >$< E.param (E.nonNullable E.timestamptz))     <> ((\row -> row.updatedAt) >$< E.param (E.nonNullable E.timestamptz)) -selectMemoryScopeColumnsStmt :: Statement Text (Maybe (Text, Maybe Text, Maybe Text))+selectMemoryScopeColumnsStmt :: Statement MemoryScopeLookup (Maybe (Text, Maybe Text, Maybe Text)) selectMemoryScopeColumnsStmt =   preparable-    "SELECT namespace, scope_kind, scope_ref FROM kioku_memories WHERE memory_id = $1"-    (E.param (E.nonNullable E.text))+    ("SELECT namespace, scope_kind, scope_ref FROM " <> memoriesTable <> " WHERE memory_space_id = $1 AND memory_id = $2")+    ( ((\(MemoryScopeLookup space _) -> space) >$< memorySpaceParam)+        <> ((\(MemoryScopeLookup _ memoryId) -> memoryId) >$< E.param (E.nonNullable E.text))+    )     ( D.rowMaybe         ( (,,)             <$> D.column (D.nonNullable D.text)@@ -451,69 +547,82 @@         )     ) -selectSceneByScopeKeyStmt :: Statement (Text, Maybe Text, Maybe Text, Text) (Maybe SceneRow)+selectSceneByScopeKeyStmt :: Statement SceneScopeKey (Maybe SceneRow) selectSceneByScopeKeyStmt =   preparable-    """-    SELECT scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md,-           atom_ids::text, source_hash, created_at, updated_at-    FROM kioku_scenes-    WHERE namespace = $1-      AND ((scope_kind = $2 AND scope_ref = $3)-           OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))-      AND scene_key = $4-    """-    ( contrazip4-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nonNullable E.text))+    ( """+      SELECT memory_space_id, scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md,+             atom_ids::text, source_hash, created_at, updated_at+      FROM+      """+        <> " "+        <> scenesTable+        <> " "+        <> """+           WHERE memory_space_id = $1+             AND namespace = $2+             AND ((scope_kind = $3 AND scope_ref = $4)+                  OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL))+             AND scene_key = $5+           """     )+    ( ((\(SceneScopeKey scope _) -> scope) >$< partitionedScopeEncoder)+        <> ((\(SceneScopeKey _ sceneKey) -> sceneKey) >$< E.param (E.nonNullable E.text))+    )     (D.rowMaybe sceneRowDecoder) -selectScenesByScopeStmt :: Statement (Text, Maybe Text, Maybe Text) [SceneRow]+selectScenesByScopeStmt :: Statement PartitionedScope [SceneRow] selectScenesByScopeStmt =   preparable-    """-    SELECT scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md,-           atom_ids::text, source_hash, created_at, updated_at-    FROM kioku_scenes-    WHERE namespace = $1-      AND ((scope_kind = $2 AND scope_ref = $3)-           OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))-    ORDER BY scene_key ASC, updated_at DESC-    """-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))+    ( """+      SELECT memory_space_id, scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md,+             atom_ids::text, source_hash, created_at, updated_at+      FROM+      """+        <> " "+        <> scenesTable+        <> " "+        <> """+           WHERE memory_space_id = $1+             AND namespace = $2+             AND ((scope_kind = $3 AND scope_ref = $4)+                  OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL))+           ORDER BY scene_key ASC, updated_at DESC+           """     )+    partitionedScopeEncoder     (D.rowList sceneRowDecoder)  -- | Delete by the row's own primary key, which was written from @sceneRowId@. -- Deriving the key here instead would re-implement the scope-identity format--- that docs/plans/13-... owns changing.-deleteSceneStmt :: Statement Text ()+-- that docs/plans/13-... owns changing. That key is now composite: @sceneRowId@ is derived+-- from the scope alone, and two memory spaces may hold the same scope.+deleteSceneStmt :: Statement SceneKey () deleteSceneStmt =   preparable-    "DELETE FROM kioku_scenes WHERE scene_id = $1"-    (E.param (E.nonNullable E.text))+    ("DELETE FROM " <> scenesTable <> " WHERE memory_space_id = $1 AND scene_id = $2")+    ( ((\(SceneKey space _) -> space) >$< memorySpaceParam)+        <> ((\(SceneKey _ sceneId) -> sceneId) >$< E.param (E.nonNullable E.text))+    )     D.noResult  upsertSceneStmt :: Statement SceneRow () upsertSceneStmt =   preparable-    """-    INSERT INTO kioku_scenes-      (scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md,-       atom_ids, source_hash, created_at, updated_at)-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)-    ON CONFLICT (scene_id) DO UPDATE SET-      title = EXCLUDED.title,-      body_md = EXCLUDED.body_md,-      atom_ids = EXCLUDED.atom_ids,-      source_hash = EXCLUDED.source_hash,-      updated_at = EXCLUDED.updated_at-    """+    ( "INSERT INTO "+        <> scenesTable+        <> "\n"+        <> """+             (memory_space_id, scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md,+              atom_ids, source_hash, created_at, updated_at)+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)+           ON CONFLICT (memory_space_id, scene_id) DO UPDATE SET+             title = EXCLUDED.title,+             body_md = EXCLUDED.body_md,+             atom_ids = EXCLUDED.atom_ids,+             source_hash = EXCLUDED.source_hash,+             updated_at = EXCLUDED.updated_at+           """+    )     sceneRowEncoder     D.noResult
src/Kioku/Distill/L3.hs view
@@ -5,8 +5,10 @@     PersonaRow (..),     fireL3PersonaTimer,     getPersonaByScope,+    PersonaTimerPayload (..),     l3PersonaProcessManagerName,     l3PersonaTimerId,+    partitionedCorrelationId,     mirrorPersonaToCurrentWorkspace,     mirrorPersonaToWorkspace,     personaMirrorPath,@@ -16,16 +18,17 @@   ) where -import Contravariant.Extras (contrazip3) import Control.Exception (IOException, try) import Crypto.Hash (Digest, SHA256) import Crypto.Hash qualified as Hash import Data.Aeson qualified as Aeson+import Data.Aeson.Types (withObject, (.:)) import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as BL+import Data.Foldable (traverse_) import Data.Functor.Contravariant ((>$<)) import Data.Int (Int32)-import Data.Maybe (catMaybes)+import Data.Maybe (catMaybes, maybeToList) import Data.Text qualified as Text import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as TextIO@@ -39,12 +42,22 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.Timer (TimerId (..), TimerRequest (..), TimerRow (..), scheduleTimerTx)+import Kioku.Api.Access+  ( MemoryContextProvider (..),+    MemorySpaceId,+    legacyMemorySpaceId,+    memoryContextSpace,+    memorySpaceIdText,+  ) import Kioku.Api.Scope (MemoryScope, scopeKindText, scopeNamespaceText, scopeRefText)+import Kioku.Database.Schema (personasTable, scenesTable) import Kioku.Distill.Persona (PersonaInput (..), PersonaOutput (..)) import Kioku.Distill.Runtime (DistillRuntime, distillWorkspaceRoot, runPersonaDistillation) import Kioku.Distill.ScopeIdentity (scopeIdentity, scopeSlugFromColumns) import Kioku.Distill.Timer.Outcome (FireOutcome (..), fireRetryDelay, timerMarkerEventId)+import Kioku.Partition (memorySpaceColumn, memorySpaceParam, parsePartitionSpace) import Kioku.Prelude+import Kioku.Workspace (legacyPersonaArtifactDir, personaArtifactDir) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import Shikumi.Schema.Types (field, unField)@@ -57,7 +70,8 @@   deriving stock (Generic, Show)  data PersonaRow = PersonaRow-  { personaId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    personaId :: !Text,     namespace :: !Text,     scopeKind :: !(Maybe Text),     scopeRef :: !(Maybe Text),@@ -77,33 +91,50 @@   }   deriving stock (Generic, Eq, Show) -newtype PersonaTimerPayload = PersonaTimerPayload-  { scope :: MemoryScope+-- | What a scheduled persona regeneration needs to know.+--+-- @memorySpaceId@ is what keeps two spaces that happen to share a namespace and scope from+-- regenerating each other's persona. Timers scheduled before the field existed decode into+-- 'Kioku.Api.Access.legacyMemorySpaceId', the same rule stored events follow.+data PersonaTimerPayload = PersonaTimerPayload+  { memorySpaceId :: !MemorySpaceId,+    scope :: !MemoryScope   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON PersonaTimerPayload where+  parseJSON =+    withObject "PersonaTimerPayload" \o ->+      PersonaTimerPayload <$> parsePartitionSpace o <*> o .: "scope"+ l3PersonaProcessManagerName :: Text l3PersonaProcessManagerName = "kioku-l3-persona"  personaDebounceSeconds :: NominalDiffTime personaDebounceSeconds = 5 -scheduleL3PersonaTimerTx :: MemoryScope -> UTCTime -> Tx.Transaction ()-scheduleL3PersonaTimerTx scope now =+scheduleL3PersonaTimerTx :: MemorySpaceId -> MemoryScope -> UTCTime -> Tx.Transaction ()+scheduleL3PersonaTimerTx memorySpaceId scope now =   scheduleTimerTx $     TimerRequest-      { timerId = l3PersonaTimerId scope fireAt,+      { timerId = l3PersonaTimerId memorySpaceId scope fireAt,         processManagerName = l3PersonaProcessManagerName,-        correlationId = scopeIdentity scope,+        correlationId = partitionedCorrelationId memorySpaceId scope,         fireAt,-        payload = Aeson.toJSON (PersonaTimerPayload scope)+        payload = Aeson.toJSON PersonaTimerPayload {memorySpaceId, scope}       }   where     fireAt = addUTCTime personaDebounceSeconds now -l3PersonaTimerId :: MemoryScope -> UTCTime -> TimerId-l3PersonaTimerId scope fireAt =+-- | The timer id and correlation id both carry the memory space.+--+-- Unlike the L1 timers, which are keyed by a globally unique session id, these are keyed by a+-- scope — and two spaces are allowed to use the same one. Without the space in the id,+-- keiro's @scheduleTimerTx@ upsert would treat one space's regeneration as a re-arming of the+-- other's and only one payload would survive.+l3PersonaTimerId :: MemorySpaceId -> MemoryScope -> UTCTime -> TimerId+l3PersonaTimerId memorySpaceId scope fireAt =   TimerId $     UUIDv5.generateNamed       l3PersonaTimerNamespace@@ -112,34 +143,42 @@     raw =       l3PersonaProcessManagerName         <> ":"-        <> scopeIdentity scope+        <> partitionedCorrelationId memorySpaceId scope         <> ":"         <> Text.pack (show fireAt) +-- | A scope identity qualified by its memory space. 'memorySpaceIdText' cannot contain @:@,+-- @\/@ or @%@ (see 'Kioku.Api.Access.mkMemorySpaceId') and 'scopeIdentity' escapes those same+-- characters, so joining the two with @:@ is injective.+partitionedCorrelationId :: MemorySpaceId -> MemoryScope -> Text+partitionedCorrelationId memorySpaceId scope =+  memorySpaceIdText memorySpaceId <> ":" <> scopeIdentity scope+ regeneratePersona ::   (IOE :> es, Store :> es) =>   DistillRuntime ->+  MemorySpaceId ->   MemoryScope ->   Eff es (Either L3Error (Maybe PersonaRow))-regeneratePersona rt scope = do-  scenes <- getPersonaScenesByScope scope+regeneratePersona rt memorySpaceId scope = do+  scenes <- getPersonaScenesByScope memorySpaceId scope   case scenes of     -- Every scene in this scope is gone, so the persona distilled from them has     -- no source left. Delete it and its mirror, symmetrically with the scene     -- delete in "Kioku.Distill.L2", and without an LLM call: there is nothing     -- to summarize. The persona is the top of the pyramid, so nothing chains on.     [] -> do-      existing <- getPersonaByScope scope+      existing <- getPersonaByScope memorySpaceId scope       case existing of         Nothing -> pure (Right Nothing)         Just row -> do-          runTransaction (Tx.statement row.personaId deletePersonaStmt)+          runTransaction (Tx.statement (PersonaKey memorySpaceId row.personaId) deletePersonaStmt)           liftIO (bestEffortRemovePersonaMirror rt row)           pure (Right Nothing)     _ -> do       let sourceHash = personaSourceHash scenes           personaId = personaRowId scope-      existing <- getPersonaByScope scope+      existing <- getPersonaByScope memorySpaceId scope       case existing of         Just row           | row.sourceHash == sourceHash -> do@@ -160,7 +199,8 @@               now <- liftIO getCurrentTime               let row =                     PersonaRow-                      { personaId,+                      { memorySpaceId,+                        personaId,                         namespace = scopeNamespaceText scope,                         scopeKind = scopeKindText scope,                         scopeRef = scopeRefText scope,@@ -174,12 +214,18 @@               liftIO (bestEffortMirrorPersona rt row)               pure (Right (Just row)) +-- | Fire one L3 persona timer.+--+-- Like the L1 handler, a background pass cannot arrive holding an authorization context: it+-- reads the memory space out of the payload and asks the provider for a decision about /that/+-- space. A refusal is a configuration fact, so it dead-letters rather than retrying forever. fireL3PersonaTimer ::   (IOE :> es, Store :> es) =>+  MemoryContextProvider (Eff es) ->   DistillRuntime ->   TimerRow ->   Eff es FireOutcome-fireL3PersonaTimer rt row+fireL3PersonaTimer contexts rt row   | row.processManagerName /= l3PersonaProcessManagerName =       pure FireNotMine   | otherwise =@@ -189,32 +235,65 @@         Aeson.Error err ->           pure (FireFailedPermanently ("L3 persona timer payload is malformed: " <> Text.pack err))         Aeson.Success payload -> do-          result <- regeneratePersona rt payload.scope-          pure $-            case result of-              Right _ -> FireCompleted (timerMarkerEventId row.timerId)-              Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))+          decision <- contexts.contextForSpace payload.memorySpaceId+          case decision of+            Left denial ->+              pure+                ( FireFailedPermanently+                    ("L3 persona timer is not authorized for its memory space: " <> Text.pack (show denial))+                )+            Right context -> do+              result <- regeneratePersona rt (memoryContextSpace context) payload.scope+              pure $+                case result of+                  Right _ -> FireCompleted (timerMarkerEventId row.timerId)+                  Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))  getPersonaByScope ::   (Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Eff es (Maybe PersonaRow)-getPersonaByScope scope =+getPersonaByScope memorySpaceId scope =   runTransaction $-    Tx.statement-      (scopeNamespaceText scope, scopeKindText scope, scopeRefText scope)-      selectPersonaByScopeStmt+    Tx.statement (scopeKey memorySpaceId scope) selectPersonaByScopeStmt  getPersonaScenesByScope ::   (Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Eff es [PersonaSceneRow]-getPersonaScenesByScope scope =+getPersonaScenesByScope memorySpaceId scope =   runTransaction $-    Tx.statement-      (scopeNamespaceText scope, scopeKindText scope, scopeRefText scope)-      selectScenesForPersonaStmt+    Tx.statement (scopeKey memorySpaceId scope) selectScenesForPersonaStmt +-- | A scope lookup inside one memory space, as a record rather than a four-tuple so that the+-- partition cannot be transposed with the namespace it sits beside.+data PartitionedScope = PartitionedScope+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    scopeKind :: !(Maybe Text),+    scopeRef :: !(Maybe Text)+  }++data PersonaKey = PersonaKey !MemorySpaceId !Text++scopeKey :: MemorySpaceId -> MemoryScope -> PartitionedScope+scopeKey memorySpaceId scope =+  PartitionedScope+    { memorySpaceId,+      namespace = scopeNamespaceText scope,+      scopeKind = scopeKindText scope,+      scopeRef = scopeRefText scope+    }++partitionedScopeEncoder :: E.Params PartitionedScope+partitionedScopeEncoder =+  ((\q -> q.memorySpaceId) >$< memorySpaceParam)+    <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))+    <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))+ mirrorPersonaToCurrentWorkspace :: PersonaRow -> IO FilePath mirrorPersonaToCurrentWorkspace row = do   workspace <- getCurrentDirectory@@ -223,14 +302,25 @@ mirrorPersonaToWorkspace :: FilePath -> PersonaRow -> IO FilePath mirrorPersonaToWorkspace workspace row = do   let path = personaMirrorPath workspace row-  createDirectoryIfMissing True (workspace </> ".kioku" </> "persona")+  createDirectoryIfMissing True (personaArtifactDir workspace row.memorySpaceId)   TextIO.writeFile path (row.bodyMd <> "\n")   pure path +-- | The mirror path is derived from the row's own memory space as well as its scope, for the+-- reason given at 'Kioku.Distill.L2.sceneMirrorPath': the slug cannot separate two spaces+-- because both are allowed to hold the same scope. personaMirrorPath :: FilePath -> PersonaRow -> FilePath personaMirrorPath workspace row =-  workspace </> ".kioku" </> "persona" </> Text.unpack (personaScopeSlug row <> ".md")+  personaArtifactDir workspace row.memorySpaceId </> Text.unpack (personaScopeSlug row <> ".md") +-- | Where this persona's mirror lived before memory spaces existed. Only the legacy space can+-- have one; see 'Kioku.Distill.L2.sceneMirrorPath'.+legacyPersonaMirrorPath :: FilePath -> PersonaRow -> Maybe FilePath+legacyPersonaMirrorPath workspace row+  | row.memorySpaceId == legacyMemorySpaceId =+      Just (legacyPersonaArtifactDir workspace </> Text.unpack (personaScopeSlug row <> ".md"))+  | otherwise = Nothing+ bestEffortMirrorPersona :: DistillRuntime -> PersonaRow -> IO () bestEffortMirrorPersona rt row = do   let write = do@@ -241,16 +331,25 @@  -- | Remove a persona's mirror file. Best-effort for the same reason writing it -- is: the database row is the durable artifact and is already deleted.+--+-- The legacy space's pre-partition mirror goes too, symmetrically with the scene delete in+-- "Kioku.Distill.L2": every scene this persona was distilled from is gone, so a surviving+-- @.kioku\/persona\/…@ file describes memories nobody may read any more. bestEffortRemovePersonaMirror :: DistillRuntime -> PersonaRow -> IO () bestEffortRemovePersonaMirror rt row = do   let remove = do         workspace <- distillWorkspaceRoot rt-        let path = personaMirrorPath workspace row-        exists <- doesFileExist path-        when exists (removeFile path)+        traverse_+          removeIfPresent+          (personaMirrorPath workspace row : maybeToList (legacyPersonaMirrorPath workspace row))   _ <- try remove :: IO (Either IOException ())   pure () +removeIfPresent :: FilePath -> IO ()+removeIfPresent path = do+  exists <- doesFileExist path+  when exists (removeFile path)+ personaScopeSlug :: PersonaRow -> Text personaScopeSlug row =   scopeSlugFromColumns row.namespace row.scopeKind row.scopeRef@@ -293,8 +392,9 @@ personaRowDecoder :: D.Row PersonaRow personaRowDecoder =   PersonaRow-    <$> D.column (D.nonNullable D.text)+    <$> memorySpaceColumn     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nonNullable D.text)@@ -313,7 +413,8 @@  personaRowEncoder :: E.Params PersonaRow personaRowEncoder =-  ((\row -> row.personaId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.personaId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.scopeKind) >$< E.param (E.nullable E.text))     <> ((\row -> row.scopeRef) >$< E.param (E.nullable E.text))@@ -323,64 +424,76 @@     <> ((\row -> row.createdAt) >$< E.param (E.nonNullable E.timestamptz))     <> ((\row -> row.updatedAt) >$< E.param (E.nonNullable E.timestamptz)) -selectPersonaByScopeStmt :: Statement (Text, Maybe Text, Maybe Text) (Maybe PersonaRow)+selectPersonaByScopeStmt :: Statement PartitionedScope (Maybe PersonaRow) selectPersonaByScopeStmt =   preparable-    """-    SELECT persona_id, namespace, scope_kind, scope_ref, body_md, scene_count,-           source_hash, created_at, updated_at-    FROM kioku_personas-    WHERE namespace = $1-      AND ((scope_kind = $2 AND scope_ref = $3)-           OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))-    """-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))+    ( """+      SELECT memory_space_id, persona_id, namespace, scope_kind, scope_ref, body_md, scene_count,+             source_hash, created_at, updated_at+      FROM+      """+        <> " "+        <> personasTable+        <> " "+        <> """+           WHERE memory_space_id = $1+             AND namespace = $2+             AND ((scope_kind = $3 AND scope_ref = $4)+                  OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL))+           """     )+    partitionedScopeEncoder     (D.rowMaybe personaRowDecoder) -selectScenesForPersonaStmt :: Statement (Text, Maybe Text, Maybe Text) [PersonaSceneRow]+selectScenesForPersonaStmt :: Statement PartitionedScope [PersonaSceneRow] selectScenesForPersonaStmt =   preparable-    """-    SELECT scene_id, title, body_md, updated_at-    FROM kioku_scenes-    WHERE namespace = $1-      AND ((scope_kind = $2 AND scope_ref = $3)-           OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))-    ORDER BY scene_key ASC, updated_at DESC-    """-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))+    ( """+      SELECT scene_id, title, body_md, updated_at+      FROM+      """+        <> " "+        <> scenesTable+        <> " "+        <> """+           WHERE memory_space_id = $1+             AND namespace = $2+             AND ((scope_kind = $3 AND scope_ref = $4)+                  OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL))+           ORDER BY scene_key ASC, updated_at DESC+           """     )+    partitionedScopeEncoder     (D.rowList personaSceneRowDecoder) --- | Delete by the row's own primary key, for the same reason 'deleteSceneStmt'--- does: the scope-identity string format is not re-implemented here.-deletePersonaStmt :: Statement Text ()+-- | Delete by the row's own primary key, for the same reason 'deleteSceneStmt' does: the+-- scope-identity string format is not re-implemented here. That key is now composite, because+-- the persona id alone is derived from the scope and two spaces may share one.+deletePersonaStmt :: Statement PersonaKey () deletePersonaStmt =   preparable-    "DELETE FROM kioku_personas WHERE persona_id = $1"-    (E.param (E.nonNullable E.text))+    ("DELETE FROM " <> personasTable <> " WHERE memory_space_id = $1 AND persona_id = $2")+    ( ((\(PersonaKey space _) -> space) >$< memorySpaceParam)+        <> ((\(PersonaKey _ personaId) -> personaId) >$< E.param (E.nonNullable E.text))+    )     D.noResult  upsertPersonaStmt :: Statement PersonaRow () upsertPersonaStmt =   preparable-    """-    INSERT INTO kioku_personas-      (persona_id, namespace, scope_kind, scope_ref, body_md, scene_count,-       source_hash, created_at, updated_at)-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)-    ON CONFLICT (persona_id) DO UPDATE SET-      body_md = EXCLUDED.body_md,-      scene_count = EXCLUDED.scene_count,-      source_hash = EXCLUDED.source_hash,-      updated_at = EXCLUDED.updated_at-    """+    ( "INSERT INTO "+        <> personasTable+        <> "\n"+        <> """+             (memory_space_id, persona_id, namespace, scope_kind, scope_ref, body_md, scene_count,+              source_hash, created_at, updated_at)+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)+           ON CONFLICT (memory_space_id, persona_id) DO UPDATE SET+             body_md = EXCLUDED.body_md,+             scene_count = EXCLUDED.scene_count,+             source_hash = EXCLUDED.source_hash,+             updated_at = EXCLUDED.updated_at+           """+    )     personaRowEncoder     D.noResult
src/Kioku/Distill/Timer.hs view
@@ -7,10 +7,12 @@     l1IdleTimerId,     l1RampTimerId,     l1TimerScheduleProjection,+    L1TimerPayload (..),   ) where  import Data.Aeson qualified as Aeson+import Data.Aeson.Types (withObject, (.:), (.:?)) import Data.ByteString qualified as BS import Data.Foldable (traverse_) import Data.Text qualified as Text@@ -21,7 +23,9 @@ import Data.UUID.V5 qualified as UUIDv5 import Keiro.Projection (InlineProjection (..)) import Keiro.Timer (TimerId (..), TimerRequest (..), scheduleTimerTx)+import Kioku.Api.Access (MemorySpaceId) import Kioku.Id (SessionId, idText)+import Kioku.Partition (parsePartitionSpace) import Kioku.Prelude import Kioku.Session.Domain   ( InteractiveSessionRecordedData (..),@@ -70,48 +74,73 @@ l1FinalTimerId :: SessionId -> TimerId l1FinalTimerId sid = l1TimerIdFor (idText sid <> ":final") +-- | What a scheduled L1 pass needs to know before it can write anything.+--+-- @memorySpaceId@ is the load-bearing field: distillation records and merges memories, and a+-- worker that could not tell which space a session belongs to would have to guess. Timers+-- scheduled before this field existed decode into 'Kioku.Api.Access.legacyMemorySpaceId', the+-- same rule stored events follow.+--+-- @turnCount@ is diagnostic only; the real freshness check is the @kioku_l1_watermarks@ read+-- inside the pass.+data L1TimerPayload = L1TimerPayload+  { kind :: !Text,+    turnCount :: !(Maybe Int),+    memorySpaceId :: !MemorySpaceId+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (ToJSON)++instance FromJSON L1TimerPayload where+  parseJSON =+    withObject "L1TimerPayload" \o ->+      L1TimerPayload+        <$> o .: "kind"+        <*> o .:? "turnCount"+        <*> parsePartitionSpace o+ timerRequestsForEvent :: SessionEvent -> [TimerRequest] timerRequestsForEvent = \case   SessionStarted d ->-    [idleRequest d.sessionId (addUTCTime idleFlushSeconds d.startedAt) (Just 0)]+    [idleRequest d.sessionId d.memorySpaceId (addUTCTime idleFlushSeconds d.startedAt) (Just 0)]   InteractiveSessionRecorded d ->-    [idleRequest d.sessionId (addUTCTime idleFlushSeconds d.startedAt) (Just 0)]+    [idleRequest d.sessionId d.memorySpaceId (addUTCTime idleFlushSeconds d.startedAt) (Just 0)]   SessionCompleted d ->-    [finalRequest d.sessionId d.completedAt]+    [finalRequest d.sessionId d.memorySpaceId d.completedAt]   SessionFailed d ->-    [finalRequest d.sessionId (failedAt d)]+    [finalRequest d.sessionId d.memorySpaceId (failedAt d)]   SessionAwaiting _ -> []   SessionResumed _ -> []   TurnRecorded d ->-    let idle = idleRequest d.sessionId (addUTCTime idleFlushSeconds d.recordedAt) (Just d.turnIndex)-        ramp = rampRequest d.sessionId d.turnIndex d.recordedAt+    let idle = idleRequest d.sessionId d.memorySpaceId (addUTCTime idleFlushSeconds d.recordedAt) (Just d.turnIndex)+        ramp = rampRequest d.sessionId d.memorySpaceId d.turnIndex d.recordedAt      in if isRampTurn d.turnIndex then [ramp, idle] else [idle] -idleRequest :: SessionId -> UTCTime -> Maybe Int -> TimerRequest-idleRequest sid = l1TimerRequest (l1IdleTimerId sid) sid "idle"+idleRequest :: SessionId -> MemorySpaceId -> UTCTime -> Maybe Int -> TimerRequest+idleRequest sid space = l1TimerRequest (l1IdleTimerId sid) sid space "idle" -rampRequest :: SessionId -> Int -> UTCTime -> TimerRequest-rampRequest sid turnIndex fireAt =-  l1TimerRequest (l1RampTimerId sid turnIndex) sid "ramp" fireAt (Just turnIndex)+rampRequest :: SessionId -> MemorySpaceId -> Int -> UTCTime -> TimerRequest+rampRequest sid space turnIndex fireAt =+  l1TimerRequest (l1RampTimerId sid turnIndex) sid space "ramp" fireAt (Just turnIndex) -finalRequest :: SessionId -> UTCTime -> TimerRequest-finalRequest sid fireAt =-  l1TimerRequest (l1FinalTimerId sid) sid "final" fireAt Nothing+finalRequest :: SessionId -> MemorySpaceId -> UTCTime -> TimerRequest+finalRequest sid space fireAt =+  l1TimerRequest (l1FinalTimerId sid) sid space "final" fireAt Nothing --- | @turnCount@ in the payload is diagnostic only; the real freshness check is--- the @kioku_l1_watermarks@ read inside the pass.-l1TimerRequest :: TimerId -> SessionId -> Text -> UTCTime -> Maybe Int -> TimerRequest-l1TimerRequest timerId sid kind fireAt turnCount =+-- | The timer /id/ deliberately does not include the memory space.+--+-- It does not need to: a session id is globally unique and the aggregate pins each session to+-- exactly one space, so two spaces cannot produce the same @(session, kind)@ pair. Folding the+-- space in would also change every id, which would leave a second idle timer armed for every+-- session already in flight at upgrade time.+l1TimerRequest :: TimerId -> SessionId -> MemorySpaceId -> Text -> UTCTime -> Maybe Int -> TimerRequest+l1TimerRequest timerId sid memorySpaceId kind fireAt turnCount =   TimerRequest     { timerId,       processManagerName = l1ExtractProcessManagerName,       correlationId = idText sid,       fireAt,-      payload =-        Aeson.object-          [ "kind" Aeson..= kind,-            "turnCount" Aeson..= turnCount-          ]+      payload = Aeson.toJSON L1TimerPayload {kind, turnCount, memorySpaceId}     }  isRampTurn :: Int -> Bool
src/Kioku/Distill/Timer/Worker.hs view
@@ -10,13 +10,20 @@   ) where +import Data.Aeson qualified as Aeson+import Data.Aeson.Types qualified as Aeson+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.Int (Int64) import Data.Text qualified as Text import Data.Time (NominalDiffTime, addUTCTime)+import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error) import Keiro.Telemetry (KeiroMetrics) import Keiro.Timer-  ( TimerRequest (..),+  ( TimerId (..),+    TimerRequest (..),     TimerRow (..),     TimerWorkerOptions (..),     deadLetterTimer,@@ -24,11 +31,12 @@     runTimerWorkerWith,     scheduleTimerTx,   )+import Kioku.Api.Access (MemoryContextProvider (..), MemorySpaceId, memorySpaceIdText) import Kioku.Distill.L1 (FindMergeCandidates, L1Error (..), L1RunMode (..), distillSessionL1) import Kioku.Distill.L2 (fireL2SceneTimer) import Kioku.Distill.L3 (fireL3PersonaTimer) import Kioku.Distill.Runtime (DistillRuntime)-import Kioku.Distill.Timer (l1ExtractProcessManagerName)+import Kioku.Distill.Timer (L1TimerPayload (..), l1ExtractProcessManagerName) import Kioku.Distill.Timer.Outcome   ( FireOutcome (..),     fireRetryDelay,@@ -36,12 +44,15 @@     unknownTimerRetryDelay,   ) import Kioku.Id (parseIdLenient)+import Kioku.Partition (parsePartitionSpace) import Kioku.Prelude import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource) import Kiroku.Store.Error (StoreError) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (EventId (..))+import OpenTelemetry.Attributes qualified as Attr+import Shibuya.Telemetry.Effect (Tracing, addAttributes, defaultSpanArguments, withSpan') import System.IO qualified as IO  -- | kioku's timer policy.@@ -55,13 +66,22 @@ kiokuTimerWorkerOptions =   TimerWorkerOptions {maxAttempts = Just 8, requeueStuckAfter = Just 300} +-- | Fire one L1 distillation timer.+--+-- A background pass discovers its own work, so it cannot arrive holding an authorization+-- context the way an interactive caller does. It reads the memory space out of the timer+-- payload — put there by the projection from the session event, and defaulted to the legacy+-- space for timers scheduled before that field existed — and asks the provider for a decision+-- about /that/ space. An embedded host wires the provider to+-- 'Kioku.Api.Access.assumeAuthorizedContextProvider'. fireL1Timer ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryContextProvider (Eff es) ->   DistillRuntime ->   FindMergeCandidates es ->   TimerRow ->   Eff es FireOutcome-fireL1Timer rt finder row+fireL1Timer contexts rt finder row   | row.processManagerName /= l1ExtractProcessManagerName =       pure FireNotMine   | otherwise =@@ -73,18 +93,34 @@             ( FireFailedPermanently                 ("L1 timer correlation id is not a session id: " <> row.correlationId)             )-        Right sid -> do-          result <- distillSessionL1 RespectWatermark rt finder sid-          pure $-            case result of-              -- Both a real pass and a watermark skip mean this timer is done.-              Right _outcome -> FireCompleted (timerMarkerEventId row.timerId)-              -- A session may legitimately be gone (deleted data); nothing to do.-              Left (L1SessionNotFound _) -> FireCompleted (timerMarkerEventId row.timerId)-              -- Everything else — a failed LLM extraction or consolidation, a-              -- read-model error, a failed write — is worth another attempt, and-              -- the attempt ceiling bounds how many.-              Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))+        Right sid ->+          -- A payload this handler cannot parse will not parse on the next attempt either.+          case Aeson.fromJSON @L1TimerPayload row.payload of+            Aeson.Error err ->+              pure (FireFailedPermanently ("L1 timer payload is malformed: " <> Text.pack err))+            Aeson.Success payload -> do+              decision <- contexts.contextForSpace payload.memorySpaceId+              case decision of+                -- Dead-letter rather than retry: a refusal to distill this space is a+                -- configuration fact, and quietly retrying it every 30 seconds forever would+                -- hide it. The dead-letter row is where an operator can see and requeue it.+                Left denial ->+                  pure+                    ( FireFailedPermanently+                        ("L1 timer is not authorized for its memory space: " <> Text.pack (show denial))+                    )+                Right context -> do+                  result <- distillSessionL1 context RespectWatermark rt finder sid+                  pure $+                    case result of+                      -- Both a real pass and a watermark skip mean this timer is done.+                      Right _outcome -> FireCompleted (timerMarkerEventId row.timerId)+                      -- A session may legitimately be gone (deleted data); nothing to do.+                      Left (L1SessionNotFound _) -> FireCompleted (timerMarkerEventId row.timerId)+                      -- Everything else — a failed LLM extraction or consolidation, a+                      -- read-model error, a failed write — is worth another attempt, and+                      -- the attempt ceiling bounds how many.+                      Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))  -- | Offer the timer to each handler in turn. The handlers identify their own -- work by process-manager name, so a 'FireNotMine' simply falls through to the@@ -92,17 +128,18 @@ -- the runner decides what to do about that. fireKiokuTimer ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryContextProvider (Eff es) ->   DistillRuntime ->   FindMergeCandidates es ->   TimerRow ->   Eff es FireOutcome-fireKiokuTimer rt finder row = do-  l1Result <- fireL1Timer rt finder row+fireKiokuTimer contexts rt finder row = do+  l1Result <- fireL1Timer contexts rt finder row   case l1Result of     FireNotMine -> do-      l2Result <- fireL2SceneTimer rt row+      l2Result <- fireL2SceneTimer contexts rt row       case l2Result of-        FireNotMine -> fireL3PersonaTimer rt row+        FireNotMine -> fireL3PersonaTimer contexts rt row         outcome -> pure outcome     outcome -> pure outcome @@ -111,6 +148,11 @@ -- Returning 'Nothing' to keiro means "do not mark this row fired"; every such -- branch has already moved the row itself, so the timer never sits in @firing@ -- waiting on the 300-second stale requeue.+--+-- Every diagnostic this writes names the memory space, including the @last_error@ that lands in+-- the dead-letter row. That column is what an operator actually reads at three in the morning,+-- and a dead-lettered distillation that does not say which tenant it belongs to is a page+-- somebody has to answer with a query. applyFireOutcome ::   (IOE :> es, Store :> es) =>   TimerRow ->@@ -123,14 +165,73 @@     rescheduleClaimedTimer row delay     pure Nothing   FireFailedPermanently reason -> do-    logTimer row ("dead-lettering: " <> reason)-    void (deadLetterTimer row.timerId reason)+    let annotated = spaceQualified row reason+    logTimer row ("dead-lettering: " <> annotated)+    void (deadLetterTimer row.timerId annotated)     pure Nothing   FireNotMine -> do     logTimer row "no handler owns this process manager; requeueing"     rescheduleClaimedTimer row unknownTimerRetryDelay     pure Nothing +-- | Prefix a diagnostic with the memory space the timer's payload names.+--+-- @unknown@ covers the payloads that have no space to name: a malformed payload, or one from a+-- process manager that is not Kioku's. Those are exactly the cases where the timer is about to+-- be dead-lettered, so saying "unknown" is more useful than silently claiming the legacy space.+spaceQualified :: TimerRow -> Text -> Text+spaceQualified row reason =+  "[memory space "+    <> maybe "unknown" memorySpaceIdText (timerPayloadSpace row.payload)+    <> "] "+    <> reason++-- | The memory space a timer payload names, for diagnostics only.+--+-- Every one of the three payload types carries the space, and each decodes it through+-- 'parsePartitionSpace' — the same function this uses — so this cannot disagree with the handler+-- that actually acts on the payload. It is read here rather than returned by the handlers so+-- that a span and a dead-letter row can name the space even when no handler claimed the timer.+timerPayloadSpace :: Aeson.Value -> Maybe MemorySpaceId+timerPayloadSpace = \case+  Aeson.Object o -> Aeson.parseMaybe parsePartitionSpace o+  _ -> Nothing++-- | Span attributes for one fire attempt.+--+-- The space is here, on the trace, and deliberately not on a metric: a memory space is+-- caller-supplied text with no bound on how many distinct values exist, and a counter labelled+-- by it is an unbounded time series per tenant. Traces are sampled and per-incident; that is the+-- right place for an identifier a caller chose.+timerSpanAttributes :: TimerRow -> HashMap Text Attr.Attribute+timerSpanAttributes row =+  HashMap.fromList+    ( [ ("kioku.timer.process_manager", Attr.toAttribute row.processManagerName),+        ("kioku.timer.id", Attr.toAttribute (timerIdText row.timerId)),+        ("kioku.timer.attempts", Attr.toAttribute (fromIntegral @Int @Int64 row.attempts))+      ]+        <> foldMap+          (\space -> [("kioku.memory_space_id", Attr.toAttribute (memorySpaceIdText space))])+          (timerPayloadSpace row.payload)+    )++-- | What the fire decided, as a bounded outcome plus an unbounded reason.+--+-- The outcome is one of four constants, so it is safe anywhere including a metric label. The+-- reason is free text — an LLM provider message, a codec error — and stays on the span.+fireOutcomeAttributes :: FireOutcome -> HashMap Text Attr.Attribute+fireOutcomeAttributes = \case+  FireCompleted _ -> HashMap.fromList [outcomeAttr "completed"]+  FireRetryLater _ note -> HashMap.fromList [outcomeAttr "retry", reasonAttr note]+  FireFailedPermanently reason -> HashMap.fromList [outcomeAttr "dead_letter", reasonAttr reason]+  FireNotMine -> HashMap.fromList [outcomeAttr "not_mine"]+  where+    outcomeAttr value = ("kioku.timer.outcome", Attr.toAttribute (value :: Text))+    reasonAttr value = ("kioku.timer.reason", Attr.toAttribute value)++timerIdText :: TimerId -> Text+timerIdText (TimerId uuid) = UUID.toText uuid+ -- | Push a claimed timer back out into the future. -- -- keiro has no single-call "reschedule a firing timer" at this pin, so this is@@ -158,16 +259,25 @@             payload = row.payload           } +-- | Claim and fire at most one due timer, inside a span that names the memory space.+--+-- keiro's own timer metrics stay exactly as they are: they carry no space and no principal, and+-- this deliberately adds neither. See 'timerSpanAttributes'. runKiokuTimerWorkerOnce ::-  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>   Maybe KeiroMetrics ->+  MemoryContextProvider (Eff es) ->   DistillRuntime ->   FindMergeCandidates es ->   UTCTime ->   Eff es (Maybe TimerRow)-runKiokuTimerWorkerOnce metrics rt finder now =+runKiokuTimerWorkerOnce metrics contexts rt finder now =   runTimerWorkerWith metrics kiokuTimerWorkerOptions now \row ->-    fireKiokuTimer rt finder row >>= applyFireOutcome row+    withSpan' "kioku.timer.fire" defaultSpanArguments \fireSpan -> do+      addAttributes fireSpan (timerSpanAttributes row)+      outcome <- fireKiokuTimer contexts rt finder row+      addAttributes fireSpan (fireOutcomeAttributes outcome)+      applyFireOutcome row outcome  -- | Claim and fire due timers until none remain, returning how many were -- processed.@@ -178,16 +288,17 @@ -- terminal state, so a timer processed in this pass is not claimable again -- within it. drainKiokuTimers ::-  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>   Maybe KeiroMetrics ->+  MemoryContextProvider (Eff es) ->   DistillRuntime ->   FindMergeCandidates es ->   Eff es Int-drainKiokuTimers metrics rt finder = go 0+drainKiokuTimers metrics contexts rt finder = go 0   where     go processed = do       now <- liftIO getCurrentTime-      claimed <- runKiokuTimerWorkerOnce metrics rt finder now+      claimed <- runKiokuTimerWorkerOnce metrics contexts rt finder now       case claimed of         Nothing -> pure processed         Just _ -> go (processed + 1)
src/Kioku/Memory.hs view
@@ -1,11 +1,53 @@+-- | Writing and reading memories.+--+-- Every write takes a 'MemoryAccessContext' first. That record is the statement "somebody has+-- already decided this caller may do this here" — obtained from+-- 'Kioku.Api.Access.authorizeMemoryAccess' behind a service boundary, or from+-- 'Kioku.Api.Access.assumeAuthorizedMemoryContext' in a trusted in-process host. Kioku's core+-- never derives one.+--+-- Each write asks the context for one permission:+--+-- * 'MemoryRecord' — 'recordWithContext', 'updateTagsWithContext',+--   'updateConfidenceWithContext'. These create or amend a memory.+-- * 'MemoryForget' — 'supersedeWithContext', 'archiveWithContext', 'mergeWithContext'. These+--   retire one.+--+-- The context is checked against the command, not merged into it: the payload names its own+-- memory space and actor, and a payload that disagrees with the context that authorized it is+-- rejected rather than quietly rewritten. That keeps the stored event and the decision that+-- allowed it the same fact.+--+-- The unsuffixed functions ('record', 'archive', …) remain for one release as deprecated+-- compatibility wrappers. They take no context and refuse any payload naming a space other than+-- 'legacyMemorySpaceId', so they cannot reach data belonging to anybody else.+--+-- Every read takes a 'MemorySpaceId' first and returns nothing outside it. Pass+-- 'Kioku.Api.Access.memoryContextSpace' of the context that authorized the read: the context is+-- what decides which space may be named, and the space is what the schema enforces. A read+-- takes the space rather than the whole context because these functions return+-- @Either ReadModelError@, and a permission denial has already been decided — a context exists+-- only for permissions 'Kioku.Api.Access.authorizeMemoryAccess' actually checked. module Kioku.Memory   ( MemoryWriteError (..),++    -- * Writing memory+    recordWithContext,+    supersedeWithContext,+    archiveWithContext,+    updateTagsWithContext,+    updateConfidenceWithContext,+    mergeWithContext,++    -- * Deprecated compatibility wrappers, confined to the legacy memory space     record,     supersede,     archive,     updateTags,     updateConfidence,     merge,++    -- * Reading memory     getMemoryRowById,     getActiveRowsInNamespace,     getActiveRowsByScope,@@ -21,6 +63,16 @@ import Keiro.Command (CommandError (..), defaultRunCommandOptions) import Keiro.Projection (runCommandWithProjections) import Keiro.ReadModel (ConsistencyMode (..), ReadModelError, runQueryWith)+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryPermission (..),+    MemorySpaceId,+    RecordedPrincipal (..),+    legacyMemorySpaceId,+    memoryContextAllows,+    memoryContextRecordedActor,+    memoryContextSpace,+  ) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Api.Types (MemoryType, confidenceToText, memoryTypeToText) import Kioku.Distill.L2 (l2SceneTimerScheduleProjection)@@ -54,29 +106,112 @@   | MemoryNotFound   | MemoryNotActive   | MemoryConflict !Text+  | -- | the context authorized other actions, but not this one+    MemoryNotPermitted !MemoryPermission+  | -- | the command names a memory space the context was not minted for:+    -- @MemorySpaceMismatch requested authorized@+    MemorySpaceMismatch !MemorySpaceId !MemorySpaceId+  | -- | the command attributes the write to somebody other than the context's own principal+    MemoryActorMismatch !RecordedPrincipal !RecordedPrincipal   deriving stock (Generic, Show) +-- | Gate a write on the decision that authorized it.+--+-- Three things have to agree and none of them is redundant. The permission check is what stops a+-- context minted for reading from being spent on a write. The space check is the isolation+-- boundary itself. The actor check is what stops a caller authorized as one principal from+-- writing an event that says another principal acted — which would be a forged audit trail, and+-- is the reason the actor is checked rather than merely defaulted.+underContext ::+  (Applicative f) =>+  MemoryAccessContext ->+  MemoryPermission ->+  MemorySpaceId ->+  RecordedPrincipal ->+  f (Either MemoryWriteError a) ->+  f (Either MemoryWriteError a)+underContext context permission space actor run+  | not (memoryContextAllows permission context) =+      pure (Left (MemoryNotPermitted permission))+  | space /= authorizedSpace =+      pure (Left (MemorySpaceMismatch space authorizedSpace))+  | actor /= authorizedActor =+      pure (Left (MemoryActorMismatch actor authorizedActor))+  | otherwise = run+  where+    authorizedSpace = memoryContextSpace context+    authorizedActor = memoryContextRecordedActor context++-- | Gate a deprecated wrapper on the one space it is allowed to touch.+--+-- A wrapper that silently retargeted a payload into the legacy space would reintroduce exactly+-- the defaulting this whole change exists to remove, so it refuses instead.+inLegacySpaceOnly ::+  (Applicative f) =>+  MemorySpaceId ->+  f (Either MemoryWriteError a) ->+  f (Either MemoryWriteError a)+inLegacySpaceOnly space run+  | space /= legacyMemorySpaceId = pure (Left (MemorySpaceMismatch space legacyMemorySpaceId))+  | otherwise = run++-- | Record a new memory in the space the context authorizes.+recordWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  RecordMemoryData ->+  Eff es (Either MemoryWriteError MemoryId)+recordWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (recordIn cmdData)++{-# DEPRECATED record "Use recordWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: record into the legacy memory space, with no authorization context. record ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   RecordMemoryData ->   Eff es (Either MemoryWriteError MemoryId)-record cmdData = do-  existing <- lookupMemory cmdData.memoryId+record cmdData = inLegacySpaceOnly cmdData.memorySpaceId (recordIn cmdData)++recordIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  RecordMemoryData ->+  Eff es (Either MemoryWriteError MemoryId)+recordIn cmdData = do+  existing <- lookupMemory cmdData.memorySpaceId cmdData.memoryId   case existing of     Left err -> pure (Left (MemoryReadFailed err))     Right (Just row) -> pure (idempotentOr "record" recordMismatch row cmdData.memoryId)     Right Nothing ->       runMemoryCommand cmdData.memoryId (RecordMemory cmdData)-        >>= acceptRejectedIfMatches cmdData.memoryId (isNothing . recordMismatch)+        >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.memoryId (isNothing . recordMismatch)   where     recordMismatch = mismatchOf memoryRecordFields cmdData +-- | Retire a memory in favour of a newer one, in the space the context authorizes.+supersedeWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  SupersedeMemoryData ->+  Eff es (Either MemoryWriteError MemoryId)+supersedeWithContext context cmdData =+  underContext context MemoryForget cmdData.memorySpaceId cmdData.actorPrincipal (supersedeIn cmdData)++{-# DEPRECATED supersede "Use supersedeWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: supersede within the legacy memory space, with no authorization context. supersede ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   SupersedeMemoryData ->   Eff es (Either MemoryWriteError MemoryId)-supersede cmdData = do-  existing <- lookupMemory cmdData.memoryId+supersede cmdData = inLegacySpaceOnly cmdData.memorySpaceId (supersedeIn cmdData)++supersedeIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  SupersedeMemoryData ->+  Eff es (Either MemoryWriteError MemoryId)+supersedeIn cmdData = do+  existing <- lookupMemory cmdData.memorySpaceId cmdData.memoryId   case existing of     Left err -> pure (Left (MemoryReadFailed err))     Right Nothing -> pure (Left MemoryNotFound)@@ -86,16 +221,34 @@       | row.status /= "active" -> pure (idempotentOr "supersede" supersedeMismatch row cmdData.memoryId)       | otherwise ->           runMemoryCommand cmdData.memoryId (SupersedeMemory cmdData)-            >>= acceptRejectedIfMatches cmdData.memoryId (isNothing . supersedeMismatch)+            >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.memoryId (isNothing . supersedeMismatch)   where     supersedeMismatch = mismatchOf memorySupersedeFields cmdData +-- | Archive a memory, in the space the context authorizes.+archiveWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  ArchiveMemoryData ->+  Eff es (Either MemoryWriteError MemoryId)+archiveWithContext context cmdData =+  underContext context MemoryForget cmdData.memorySpaceId cmdData.actorPrincipal (archiveIn cmdData)++{-# DEPRECATED archive "Use archiveWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: archive within the legacy memory space, with no authorization context. archive ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   ArchiveMemoryData ->   Eff es (Either MemoryWriteError MemoryId)-archive cmdData = do-  existing <- lookupMemory cmdData.memoryId+archive cmdData = inLegacySpaceOnly cmdData.memorySpaceId (archiveIn cmdData)++archiveIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  ArchiveMemoryData ->+  Eff es (Either MemoryWriteError MemoryId)+archiveIn cmdData = do+  existing <- lookupMemory cmdData.memorySpaceId cmdData.memoryId   case existing of     Left err -> pure (Left (MemoryReadFailed err))     Right Nothing -> pure (Left MemoryNotFound)@@ -103,16 +256,34 @@       | row.status /= "active" -> pure (idempotentOr "archive" archiveMismatch row cmdData.memoryId)       | otherwise ->           runMemoryCommand cmdData.memoryId (ArchiveMemory cmdData)-            >>= acceptRejectedIfMatches cmdData.memoryId (isNothing . archiveMismatch)+            >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.memoryId (isNothing . archiveMismatch)   where     archiveMismatch = mismatchOf memoryArchiveFields cmdData +-- | Replace a memory's tags, in the space the context authorizes.+updateTagsWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  UpdateMemoryTagsData ->+  Eff es (Either MemoryWriteError MemoryId)+updateTagsWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (updateTagsIn cmdData)++{-# DEPRECATED updateTags "Use updateTagsWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: retag within the legacy memory space, with no authorization context. updateTags ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   UpdateMemoryTagsData ->   Eff es (Either MemoryWriteError MemoryId)-updateTags cmdData = do-  existing <- lookupMemory cmdData.memoryId+updateTags cmdData = inLegacySpaceOnly cmdData.memorySpaceId (updateTagsIn cmdData)++updateTagsIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  UpdateMemoryTagsData ->+  Eff es (Either MemoryWriteError MemoryId)+updateTagsIn cmdData = do+  existing <- lookupMemory cmdData.memorySpaceId cmdData.memoryId   case existing of     Left err -> pure (Left (MemoryReadFailed err))     Right Nothing -> pure (Left MemoryNotFound)@@ -121,12 +292,30 @@       | row.tags == cmdData.tags -> pure (Right cmdData.memoryId)       | otherwise -> runMemoryCommand cmdData.memoryId (UpdateMemoryTags cmdData) +-- | Re-score a memory's confidence, in the space the context authorizes.+updateConfidenceWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  UpdateMemoryConfidenceData ->+  Eff es (Either MemoryWriteError MemoryId)+updateConfidenceWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (updateConfidenceIn cmdData)++{-# DEPRECATED updateConfidence "Use updateConfidenceWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: re-score within the legacy memory space, with no authorization context. updateConfidence ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   UpdateMemoryConfidenceData ->   Eff es (Either MemoryWriteError MemoryId)-updateConfidence cmdData = do-  existing <- lookupMemory cmdData.memoryId+updateConfidence cmdData = inLegacySpaceOnly cmdData.memorySpaceId (updateConfidenceIn cmdData)++updateConfidenceIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  UpdateMemoryConfidenceData ->+  Eff es (Either MemoryWriteError MemoryId)+updateConfidenceIn cmdData = do+  existing <- lookupMemory cmdData.memorySpaceId cmdData.memoryId   case existing of     Left err -> pure (Left (MemoryReadFailed err))     Right Nothing -> pure (Left MemoryNotFound)@@ -135,19 +324,50 @@       | row.confidence == confidenceToText cmdData.confidence -> pure (Right cmdData.memoryId)       | otherwise -> runMemoryCommand cmdData.memoryId (UpdateMemoryConfidence cmdData) --- | Merge @loser@ into @winner@.+-- | Merge @loser@ into @winner@, in the space the context authorizes. -- -- Unlike the other writes, @mergedAt@ is generated here rather than supplied by the caller, -- so a retry cannot re-deliver an identical timestamp. Idempotency therefore matches on the -- merge target alone: merging into the same winner twice is a duplicate, merging into a -- different one is a conflict.+--+-- Both memories must live in the authorized space. The loser is checked by the aggregate guard+-- on the command below; the winner is only referenced, and 'Kioku.Distill.L1' — the one caller+-- that supplies a winner it did not just write — resolves both from the same session.+mergeWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  MemoryId ->+  MemoryId ->+  Eff es (Either MemoryWriteError MemoryId)+mergeWithContext context loser winner =+  underContext context MemoryForget space actor (mergeIn space actor loser winner)+  where+    space = memoryContextSpace context+    actor = memoryContextRecordedActor context++{-# DEPRECATED merge "Use mergeWithContext. This wrapper writes only into legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: merge within the legacy memory space, with no authorization context.+--+-- The recorded actor is 'UnattributedPrincipal' rather than an invented one: this path has no+-- context, so nothing here knows who is merging. merge ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   MemoryId ->   MemoryId ->   Eff es (Either MemoryWriteError MemoryId)-merge loser winner = do-  existing <- lookupMemory loser+merge = mergeIn legacyMemorySpaceId UnattributedPrincipal++mergeIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemorySpaceId ->+  RecordedPrincipal ->+  MemoryId ->+  MemoryId ->+  Eff es (Either MemoryWriteError MemoryId)+mergeIn memorySpaceId actorPrincipal loser winner = do+  existing <- lookupMemory memorySpaceId loser   case existing of     Left err -> pure (Left (MemoryReadFailed err))     Right Nothing -> pure (Left MemoryNotFound)@@ -155,8 +375,18 @@       | row.status /= "active" -> pure (idempotentOr "merge" mergeMismatch row loser)       | otherwise -> do           now <- liftIO getCurrentTime-          runMemoryCommand loser (MergeMemory (MergeMemoryData loser winner now))-            >>= acceptRejectedIfMatches loser (isNothing . mergeMismatch)+          runMemoryCommand+            loser+            ( MergeMemory+                MergeMemoryData+                  { memoryId = loser,+                    memorySpaceId,+                    actorPrincipal,+                    mergedInto = winner,+                    mergedAt = now+                  }+            )+            >>= acceptRejectedIfMatches memorySpaceId loser (isNothing . mergeMismatch)   where     mergeMismatch = mismatchOf memoryMergeFields winner @@ -177,6 +407,13 @@ -- Everything that carries meaning — content, scope, type, priority, confidence, tags, -- lineage, and the merge/supersession target — is compared, which is what the review -- actually asked for: a reused id with different /content/ must not report success.+--+-- The memory space is absent from these comparisons because it can no longer differ. Every+-- lookup that produces the row is now scoped to the command's own space, so a row from another+-- space is simply not found and the write proceeds to the aggregate, which refuses it. That+-- closes the residual this comment used to describe: presenting the id of a memory in another+-- space no longer reveals, through an idempotent answer, that the id exists or that it is+-- active. mismatchOf :: [FieldCheck cmd] -> cmd -> MemoryRow -> Maybe Text mismatchOf checks cmd row =   fst <$> find (\(_, matches) -> not (matches cmd row)) checks@@ -199,13 +436,14 @@ -- 'Kioku.Session.acceptRejectedIfMatches' — same contract, memory side. acceptRejectedIfMatches ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryId ->   (MemoryRow -> Bool) ->   Either MemoryWriteError MemoryId ->   Eff es (Either MemoryWriteError MemoryId)-acceptRejectedIfMatches mid matches = \case+acceptRejectedIfMatches space mid matches = \case   Left err@(MemoryCommandRejected CommandRejected) -> do-    reread <- lookupMemory mid+    reread <- lookupMemory space mid     pure case reread of       Right (Just row) | matches row -> Right mid       _ -> Left err@@ -244,15 +482,23 @@     ("mergedInto", \winner row -> row.supersededBy == Just (idText winner))   ] +-- | Look a memory up inside one space. A memory that lives elsewhere is 'Nothing' here, which+-- is what makes the write paths' idempotency prechecks unable to answer questions about it. lookupMemory ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryId ->   Eff es (Either ReadModelError (Maybe MemoryRow))-lookupMemory mid =-  runQueryWith Nothing Eventual memoryByIdReadModel (MemoryByIdQuery (idText mid))+lookupMemory space mid =+  runQueryWith+    Nothing+    Eventual+    memoryByIdReadModel+    MemoryByIdQuery {memorySpaceId = space, memoryId = idText mid}  getMemoryRowById ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryId ->   Eff es (Either ReadModelError (Maybe MemoryRow)) getMemoryRowById =@@ -260,43 +506,73 @@  getActiveRowsInNamespace ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   Eff es (Either ReadModelError [MemoryRow])-getActiveRowsInNamespace (Namespace ns) =-  runQueryWith Nothing Eventual memoriesByNamespaceRowsReadModel (MemoriesByNamespaceQuery ns)+getActiveRowsInNamespace space (Namespace ns) =+  runQueryWith+    Nothing+    Eventual+    memoriesByNamespaceRowsReadModel+    MemoriesByNamespaceQuery {memorySpaceId = space, namespace = ns}  getActiveRowsByScope ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Eff es (Either ReadModelError [MemoryRow])-getActiveRowsByScope scope =+getActiveRowsByScope space scope =   runQueryWith     Nothing     Eventual     memoriesByScopeRowsReadModel-    (MemoriesByScopeQuery (scopeNamespaceText scope) (scopeKindText scope) (scopeRefText scope))+    MemoriesByScopeQuery+      { memorySpaceId = space,+        namespace = scopeNamespaceText scope,+        scopeKind = scopeKindText scope,+        scopeRef = scopeRefText scope+      }  getRowsBySession ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Either ReadModelError [MemoryRow])-getRowsBySession sid =-  runQueryWith Nothing Eventual memoriesBySessionRowsReadModel (MemoriesBySessionQuery (idText sid))+getRowsBySession space sid =+  runQueryWith+    Nothing+    Eventual+    memoriesBySessionRowsReadModel+    MemoriesBySessionQuery {memorySpaceId = space, sessionId = idText sid}  getActiveRowsByType ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   MemoryType ->   Eff es (Either ReadModelError [MemoryRow])-getActiveRowsByType (Namespace ns) memoryType =-  runQueryWith Nothing Eventual memoriesByTypeRowsReadModel (MemoriesByTypeQuery ns (memoryTypeToText memoryType))+getActiveRowsByType space (Namespace ns) memoryType =+  runQueryWith+    Nothing+    Eventual+    memoriesByTypeRowsReadModel+    MemoriesByTypeQuery+      { memorySpaceId = space,+        namespace = ns,+        memoryType = memoryTypeToText memoryType+      }  getSupersessionChain ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryId ->   Eff es (Either ReadModelError [MemoryRow])-getSupersessionChain mid =-  runQueryWith Nothing Eventual memorySupersessionChainReadModel (MemorySupersessionChainQuery (idText mid))+getSupersessionChain space mid =+  runQueryWith+    Nothing+    Eventual+    memorySupersessionChainReadModel+    MemorySupersessionChainQuery {memorySpaceId = space, memoryId = idText mid}  runMemoryCommand ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
src/Kioku/Memory/Domain.hs view
@@ -12,6 +12,7 @@     MergeMemoryData (..),     MemoryCommand (..),     commandMemoryId,+    commandMemorySpaceId,     MemoryRecordedData (..),     MemorySupersededData (..),     MemoryArchivedData (..),@@ -20,27 +21,41 @@     MemoryMergedData (..),     MemoryEvent (..),     eventMemoryId,+    eventMemorySpaceId,     memoryTransducer,   ) where +import Data.Aeson.Types (withObject, (.:), (.:?)) import Data.Set (Set)+import Keiki.Builder ((=:)) import Keiki.Builder qualified as B-import Keiki.Core (HsPred, SymTransducer)+import Keiki.Core (HsPred, SymTransducer, (.==)) import Keiki.Generics (emptyRegFile) import Keiki.Generics.TH (deriveAggregate)+import Kioku.Api.Access (MemorySpaceId, PrincipalRef, RecordedPrincipal) import Kioku.Api.Scope (MemoryScope) import Kioku.Api.Types (Confidence, MemoryType) import Kioku.Id (MemoryId, SessionId)+import Kioku.Partition (parsePartitionSpace, parseRecordedActor, parseRecordedActorFromAgent, parseRecordedOwner) import Kioku.Prelude  data MemoryVertex = NotCreated | Active | Superseded | Merged | Archived   deriving stock (Eq, Ord, Show, Enum, Bounded) -type MemoryRegs = '[]+-- | The memory space this aggregate was created in, replayed from its @MemoryRecorded@ event.+--+-- It is aggregate state rather than a read-model lookup because that is what makes the+-- cross-space check survive a concurrency retry: keiro re-runs the edge against the post-conflict+-- state, so a command naming a different space is refused by the state machine itself and never+-- by a racy precheck. A memory is created in exactly one space and never moves.+type MemoryRegs = '[ '("memorySpaceId", MemorySpaceId)]  data RecordMemoryData = RecordMemoryData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,+    ownerPrincipal :: !(Maybe PrincipalRef),     agentId :: !Text,     sessionId :: !(Maybe SessionId),     scope :: !MemoryScope,@@ -56,6 +71,8 @@  data SupersedeMemoryData = SupersedeMemoryData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     supersededBy :: !MemoryId,     supersededAt :: !UTCTime   }@@ -63,12 +80,16 @@  data ArchiveMemoryData = ArchiveMemoryData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     archivedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)  data UpdateMemoryTagsData = UpdateMemoryTagsData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     tags :: !(Set Text),     updatedAt :: !UTCTime   }@@ -76,6 +97,8 @@  data UpdateMemoryConfidenceData = UpdateMemoryConfidenceData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     confidence :: !Confidence,     updatedAt :: !UTCTime   }@@ -83,6 +106,8 @@  data MergeMemoryData = MergeMemoryData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     mergedInto :: !MemoryId,     mergedAt :: !UTCTime   }@@ -106,8 +131,27 @@   UpdateMemoryConfidence d -> d.memoryId   MergeMemory d -> d.memoryId +-- | The memory space a command claims to act in. Every command names one; the aggregate refuses+-- any that disagrees with the space the memory was created in.+commandMemorySpaceId :: MemoryCommand -> MemorySpaceId+commandMemorySpaceId = \case+  RecordMemory d -> d.memorySpaceId+  SupersedeMemory d -> d.memorySpaceId+  ArchiveMemory d -> d.memorySpaceId+  UpdateMemoryTags d -> d.memorySpaceId+  UpdateMemoryConfidence d -> d.memorySpaceId+  MergeMemory d -> d.memorySpaceId++-- | The @FromJSON@ instances below are hand-written for one reason: every payload already on+-- disk was written before memory spaces existed, and it has to keep decoding. 'Kioku.Partition'+-- owns what an older payload means; these instances only say which rule applies to which event.+--+-- @ToJSON@ stays derived, so encoding only ever emits the new form. data MemoryRecordedData = MemoryRecordedData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,+    ownerPrincipal :: !(Maybe PrincipalRef),     agentId :: !Text,     sessionId :: !(Maybe SessionId),     scope :: !MemoryScope,@@ -120,47 +164,127 @@     recordedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +-- | The one memory event that carried an agent label, so the one whose legacy actor is a real+-- value rather than "unrecorded".+instance FromJSON MemoryRecordedData where+  parseJSON =+    withObject "MemoryRecordedData" \o ->+      MemoryRecordedData+        <$> o .: "memoryId"+        <*> parsePartitionSpace o+        <*> parseRecordedActorFromAgent o+        <*> parseRecordedOwner o+        <*> o .: "agentId"+        <*> o .:? "sessionId"+        <*> o .: "scope"+        <*> o .: "memoryType"+        <*> o .: "content"+        <*> o .: "priority"+        <*> o .: "confidence"+        <*> o .: "tags"+        <*> o .:? "supersedes"+        <*> o .: "recordedAt"+ data MemorySupersededData = MemorySupersededData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     supersededBy :: !MemoryId,     supersededAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON MemorySupersededData where+  parseJSON =+    withObject "MemorySupersededData" \o ->+      MemorySupersededData+        <$> o .: "memoryId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "supersededBy"+        <*> o .: "supersededAt"+ data MemoryArchivedData = MemoryArchivedData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     archivedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON MemoryArchivedData where+  parseJSON =+    withObject "MemoryArchivedData" \o ->+      MemoryArchivedData+        <$> o .: "memoryId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "archivedAt"+ data MemoryTagsUpdatedData = MemoryTagsUpdatedData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     tags :: !(Set Text),     updatedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON MemoryTagsUpdatedData where+  parseJSON =+    withObject "MemoryTagsUpdatedData" \o ->+      MemoryTagsUpdatedData+        <$> o .: "memoryId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "tags"+        <*> o .: "updatedAt"+ data MemoryConfidenceUpdatedData = MemoryConfidenceUpdatedData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     confidence :: !Confidence,     updatedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON MemoryConfidenceUpdatedData where+  parseJSON =+    withObject "MemoryConfidenceUpdatedData" \o ->+      MemoryConfidenceUpdatedData+        <$> o .: "memoryId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "confidence"+        <*> o .: "updatedAt"+ data MemoryMergedData = MemoryMergedData   { memoryId :: !MemoryId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     mergedInto :: !MemoryId,     mergedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON MemoryMergedData where+  parseJSON =+    withObject "MemoryMergedData" \o ->+      MemoryMergedData+        <$> o .: "memoryId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "mergedInto"+        <*> o .: "mergedAt"+ data MemoryEvent   = MemoryRecorded !MemoryRecordedData   | MemorySuperseded !MemorySupersededData@@ -185,6 +309,17 @@   MemoryConfidenceUpdated d -> d.memoryId   MemoryMerged d -> d.memoryId +-- | The memory space a stored event belongs to. Every event carries one, including every event+-- written before memory spaces existed: those decode into 'legacyMemorySpaceId'.+eventMemorySpaceId :: MemoryEvent -> MemorySpaceId+eventMemorySpaceId = \case+  MemoryRecorded d -> d.memorySpaceId+  MemorySuperseded d -> d.memorySpaceId+  MemoryArchived d -> d.memorySpaceId+  MemoryTagsUpdated d -> d.memorySpaceId+  MemoryConfidenceUpdated d -> d.memorySpaceId+  MemoryMerged d -> d.memorySpaceId+ $(deriveAggregate ''MemoryCommand ''MemoryRegs ''MemoryEvent)  memoryTransducer ::@@ -198,10 +333,16 @@   B.buildTransducer NotCreated emptyRegFile isTerminal do     B.from NotCreated do       B.onCmd inCtorRecordMemory $ \d -> B.do+        -- 'emptyRegFile' binds the slot to a deferred error, so this edge -- the only way into+        -- Active, and thus the only way to reach any guard below -- must initialize it.+        B.slot @"memorySpaceId" =: d.memorySpaceId         B.emit           wireMemoryRecorded           MemoryRecordedTermFields             { memoryId = d.memoryId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,+              ownerPrincipal = d.ownerPrincipal,               agentId = d.agentId,               sessionId = d.sessionId,               scope = d.scope,@@ -216,50 +357,69 @@         B.goto Active      B.from Active do+      -- Every edge below repeats the same guard: the command must name the space this memory+      -- was created in. A caller holding a context for one space cannot supersede, archive,+      -- retag, re-score, or merge a memory that lives in another, and the refusal comes from the+      -- state machine rather than from a read-model precheck that a concurrent write could stale.       B.onCmd inCtorSupersedeMemory $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireMemorySuperseded           MemorySupersededTermFields             { memoryId = d.memoryId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               supersededBy = d.supersededBy,               supersededAt = d.supersededAt             }         B.goto Superseded        B.onCmd inCtorArchiveMemory $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireMemoryArchived           MemoryArchivedTermFields             { memoryId = d.memoryId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               archivedAt = d.archivedAt             }         B.goto Archived        B.onCmd inCtorUpdateMemoryTags $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireMemoryTagsUpdated           MemoryTagsUpdatedTermFields             { memoryId = d.memoryId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               tags = d.tags,               updatedAt = d.updatedAt             }         B.goto Active        B.onCmd inCtorUpdateMemoryConfidence $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireMemoryConfidenceUpdated           MemoryConfidenceUpdatedTermFields             { memoryId = d.memoryId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               confidence = d.confidence,               updatedAt = d.updatedAt             }         B.goto Active        B.onCmd inCtorMergeMemory $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireMemoryMerged           MemoryMergedTermFields             { memoryId = d.memoryId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               mergedInto = d.mergedInto,               mergedAt = d.mergedAt             }
src/Kioku/Memory/Embedding/Worker.hs view
@@ -1,6 +1,32 @@+-- | The embedding worker: it computes a vector for each memory's content and writes it back onto+-- the same row.+--+-- Like the distillation timers, this worker discovers its own work, so it cannot arrive holding+-- an authorization context. It takes the memory space out of the delivered @MemoryRecorded@+-- event and asks a 'MemoryContextProvider' for a decision about /that/ space; a refusal+-- dead-letters, because a worker that is not allowed to embed a space is a configuration fact+-- and retrying it every second would hide it.+--+-- Three things then carry the partition, and each closes a different hole:+--+-- * the state read returns the row's /own/ space, and a disagreement with the envelope is+--   'EmbedSpaceMismatch' — dead-lettered, never mutated. Scoping that read by the envelope's+--   space instead would turn the disagreement into "no such memory", which acks as a success;+-- * the update names the space as well as the id, so a redelivery cannot enrich a row outside+--   the space its event named however stale the envelope has become;+-- * the backfill scan takes an 'EmbeddingBackfillScope', so an operator can run the pass for one+--   space rather than for every space in the database.+--+-- None of this is an authorization boundary in the sense recall is: a memory's embedding is a+-- property of the memory, no content reaches a caller, and a process holding the database+-- credentials may already act in any space in that database — which is what+-- 'Kioku.Api.Access.assumeAuthorizedContextProvider' says out loud. It is a /durable work+-- identity/ boundary: at-least-once delivery means the same envelope is handled repeatedly, and+-- every one of those attempts must land in the space the event named. module Kioku.Memory.Embedding.Worker   ( EmbeddingWorkerEnv (..),     EmbedOutcome (..),+    EmbeddingBackfillScope (..),     backfillMissingEmbeddings,     embeddingHandler,     embeddingWorkerProcessor,@@ -26,10 +52,20 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.Codec (decodeRecorded)+import Kioku.Api.Access+  ( MemoryContextProvider (..),+    MemoryPermission (..),+    MemorySpaceId,+    memoryContextAllows,+    memoryContextSpace,+    memorySpaceIdText,+  )+import Kioku.Database.Schema (memoriesTable) import Kioku.Id (MemoryId, idText) import Kioku.Memory.Domain (MemoryEvent (..), MemoryRecordedData (..)) import Kioku.Memory.Embedding (EmbedError, embedWithRetry, sha256Hex) import Kioku.Memory.EventStream (memoryCodec)+import Kioku.Partition (memorySpaceColumn, memorySpaceParam) import Kioku.Prelude import Kioku.Recall.Capability (VectorCapability (..)) import Kioku.Worker.Failure (embeddingRetryDelay, isTransientStoreError)@@ -56,7 +92,8 @@ import System.IO qualified as IO  data EmbeddingCandidate = EmbeddingCandidate-  { memoryId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text,     content :: !Text,     contentHash :: !(Maybe Text),     hasEmbedding :: !Bool@@ -64,7 +101,8 @@   deriving stock (Generic, Eq, Show)  data EmbeddingUpdate = EmbeddingUpdate-  { memoryId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text,     embedding :: !(Vector Double),     embeddingModel :: !Text,     dimensions :: !Int,@@ -72,12 +110,28 @@   }   deriving stock (Generic, Eq, Show) +-- | What the row itself says, including which space it is in.+--+-- The space is read back rather than asserted because that is the only way the handler can tell+-- a stale envelope from a missing memory. See 'EmbedSpaceMismatch'. data EmbeddingState = EmbeddingState-  { contentHash :: !(Maybe Text),+  { memorySpaceId :: !MemorySpaceId,+    contentHash :: !(Maybe Text),     hasEmbedding :: !Bool   }   deriving stock (Generic, Eq, Show) +-- | Which memory spaces one backfill pass covers.+--+-- 'BackfillEverySpace' is what the continuous worker runs at startup: it serves every space the+-- database holds, so recovering embeddings for only one of them would leave the rest silently+-- unsearchable. 'BackfillOneSpace' is for an operator repairing a single tenant, and for the+-- case where a pass over every space would be too large to finish.+data EmbeddingBackfillScope+  = BackfillEverySpace+  | BackfillOneSpace !MemorySpaceId+  deriving stock (Generic, Eq, Show)+ -- | Everything the embedding path needs from the outside world. -- -- The provider call is a field rather than a direct 'embedWithRetry' call so@@ -104,21 +158,28 @@ -- "the memory is not there to embed"; neither is a failure. The distinction -- that matters to the handler is 'EmbedFailed', which used to be indistinguishable -- from success.+--+-- 'EmbedSpaceMismatch' is the one outcome that must never be quiet. It means a delivered event+-- named one memory space and the row it names is in another, which is a forged or corrupt+-- envelope rather than an ordinary failure — no retry can fix it and nothing was written. It+-- carries the envelope's space first and the row's second. data EmbedOutcome   = EmbedSkipped   | EmbedStored   | EmbedFailed !EmbedError+  | EmbedSpaceMismatch !MemorySpaceId !MemorySpaceId   deriving stock (Generic, Eq, Show)  runEmbeddingWorkerHost ::   (IOE :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>   KirokuStore ->+  MemoryContextProvider (Eff es) ->   VectorCapability ->   EmbeddingModel ->   Int ->   Eff es ()-runEmbeddingWorkerHost store capability model dims = do-  processor <- embeddingWorkerProcessor capability model dims store+runEmbeddingWorkerHost store contexts capability model dims = do+  processor <- embeddingWorkerProcessor contexts capability model dims store   started <- runApp defaultAppConfig [processor]   case started of     Left appErr ->@@ -129,12 +190,13 @@  embeddingWorkerProcessor ::   (IOE :> es, Store :> es, Error StoreError :> es) =>+  MemoryContextProvider (Eff es) ->   VectorCapability ->   EmbeddingModel ->   Int ->   KirokuStore ->   Eff es (ProcessorId, QueueProcessor es)-embeddingWorkerProcessor capability model dims store = do+embeddingWorkerProcessor contexts capability model dims store = do   adapter <- kirokuAdapter store embeddingAdapterConfig   pure     ( ProcessorId embeddingWorkerName,@@ -143,7 +205,7 @@           -- The kiroku bridge is ack-coupled: a synchronous exception escaping           -- the handler leaves the ack unfinalized and blocks the subscription           -- worker forever. The guard turns that into a one-second retry.-          handler = guardKirokuHandler (embeddingMessageHandler capability (mkEmbeddingWorkerEnv model dims)),+          handler = guardKirokuHandler (embeddingMessageHandler contexts capability (mkEmbeddingWorkerEnv model dims)),           ordering = StrictInOrder,           concurrency = Serial         }@@ -161,31 +223,40 @@ -- * a permanent store error (a dimension mismatch, a broken schema) would fail --   identically for every subsequent event — halting is the honest response, --   because dead-lettering would quietly drain the whole stream.+--+-- Two branches are about the partition rather than about durability. A provider that refuses+-- this event's memory space dead-letters, matching 'Kioku.Distill.Timer.Worker.fireL1Timer': a+-- worker that may not embed a space is a configuration fact, and an operator requeues the+-- dead-letter row once it is fixed. An envelope whose space disagrees with the row's own space+-- dead-letters too, and writes nothing. embeddingHandler ::   (IOE :> es, Store :> es, Error StoreError :> es) =>+  MemoryContextProvider (Eff es) ->   VectorCapability ->   EmbeddingWorkerEnv ->   Ingested es RecordedEvent ->   Eff es AckDecision-embeddingHandler capability env ingested =-  handleEmbeddingEnvelope capability env ingested.envelope+embeddingHandler contexts capability env ingested =+  handleEmbeddingEnvelope contexts capability env ingested.envelope  embeddingMessageHandler ::   (IOE :> es, Store :> es, Error StoreError :> es) =>+  MemoryContextProvider (Eff es) ->   VectorCapability ->   EmbeddingWorkerEnv ->   Message es RecordedEvent ->   Eff es AckDecision-embeddingMessageHandler capability env message =-  handleEmbeddingEnvelope capability env message.envelope+embeddingMessageHandler contexts capability env message =+  handleEmbeddingEnvelope contexts capability env message.envelope  handleEmbeddingEnvelope ::   (IOE :> es, Store :> es, Error StoreError :> es) =>+  MemoryContextProvider (Eff es) ->   VectorCapability ->   EmbeddingWorkerEnv ->   Envelope RecordedEvent ->   Eff es AckDecision-handleEmbeddingEnvelope capability env envelope =+handleEmbeddingEnvelope contexts capability env envelope =   EffError.catchError @StoreError run \_callStack storeErr ->     if isTransientStoreError storeErr       then do@@ -203,13 +274,48 @@           logWorker ("undecodable event, dead-lettering: " <> Text.pack (show codecErr))           pure (AckDeadLetter (InvalidPayload (Text.pack (show codecErr))))         Right (MemoryRecorded d) -> do-          outcome <- embedMemoryContent capability env (idText (d.memoryId :: MemoryId)) d.content-          case outcome of-            EmbedFailed err -> do-              logWorker ("embedding failed, retrying: " <> Text.pack (show err))-              pure (AckRetry retryDelay)-            EmbedStored -> pure AckOk-            EmbedSkipped -> pure AckOk+          decision <- contexts.contextForSpace d.memorySpaceId+          case decision of+            Left denial -> do+              let reason =+                    "not authorized to embed memory space "+                      <> memorySpaceIdText d.memorySpaceId+                      <> ": "+                      <> Text.pack (show denial)+              logWorker ("dead-lettering: " <> reason)+              pure (AckDeadLetter (InvalidPayload reason))+            Right context+              | not (memoryContextAllows MemoryDistill context) -> do+                  let reason =+                        "context for memory space "+                          <> memorySpaceIdText d.memorySpaceId+                          <> " does not grant distill"+                  logWorker ("dead-lettering: " <> reason)+                  pure (AckDeadLetter (InvalidPayload reason))+              | otherwise -> do+                  outcome <-+                    embedMemoryContent+                      capability+                      env+                      (memoryContextSpace context)+                      (idText (d.memoryId :: MemoryId))+                      d.content+                  case outcome of+                    EmbedFailed err -> do+                      logWorker ("embedding failed, retrying: " <> Text.pack (show err))+                      pure (AckRetry retryDelay)+                    EmbedSpaceMismatch expected actual -> do+                      let reason =+                            "event claims memory space "+                              <> memorySpaceIdText expected+                              <> " but memory "+                              <> idText (d.memoryId :: MemoryId)+                              <> " is in "+                              <> memorySpaceIdText actual+                      logWorker ("dead-lettering: " <> reason)+                      pure (AckDeadLetter (InvalidPayload reason))+                    EmbedStored -> pure AckOk+                    EmbedSkipped -> pure AckOk         -- The subscription is filtered to MemoryRecorded, so this is unreachable         -- today; acking is the harmless answer if the filter ever widens.         Right _ -> pure AckOk@@ -218,23 +324,35 @@ logWorker msg =   liftIO (IO.hPutStrLn IO.stderr (Text.unpack (embeddingWorkerName <> ": " <> msg))) +-- | Embed every active memory that is missing a current vector, in one space or in all of them.+--+-- A candidate carries the space it was read from, so the update writes back into that same+-- space. There is no mismatch branch here and there cannot be one: unlike the subscription+-- handler, this pass has no envelope to disagree with the row.+-- It takes a whole 'EmbeddingWorkerEnv' rather than a model and a dimension count, for the+-- reason that record exists: a test can drive the pass with a fake provider, which is the only+-- way to assert /which rows/ a scope selected without an embedding API key and a network. backfillMissingEmbeddings ::   (IOE :> es, Store :> es) =>   VectorCapability ->-  EmbeddingModel ->-  Int ->+  EmbeddingWorkerEnv ->+  EmbeddingBackfillScope ->   Eff es Int-backfillMissingEmbeddings VectorAvailable model dims = do-  candidates <- runTransaction (Tx.statement () selectEmbeddingCandidatesStmt)+backfillMissingEmbeddings VectorAvailable env scope = do+  candidates <- runTransaction candidateQuery   foldM embedCandidate 0 candidates   where-    env = mkEmbeddingWorkerEnv model dims+    candidateQuery =+      case scope of+        BackfillEverySpace -> Tx.statement () selectEmbeddingCandidatesStmt+        BackfillOneSpace space -> Tx.statement space selectEmbeddingCandidatesInSpaceStmt      embedCandidate count candidate       | shouldSkipEmbedding candidate.hasEmbedding candidate.contentHash contentHash =           pure count       | otherwise = do-          outcome <- embedAndStore env candidate.memoryId candidate.content contentHash+          outcome <-+            embedAndStore env candidate.memorySpaceId candidate.memoryId candidate.content contentHash           case outcome of             EmbedStored -> pure (count + 1)             EmbedSkipped -> pure count@@ -243,29 +361,49 @@             EmbedFailed err -> do               logWorker ("backfill skipped " <> candidate.memoryId <> ": " <> Text.pack (show err))               pure count+            -- Unreachable: the candidate's space came from the row being updated.+            EmbedSpaceMismatch expected actual -> do+              logWorker+                ( "backfill skipped "+                    <> candidate.memoryId+                    <> ": read in "+                    <> memorySpaceIdText expected+                    <> " but now in "+                    <> memorySpaceIdText actual+                )+              pure count       where         contentHash = sha256Hex candidate.content backfillMissingEmbeddings _ _ _ = pure 0 +-- | Embed one memory, refusing to touch it if it is not in the space the caller named.+--+-- The state read is keyed by the memory id alone, which is globally unique, and returns the+-- row's own space. That is deliberate and is the opposite of a leak: reading the space in order+-- to compare it is what makes a disagreement loud. Scoping the read by @space AND id@ would+-- report a memory in another space as absent, and absent is an ack. embedMemoryContent ::   (IOE :> es, Store :> es) =>   VectorCapability ->   EmbeddingWorkerEnv ->+  MemorySpaceId ->   Text ->   Text ->   Eff es EmbedOutcome-embedMemoryContent VectorAvailable env memoryId content = do+embedMemoryContent VectorAvailable env memorySpaceId memoryId content = do   existing <- runTransaction (Tx.statement memoryId selectEmbeddingStateStmt)   case existing of     Nothing -> pure EmbedSkipped     Just state+      | state.memorySpaceId /= memorySpaceId ->+          pure (EmbedSpaceMismatch memorySpaceId state.memorySpaceId)       | shouldSkipEmbedding state.hasEmbedding state.contentHash contentHash ->           pure EmbedSkipped       | otherwise ->-          embedAndStore env memoryId content contentHash+          embedAndStore env memorySpaceId memoryId content contentHash   where     contentHash = sha256Hex content-embedMemoryContent _ _ _ _ = pure EmbedSkipped+embedMemoryContent _ _ _ _ _ = pure EmbedSkipped  shouldSkipEmbedding :: Bool -> Maybe Text -> Text -> Bool shouldSkipEmbedding hasEmbedding storedContentHash contentHash =@@ -274,11 +412,12 @@ embedAndStore ::   (IOE :> es, Store :> es) =>   EmbeddingWorkerEnv ->+  MemorySpaceId ->   Text ->   Text ->   Text ->   Eff es EmbedOutcome-embedAndStore env memoryId content contentHash = do+embedAndStore env memorySpaceId memoryId content contentHash = do   result <- liftIO (env.embed content)   case result of     Left err -> pure (EmbedFailed err)@@ -286,7 +425,8 @@       runTransaction $         Tx.statement           EmbeddingUpdate-            { memoryId,+            { memorySpaceId,+              memoryId,               embedding,               embeddingModel = env.model.modelId,               dimensions = env.dimensions,@@ -298,57 +438,105 @@ selectEmbeddingCandidatesStmt :: Statement () [EmbeddingCandidate] selectEmbeddingCandidatesStmt =   preparable-    """-    SELECT memory_id, content, content_hash, embedding IS NOT NULL AS has_embedding-    FROM kiroku.kioku_memories-    WHERE status = 'active'-    ORDER BY created_at ASC-    """+    ( """+      SELECT memory_space_id, memory_id, content, content_hash, embedding IS NOT NULL AS has_embedding+      FROM+      """+        <> " "+        <> memoriesTable+        <> " "+        <> """+           WHERE status = 'active'+           ORDER BY created_at ASC+           """+    )     E.noParams     (D.rowList embeddingCandidateDecoder) +-- | The same scan bounded to one space, so an operator can repair one tenant.+--+-- @memory_space_id@ leads @kioku_memories_space_namespace_idx@, but this predicate carries no+-- namespace and orders by @created_at@, so the planner is free to prefer a scan. That is+-- correct: a backfill visits every unembedded row in the space by definition, and the point of+-- the predicate here is which rows are eligible, not how they are reached.+selectEmbeddingCandidatesInSpaceStmt :: Statement MemorySpaceId [EmbeddingCandidate]+selectEmbeddingCandidatesInSpaceStmt =+  preparable+    ( """+      SELECT memory_space_id, memory_id, content, content_hash, embedding IS NOT NULL AS has_embedding+      FROM+      """+        <> " "+        <> memoriesTable+        <> " "+        <> """+           WHERE status = 'active' AND memory_space_id = $1+           ORDER BY created_at ASC+           """+    )+    memorySpaceParam+    (D.rowList embeddingCandidateDecoder)+ selectEmbeddingStateStmt :: Statement Text (Maybe EmbeddingState) selectEmbeddingStateStmt =   preparable-    """-    SELECT content_hash, embedding IS NOT NULL AS has_embedding-    FROM kiroku.kioku_memories-    WHERE memory_id = $1 AND status = 'active'-    """+    ( """+      SELECT memory_space_id, content_hash, embedding IS NOT NULL AS has_embedding+      FROM+      """+        <> " "+        <> memoriesTable+        <> " "+        <> """+           WHERE memory_id = $1 AND status = 'active'+           """+    )     (E.param (E.nonNullable E.text))     (D.rowMaybe embeddingStateDecoder)  embeddingCandidateDecoder :: D.Row EmbeddingCandidate embeddingCandidateDecoder =   EmbeddingCandidate-    <$> D.column (D.nonNullable D.text)+    <$> memorySpaceColumn     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nonNullable D.bool)  embeddingStateDecoder :: D.Row EmbeddingState embeddingStateDecoder =   EmbeddingState-    <$> D.column (D.nullable D.text)+    <$> memorySpaceColumn+    <*> D.column (D.nullable D.text)     <*> D.column (D.nonNullable D.bool) +-- | The write names the space as well as the id.+--+-- 'embedMemoryContent' has already compared the two, so this predicate can never be the thing+-- that rejects a row — but the comparison and the write are two statements, and between them a+-- memory could in principle be rewritten into another space. The predicate is what makes the+-- write itself, rather than a check that preceded it, the thing that is partition-safe. upsertEmbeddingStmt :: Statement EmbeddingUpdate () upsertEmbeddingStmt =   preparable-    """-    UPDATE kiroku.kioku_memories-    SET embedding = $2::vector,-        embedding_model = $3,-        dimensions = $4,-        content_hash = $5-    WHERE memory_id = $1-    """+    ( "UPDATE "+        <> memoriesTable+        <> "\n"+        <> """+           SET embedding = $3::vector,+               embedding_model = $4,+               dimensions = $5,+               content_hash = $6+           WHERE memory_space_id = $1 AND memory_id = $2+           """+    )     embeddingUpdateEncoder     D.noResult  embeddingUpdateEncoder :: E.Params EmbeddingUpdate embeddingUpdateEncoder =-  ((\update -> update.memoryId) >$< E.param (E.nonNullable E.text))+  ((\update -> update.memorySpaceId) >$< memorySpaceParam)+    <> ((\update -> update.memoryId) >$< E.param (E.nonNullable E.text))     <> ((\update -> vectorLiteral update.embedding) >$< E.param (E.nonNullable E.text))     <> ((\update -> update.embeddingModel) >$< E.param (E.nonNullable E.text))     <> ((\update -> fromIntegral @Int @Int32 update.dimensions) >$< E.param (E.nonNullable E.int4))
src/Kioku/Memory/EventStream.hs view
@@ -17,6 +17,7 @@ import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow) import Keiro.Stream (Stream) import Keiro.Stream qualified as Stream+import Kioku.Api.Access (RecordedPrincipal (..), legacyMemorySpaceId, legacyPrincipalRef) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Id (MemoryId, SessionId, idText, parseIdLenient) import Kioku.Memory.Domain@@ -92,18 +93,26 @@       "agent_memory_confidence_updated" -> MemoryConfidenceUpdated <$> parseLegacyMemoryConfidenceUpdated payload       other -> fail ("Unknown Rei AgentMemoryEvent tag: " <> Text.unpack other) +-- | Rei payloads predate memory spaces by even more than Kioku's own older events do, so they+-- take the same two rules: the legacy space, and the legacy agent label kept verbatim. Rei's+-- @agentId@ is exactly the free-text label 'LegacyPrincipal' exists to mark. parseLegacyMemoryRecorded :: Value -> Parser MemoryRecordedData parseLegacyMemoryRecorded =   withObject "Rei AgentMemoryRecordedData" $ \o -> do     memoryId <- parseLegacyMemoryId =<< o .: "memoryId"+    agentId <- o .: "agentId"     sessionId <- traverse parseLegacySessionId =<< o .:? "sessionId"     scope <- parseLegacyAnchor =<< o .: "anchor"     supersedes <- traverse parseLegacyMemoryId =<< o .:? "supersedes"-    MemoryRecordedData memoryId-      <$> o .: "agentId"-      <*> pure sessionId-      <*> pure scope-      <*> o .: "memoryType"+    MemoryRecordedData+      memoryId+      legacyMemorySpaceId+      (LegacyPrincipal (legacyPrincipalRef agentId))+      Nothing+      agentId+      sessionId+      scope+      <$> o .: "memoryType"       <*> o .: "content"       <*> pure 100       <*> o .: "confidence"@@ -116,6 +125,8 @@   withObject "Rei AgentMemorySupersededData" $ \o ->     MemorySupersededData       <$> (parseLegacyMemoryId =<< o .: "memoryId")+      <*> pure legacyMemorySpaceId+      <*> pure UnattributedPrincipal       <*> (parseLegacyMemoryId =<< o .: "supersededBy")       <*> o .: "supersededAt" @@ -124,6 +135,8 @@   withObject "Rei AgentMemoryArchivedData" $ \o ->     MemoryArchivedData       <$> (parseLegacyMemoryId =<< o .: "memoryId")+      <*> pure legacyMemorySpaceId+      <*> pure UnattributedPrincipal       <*> o .: "archivedAt"  parseLegacyMemoryTagsUpdated :: Value -> Parser MemoryTagsUpdatedData@@ -131,6 +144,8 @@   withObject "Rei AgentMemoryTagsUpdatedData" $ \o ->     MemoryTagsUpdatedData       <$> (parseLegacyMemoryId =<< o .: "memoryId")+      <*> pure legacyMemorySpaceId+      <*> pure UnattributedPrincipal       <*> o .: "tags"       <*> o .: "updatedAt" @@ -139,6 +154,8 @@   withObject "Rei AgentMemoryConfidenceUpdatedData" $ \o ->     MemoryConfidenceUpdatedData       <$> (parseLegacyMemoryId =<< o .: "memoryId")+      <*> pure legacyMemorySpaceId+      <*> pure UnattributedPrincipal       <*> o .: "confidence"       <*> o .: "updatedAt" 
src/Kioku/Memory/ReadModel.hs view
@@ -1,3 +1,10 @@+-- | The @kioku.memories@ projection and every query over it.+--+-- Every row belongs to exactly one memory space, and every statement below names that space+-- first. The predicate is not redundant with the primary key even though @memory_id@ is+-- globally unique: it is what stops a caller holding a leaked id from reading, or a projection+-- from writing, outside the space it was authorized for, and it makes the partition visible in+-- every query a reviewer reads. module Kioku.Memory.ReadModel   ( memoryInlineProjection,     MemoryRow (..),@@ -20,7 +27,6 @@   ) where -import Contravariant.Extras (contrazip2, contrazip3) import Data.Aeson qualified as Aeson import Data.ByteString.Lazy qualified as BL import Data.Functor.Contravariant ((>$<))@@ -35,15 +41,19 @@ import Hasql.Transaction qualified as Tx import Keiro.Projection (InlineProjection (..)) import Keiro.ReadModel (ConsistencyMode (..), ReadModel (..), StrongScope (..))+import Kioku.Api.Access (MemorySpaceId) import Kioku.Api.Scope (scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Api.Types (MemoryRecord (..), confidenceToText, memoryTypeToText)+import Kioku.Database.Schema (kiokuSchema, memoriesRelation, memoriesTable) import Kioku.Id (idText) import Kioku.Memory.Domain+import Kioku.Partition (memorySpaceColumn, memorySpaceParam) import Kioku.Prelude import Kiroku.Store.Types (RecordedEvent)  data MemoryRow = MemoryRow-  { memoryId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text,     agentId :: !Text,     sessionId :: !(Maybe Text),     namespace :: !Text,@@ -62,17 +72,40 @@   }   deriving stock (Generic, Eq, Show) -newtype MemoryByIdQuery = MemoryByIdQuery Text+-- | Every query names its memory space in a field rather than a tuple position, so a call site+-- cannot transpose the partition with the namespace it happens to sit beside.+data MemoryByIdQuery = MemoryByIdQuery+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text+  } -newtype MemoriesByNamespaceQuery = MemoriesByNamespaceQuery Text+data MemoriesByNamespaceQuery = MemoriesByNamespaceQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text+  } -data MemoriesByScopeQuery = MemoriesByScopeQuery Text (Maybe Text) (Maybe Text)+data MemoriesByScopeQuery = MemoriesByScopeQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    scopeKind :: !(Maybe Text),+    scopeRef :: !(Maybe Text)+  } -newtype MemoriesBySessionQuery = MemoriesBySessionQuery Text+data MemoriesBySessionQuery = MemoriesBySessionQuery+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text+  } -data MemoriesByTypeQuery = MemoriesByTypeQuery Text Text+data MemoriesByTypeQuery = MemoriesByTypeQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    memoryType :: !Text+  } -newtype MemorySupersessionChainQuery = MemorySupersessionChainQuery Text+data MemorySupersessionChainQuery = MemorySupersessionChainQuery+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text+  }  memoryInlineProjection :: InlineProjection MemoryEvent memoryInlineProjection =@@ -81,27 +114,63 @@       apply = applyMemoryEvent     } +-- | Project one memory event.+--+-- Each non-recording event carries the memory space its aggregate is pinned to, and the update+-- statements below match on it as well as on the id. That is not defensive noise: the aggregate+-- refuses a command naming the wrong space, so a mismatch here can only mean the projection and+-- the event stream disagree, and quietly rewriting a row in another space is the one outcome+-- worse than writing nothing. applyMemoryEvent :: MemoryEvent -> RecordedEvent -> Tx.Transaction () applyMemoryEvent event _recorded =   case event of     MemoryRecorded d -> Tx.statement (recordedRow d) upsertMemoryStmt     MemorySuperseded d ->       Tx.statement-        (idText d.memoryId, idText d.supersededBy, d.supersededAt)+        (MemoryStatusChange d.memorySpaceId (idText d.memoryId) (Just (idText d.supersededBy)) d.supersededAt)         updateMemorySupersededStmt     MemoryArchived d ->-      Tx.statement (idText d.memoryId, d.archivedAt) updateMemoryArchivedStmt+      Tx.statement+        (MemoryStatusChange d.memorySpaceId (idText d.memoryId) Nothing d.archivedAt)+        updateMemoryArchivedStmt     MemoryTagsUpdated d ->-      Tx.statement (idText d.memoryId, d.tags, d.updatedAt) updateMemoryTagsStmt+      Tx.statement (MemoryTagsChange d.memorySpaceId (idText d.memoryId) d.tags d.updatedAt) updateMemoryTagsStmt     MemoryConfidenceUpdated d ->-      Tx.statement (idText d.memoryId, confidenceToText d.confidence, d.updatedAt) updateMemoryConfidenceStmt+      Tx.statement+        (MemoryConfidenceChange d.memorySpaceId (idText d.memoryId) (confidenceToText d.confidence) d.updatedAt)+        updateMemoryConfidenceStmt     MemoryMerged d ->-      Tx.statement (idText d.memoryId, idText d.mergedInto, d.mergedAt) updateMemoryMergedStmt+      Tx.statement+        (MemoryStatusChange d.memorySpaceId (idText d.memoryId) (Just (idText d.mergedInto)) d.mergedAt)+        updateMemoryMergedStmt +-- | The parameters of a retirement: which memory, in which space, retired in favour of what.+data MemoryStatusChange = MemoryStatusChange+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text,+    supersededBy :: !(Maybe Text),+    updatedAt :: !UTCTime+  }++data MemoryTagsChange = MemoryTagsChange+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text,+    tags :: !(Set Text),+    updatedAt :: !UTCTime+  }++data MemoryConfidenceChange = MemoryConfidenceChange+  { memorySpaceId :: !MemorySpaceId,+    memoryId :: !Text,+    confidence :: !Text,+    updatedAt :: !UTCTime+  }+ recordedRow :: MemoryRecordedData -> MemoryRow recordedRow d =   MemoryRow-    { memoryId = idText d.memoryId,+    { memorySpaceId = d.memorySpaceId,+      memoryId = idText d.memoryId,       agentId = d.agentId,       sessionId = idText <$> d.sessionId,       namespace = scopeNamespaceText d.scope,@@ -123,147 +192,167 @@ memoryByIdReadModel =   ReadModel     { name = "kioku-memory-by-id",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoryByIdQuery mid) -> Tx.statement mid selectMemoryByIdStmt+      query = \q -> Tx.statement q selectMemoryByIdStmt     }  memoriesByNamespaceReadModel :: ReadModel MemoriesByNamespaceQuery [MemoryRecord] memoriesByNamespaceReadModel =   ReadModel     { name = "kioku-memories-by-namespace",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesByNamespaceQuery ns) -> Tx.statement ns selectActiveByNamespaceStmt+      query = \q -> Tx.statement q selectActiveByNamespaceStmt     }  memoriesByNamespaceRowsReadModel :: ReadModel MemoriesByNamespaceQuery [MemoryRow] memoriesByNamespaceRowsReadModel =   ReadModel     { name = "kioku-memory-rows-by-namespace",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesByNamespaceQuery ns) -> Tx.statement ns selectActiveByNamespaceRowsStmt+      query = \q -> Tx.statement q selectActiveByNamespaceRowsStmt     }  memoriesByScopeReadModel :: ReadModel MemoriesByScopeQuery [MemoryRecord] memoriesByScopeReadModel =   ReadModel     { name = "kioku-memories-by-scope",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesByScopeQuery ns sk sr) -> Tx.statement (ns, sk, sr) selectActiveByScopeStmt+      query = \q -> Tx.statement q selectActiveByScopeStmt     }  memoriesByScopeRowsReadModel :: ReadModel MemoriesByScopeQuery [MemoryRow] memoriesByScopeRowsReadModel =   ReadModel     { name = "kioku-memory-rows-by-scope",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesByScopeQuery ns sk sr) -> Tx.statement (ns, sk, sr) selectActiveByScopeRowsStmt+      query = \q -> Tx.statement q selectActiveByScopeRowsStmt     }  memoriesBySessionReadModel :: ReadModel MemoriesBySessionQuery [MemoryRecord] memoriesBySessionReadModel =   ReadModel     { name = "kioku-memories-by-session",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesBySessionQuery sid) -> Tx.statement sid selectBySessionStmt+      query = \q -> Tx.statement q selectBySessionStmt     }  memoriesBySessionRowsReadModel :: ReadModel MemoriesBySessionQuery [MemoryRow] memoriesBySessionRowsReadModel =   ReadModel     { name = "kioku-memory-rows-by-session",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesBySessionQuery sid) -> Tx.statement sid selectBySessionRowsStmt+      query = \q -> Tx.statement q selectBySessionRowsStmt     }  memoriesByTypeReadModel :: ReadModel MemoriesByTypeQuery [MemoryRecord] memoriesByTypeReadModel =   ReadModel     { name = "kioku-memories-by-type",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesByTypeQuery ns mt) -> Tx.statement (ns, mt) selectByTypeStmt+      query = \q -> Tx.statement q selectByTypeStmt     }  memoriesByTypeRowsReadModel :: ReadModel MemoriesByTypeQuery [MemoryRow] memoriesByTypeRowsReadModel =   ReadModel     { name = "kioku-memory-rows-by-type",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemoriesByTypeQuery ns mt) -> Tx.statement (ns, mt) selectByTypeRowsStmt+      query = \q -> Tx.statement q selectByTypeRowsStmt     }  memorySupersessionChainReadModel :: ReadModel MemorySupersessionChainQuery [MemoryRow] memorySupersessionChainReadModel =   ReadModel     { name = "kioku-memory-supersession-chain",-      schema = "kiroku",-      tableName = "kioku_memories",+      schema = kiokuSchema,+      tableName = memoriesRelation,       subscriptionName = "kioku-memory-inline",-      version = 1,-      shapeHash = "kioku-memory-v1",+      version = memoryReadModelVersion,+      shapeHash = memoryReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(MemorySupersessionChainQuery mid) -> Tx.statement mid selectSupersessionChainStmt+      query = \q -> Tx.statement q selectSupersessionChainStmt     } +-- | The registry identity of every memory read model.+--+-- v2 was the memory-space partition: the row carries @memory_space_id@ and every query filters+-- on it. v3 is the relocation of this projection from @kiroku.kioku_memories@ to+-- @kioku.memories@. Keiro's registry stores a logical name, version, and shape hash but no+-- physical relation locator, so the version bump is the only signal available — and it is the+-- one that matters, because it makes a binary compiled before the move fail closed with+-- @ReadModelStaleSchema@ instead of issuing SQL at a relation that is no longer there.+--+-- Neither bump requires a rebuild: the migration leaves the table data correct for the new+-- version (v2 backfilled every pre-existing row into the legacy space; v3 moved the rows+-- without touching them), so @Kioku.ReadModel.reconcileReadModelRegistry@ can advance the guard+-- on its own.+memoryReadModelVersion :: Int+memoryReadModelVersion = 3++memoryReadModelShapeHash :: Text+memoryReadModelShapeHash = "kioku-memory-v3"+ memoryRowDecoder :: D.Row MemoryRow memoryRowDecoder =   MemoryRow-    <$> D.column (D.nonNullable D.text)+    <$> memorySpaceColumn     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.text)@@ -308,11 +397,13 @@  memoryRowColumns :: Text memoryRowColumns =-  "memory_id, agent_id, session_id, namespace, scope_kind, scope_ref, memory_type, content, priority, confidence, tags::text, status, superseded_by, supersedes, created_at, updated_at"+  "memory_space_id, memory_id, agent_id, session_id, namespace, scope_kind, scope_ref, memory_type, content, priority, confidence, tags::text, status, superseded_by, supersedes, created_at, updated_at"  qualifiedMemoryRowColumns :: Text -> Text qualifiedMemoryRowColumns prefix =   prefix+    <> ".memory_space_id, "+    <> prefix     <> ".memory_id, "     <> prefix     <> ".agent_id, "@@ -345,168 +436,208 @@     <> prefix     <> ".updated_at" -selectMemoryByIdStmt :: Statement Text (Maybe MemoryRow)+selectMemoryByIdStmt :: Statement MemoryByIdQuery (Maybe MemoryRow) selectMemoryByIdStmt =   preparable     ( "SELECT "         <> memoryRowColumns-        <> " FROM kioku_memories WHERE memory_id = $1"+        <> " FROM "+        <> memoriesTable+        <> " WHERE memory_space_id = $1 AND memory_id = $2"     )-    (E.param (E.nonNullable E.text))+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.memoryId) >$< E.param (E.nonNullable E.text))+    )     (D.rowMaybe memoryRowDecoder) -selectActiveByNamespaceStmt :: Statement Text [MemoryRecord]+selectActiveByNamespaceStmt :: Statement MemoriesByNamespaceQuery [MemoryRecord] selectActiveByNamespaceStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 ORDER BY priority ASC, created_at DESC"-    )-    (E.param (E.nonNullable E.text))+    (activeByNamespaceSql memoryRowColumns)+    namespaceQueryEncoder     (D.rowList memoryRecordDecoder) -selectActiveByNamespaceRowsStmt :: Statement Text [MemoryRow]+selectActiveByNamespaceRowsStmt :: Statement MemoriesByNamespaceQuery [MemoryRow] selectActiveByNamespaceRowsStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 ORDER BY priority ASC, created_at DESC"-    )-    (E.param (E.nonNullable E.text))+    (activeByNamespaceSql memoryRowColumns)+    namespaceQueryEncoder     (D.rowList memoryRowDecoder) --- | Active memories carrying __exactly__ the given scope.+activeByNamespaceSql :: Text -> Text+activeByNamespaceSql columns =+  "SELECT "+    <> columns+    <> " FROM "+    <> memoriesTable+    <> " WHERE status = 'active' AND memory_space_id = $1 AND namespace = $2 ORDER BY priority ASC, created_at DESC"++namespaceQueryEncoder :: E.Params MemoriesByNamespaceQuery+namespaceQueryEncoder =+  ((\q -> q.memorySpaceId) >$< memorySpaceParam)+    <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))++-- | Active memories carrying __exactly__ the given scope, inside one memory space. -- -- Recall searches namespace-wide for a global scope; scoped reads are exact-scope. The -- predicate below /requires/ @scope_kind@ and @scope_ref@ to be NULL for a global scope, -- where 'Kioku.Recall.selectFtsCandidatesStmt' would drop the scope filter entirely and--- return the whole namespace. Both behaviours are intentional; see docs/user/recall.md.-selectActiveByScopeStmt :: Statement (Text, Maybe Text, Maybe Text) [MemoryRecord]+-- return the whole namespace. Both behaviours are intentional; see docs/user/recall.md. The+-- memory-space predicate is not part of that asymmetry — it never widens, in either query.+selectActiveByScopeStmt :: Statement MemoriesByScopeQuery [MemoryRecord] selectActiveByScopeStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND ((scope_kind = $2 AND scope_ref = $3) OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL)) ORDER BY priority ASC, created_at DESC"-    )-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))-    )+    (activeByScopeSql memoryRowColumns)+    scopeQueryEncoder     (D.rowList memoryRecordDecoder) -selectActiveByScopeRowsStmt :: Statement (Text, Maybe Text, Maybe Text) [MemoryRow]+selectActiveByScopeRowsStmt :: Statement MemoriesByScopeQuery [MemoryRow] selectActiveByScopeRowsStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND ((scope_kind = $2 AND scope_ref = $3) OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL)) ORDER BY priority ASC, created_at DESC"-    )-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))-    )+    (activeByScopeSql memoryRowColumns)+    scopeQueryEncoder     (D.rowList memoryRowDecoder) -selectBySessionStmt :: Statement Text [MemoryRecord]+activeByScopeSql :: Text -> Text+activeByScopeSql columns =+  "SELECT "+    <> columns+    <> " FROM "+    <> memoriesTable+    <> " WHERE status = 'active' AND memory_space_id = $1 AND namespace = $2 AND ((scope_kind = $3 AND scope_ref = $4) OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL)) ORDER BY priority ASC, created_at DESC"++scopeQueryEncoder :: E.Params MemoriesByScopeQuery+scopeQueryEncoder =+  ((\q -> q.memorySpaceId) >$< memorySpaceParam)+    <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))+    <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))++selectBySessionStmt :: Statement MemoriesBySessionQuery [MemoryRecord] selectBySessionStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE session_id = $1 ORDER BY created_at DESC"-    )-    (E.param (E.nonNullable E.text))+    (bySessionSql memoryRowColumns)+    sessionQueryEncoder     (D.rowList memoryRecordDecoder) -selectBySessionRowsStmt :: Statement Text [MemoryRow]+selectBySessionRowsStmt :: Statement MemoriesBySessionQuery [MemoryRow] selectBySessionRowsStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE session_id = $1 ORDER BY created_at DESC"-    )-    (E.param (E.nonNullable E.text))+    (bySessionSql memoryRowColumns)+    sessionQueryEncoder     (D.rowList memoryRowDecoder) -selectByTypeStmt :: Statement (Text, Text) [MemoryRecord]+bySessionSql :: Text -> Text+bySessionSql columns =+  "SELECT "+    <> columns+    <> " FROM "+    <> memoriesTable+    <> " WHERE memory_space_id = $1 AND session_id = $2 ORDER BY created_at DESC"++sessionQueryEncoder :: E.Params MemoriesBySessionQuery+sessionQueryEncoder =+  ((\q -> q.memorySpaceId) >$< memorySpaceParam)+    <> ((\q -> q.sessionId) >$< E.param (E.nonNullable E.text))++selectByTypeStmt :: Statement MemoriesByTypeQuery [MemoryRecord] selectByTypeStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND memory_type = $2 ORDER BY priority ASC, created_at DESC"-    )-    ( contrazip2-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.text))-    )+    (byTypeSql memoryRowColumns)+    typeQueryEncoder     (D.rowList memoryRecordDecoder) -selectByTypeRowsStmt :: Statement (Text, Text) [MemoryRow]+selectByTypeRowsStmt :: Statement MemoriesByTypeQuery [MemoryRow] selectByTypeRowsStmt =   preparable-    ( "SELECT "-        <> memoryRowColumns-        <> " FROM kioku_memories WHERE status = 'active' AND namespace = $1 AND memory_type = $2 ORDER BY priority ASC, created_at DESC"-    )-    ( contrazip2-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.text))-    )+    (byTypeSql memoryRowColumns)+    typeQueryEncoder     (D.rowList memoryRowDecoder) -selectSupersessionChainStmt :: Statement Text [MemoryRow]+byTypeSql :: Text -> Text+byTypeSql columns =+  "SELECT "+    <> columns+    <> " FROM "+    <> memoriesTable+    <> " WHERE status = 'active' AND memory_space_id = $1 AND namespace = $2 AND memory_type = $3 ORDER BY priority ASC, created_at DESC"++typeQueryEncoder :: E.Params MemoriesByTypeQuery+typeQueryEncoder =+  ((\q -> q.memorySpaceId) >$< memorySpaceParam)+    <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.memoryType) >$< E.param (E.nonNullable E.text))++-- | Walk a memory's supersession chain, without leaving the space it started in.+--+-- The space predicate is repeated on the recursive arm, not just the anchor. Supersession+-- lineage is stored as bare ids in @supersedes@ and @superseded_by@, so an id written before+-- the partition existed — or a bug anywhere upstream — could otherwise walk the chain straight+-- into another space and return its content.+selectSupersessionChainStmt :: Statement MemorySupersessionChainQuery [MemoryRow] selectSupersessionChainStmt =   preparable     ( "WITH RECURSIVE chain AS ("         <> "SELECT "         <> memoryRowColumns-        <> " FROM kioku_memories WHERE memory_id = $1 "+        <> " FROM "+        <> memoriesTable+        <> " WHERE memory_space_id = $1 AND memory_id = $2 "         <> "UNION "         <> "SELECT "         <> qualifiedMemoryRowColumns "m"-        <> " FROM kioku_memories m JOIN chain c ON "+        <> " FROM "+        <> memoriesTable+        <> " m JOIN chain c ON m.memory_space_id = c.memory_space_id AND ("         <> "m.memory_id = c.supersedes "         <> "OR m.supersedes = c.memory_id "         <> "OR m.memory_id = c.superseded_by "         <> "OR m.superseded_by = c.memory_id"-        <> ") SELECT "+        <> ")) SELECT "         <> memoryRowColumns         <> " FROM chain ORDER BY created_at ASC, memory_id ASC"     )-    (E.param (E.nonNullable E.text))+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.memoryId) >$< E.param (E.nonNullable E.text))+    )     (D.rowList memoryRowDecoder)  upsertMemoryStmt :: Statement MemoryRow () upsertMemoryStmt =   preparable-    """-    INSERT INTO kioku_memories-      (memory_id, agent_id, session_id, namespace, scope_kind, scope_ref, memory_type, content,-       priority, confidence, tags, status, superseded_by, supersedes, created_at, updated_at)-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14, $15, $16)-    ON CONFLICT (memory_id) DO UPDATE SET-      agent_id = EXCLUDED.agent_id,-      session_id = EXCLUDED.session_id,-      namespace = EXCLUDED.namespace,-      scope_kind = EXCLUDED.scope_kind,-      scope_ref = EXCLUDED.scope_ref,-      memory_type = EXCLUDED.memory_type,-      content = EXCLUDED.content,-      priority = EXCLUDED.priority,-      confidence = EXCLUDED.confidence,-      tags = EXCLUDED.tags,-      status = EXCLUDED.status,-      superseded_by = EXCLUDED.superseded_by,-      supersedes = EXCLUDED.supersedes,-      updated_at = EXCLUDED.updated_at-    """+    ( "INSERT INTO "+        <> memoriesTable+        <> "\n"+        <> """+           (memory_space_id, memory_id, agent_id, session_id, namespace, scope_kind, scope_ref,+              memory_type, content, priority, confidence, tags, status, superseded_by, supersedes,+              created_at, updated_at)+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15, $16, $17)+           ON CONFLICT (memory_id) DO UPDATE SET+             memory_space_id = EXCLUDED.memory_space_id,+             agent_id = EXCLUDED.agent_id,+             session_id = EXCLUDED.session_id,+             namespace = EXCLUDED.namespace,+             scope_kind = EXCLUDED.scope_kind,+             scope_ref = EXCLUDED.scope_ref,+             memory_type = EXCLUDED.memory_type,+             content = EXCLUDED.content,+             priority = EXCLUDED.priority,+             confidence = EXCLUDED.confidence,+             tags = EXCLUDED.tags,+             status = EXCLUDED.status,+             superseded_by = EXCLUDED.superseded_by,+             supersedes = EXCLUDED.supersedes,+             updated_at = EXCLUDED.updated_at+           """+    )     memoryRowEncoder     D.noResult  memoryRowEncoder :: E.Params MemoryRow memoryRowEncoder =-  ((\row -> row.memoryId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.memoryId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.agentId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.sessionId) >$< E.param (E.nullable E.text))     <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))@@ -523,53 +654,52 @@     <> ((\row -> row.createdAt) >$< E.param (E.nonNullable E.timestamptz))     <> ((\row -> row.updatedAt) >$< E.param (E.nonNullable E.timestamptz)) -updateMemorySupersededStmt :: Statement (Text, Text, UTCTime) ()+statusChangeEncoder :: E.Params MemoryStatusChange+statusChangeEncoder =+  ((\c -> c.memorySpaceId) >$< memorySpaceParam)+    <> ((\c -> c.memoryId) >$< E.param (E.nonNullable E.text))+    <> ((\c -> c.supersededBy) >$< E.param (E.nullable E.text))+    <> ((\c -> c.updatedAt) >$< E.param (E.nonNullable E.timestamptz))++updateMemorySupersededStmt :: Statement MemoryStatusChange () updateMemorySupersededStmt =   preparable-    "UPDATE kioku_memories SET status = 'superseded', superseded_by = $2, updated_at = $3 WHERE memory_id = $1"-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.timestamptz))-    )+    ("UPDATE " <> memoriesTable <> " SET status = 'superseded', superseded_by = $3, updated_at = $4 WHERE memory_space_id = $1 AND memory_id = $2")+    statusChangeEncoder     D.noResult -updateMemoryArchivedStmt :: Statement (Text, UTCTime) ()+updateMemoryArchivedStmt :: Statement MemoryStatusChange () updateMemoryArchivedStmt =   preparable-    "UPDATE kioku_memories SET status = 'archived', updated_at = $2 WHERE memory_id = $1"-    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))+    ("UPDATE " <> memoriesTable <> " SET status = 'archived', updated_at = $4 WHERE memory_space_id = $1 AND memory_id = $2")+    statusChangeEncoder     D.noResult -updateMemoryTagsStmt :: Statement (Text, Set Text, UTCTime) ()-updateMemoryTagsStmt =+updateMemoryMergedStmt :: Statement MemoryStatusChange ()+updateMemoryMergedStmt =   preparable-    "UPDATE kioku_memories SET tags = $2::jsonb, updated_at = $3 WHERE memory_id = $1"-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (encodeTags >$< E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.timestamptz))-    )+    ("UPDATE " <> memoriesTable <> " SET status = 'merged', superseded_by = $3, updated_at = $4 WHERE memory_space_id = $1 AND memory_id = $2")+    statusChangeEncoder     D.noResult -updateMemoryConfidenceStmt :: Statement (Text, Text, UTCTime) ()-updateMemoryConfidenceStmt =+updateMemoryTagsStmt :: Statement MemoryTagsChange ()+updateMemoryTagsStmt =   preparable-    "UPDATE kioku_memories SET confidence = $2, updated_at = $3 WHERE memory_id = $1"-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.timestamptz))+    ("UPDATE " <> memoriesTable <> " SET tags = $3::jsonb, updated_at = $4 WHERE memory_space_id = $1 AND memory_id = $2")+    ( ((\c -> c.memorySpaceId) >$< memorySpaceParam)+        <> ((\c -> c.memoryId) >$< E.param (E.nonNullable E.text))+        <> ((encodeTags . (\c -> c.tags)) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.updatedAt) >$< E.param (E.nonNullable E.timestamptz))     )     D.noResult -updateMemoryMergedStmt :: Statement (Text, Text, UTCTime) ()-updateMemoryMergedStmt =+updateMemoryConfidenceStmt :: Statement MemoryConfidenceChange ()+updateMemoryConfidenceStmt =   preparable-    "UPDATE kioku_memories SET status = 'merged', superseded_by = $2, updated_at = $3 WHERE memory_id = $1"-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.timestamptz))+    ("UPDATE " <> memoriesTable <> " SET confidence = $3, updated_at = $4 WHERE memory_space_id = $1 AND memory_id = $2")+    ( ((\c -> c.memorySpaceId) >$< memorySpaceParam)+        <> ((\c -> c.memoryId) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.confidence) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.updatedAt) >$< E.param (E.nonNullable E.timestamptz))     )     D.noResult
+ src/Kioku/Partition.hs view
@@ -0,0 +1,92 @@+-- | Reading the memory-space partition off events that predate it.+--+-- Every command and event Kioku writes now names the memory space it belongs to and the+-- principal that acted. Events already on disk name neither, and they must keep decoding+-- forever: an event that stops parsing is an aggregate that can no longer be rebuilt and a+-- projection that can no longer be replayed.+--+-- These helpers are the single place that decides what an older payload means, so that no+-- module invents its own default. The rules are:+--+-- * A payload with no @memorySpaceId@ belongs to 'legacyMemorySpaceId'. Absence of a partition+--   never means "visible everywhere" — see @docs\/adr\/legacy-data-lands-in-one-explicit-space.md@.+-- * A payload with no @actorPrincipal@ but with the old free-text @agentId@ is attributed to+--   that label, marked as legacy. It is never rewritten into a directory-issued principal id.+-- * A payload with neither is 'UnattributedPrincipal'. Most pre-partition events — archiving a+--   memory, completing a session, recording a turn — genuinely recorded no actor, and inventing+--   one would put a fabricated identity into an audit trail.+--+-- Encoding always writes the new form. There is no path that emits a payload without a space.+--+-- The same module owns how a memory space is written down in PostgreSQL, for the same reason:+-- @memory_space_id@ is a plain @text@ column, and exactly one pair of functions turns a+-- 'MemorySpaceId' into it and back.+module Kioku.Partition+  ( parsePartitionSpace,+    parseRecordedActor,+    parseRecordedActorFromAgent,+    parseRecordedOwner,+    memorySpaceColumn,+    memorySpaceParam,+  )+where++import Data.Aeson.Types (Object, Parser, (.!=), (.:), (.:?))+import Data.Functor.Contravariant ((>$<))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Kioku.Api.Access+  ( LegacyPrincipalRef,+    MemorySpaceId,+    PrincipalRef,+    RecordedPrincipal (..),+    legacyMemorySpaceId,+    legacyPrincipalRef,+    memorySpaceIdText,+    mkMemorySpaceId,+  )+import Kioku.Prelude++-- | The memory space a payload belongs to, defaulting an older payload into the legacy space.+parsePartitionSpace :: Object -> Parser MemorySpaceId+parsePartitionSpace o = o .:? "memorySpaceId" .!= legacyMemorySpaceId++-- | The actor a payload records, for events that never carried an agent label.+parseRecordedActor :: Object -> Parser RecordedPrincipal+parseRecordedActor o = o .:? "actorPrincipal" .!= UnattributedPrincipal++-- | The actor a payload records, for events that carried the old free-text @agentId@.+--+-- The legacy label is kept verbatim and marked. Turning @demo-agent@ into @agent_demo-agent@+-- would manufacture an identifier no directory ever issued, and every later authorization+-- decision made against it would be a decision about a string somebody typed.+parseRecordedActorFromAgent :: Object -> Parser RecordedPrincipal+parseRecordedActorFromAgent o = do+  explicit <- o .:? "actorPrincipal"+  case explicit of+    Just actor -> pure actor+    Nothing -> legacyActor <$> o .: "agentId"+  where+    legacyActor :: Text -> RecordedPrincipal+    legacyActor = LegacyPrincipal . (legacyPrincipalRef :: Text -> LegacyPrincipalRef)++-- | The owning principal, which only writes made through the memory-space API can carry.+--+-- There is no legacy fallback and there must not be one: ownership did not exist before this+-- field did, so an older payload has no owner rather than an implied one.+parseRecordedOwner :: Object -> Parser (Maybe PrincipalRef)+parseRecordedOwner o = o .:? "ownerPrincipal"++-- | Decode a non-null @memory_space_id@ column.+--+-- It goes through 'mkMemorySpaceId' rather than being taken as raw text, so a row that somehow+-- holds a value no caller could have constructed — an empty string, something with a @:@ in it —+-- fails the read loudly instead of becoming a space id that compares equal to nothing.+memorySpaceColumn :: D.Row MemorySpaceId+memorySpaceColumn =+  D.column (D.nonNullable (D.refine mkMemorySpaceId D.text))++-- | Encode one @memory_space_id@ query parameter.+memorySpaceParam :: E.Params MemorySpaceId+memorySpaceParam =+  memorySpaceIdText >$< E.param (E.nonNullable E.text)
src/Kioku/Recall.hs view
@@ -1,9 +1,43 @@+-- | Running a recall request, and the unranked scope scans beside it.+--+-- A recall call names two things that used to be one. The __target__ says what to search — one+-- exact scope, or every scope in one namespace — and comes from the caller. The __memory space__+-- says whose memories those are, and comes from the 'MemoryAccessContext' that authorized the+-- call. Widening the first can never widen the second, which is the property the vocabulary in+-- "Kioku.Api.Recall" exists to make visible at every call site.+--+-- The old entry point is still here. 'legacyRecall' takes the pre-target 'RecallRequest', maps+-- its 'MemoryScope' through 'legacyRecallTarget', and returns exactly the rows it returns today;+-- it is deprecated so that an unmigrated caller finds out at compile time rather than by counting+-- rows. See @docs\/user\/recall.md@ for the migration table and the removal condition. module Kioku.Recall-  ( RecallStrategy (..),-    RecallRequest (..),+  ( -- * What to search for+    RecallTarget (..),+    RecallQuery (..),+    RecallStrategy (..),+    RecallLimit,+    mkRecallQuery,+    mkRecallLimit,+    recallLimitInt,+    defaultRecallLimit,+    maxRecallLimit,+    recallStrategyText,+    parseRecallStrategy,+    allRecallStrategies,+    recallTargetNamespace,+    recallTargetExactScope,+    recallTargetIsNamespaceWide,++    -- * Running it+    RecallError (..),     RecallHit (..),     RecallExecutionPlan (..),     recall,++    -- * The pre-target API, kept for one release+    RecallRequest (..),+    legacyRecall,+    legacyRecallTarget,     planRecallExecution,     fuseRecallCandidates,     blendScore,@@ -21,17 +55,22 @@      -- * Test seams     -- $testSeams+    ResolvedRecall,+    resolveRecall,     selectFtsCandidates,     selectVectorCandidates,     vectorLiteral,-    selectVectorCandidatesStmt,-    selectVectorCandidatesExactStmt,     selectVectorCandidatesDiagnosed,     VectorChannelOutcome (..),     vectorChannelStarved,-    VectorCandidateQuery,-    vectorCandidateQuery,-    memoryRecordColumns,+    FtsCandidateSql,+    ftsCandidateSql,+    explainFtsCandidates,+    VectorCandidateSql,+    vectorCandidateSql,+    runVectorAnnCandidates,+    explainVectorAnnCandidates,+    explainVectorExactCandidates,     candidatePoolSize,   ) where@@ -58,8 +97,28 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.ReadModel (ConsistencyMode (..), ReadModelError, runQueryWith)-import Kioku.Api.Scope (MemoryScope (..), Namespace (..), scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText)+import Kioku.Api.Access (MemoryAccessContext, MemorySpaceId, memoryContextSpace)+import Kioku.Api.Recall+  ( RecallLimit,+    RecallQuery (..),+    RecallStrategy (..),+    RecallTarget (..),+    allRecallStrategies,+    defaultRecallLimit,+    legacyRecallTarget,+    maxRecallLimit,+    mkRecallLimit,+    mkRecallQuery,+    parseRecallStrategy,+    recallLimitInt,+    recallStrategyText,+    recallTargetExactScope,+    recallTargetIsNamespaceWide,+    recallTargetNamespace,+  )+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..), scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Api.Types (MemoryRecord (..), MemoryType, memoryTypeToText)+import Kioku.Database.Schema (memoriesTable) import Kioku.Id (MemoryId, SessionId, idText) import Kioku.Memory.Embedding (embedWithRetry) import Kioku.Memory.ReadModel@@ -75,6 +134,7 @@     memoriesByTypeReadModel,     memoryByIdReadModel,   )+import Kioku.Partition (memorySpaceParam) import Kioku.Prelude import Kioku.Recall.Capability (VectorCapability (..)) import Kiroku.Store.Effect (Store)@@ -82,38 +142,64 @@  -- $testSeams -- Exported so the candidate SQL can be exercised directly against a real database--- (@Kioku.RecallSqlSpec@) rather than only through 'recall', which would drag in an--- embedding endpoint. They are not part of the intended public API.+-- (@Kioku.RecallSqlSpec@, @Kioku.RecallTargetSpec@) rather than only through 'recall', which+-- would drag in an embedding endpoint. They are not part of the intended public API. ----- 'selectVectorCandidatesStmt', 'vectorCandidateQuery', 'memoryRecordColumns' and--- 'candidatePoolSize' are exported for @Kioku.RecallHarness@, the recall-quality instrument. It--- needs the statement itself (rather than 'selectVectorCandidates', which wraps it in its own--- transaction) so that it can run it under a @SET LOCAL@, and it needs the projection and the--- pool size so that its @EXPLAIN@ describes the query that actually runs. That last one is not a--- nicety: the projection's row width sets the cost of the top-N sort the /exact/ plan needs, and--- that cost is what the planner weighs against the HNSW scan — so an @EXPLAIN@ carrying a--- different select list can silently choose a different plan and report a different answer.--- Restating them in the harness rather than exporting them is how the harness got that wrong--- once.--data RecallStrategy = Keyword | Embedding | Hybrid-  deriving stock (Generic, Eq, Show)+-- 'vectorCandidateSql', 'runVectorAnnCandidates' and 'explainVectorAnnCandidates' are exported+-- for @Kioku.RecallHarness@, the recall-quality instrument. It needs the approximate pass on its+-- own — 'selectVectorCandidates' runs the fallback too, and wraps both in its own transaction —+-- so that it can run it under a @SET LOCAL@ and measure the approximate pass in isolation. And+-- it needs an @EXPLAIN@ of /that/ statement, which is why 'explainVectorAnnCandidates' lives+-- here beside the statement it describes rather than in the harness.+--+-- __The harness used to restate the SQL and it went wrong twice.__ An @EXPLAIN@ whose SQL differs+-- from the shipping statement in any way the planner cares about can choose a different plan and+-- report a different answer, silently and flatteringly. Once the copy selected @memory_id@ alone:+-- the narrow row made the top-N sort look cheap, the planner took the exact plan, and the+-- @EXPLAIN@ reported fifty happy rows while the real query took the HNSW plan and returned zero.+-- Once it omitted the @memory_space_id@ predicate, which is the leading column of every+-- partition-first index, and reported an access path no live query can produce. There is now no+-- copy to drift: the @EXPLAIN@ is built from the same SQL text and the same parameters as the+-- statement it explains. --- | A recall request.+-- | The pre-'RecallTarget' recall request. ----- __Global scope means "namespace-wide" here.__ Recall searches namespace-wide for a global--- scope; scoped reads are exact-scope. A 'ScopeGlobal' request returns every active memory in--- the namespace, entity-scoped rows included — the scope filter simply vanishes. That is the--- opposite of what 'getActiveByScope' does with the same value. See docs/user/recall.md.+-- __Global scope means \"namespace-wide\" here.__ A 'ScopeGlobal' request returns every active+-- memory in the namespace, entity-scoped rows included — the scope filter simply vanishes. That+-- is the opposite of what 'getActiveByScope' does with the same value, and it is the ambiguity+-- 'RecallTarget' replaces. The behaviour is preserved exactly for one release; see+-- 'legacyRecall'.+--+-- __The memory space was never part of that asymmetry.__ It is an equality predicate on every+-- channel and it never widens, whatever the scope says. data RecallRequest = RecallRequest-  { -- | 'ScopeGlobal' searches the whole namespace; an entity scope matches exactly.+  { memorySpaceId :: !MemorySpaceId,+    -- | 'ScopeGlobal' searches the whole namespace; an entity scope matches exactly.     scope :: !MemoryScope,     query :: !Text,     strategy :: !RecallStrategy,     maxResults :: !Int   }   deriving stock (Generic, Eq, Show)+{-# DEPRECATED RecallRequest "Use RecallQuery and a RecallTarget. ScopeGlobal here means namespace-wide; the exact global bucket is ExactScope (ScopeGlobal ns)." #-} +-- | Why a recall call could not run at all, as distinct from running and matching nothing.+--+-- Keeping those apart is the whole point of returning an 'Either' here. A caller cannot tell+-- \"this could not be asked\" from \"there is nothing here\" if both arrive as an empty list, and+-- only one of them is worth acting on.+-- Every 'RecallTarget' is now executable, so the only way to reach this channel is the legacy+-- request's own memory space disagreeing with the context that authorized it. The channel stays+-- on 'recall' rather than collapsing to a total function: removing it is a public signature+-- change for every call site, which belongs with the consumer migration in+-- @docs\/plans\/30-migrate-recall-consumers-to-explicit-targets.md@ rather than here.+data RecallError+  = -- | A 'RecallRequest' named a memory space that is not the one its context authorizes:+    -- @RecallSpaceMismatch requested authorized@. Only 'legacyRecall' can produce this, because+    -- only the legacy request carries a space of its own.+    RecallSpaceMismatch !MemorySpaceId !MemorySpaceId+  deriving stock (Generic, Eq, Show)+ data RecallHit = RecallHit   { memory :: !MemoryRecord,     score :: !Double,@@ -129,24 +215,119 @@   }   deriving stock (Generic, Eq, Show) -data RecallCandidateQuery = RecallCandidateQuery-  { query :: !Text,+-- | Which rows inside one namespace a resolved target admits.+--+-- Three targets, three predicates, and — this is the whole point — no value that means two of+-- them. The representation this replaces spelled all three with a nullable @scope_kind@ and+-- @scope_ref@ pair, in which NULL meant /omit the scope filter/; the exact global bucket, whose+-- rows are exactly the ones whose scope columns /are/ NULL, therefore had no way to say so and+-- was refused rather than answered wrongly. See+-- @docs\/adr\/each-recall-target-gets-its-own-statement.md@.+data ScopeBound+  = -- | Only the rows recorded with no entity scope: @scope_kind IS NULL AND scope_ref IS NULL@.+    GlobalBucketOnly+  | -- | Only the rows carrying exactly this kind and ref: @scope_kind = $4 AND scope_ref = $5@.+    EntityScopeOnly !Text !Text+  | -- | Every scope in the namespace: no scope comparison at all. The memory-space and namespace+    -- predicates still apply, which is why this widens breadth without widening tenancy.+    EveryScopeInNamespace+  deriving stock (Generic, Eq, Show)++-- | A recall request bound to the memory space that authorized it, with its target already+-- compiled to the scope predicate the candidate SQL will carry.+--+-- 'resolveRecall' is the only way to build one, which is what makes the binding trustworthy: the+-- space comes from a 'MemoryAccessContext' and the bound comes from a 'RecallTarget', and neither+-- can be supplied independently of the other. Everything downstream of this type — all three+-- candidate statement families, the fusion, the budgets — sees a request that has already had its+-- authority and its breadth decided.+data ResolvedRecall = ResolvedRecall+  { memorySpaceId :: !MemorySpaceId,     namespace :: !Text,-    scopeKind :: !(Maybe Text),-    scopeRef :: !(Maybe Text),+    scopeBound :: !ScopeBound,+    query :: !Text,+    strategy :: !RecallStrategy,+    maxResults :: !Int+  }+  deriving stock (Generic, Eq, Show)++-- | Bind a request to one authorized memory space and compile its target to a scope bound.+--+-- This is the single mapping from what a caller asked for to what the SQL is given, and it is+-- deliberately the only one:+--+-- @+-- 'ExactScope' ('ScopeGlobal' ns)       -> namespace = ns, 'GlobalBucketOnly'+-- 'ExactScope' ('ScopeEntity' ns k r)   -> namespace = ns, 'EntityScopeOnly' k r+-- 'NamespaceWide' ns                  -> namespace = ns, 'EveryScopeInNamespace'+-- @+--+-- It is total. Every target has a predicate, the limit was validated into a 'RecallLimit' before+-- the request was built, and the space is an argument rather than a field of the request — so no+-- call site can widen a target and a tenancy in the same edit, and none of the three meanings can+-- fail to be expressible.+resolveRecall :: MemorySpaceId -> RecallQuery -> ResolvedRecall+resolveRecall space request =+  case request.target of+    ExactScope (ScopeGlobal (Namespace ns)) -> bind ns GlobalBucketOnly+    ExactScope (ScopeEntity (Namespace ns) (ScopeKind kind) ref) ->+      bind ns (EntityScopeOnly kind ref)+    NamespaceWide (Namespace ns) -> bind ns EveryScopeInNamespace+  where+    bind ns bound =+      ResolvedRecall+        { memorySpaceId = space,+          namespace = ns,+          scopeBound = bound,+          query = request.query,+          strategy = request.strategy,+          maxResults = recallLimitInt request.maxResults+        }++-- | The parameters a candidate query takes when its scope predicate needs none of its own:+-- @$1@ the match text, @$2@ the memory space, @$3@ the namespace, @$4@ the row limit.+--+-- Both bounds that use this record — 'GlobalBucketOnly' and 'EveryScopeInNamespace' — carry no+-- scope /values/, and that is precisely why they must not share a statement: they differ by the+-- SQL they compile to, not by a parameter, so which one ran is visible in the statement's name+-- and in its query plan rather than hidden in a NULL.+data BoundedCandidateParams = BoundedCandidateParams+  { match :: !Text,+    memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,     limit :: !Int32   }   deriving stock (Generic, Eq, Show) -data VectorCandidateQuery = VectorCandidateQuery-  { queryVector :: !Text,+-- | 'BoundedCandidateParams' plus the two scope comparisons an entity bound makes: @$4@ the+-- scope kind and @$5@ the scope ref, which moves the row limit to @$6@. Both are non-nullable,+-- so this record cannot express \"no scope filter\" even by accident.+data EntityCandidateParams = EntityCandidateParams+  { match :: !Text,+    memorySpaceId :: !MemorySpaceId,     namespace :: !Text,-    scopeKind :: !(Maybe Text),-    scopeRef :: !(Maybe Text),+    scopeKind :: !Text,+    scopeRef :: !Text,     limit :: !Int32   }   deriving stock (Generic, Eq, Show) +-- | A full-text candidate query, already committed to one of the three statement families.+data FtsCandidateSql+  = FtsInGlobalBucket !BoundedCandidateParams+  | FtsInEntityScope !EntityCandidateParams+  | FtsAcrossNamespace !BoundedCandidateParams+  deriving stock (Generic, Eq, Show)++-- | A vector candidate query, already committed to one of the three statement families. The same+-- value drives the approximate pass, the exact fallback, and the @EXPLAIN@ of the approximate+-- pass, so those three can never describe different queries.+data VectorCandidateSql+  = VectorInGlobalBucket !BoundedCandidateParams+  | VectorInEntityScope !EntityCandidateParams+  | VectorAcrossNamespace !BoundedCandidateParams+  deriving stock (Generic, Eq, Show)+ data FusedCandidate = FusedCandidate   { memory :: !MemoryRecord,     ftsRank :: !(Maybe Int),@@ -157,16 +338,81 @@ -- | Run a recall request: plan, optionally embed the query, select candidates from each -- active channel, fuse by reciprocal rank, score, and trim. ----- Recall searches namespace-wide for a global scope; scoped reads are exact-scope.+-- The memory space searched is 'Kioku.Api.Access.memoryContextSpace' of the context, and nothing+-- in the request can change it. That is why the context is passed here rather than a bare space:+-- a target that widens from one scope to a whole namespace is a retrieval choice, and it must be+-- impossible for that choice to also select whose memories are searched.+--+-- The context is not asked for a second permission. A 'MemoryAccessContext' exists only for+-- permissions 'Kioku.Api.Access.authorizeMemoryAccess' already checked against this space, which+-- is the same reason the read functions below take only a space — see "Kioku.Memory". recall ::   (IOE :> es, Store :> es) =>   EmbeddingModel ->   VectorCapability ->+  MemoryAccessContext ->+  RecallQuery ->+  Eff es (Either RecallError [RecallHit])+recall model capability context request =+  Right <$> runResolvedRecall model capability (resolveRecall (memoryContextSpace context) request)++{-# DEPRECATED legacyRecall "Use recall with a RecallQuery. ScopeGlobal in a RecallRequest means namespace-wide, which is legacyRecallTarget's mapping; the exact global bucket is ExactScope (ScopeGlobal ns)." #-}++-- | Run a pre-'RecallTarget' 'RecallRequest' and return exactly what it returns today.+--+-- The scope is mapped through 'legacyRecallTarget', so a global scope stays namespace-wide. The+-- request's own @memorySpaceId@ must be the one the context authorizes; a request naming another+-- space is refused with 'RecallSpaceMismatch' rather than quietly retargeted, for the same reason+-- the deprecated write wrappers in "Kioku.Memory" refuse rather than rewrite.+--+-- Two edges of @maxResults@ are handled here rather than by 'mkRecallLimit', because this+-- function's contract is "what you get today":+--+-- * a zero or negative limit returns no hits, which is what @take (max 0 n)@ did;+-- * a limit above 'maxRecallLimit' is clamped to it, which is unobservable — each channel+--   contributes at most 50 candidates, so a fused result set holds at most 100 distinct+--   memories and a larger @take@ never had anything more to take.+legacyRecall ::+  (IOE :> es, Store :> es) =>+  EmbeddingModel ->+  VectorCapability ->+  MemoryAccessContext ->   RecallRequest ->+  Eff es (Either RecallError [RecallHit])+legacyRecall model capability context req+  | req.memorySpaceId /= authorized =+      pure (Left (RecallSpaceMismatch req.memorySpaceId authorized))+  | req.maxResults <= 0 = pure (Right [])+  | otherwise =+      recall+        model+        capability+        context+        RecallQuery+          { target = legacyRecallTarget req.scope,+            query = req.query,+            strategy = req.strategy,+            maxResults = legacyRecallLimit req.maxResults+          }+  where+    authorized = memoryContextSpace context++-- | Total by construction: 'legacyRecall' reaches this only with @requested >= 1@, so the+-- 'defaultRecallLimit' fallback is unreachable and exists to keep the function total rather than+-- to define behaviour.+legacyRecallLimit :: Int -> RecallLimit+legacyRecallLimit requested =+  either (const defaultRecallLimit) id (mkRecallLimit (max 1 (min maxRecallLimit requested)))++runResolvedRecall ::+  (IOE :> es, Store :> es) =>+  EmbeddingModel ->+  VectorCapability ->+  ResolvedRecall ->   Eff es [RecallHit]-recall model capability req = do+runResolvedRecall model capability resolved = do   now <- liftIO getCurrentTime-  executeRecallPlan now model req (planRecallExecution capability req.strategy)+  executeRecallPlan now model resolved (planRecallExecution capability resolved.strategy)  planRecallExecution :: VectorCapability -> RecallStrategy -> RecallExecutionPlan planRecallExecution capability strategy =@@ -194,7 +440,7 @@   (IOE :> es, Store :> es) =>   UTCTime ->   EmbeddingModel ->-  RecallRequest ->+  ResolvedRecall ->   RecallExecutionPlan ->   Eff es [RecallHit] executeRecallPlan now model req execution@@ -208,7 +454,7 @@   (IOE :> es, Store :> es) =>   UTCTime ->   EmbeddingModel ->-  RecallRequest ->+  ResolvedRecall ->   RecallExecutionPlan ->   Eff es [RecallHit] embedThenRecall now model req execution = do@@ -224,7 +470,7 @@ keywordOnly ::   (Store :> es) =>   UTCTime ->-  RecallRequest ->+  ResolvedRecall ->   Eff es [RecallHit] keywordOnly now req = do   ftsRows <- selectFtsCandidates req@@ -234,21 +480,18 @@ selectIf True action = action selectIf False _ = pure [] -finishRecall :: UTCTime -> RecallRequest -> [MemoryRecord] -> [MemoryRecord] -> [RecallHit]+finishRecall :: UTCTime -> ResolvedRecall -> [MemoryRecord] -> [MemoryRecord] -> [RecallHit] finishRecall now req ftsRows vecRows =   applyCharacterBudgets perMemoryCharacterBudget totalCharacterBudget $-    take (max 0 req.maxResults) $+    take req.maxResults $       fuseRecallCandidates now ftsRows vecRows  selectFtsCandidates ::   (Store :> es) =>-  RecallRequest ->+  ResolvedRecall ->   Eff es [MemoryRecord] selectFtsCandidates req =-  runTransaction $-    Tx.statement-      (candidateQuery req)-      selectFtsCandidatesStmt+  runTransaction (runFtsCandidates (ftsCandidateSql req))  -- | What the vector channel did, so that a degraded semantic half stops being invisible. --@@ -278,7 +521,7 @@  selectVectorCandidates ::   (Store :> es) =>-  RecallRequest ->+  ResolvedRecall ->   Vector Double ->   Eff es [MemoryRecord] selectVectorCandidates req queryVector =@@ -319,7 +562,7 @@ -- fills the pool and the fallback never fires, so the common path is unchanged. selectVectorCandidatesDiagnosed ::   (Store :> es) =>-  RecallRequest ->+  ResolvedRecall ->   Vector Double ->   Eff es (VectorChannelOutcome, [MemoryRecord]) selectVectorCandidatesDiagnosed req queryVector =@@ -338,7 +581,7 @@     -- remedy a previous plan prescribed — /does/ move the planner, onto an ANN scan that then     -- starves, which is how this defect was originally mis-diagnosed.     Tx.sql efSearchSetting-    annRows <- Tx.statement query selectVectorCandidatesStmt+    annRows <- runVectorAnnCandidates candidates     if length annRows >= fromIntegral candidatePoolSize       then         pure@@ -350,7 +593,7 @@             annRows           )       else do-        exactRows <- Tx.statement query selectVectorCandidatesExactStmt+        exactRows <- runVectorExactCandidates candidates         pure           ( VectorChannelOutcome               { annRows = length annRows,@@ -360,7 +603,7 @@             exactRows           )   where-    query = vectorCandidateQuery req queryVector+    candidates = vectorCandidateSql req queryVector  -- | @SET LOCAL@, so it lives exactly as long as the transaction the query runs in and cannot -- leak into the rest of the connection.@@ -368,23 +611,43 @@ efSearchSetting =   TE.encodeUtf8 ("SET LOCAL hnsw.ef_search = " <> Text.pack (show candidatePoolSize)) -candidateQuery :: RecallRequest -> RecallCandidateQuery-candidateQuery req =-  RecallCandidateQuery-    { query = req.query,-      namespace = scopeNamespaceText req.scope,-      scopeKind = scopeKindText req.scope,-      scopeRef = scopeRefText req.scope,+-- | Compile a resolved request into a full-text candidate query. The match text is the caller's+-- query, which @websearch_to_tsquery@ reads twice: once to filter and once to rank.+ftsCandidateSql :: ResolvedRecall -> FtsCandidateSql+ftsCandidateSql req =+  case req.scopeBound of+    GlobalBucketOnly -> FtsInGlobalBucket (boundedParams req req.query)+    EntityScopeOnly kind ref -> FtsInEntityScope (entityParams req req.query kind ref)+    EveryScopeInNamespace -> FtsAcrossNamespace (boundedParams req req.query)++-- | Compile a resolved request and an embedded query into a vector candidate query. The match+-- text is the vector literal the @$1::vector@ cast reads.+vectorCandidateSql :: ResolvedRecall -> Vector Double -> VectorCandidateSql+vectorCandidateSql req queryVector =+  case req.scopeBound of+    GlobalBucketOnly -> VectorInGlobalBucket (boundedParams req literal)+    EntityScopeOnly kind ref -> VectorInEntityScope (entityParams req literal kind ref)+    EveryScopeInNamespace -> VectorAcrossNamespace (boundedParams req literal)+  where+    literal = vectorLiteral queryVector++boundedParams :: ResolvedRecall -> Text -> BoundedCandidateParams+boundedParams req match =+  BoundedCandidateParams+    { match,+      memorySpaceId = req.memorySpaceId,+      namespace = req.namespace,       limit = candidatePoolSize     } -vectorCandidateQuery :: RecallRequest -> Vector Double -> VectorCandidateQuery-vectorCandidateQuery req queryVector =-  VectorCandidateQuery-    { queryVector = vectorLiteral queryVector,-      namespace = scopeNamespaceText req.scope,-      scopeKind = scopeKindText req.scope,-      scopeRef = scopeRefText req.scope,+entityParams :: ResolvedRecall -> Text -> Text -> Text -> EntityCandidateParams+entityParams req match scopeKind scopeRef =+  EntityCandidateParams+    { match,+      memorySpaceId = req.memorySpaceId,+      namespace = req.namespace,+      scopeKind,+      scopeRef,       limit = candidatePoolSize     } @@ -517,31 +780,64 @@ clamp01 :: Double -> Double clamp01 = max 0 . min 1 +-- * The three statement families++-- $+-- Every candidate statement is one of nine: three channels (full text, the approximate vector+-- pass, the exact vector pass) times three bounds ('GlobalBucketOnly', 'EntityScopeOnly',+-- 'EveryScopeInNamespace'). They are generated from one SQL template per channel and one scope+-- clause per bound, so the three bounds can only ever differ in the scope clause — the memory+-- space, the namespace and the @status@ filter are written once and cannot drift apart between+-- families.+--+-- The nine exist instead of three parameterised statements because the widening is the security+-- property. A single statement with a nullable scope pair has to spell the predicate+-- @(($4 IS NULL AND $5 IS NULL) OR (scope_kind = $4 AND scope_ref = $5))@, in which passing NULL+-- silently drops the scope filter — so a caller that meant \"the global bucket\" and a caller+-- that meant \"the whole namespace\" issue the identical query with the identical parameters, and+-- neither a reviewer nor a query plan can tell them apart. Splitting them puts the difference in+-- the statement name and in the @Index Cond@.++-- | How a bound is spelled in SQL, and which positional parameter the row limit therefore lands+-- on. An entity bound consumes @$4@ and @$5@ for its comparisons, which pushes its limit to @$6@.+data ScopeClause = ScopeClause+  { predicate :: !Text,+    limitParam :: !Text+  }++globalBucketClause, entityScopeClause, namespaceWideClause :: ScopeClause+globalBucketClause = ScopeClause "AND scope_kind IS NULL AND scope_ref IS NULL" "$4"+entityScopeClause = ScopeClause "AND scope_kind = $4 AND scope_ref = $5" "$6"+namespaceWideClause = ScopeClause "" "$4"++-- | The predicates every candidate query carries before its scope clause: one authorized memory+-- space, one namespace, active rows only. @$1@ is the match text, @$2@ the space, @$3@ the+-- namespace.+--+-- The memory space is first and mandatory in all nine statements. Namespace-wide means every+-- scope in one space, never every space — see+-- @docs\/adr\/namespace-is-not-a-security-boundary.md@.+partitionPredicates :: Text+partitionPredicates =+  "WHERE status = 'active' AND memory_space_id = $2 AND namespace = $3 "+ -- | Full-text candidates. ----- The scope predicate @(($3 IS NULL AND $4 IS NULL) OR (scope_kind = $3 AND scope_ref = $4))@--- is why recall searches namespace-wide for a global scope; scoped reads are exact-scope. For--- a global scope both parameters are NULL, the first disjunct is always true, and the filter--- vanishes. 'Kioku.Memory.ReadModel.selectActiveByScopeStmt' requires the columns to be NULL--- instead. The @ORDER BY@ here is free to carry a recency tiebreak: a GIN index provides no--- ordering, so there is no pathkey to preserve.-selectFtsCandidatesStmt :: Statement RecallCandidateQuery [MemoryRecord]-selectFtsCandidatesStmt =-  preparable-    ( "SELECT "-        <> memoryRecordColumns-        <> """-            FROM kiroku.kioku_memories-           WHERE status = 'active'-             AND namespace = $2-             AND (($3 IS NULL AND $4 IS NULL) OR (scope_kind = $3 AND scope_ref = $4))-             AND content_tsv @@ websearch_to_tsquery('english', $1)-           ORDER BY ts_rank(content_tsv, websearch_to_tsquery('english', $1)) DESC, created_at DESC-           LIMIT $5-           """-    )-    recallCandidateQueryEncoder-    (D.rowList memoryRecordDecoder)+-- The @ORDER BY@ is free to carry a recency tiebreak: a GIN index provides no ordering, so+-- there is no pathkey to preserve.+ftsCandidateQuerySql :: ScopeClause -> Text+ftsCandidateQuerySql scope =+  "SELECT "+    <> memoryRecordColumns+    <> "FROM "+    <> memoriesTable+    <> " "+    <> partitionPredicates+    <> scope.predicate+    <> " AND content_tsv @@ websearch_to_tsquery('english', $1) "+    <> "ORDER BY ts_rank(content_tsv, websearch_to_tsquery('english', $1)) DESC, created_at DESC "+    <> "LIMIT "+    <> scope.limitParam  -- | Vector candidates, ordered by cosine distance and nothing else. --@@ -554,80 +850,140 @@ -- so a caller could not re-break ties anyway, and exact ties between 1536-dimension float -- vectors essentially do not occur. ----- Recall that this is a *post-filtered* ANN scan: the namespace, scope and status predicates--- are applied to rows the index has already chosen by distance. See 'candidatePoolSize' for--- what that costs.------ The scope predicate is the same one 'selectFtsCandidatesStmt' carries: recall searches--- namespace-wide for a global scope; scoped reads are exact-scope.-selectVectorCandidatesStmt :: Statement VectorCandidateQuery [MemoryRecord]-selectVectorCandidatesStmt =-  preparable-    ( "SELECT "-        <> memoryRecordColumns-        <> """-            FROM kiroku.kioku_memories-           WHERE status = 'active'-             AND namespace = $2-             AND (($3 IS NULL AND $4 IS NULL) OR (scope_kind = $3 AND scope_ref = $4))-             AND embedding IS NOT NULL-           ORDER BY embedding <=> $1::vector-           LIMIT $5-           """-    )-    vectorCandidateQueryEncoder-    (D.rowList memoryRecordDecoder)+-- Recall that this is a *post-filtered* ANN scan: the space, namespace, scope and status+-- predicates are applied to rows the index has already chosen by distance. See+-- 'candidatePoolSize' for what that costs and 'selectVectorCandidatesDiagnosed' for the pass+-- that rescues it.+vectorAnnCandidateQuerySql :: ScopeClause -> Text+vectorAnnCandidateQuerySql scope =+  "SELECT "+    <> memoryRecordColumns+    <> "FROM "+    <> memoriesTable+    <> " "+    <> partitionPredicates+    <> scope.predicate+    <> " AND embedding IS NOT NULL "+    <> "ORDER BY embedding <=> $1::vector "+    <> "LIMIT "+    <> scope.limitParam --- | The exact vector scan: every embedded row in the caller's scope, ranked by distance, top-N.+-- | The exact vector scan: every embedded row inside the bound, ranked by distance, top-N. -- It cannot starve, because the filter is applied /before/ the ranking rather than after it. -- -- The @OFFSET 0@ is the whole mechanism and must not be "tidied away". It is an optimisation -- fence: it stops Postgres from pulling the subquery up into the outer query, which in turn stops -- the outer @ORDER BY embedding <=> …@ from reaching the HNSW index. Without it the planner--- flattens the two levels back into 'selectVectorCandidatesStmt' and we are measuring — and+-- flattens the two levels back into 'vectorAnnCandidateQuerySql' and we are measuring — and -- shipping — the very query we are trying to avoid. ----- A @MATERIALIZED@ CTE would also fence it, and was rejected: materialising forces every in-scope+-- A @MATERIALIZED@ CTE would also fence it, and was rejected: materialising forces every in-bound -- row's 1536-dimension embedding into memory (about 6KB each, so ~120MB for a 20000-row scope), -- whereas the fence streams and the top-N sort holds only 50 rows. ----- The predicates are identical to 'selectVectorCandidatesStmt''s, including @embedding IS NOT--- NULL@ — here it is a correctness filter rather than an index-matching one, but it must stay--- either way, since a NULL embedding has no distance to anything.-selectVectorCandidatesExactStmt :: Statement VectorCandidateQuery [MemoryRecord]-selectVectorCandidatesExactStmt =-  preparable-    ( "SELECT "-        <> memoryRecordColumns-        <> """-            FROM (SELECT *-                    FROM kiroku.kioku_memories-                   WHERE status = 'active'-                     AND namespace = $2-                     AND (($3 IS NULL AND $4 IS NULL) OR (scope_kind = $3 AND scope_ref = $4))-                     AND embedding IS NOT NULL-                  OFFSET 0) AS scoped-           ORDER BY embedding <=> $1::vector-           LIMIT $5-           """-    )-    vectorCandidateQueryEncoder-    (D.rowList memoryRecordDecoder)+-- The predicates are identical to the approximate pass's, including @embedding IS NOT NULL@ —+-- here it is a correctness filter rather than an index-matching one, but it must stay either way,+-- since a NULL embedding has no distance to anything.+vectorExactCandidateQuerySql :: ScopeClause -> Text+vectorExactCandidateQuerySql scope =+  "SELECT "+    <> memoryRecordColumns+    <> "FROM (SELECT * FROM "+    <> memoriesTable+    <> " "+    <> partitionPredicates+    <> scope.predicate+    <> " AND embedding IS NOT NULL OFFSET 0) AS scoped "+    <> "ORDER BY embedding <=> $1::vector "+    <> "LIMIT "+    <> scope.limitParam -recallCandidateQueryEncoder :: E.Params RecallCandidateQuery-recallCandidateQueryEncoder =-  ((\q -> q.query) >$< E.param (E.nonNullable E.text))+-- | Run the full-text channel against whichever family the target chose.+--+-- The @case@ is total over 'FtsCandidateSql', so a fourth bound cannot be added without deciding+-- what SQL it compiles to.+runFtsCandidates :: FtsCandidateSql -> Tx.Transaction [MemoryRecord]+runFtsCandidates = \case+  FtsInGlobalBucket params -> Tx.statement params (boundedRows ftsCandidateQuerySql globalBucketClause)+  FtsInEntityScope params -> Tx.statement params (entityRows ftsCandidateQuerySql)+  FtsAcrossNamespace params -> Tx.statement params (boundedRows ftsCandidateQuerySql namespaceWideClause)++-- | Run the approximate vector pass against whichever family the target chose.+runVectorAnnCandidates :: VectorCandidateSql -> Tx.Transaction [MemoryRecord]+runVectorAnnCandidates = \case+  VectorInGlobalBucket params -> Tx.statement params (boundedRows vectorAnnCandidateQuerySql globalBucketClause)+  VectorInEntityScope params -> Tx.statement params (entityRows vectorAnnCandidateQuerySql)+  VectorAcrossNamespace params -> Tx.statement params (boundedRows vectorAnnCandidateQuerySql namespaceWideClause)++-- | Run the exact vector pass against whichever family the target chose.+runVectorExactCandidates :: VectorCandidateSql -> Tx.Transaction [MemoryRecord]+runVectorExactCandidates = \case+  VectorInGlobalBucket params -> Tx.statement params (boundedRows vectorExactCandidateQuerySql globalBucketClause)+  VectorInEntityScope params -> Tx.statement params (entityRows vectorExactCandidateQuerySql)+  VectorAcrossNamespace params -> Tx.statement params (boundedRows vectorExactCandidateQuerySql namespaceWideClause)++-- | @EXPLAIN (ANALYZE, BUFFERS)@ over the full-text channel, built from the same SQL text and+-- given the same parameters as 'runFtsCandidates'.+explainFtsCandidates :: FtsCandidateSql -> Tx.Transaction [Text]+explainFtsCandidates = \case+  FtsInGlobalBucket params -> Tx.statement params (boundedPlan ftsCandidateQuerySql globalBucketClause)+  FtsInEntityScope params -> Tx.statement params (entityPlan ftsCandidateQuerySql)+  FtsAcrossNamespace params -> Tx.statement params (boundedPlan ftsCandidateQuerySql namespaceWideClause)++-- | @EXPLAIN (ANALYZE, BUFFERS)@ over the approximate vector pass, built from the same SQL text+-- and given the same parameters as 'runVectorAnnCandidates'. See the test-seam note at the top of+-- this module for why these live here rather than in the harness that uses them.+explainVectorAnnCandidates :: VectorCandidateSql -> Tx.Transaction [Text]+explainVectorAnnCandidates = explainVector vectorAnnCandidateQuerySql++-- | @EXPLAIN (ANALYZE, BUFFERS)@ over the exact vector pass — the one whose access path is a+-- scan of the bound rather than of the embedding index, and therefore the one where a+-- partition-leading index earns its keep.+explainVectorExactCandidates :: VectorCandidateSql -> Tx.Transaction [Text]+explainVectorExactCandidates = explainVector vectorExactCandidateQuerySql++explainVector :: (ScopeClause -> Text) -> VectorCandidateSql -> Tx.Transaction [Text]+explainVector sqlFor = \case+  VectorInGlobalBucket params -> Tx.statement params (boundedPlan sqlFor globalBucketClause)+  VectorInEntityScope params -> Tx.statement params (entityPlan sqlFor)+  VectorAcrossNamespace params -> Tx.statement params (boundedPlan sqlFor namespaceWideClause)++boundedPlan :: (ScopeClause -> Text) -> ScopeClause -> Statement BoundedCandidateParams [Text]+boundedPlan sqlFor scope =+  preparable (explained (sqlFor scope)) boundedCandidateEncoder planDecoder++entityPlan :: (ScopeClause -> Text) -> Statement EntityCandidateParams [Text]+entityPlan sqlFor =+  preparable (explained (sqlFor entityScopeClause)) entityCandidateEncoder planDecoder++explained :: Text -> Text+explained sql = "EXPLAIN (ANALYZE, BUFFERS) " <> sql++planDecoder :: D.Result [Text]+planDecoder = D.rowList (D.column (D.nonNullable D.text))++boundedRows :: (ScopeClause -> Text) -> ScopeClause -> Statement BoundedCandidateParams [MemoryRecord]+boundedRows sqlFor scope =+  preparable (sqlFor scope) boundedCandidateEncoder (D.rowList memoryRecordDecoder)++entityRows :: (ScopeClause -> Text) -> Statement EntityCandidateParams [MemoryRecord]+entityRows sqlFor =+  preparable (sqlFor entityScopeClause) entityCandidateEncoder (D.rowList memoryRecordDecoder)++boundedCandidateEncoder :: E.Params BoundedCandidateParams+boundedCandidateEncoder =+  ((\q -> q.match) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.memorySpaceId) >$< memorySpaceParam)     <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))-    <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))-    <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))     <> ((\q -> q.limit) >$< E.param (E.nonNullable E.int4)) -vectorCandidateQueryEncoder :: E.Params VectorCandidateQuery-vectorCandidateQueryEncoder =-  ((\q -> q.queryVector) >$< E.param (E.nonNullable E.text))+entityCandidateEncoder :: E.Params EntityCandidateParams+entityCandidateEncoder =+  ((\q -> q.match) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.memorySpaceId) >$< memorySpaceParam)     <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))-    <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))-    <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))+    <> ((\q -> q.scopeKind) >$< E.param (E.nonNullable E.text))+    <> ((\q -> q.scopeRef) >$< E.param (E.nonNullable E.text))     <> ((\q -> q.limit) >$< E.param (E.nonNullable E.int4))  memoryRecordColumns :: Text@@ -771,55 +1127,82 @@ -- not by this. For the read-side equivalent of recall's breadth, use 'getActiveInNamespace'. getActiveByScope ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Eff es (Either ReadModelError [MemoryRecord])-getActiveByScope scope =+getActiveByScope space scope =   runQueryWith     Nothing     Eventual     memoriesByScopeReadModel-    (MemoriesByScopeQuery (scopeNamespaceText scope) (scopeKindText scope) (scopeRefText scope))+    MemoriesByScopeQuery+      { memorySpaceId = space,+        namespace = scopeNamespaceText scope,+        scopeKind = scopeKindText scope,+        scopeRef = scopeRefText scope+      }  -- | Every active memory in the namespace, whatever its scope. This is the read-side--- equivalent of what 'recall' does with a global scope.+-- equivalent of what 'recall' does with a global scope — inside one memory space. getActiveInNamespace ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   Eff es (Either ReadModelError [MemoryRecord])-getActiveInNamespace (Namespace ns) =-  runQueryWith Nothing Eventual memoriesByNamespaceReadModel (MemoriesByNamespaceQuery ns)+getActiveInNamespace space (Namespace ns) =+  runQueryWith+    Nothing+    Eventual+    memoriesByNamespaceReadModel+    MemoriesByNamespaceQuery {memorySpaceId = space, namespace = ns}  -- | The global bucket of a namespace: rows recorded with no entity scope. Not the same as a -- 'recall' scoped to the namespace, which also returns entity-scoped rows. getGlobal ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   Eff es (Either ReadModelError [MemoryRecord])-getGlobal ns =-  getActiveByScope (ScopeGlobal ns)+getGlobal space ns =+  getActiveByScope space (ScopeGlobal ns)  getById ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryId ->   Eff es (Either ReadModelError (Maybe MemoryRecord))-getById mid =+getById space mid =   fmap (fmap (fmap memoryRowToRecord)) $-    runQueryWith Nothing Eventual memoryByIdReadModel (MemoryByIdQuery (idText mid))+    runQueryWith+      Nothing+      Eventual+      memoryByIdReadModel+      MemoryByIdQuery {memorySpaceId = space, memoryId = idText mid}  getBySession ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Either ReadModelError [MemoryRecord])-getBySession sid =-  runQueryWith Nothing Eventual memoriesBySessionReadModel (MemoriesBySessionQuery (idText sid))+getBySession space sid =+  runQueryWith+    Nothing+    Eventual+    memoriesBySessionReadModel+    MemoriesBySessionQuery {memorySpaceId = space, sessionId = idText sid}  getByType ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   MemoryType ->   Eff es (Either ReadModelError [MemoryRecord])-getByType (Namespace ns) mt =-  runQueryWith Nothing Eventual memoriesByTypeReadModel (MemoriesByTypeQuery ns (memoryTypeToText mt))+getByType space (Namespace ns) mt =+  runQueryWith+    Nothing+    Eventual+    memoriesByTypeReadModel+    MemoriesByTypeQuery {memorySpaceId = space, namespace = ns, memoryType = memoryTypeToText mt}  memoryRowToRecord :: MemoryRow -> MemoryRecord memoryRowToRecord row =
src/Kioku/Recall/Capability.hs view
@@ -21,7 +21,7 @@   | VectorExtensionUnavailable   | VectorColumnsUnavailable ![Text]   | -- | @KIOKU_EMBEDDING_DIMENSIONS@ disagrees with the declared width of the-    -- @kioku_memories.embedding@ column: configured first, actual second. Every embedding+    -- @kioku.memories.embedding@ column: configured first, actual second. Every embedding     -- write would fail on the @::vector@ cast, one event at a time, forever.     VectorDimensionMismatch !Int !Int   deriving stock (Generic, Eq, Show)@@ -80,6 +80,12 @@ -- query would fail with @42704@ while capability detection reported everything healthy. -- @to_regtype@ resolves against the live @search_path@, which is exactly the question the -- query asks.+--+-- The column probes name @kioku.memories@ because that is where the projection lives; the+-- @vector@ type probe deliberately does /not/ name a schema, because the extension was never+-- moved and its resolution is still whatever the connection's search path makes it. Those two+-- questions are separate on purpose — see+-- @docs\/adr\/projections-live-in-the-kioku-schema.md@. detectVectorCapabilityStmt :: Statement () CapabilityProbe detectVectorCapabilityStmt =   preparable@@ -89,30 +95,30 @@       EXISTS (         SELECT 1         FROM information_schema.columns-        WHERE table_schema = 'kiroku' AND table_name = 'kioku_memories' AND column_name = 'embedding'+        WHERE table_schema = 'kioku' AND table_name = 'memories' AND column_name = 'embedding'       ) AS has_embedding,       EXISTS (         SELECT 1         FROM information_schema.columns-        WHERE table_schema = 'kiroku' AND table_name = 'kioku_memories' AND column_name = 'embedding_model'+        WHERE table_schema = 'kioku' AND table_name = 'memories' AND column_name = 'embedding_model'       ) AS has_embedding_model,       EXISTS (         SELECT 1         FROM information_schema.columns-        WHERE table_schema = 'kiroku' AND table_name = 'kioku_memories' AND column_name = 'dimensions'+        WHERE table_schema = 'kioku' AND table_name = 'memories' AND column_name = 'dimensions'       ) AS has_dimensions,       EXISTS (         SELECT 1         FROM information_schema.columns-        WHERE table_schema = 'kiroku' AND table_name = 'kioku_memories' AND column_name = 'content_hash'+        WHERE table_schema = 'kioku' AND table_name = 'memories' AND column_name = 'content_hash'       ) AS has_content_hash,       (         SELECT a.atttypmod         FROM pg_attribute a         JOIN pg_class c ON c.oid = a.attrelid         JOIN pg_namespace n ON n.oid = c.relnamespace-        WHERE n.nspname = 'kiroku'-          AND c.relname = 'kioku_memories'+        WHERE n.nspname = 'kioku'+          AND c.relname = 'memories'           AND a.attname = 'embedding'           AND NOT a.attisdropped       ) AS embedding_typmod
src/Kioku/Session.hs view
@@ -1,6 +1,33 @@+-- | Starting, driving, and reading agent sessions.+--+-- Every write takes a 'MemoryAccessContext' first, for the reasons set out in "Kioku.Memory":+-- Kioku's core never decides who may write where, it only refuses to write without a decision.+-- All session writes ask for the 'MemoryRecord' permission — a session, its turns, and its+-- lifecycle are memory being recorded.+--+-- A session belongs to the space it was started in, and the aggregate refuses every later+-- command that names a different one. That includes 'forceResumeWithContext': waiving the+-- correlation-key check is an operator override for a lost key, not for the isolation boundary.+--+-- The unsuffixed functions ('start', 'complete', …) remain for one release as deprecated+-- compatibility wrappers confined to 'legacyMemorySpaceId'. Every read takes a 'MemorySpaceId'+-- first and returns nothing outside it; see "Kioku.Memory" for why it takes the space rather+-- than the whole context, and @docs\/user\/upgrading-to-memory-spaces.md@ for the upgrade. module Kioku.Session   ( SessionRow (..),     SessionWriteError (..),++    -- * Writing sessions+    startWithContext,+    awaitInputWithContext,+    resumeWithContext,+    forceResumeWithContext,+    completeWithContext,+    failSessionWithContext,+    recordInteractiveWithContext,+    recordTurnWithContext,++    -- * Deprecated compatibility wrappers, confined to the legacy memory space     start,     awaitInput,     resume,@@ -9,6 +36,8 @@     failSession,     recordInteractive,     recordTurn,++    -- * Reading sessions     getById,     getRecentInNamespace,     getByScope,@@ -28,6 +57,16 @@ import Keiro.Command (CommandError (..), defaultRunCommandOptions) import Keiro.Projection (runCommandWithProjections) import Keiro.ReadModel (ConsistencyMode (..), ReadModelError, runQueryWith)+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryPermission (..),+    MemorySpaceId,+    RecordedPrincipal (..),+    legacyMemorySpaceId,+    memoryContextAllows,+    memoryContextRecordedActor,+    memoryContextSpace,+  ) import Kioku.Api.Scope (MemoryScope, Namespace (..), scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Distill.Timer (l1TimerScheduleProjection) import Kioku.Id (SessionId, idText)@@ -70,28 +109,86 @@   | SessionCorrelationMismatch   | SessionInvalidLineage !Text   | SessionConflict !Text+  | -- | the context authorized other actions, but not this one+    SessionNotPermitted !MemoryPermission+  | -- | the command names a memory space the context was not minted for:+    -- @SessionSpaceMismatch requested authorized@+    SessionSpaceMismatch !MemorySpaceId !MemorySpaceId+  | -- | the command attributes the write to somebody other than the context's own principal+    SessionActorMismatch !RecordedPrincipal !RecordedPrincipal   deriving stock (Generic, Show) +-- | Gate a write on the decision that authorized it. See 'Kioku.Memory.underContext' — same+-- three checks, same reasons: a context minted for one action cannot be spent on another, on+-- another space, or in somebody else's name.+underContext ::+  (Applicative f) =>+  MemoryAccessContext ->+  MemoryPermission ->+  MemorySpaceId ->+  RecordedPrincipal ->+  f (Either SessionWriteError a) ->+  f (Either SessionWriteError a)+underContext context permission space actor run+  | not (memoryContextAllows permission context) =+      pure (Left (SessionNotPermitted permission))+  | space /= authorizedSpace =+      pure (Left (SessionSpaceMismatch space authorizedSpace))+  | actor /= authorizedActor =+      pure (Left (SessionActorMismatch actor authorizedActor))+  | otherwise = run+  where+    authorizedSpace = memoryContextSpace context+    authorizedActor = memoryContextRecordedActor context++-- | Gate a deprecated wrapper on the one space it is allowed to touch.+inLegacySpaceOnly ::+  (Applicative f) =>+  MemorySpaceId ->+  f (Either SessionWriteError a) ->+  f (Either SessionWriteError a)+inLegacySpaceOnly space run+  | space /= legacyMemorySpaceId = pure (Left (SessionSpaceMismatch space legacyMemorySpaceId))+  | otherwise = run+ -- | The deepest delegation chain a session may declare. Far above any legitimate agent -- hierarchy; it exists to bound absurd input, not to express a product limit. maxDelegationDepth :: Int maxDelegationDepth = 64 +-- | Start a session in the space the context authorizes.+startWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  StartSessionData ->+  Eff es (Either SessionWriteError SessionId)+startWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (startIn cmdData)++{-# DEPRECATED start "Use startWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: start a session in the legacy memory space, with no authorization context. start ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   StartSessionData ->   Eff es (Either SessionWriteError SessionId)-start cmdData =+start cmdData = inLegacySpaceOnly cmdData.memorySpaceId (startIn cmdData)++startIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  StartSessionData ->+  Eff es (Either SessionWriteError SessionId)+startIn cmdData =   case validateLineage cmdData of     Just reason -> pure (Left (SessionInvalidLineage reason))     Nothing -> do-      existing <- getById cmdData.sessionId+      existing <- getById cmdData.memorySpaceId cmdData.sessionId       case existing of         Left err -> pure (Left (SessionReadFailed err))         Right (Just row) -> pure (idempotentOr "start" startMismatch row cmdData.sessionId)         Right Nothing ->           runSessionCommand cmdData.sessionId (StartSession cmdData)-            >>= acceptRejectedIfMatches cmdData.sessionId (isNothing . startMismatch)+            >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.sessionId (isNothing . startMismatch)   where     startMismatch = mismatchOf sessionStartFields cmdData @@ -146,11 +243,12 @@ -- | Look up the session and hand its row plus parsed status to the caller. withExistingSession ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   (SessionRow -> SessionStatus -> Eff es (Either SessionWriteError SessionId)) ->   Eff es (Either SessionWriteError SessionId)-withExistingSession sid k = do-  existing <- getById sid+withExistingSession space sid k = do+  existing <- getById space sid   case existing of     Left err -> pure (Left (SessionReadFailed err))     Right Nothing -> pure (Left SessionNotFound)@@ -200,13 +298,14 @@ -- genuinely conflicting loser still gets its rejection. acceptRejectedIfMatches ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   (SessionRow -> Bool) ->   Either SessionWriteError SessionId ->   Eff es (Either SessionWriteError SessionId)-acceptRejectedIfMatches sid matches = \case+acceptRejectedIfMatches space sid matches = \case   Left err@(SessionCommandRejected CommandRejected) -> do-    reread <- getById sid+    reread <- getById space sid     pure case reread of       Right (Just row) | matches row -> Right sid       _ -> Left err@@ -257,12 +356,30 @@ sessionFailFields =   [("errorMessage", \d row -> row.errorMessage == Just d.errorMessage)] +-- | Complete a session in the space the context authorizes.+completeWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  CompleteSessionData ->+  Eff es (Either SessionWriteError SessionId)+completeWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (completeIn cmdData)++{-# DEPRECATED complete "Use completeWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: complete within the legacy memory space, with no authorization context. complete ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   CompleteSessionData ->   Eff es (Either SessionWriteError SessionId)-complete cmdData =-  withExistingSession cmdData.sessionId \row status ->+complete cmdData = inLegacySpaceOnly cmdData.memorySpaceId (completeIn cmdData)++completeIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  CompleteSessionData ->+  Eff es (Either SessionWriteError SessionId)+completeIn cmdData =+  withExistingSession cmdData.memorySpaceId cmdData.sessionId \row status ->     case status of       StatusRunning -> runComplete       StatusAwaiting -> runComplete@@ -274,14 +391,32 @@     completeMismatch = mismatchOf sessionCompleteFields cmdData     runComplete =       runSessionCommand cmdData.sessionId (CompleteSession cmdData)-        >>= acceptRejectedIfMatches cmdData.sessionId (isNothing . completeMismatch)+        >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.sessionId (isNothing . completeMismatch) +-- | Fail a session in the space the context authorizes.+failSessionWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  FailSessionData ->+  Eff es (Either SessionWriteError SessionId)+failSessionWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (failSessionIn cmdData)++{-# DEPRECATED failSession "Use failSessionWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: fail within the legacy memory space, with no authorization context. failSession ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   FailSessionData ->   Eff es (Either SessionWriteError SessionId)-failSession cmdData =-  withExistingSession cmdData.sessionId \row status ->+failSession cmdData = inLegacySpaceOnly cmdData.memorySpaceId (failSessionIn cmdData)++failSessionIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  FailSessionData ->+  Eff es (Either SessionWriteError SessionId)+failSessionIn cmdData =+  withExistingSession cmdData.memorySpaceId cmdData.sessionId \row status ->     case status of       StatusRunning -> runFail       StatusAwaiting -> runFail@@ -294,19 +429,37 @@     failMismatch = mismatchOf sessionFailFields cmdData     runFail =       runSessionCommand cmdData.sessionId (FailSession cmdData)-        >>= acceptRejectedIfMatches cmdData.sessionId (isNothing . failMismatch)+        >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.sessionId (isNothing . failMismatch) +-- | Park a session in the space the context authorizes.+awaitInputWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  AwaitInputData ->+  Eff es (Either SessionWriteError SessionId)+awaitInputWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (awaitInputIn cmdData)++{-# DEPRECATED awaitInput "Use awaitInputWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: park within the legacy memory space, with no authorization context. awaitInput ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   AwaitInputData ->   Eff es (Either SessionWriteError SessionId)-awaitInput cmdData =-  withExistingSession cmdData.sessionId \row status ->+awaitInput cmdData = inLegacySpaceOnly cmdData.memorySpaceId (awaitInputIn cmdData)++awaitInputIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  AwaitInputData ->+  Eff es (Either SessionWriteError SessionId)+awaitInputIn cmdData =+  withExistingSession cmdData.memorySpaceId cmdData.sessionId \row status ->     case status of       StatusAwaiting -> pure (idempotentOr "awaitInput" awaitMismatch row cmdData.sessionId)       StatusRunning ->         runSessionCommand cmdData.sessionId (AwaitInput cmdData)-          >>= acceptRejectedIfMatches cmdData.sessionId (isNothing . awaitMismatch)+          >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.sessionId (isNothing . awaitMismatch)       _ -> pure (Left SessionNotRunning)   where     awaitMismatch = mismatchOf sessionAwaitFields cmdData@@ -320,12 +473,29 @@ -- The precheck below only shapes a friendly early error. The real enforcement is the -- aggregate's own guard, which keiro re-evaluates after any optimistic-concurrency retry — -- so a stale caller cannot resume a wait that was already resumed and re-parked.+resumeWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  ResumeSessionData ->+  Eff es (Either SessionWriteError SessionId)+resumeWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (resumeIn cmdData)++{-# DEPRECATED resume "Use resumeWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: resume within the legacy memory space, with no authorization context. resume ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   ResumeSessionData ->   Eff es (Either SessionWriteError SessionId)-resume cmdData =-  withExistingSession cmdData.sessionId \row status ->+resume cmdData = inLegacySpaceOnly cmdData.memorySpaceId (resumeIn cmdData)++resumeIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  ResumeSessionData ->+  Eff es (Either SessionWriteError SessionId)+resumeIn cmdData =+  withExistingSession cmdData.memorySpaceId cmdData.sessionId \row status ->     case status of       -- Already running: a re-delivery of *this* resume is a success; a different input       -- means someone else answered the wait, which is a conflict, not an idempotent hit.@@ -335,7 +505,7 @@             pure (Left SessionCorrelationMismatch)         | otherwise ->             runSessionCommand cmdData.sessionId (ResumeSession cmdData)-              >>= acceptRejectedIfMatches cmdData.sessionId (isNothing . resumeMismatch)+              >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.sessionId (isNothing . resumeMismatch)       _ -> pure (Left SessionNotAwaiting)   where     resumeMismatch = mismatchOf sessionResumeFields cmdData@@ -344,7 +514,25 @@ -- -- An operator/host override for unsticking a session whose awaited key is lost or wrong. -- It is inherently last-writer-wins: if the session is concurrently re-parked on a new--- wait, a force resume may answer the wrong one. Prefer 'resume'.+-- wait, a force resume may answer the wrong one. Prefer 'resumeWithContext'.+--+-- @force@ waives the correlation-key check and nothing else. The session must still belong to+-- the space this context authorizes, and the aggregate enforces that independently.+forceResumeWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  SessionId ->+  Text ->+  UTCTime ->+  Eff es (Either SessionWriteError SessionId)+forceResumeWithContext context sid input resumedAt =+  resumeWithContext+    context+    (forcedResumeData (memoryContextSpace context) (memoryContextRecordedActor context) sid input resumedAt)++{-# DEPRECATED forceResume "Use forceResumeWithContext. This wrapper acts only in legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: force-resume within the legacy memory space, with no authorization context. forceResume ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   SessionId ->@@ -352,27 +540,50 @@   UTCTime ->   Eff es (Either SessionWriteError SessionId) forceResume sid input resumedAt =-  resume-    ResumeSessionData-      { sessionId = sid,-        correlationKey = Nothing,-        force = True,-        input,-        resumedAt-      }+  resumeIn (forcedResumeData legacyMemorySpaceId UnattributedPrincipal sid input resumedAt) +forcedResumeData :: MemorySpaceId -> RecordedPrincipal -> SessionId -> Text -> UTCTime -> ResumeSessionData+forcedResumeData memorySpaceId actorPrincipal sid input resumedAt =+  ResumeSessionData+    { sessionId = sid,+      memorySpaceId,+      actorPrincipal,+      correlationKey = Nothing,+      force = True,+      input,+      resumedAt+    }++-- | Record an interactive session in the space the context authorizes.+recordInteractiveWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  RecordInteractiveSessionData ->+  Eff es (Either SessionWriteError SessionId)+recordInteractiveWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (recordInteractiveIn cmdData)++{-# DEPRECATED recordInteractive "Use recordInteractiveWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: record an interactive session in the legacy memory space, with no context. recordInteractive ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   RecordInteractiveSessionData ->   Eff es (Either SessionWriteError SessionId)-recordInteractive cmdData = do-  existing <- getById cmdData.sessionId+recordInteractive cmdData = inLegacySpaceOnly cmdData.memorySpaceId (recordInteractiveIn cmdData)++recordInteractiveIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  RecordInteractiveSessionData ->+  Eff es (Either SessionWriteError SessionId)+recordInteractiveIn cmdData = do+  existing <- getById cmdData.memorySpaceId cmdData.sessionId   case existing of     Left err -> pure (Left (SessionReadFailed err))     Right (Just row) -> pure (idempotentOr "recordInteractive" interactiveMismatch row cmdData.sessionId)     Right Nothing ->       runSessionCommand cmdData.sessionId (RecordInteractiveSession cmdData)-        >>= acceptRejectedIfMatches cmdData.sessionId (isNothing . interactiveMismatch)+        >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.sessionId (isNothing . interactiveMismatch)   where     interactiveMismatch = mismatchOf sessionInteractiveFields cmdData @@ -387,15 +598,32 @@ -- surfaced as @StoreFailed@: turn ids are host-generated, so a cross-session collision is a -- caller bug, and mapping that specific SQL error from inside keiro's projection -- transaction is not worth the machinery.+recordTurnWithContext ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  RecordTurnData ->+  Eff es (Either SessionWriteError SessionId)+recordTurnWithContext context cmdData =+  underContext context MemoryRecord cmdData.memorySpaceId cmdData.actorPrincipal (recordTurnIn cmdData)++{-# DEPRECATED recordTurn "Use recordTurnWithContext. This wrapper accepts only legacyMemorySpaceId and will be removed." #-}++-- | Deprecated: record a turn within the legacy memory space, with no authorization context. recordTurn ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   RecordTurnData ->   Eff es (Either SessionWriteError SessionId)-recordTurn cmdData =-  withExistingSession cmdData.sessionId \_row status ->+recordTurn cmdData = inLegacySpaceOnly cmdData.memorySpaceId (recordTurnIn cmdData)++recordTurnIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  RecordTurnData ->+  Eff es (Either SessionWriteError SessionId)+recordTurnIn cmdData =+  withExistingSession cmdData.memorySpaceId cmdData.sessionId \_row status ->     case status of       StatusRunning -> do-        turns <- getTurns cmdData.sessionId+        turns <- getTurns cmdData.memorySpaceId cmdData.sessionId         case turns of           Left err -> pure (Left (SessionReadFailed err))           Right existingTurns ->@@ -415,7 +643,7 @@   Eff es (Either SessionWriteError SessionId) acceptRejectedTurnIfMatches d = \case   Left err@(SessionCommandRejected CommandRejected) -> do-    turns <- getTurns d.sessionId+    turns <- getTurns d.memorySpaceId d.sessionId     pure case turns of       Right rows         | Just row <- find (\row -> row.turnIndex == d.turnIndex) rows,@@ -450,78 +678,133 @@  getById ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Either ReadModelError (Maybe SessionRow))-getById sid =-  runQueryWith Nothing Eventual sessionByIdReadModel (SessionByIdQuery (idText sid))+getById space sid =+  runQueryWith+    Nothing+    Eventual+    sessionByIdReadModel+    SessionByIdQuery {memorySpaceId = space, sessionId = idText sid}  getRecentInNamespace ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   Int ->   Eff es (Either ReadModelError [SessionRow])-getRecentInNamespace ns limit =-  runQueryWith Nothing Eventual sessionsByNamespaceReadModel (SessionsByNamespaceQuery (namespaceText ns) limit)+getRecentInNamespace space ns limit =+  runQueryWith+    Nothing+    Eventual+    sessionsByNamespaceReadModel+    SessionsByNamespaceQuery {memorySpaceId = space, namespace = namespaceText ns, limit}  getByScope ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   MemoryScope ->   Eff es (Either ReadModelError [SessionRow])-getByScope scope =+getByScope space scope =   runQueryWith     Nothing     Eventual     sessionsByScopeReadModel-    (SessionsByScopeQuery (scopeNamespaceText scope) (scopeKindText scope) (scopeRefText scope))+    SessionsByScopeQuery+      { memorySpaceId = space,+        namespace = scopeNamespaceText scope,+        scopeKind = scopeKindText scope,+        scopeRef = scopeRefText scope+      }  getByFocus ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   Text ->   Eff es (Either ReadModelError [SessionRow])-getByFocus ns focus =-  runQueryWith Nothing Eventual sessionsByFocusReadModel (SessionsByFocusQuery (namespaceText ns) focus)+getByFocus space ns focus =+  runQueryWith+    Nothing+    Eventual+    sessionsByFocusReadModel+    SessionsByFocusQuery {memorySpaceId = space, namespace = namespaceText ns, focus}  getByStartedRange ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   UTCTime ->   UTCTime ->   Eff es (Either ReadModelError [SessionRow])-getByStartedRange ns startedAfter startedBefore =-  runQueryWith Nothing Eventual sessionsByStartedRangeReadModel (SessionsByStartedRangeQuery (namespaceText ns) startedAfter startedBefore)+getByStartedRange space ns startedAfter startedBefore =+  runQueryWith+    Nothing+    Eventual+    sessionsByStartedRangeReadModel+    SessionsByStartedRangeQuery+      { memorySpaceId = space,+        namespace = namespaceText ns,+        startedAfter,+        startedBefore+      }  getChain ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Either ReadModelError [SessionRow])-getChain sid =-  runQueryWith Nothing Eventual sessionChainReadModel (SessionChainQuery (idText sid))+getChain space sid =+  runQueryWith+    Nothing+    Eventual+    sessionChainReadModel+    SessionChainQuery {memorySpaceId = space, sessionId = idText sid}  getDelegationChildren ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Either ReadModelError [SessionRow])-getDelegationChildren sid =-  runQueryWith Nothing Eventual sessionDelegationChildrenReadModel (SessionDelegationChildrenQuery (idText sid))+getDelegationChildren space sid =+  runQueryWith+    Nothing+    Eventual+    sessionDelegationChildrenReadModel+    SessionDelegationChildrenQuery {memorySpaceId = space, parentSessionId = idText sid}  getAwaitingByCorrelationKey ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   Namespace ->   Text ->   Eff es (Either ReadModelError [SessionRow])-getAwaitingByCorrelationKey ns correlationKey =-  runQueryWith Nothing Eventual awaitingSessionsByCorrelationKeyReadModel (AwaitingSessionsByCorrelationKeyQuery (namespaceText ns) correlationKey)+getAwaitingByCorrelationKey space ns correlationKey =+  runQueryWith+    Nothing+    Eventual+    awaitingSessionsByCorrelationKeyReadModel+    AwaitingSessionsByCorrelationKeyQuery+      { memorySpaceId = space,+        namespace = namespaceText ns,+        correlationKey+      }  namespaceText :: Namespace -> Text namespaceText (Namespace ns) = ns  getTurns ::   (IOE :> es, Store :> es) =>+  MemorySpaceId ->   SessionId ->   Eff es (Either ReadModelError [TurnRow])-getTurns sid =-  runQueryWith Nothing Eventual turnsBySessionReadModel (TurnsBySessionQuery (idText sid))+getTurns space sid =+  runQueryWith+    Nothing+    Eventual+    turnsBySessionReadModel+    TurnsBySessionQuery {memorySpaceId = space, sessionId = idText sid}  runSessionCommand ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
src/Kioku/Session/Domain.hs view
@@ -13,6 +13,7 @@     RecordTurnData (..),     SessionCommand (..),     commandSessionId,+    commandMemorySpaceId,     SessionStartedData (..),     SessionCompletedData (..),     SessionFailedData (..),@@ -22,6 +23,7 @@     TurnRecordedData (..),     SessionEvent (..),     eventSessionId,+    eventMemorySpaceId,     sessionTransducer,   ) where@@ -32,8 +34,10 @@ import Keiki.Core (HsPred, SymTransducer, lit, (.==), (.||)) import Keiki.Generics (emptyRegFile) import Keiki.Generics.TH (deriveAggregate)+import Kioku.Api.Access (MemorySpaceId, PrincipalRef, RecordedPrincipal) import Kioku.Api.Scope (MemoryScope) import Kioku.Id (SessionId)+import Kioku.Partition (parsePartitionSpace, parseRecordedActor, parseRecordedActorFromAgent, parseRecordedOwner) import Kioku.Prelude  data SessionVertex = NotCreated | Running | Completed | Failed | Interactive | Awaiting@@ -48,13 +52,22 @@ -- @lastTurnIndex@ is the highest turn index committed so far (-1 before any turn), which -- makes @RecordTurn@'s strictly-increasing index contract enforceable in the state machine -- rather than only at the command layer.+--+-- @memorySpaceId@ is the space the session was started in. Every later command must name it,+-- so a caller authorized for one space cannot append a turn to, park, resume, complete, or fail+-- a session belonging to another. Keeping it in the register file rather than re-reading a row+-- is what makes that check survive an optimistic-concurrency retry. type SessionRegs =   '[ '("awaitedCorrelationKey", Maybe Text),-     '("lastTurnIndex", Int)+     '("lastTurnIndex", Int),+     '("memorySpaceId", MemorySpaceId)    ]  data StartSessionData = StartSessionData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,+    ownerPrincipal :: !(Maybe PrincipalRef),     agentId :: !Text,     focus :: !Text,     scope :: !MemoryScope,@@ -68,6 +81,8 @@  data CompleteSessionData = CompleteSessionData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     completedAt :: !UTCTime,     modelUsed :: !(Maybe Text),     summary :: !(Maybe Text)@@ -76,6 +91,8 @@  data FailSessionData = FailSessionData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     failedAt :: !UTCTime,     errorMessage :: !Text   }@@ -99,6 +116,8 @@ -- fires and nothing expires when it passes (MasterPlan 2 decision, 2026-07-07). data AwaitInputData = AwaitInputData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     reason :: !Text,     correlationKey :: !(Maybe Text),     deadline :: !(Maybe UTCTime),@@ -113,6 +132,8 @@ -- inherently last-writer-wins. data ResumeSessionData = ResumeSessionData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     correlationKey :: !(Maybe Text),     force :: !Bool,     input :: !Text,@@ -122,6 +143,9 @@  data RecordInteractiveSessionData = RecordInteractiveSessionData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,+    ownerPrincipal :: !(Maybe PrincipalRef),     agentId :: !Text,     focus :: !Text,     scope :: !MemoryScope,@@ -132,6 +156,8 @@  data RecordTurnData = RecordTurnData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     turnId :: !Text,     turnIndex :: !Int,     role :: !Text,@@ -163,8 +189,26 @@   RecordInteractiveSession d -> d.sessionId   RecordTurn d -> d.sessionId +-- | The memory space a command claims to act in. Every command names one; the aggregate refuses+-- any that disagrees with the space the session was started in.+commandMemorySpaceId :: SessionCommand -> MemorySpaceId+commandMemorySpaceId = \case+  StartSession d -> d.memorySpaceId+  CompleteSession d -> d.memorySpaceId+  FailSession d -> d.memorySpaceId+  AwaitInput d -> d.memorySpaceId+  ResumeSession d -> d.memorySpaceId+  RecordInteractiveSession d -> d.memorySpaceId+  RecordTurn d -> d.memorySpaceId++-- | As on the memory side, every @FromJSON@ instance below is hand-written so that payloads+-- written before memory spaces existed keep decoding. 'Kioku.Partition' owns the defaults;+-- @ToJSON@ stays derived so encoding only ever emits the new form. data SessionStartedData = SessionStartedData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,+    ownerPrincipal :: !(Maybe PrincipalRef),     agentId :: !Text,     focus :: !Text,     scope :: !MemoryScope,@@ -182,6 +226,9 @@     withObject "SessionStartedData" \o ->       SessionStartedData         <$> o .: "sessionId"+        <*> parsePartitionSpace o+        <*> parseRecordedActorFromAgent o+        <*> parseRecordedOwner o         <*> o .: "agentId"         <*> o .: "focus"         <*> o .: "scope"@@ -193,33 +240,74 @@  data SessionCompletedData = SessionCompletedData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     completedAt :: !UTCTime,     modelUsed :: !(Maybe Text),     summary :: !(Maybe Text)   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON SessionCompletedData where+  parseJSON =+    withObject "SessionCompletedData" \o ->+      SessionCompletedData+        <$> o .: "sessionId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "completedAt"+        <*> o .:? "modelUsed"+        <*> o .:? "summary"+ data SessionFailedData = SessionFailedData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     failedAt :: !UTCTime,     errorMessage :: !Text   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON SessionFailedData where+  parseJSON =+    withObject "SessionFailedData" \o ->+      SessionFailedData+        <$> o .: "sessionId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "failedAt"+        <*> o .: "errorMessage"+ data SessionAwaitingData = SessionAwaitingData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     reason :: !Text,     correlationKey :: !(Maybe Text),     deadline :: !(Maybe UTCTime),     awaitedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON SessionAwaitingData where+  parseJSON =+    withObject "SessionAwaitingData" \o ->+      SessionAwaitingData+        <$> o .: "sessionId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "reason"+        <*> o .:? "correlationKey"+        <*> o .:? "deadline"+        <*> o .: "awaitedAt"+ data SessionResumedData = SessionResumedData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     correlationKey :: !(Maybe Text),     force :: !Bool,     input :: !Text,@@ -236,14 +324,19 @@   parseJSON =     withObject "SessionResumedData" \o -> do       sessionId <- o .: "sessionId"+      memorySpaceId <- parsePartitionSpace o+      actorPrincipal <- parseRecordedActor o       correlationKey <- o .:? "correlationKey"       force <- o .:? "force" .!= isNothing correlationKey       input <- o .: "input"       resumedAt <- o .: "resumedAt"-      pure SessionResumedData {sessionId, correlationKey, force, input, resumedAt}+      pure SessionResumedData {sessionId, memorySpaceId, actorPrincipal, correlationKey, force, input, resumedAt}  data InteractiveSessionRecordedData = InteractiveSessionRecordedData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,+    ownerPrincipal :: !(Maybe PrincipalRef),     agentId :: !Text,     focus :: !Text,     scope :: !MemoryScope,@@ -251,10 +344,26 @@     startedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON InteractiveSessionRecordedData where+  parseJSON =+    withObject "InteractiveSessionRecordedData" \o ->+      InteractiveSessionRecordedData+        <$> o .: "sessionId"+        <*> parsePartitionSpace o+        <*> parseRecordedActorFromAgent o+        <*> parseRecordedOwner o+        <*> o .: "agentId"+        <*> o .: "focus"+        <*> o .: "scope"+        <*> o .:? "subjectRef"+        <*> o .: "startedAt"+ data TurnRecordedData = TurnRecordedData   { sessionId :: !SessionId,+    memorySpaceId :: !MemorySpaceId,+    actorPrincipal :: !RecordedPrincipal,     turnId :: !Text,     turnIndex :: !Int,     role :: !Text,@@ -265,8 +374,24 @@     recordedAt :: !UTCTime   }   deriving stock (Generic, Eq, Show)-  deriving anyclass (FromJSON, ToJSON)+  deriving anyclass (ToJSON) +instance FromJSON TurnRecordedData where+  parseJSON =+    withObject "TurnRecordedData" \o ->+      TurnRecordedData+        <$> o .: "sessionId"+        <*> parsePartitionSpace o+        <*> parseRecordedActor o+        <*> o .: "turnId"+        <*> o .: "turnIndex"+        <*> o .: "role"+        <*> o .: "content"+        <*> o .:? "toolSummary"+        <*> o .:? "promptTokens"+        <*> o .:? "outputTokens"+        <*> o .: "recordedAt"+ data SessionEvent   = SessionStarted !SessionStartedData   | SessionCompleted !SessionCompletedData@@ -293,6 +418,18 @@   InteractiveSessionRecorded d -> d.sessionId   TurnRecorded d -> d.sessionId +-- | The memory space a stored event belongs to. Every event carries one, including every event+-- written before memory spaces existed: those decode into 'legacyMemorySpaceId'.+eventMemorySpaceId :: SessionEvent -> MemorySpaceId+eventMemorySpaceId = \case+  SessionStarted d -> d.memorySpaceId+  SessionCompleted d -> d.memorySpaceId+  SessionFailed d -> d.memorySpaceId+  SessionAwaiting d -> d.memorySpaceId+  SessionResumed d -> d.memorySpaceId+  InteractiveSessionRecorded d -> d.memorySpaceId+  TurnRecorded d -> d.memorySpaceId+ $(deriveAggregate ''SessionCommand ''SessionRegs ''SessionEvent)  sessionTransducer ::@@ -307,13 +444,17 @@     B.from NotCreated do       B.onCmd inCtorStartSession $ \d -> B.do         -- 'emptyRegFile' binds every slot to a deferred error, so this edge — the only way-        -- into Running, and thus into Awaiting and RecordTurn — must initialize both.+        -- into Running, and thus into Awaiting and RecordTurn — must initialize all three.         B.slot @"awaitedCorrelationKey" =: lit Nothing         B.slot @"lastTurnIndex" =: lit (-1)+        B.slot @"memorySpaceId" =: d.memorySpaceId         B.emit           wireSessionStarted           SessionStartedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,+              ownerPrincipal = d.ownerPrincipal,               agentId = d.agentId,               focus = d.focus,               scope = d.scope,@@ -326,10 +467,17 @@         B.goto Running        B.onCmd inCtorRecordInteractiveSession $ \d -> B.do+        -- Interactive is terminal, so nothing later reads this slot. It is still set, because a+        -- register left bound to 'emptyRegFile''s deferred error is a trap for the next edge+        -- somebody adds here.+        B.slot @"memorySpaceId" =: d.memorySpaceId         B.emit           wireInteractiveSessionRecorded           InteractiveSessionRecordedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,+              ownerPrincipal = d.ownerPrincipal,               agentId = d.agentId,               focus = d.focus,               scope = d.scope,@@ -339,11 +487,16 @@         B.goto Interactive      B.from Running do+      -- Every edge out of a live session repeats the space guard: the command must name the+      -- space the session was started in.       B.onCmd inCtorCompleteSession $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireSessionCompleted           SessionCompletedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               completedAt = d.completedAt,               modelUsed = d.modelUsed,               summary = d.summary@@ -351,10 +504,13 @@         B.goto Completed        B.onCmd inCtorFailSession $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireSessionFailed           SessionFailedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               failedAt = d.failedAt,               errorMessage = d.errorMessage             }@@ -365,12 +521,15 @@         -- re-delivered or out-of-order turn cannot silently overwrite a committed one.         -- 'turnIndex' is already in the event payload, so replay recovers it and existing         -- strictly-increasing streams rehydrate unchanged (verified by Audit B).+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.requireGt d.turnIndex (B.reg @"lastTurnIndex")         B.slot @"lastTurnIndex" =: d.turnIndex         B.emit           wireTurnRecorded           TurnRecordedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               turnId = d.turnId,               turnIndex = d.turnIndex,               role = d.role,@@ -383,11 +542,14 @@         B.goto Running        B.onCmd inCtorAwaitInput $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.slot @"awaitedCorrelationKey" =: d.correlationKey         B.emit           wireSessionAwaiting           SessionAwaitingTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               reason = d.reason,               correlationKey = d.correlationKey,               deadline = d.deadline,@@ -400,6 +562,11 @@         -- The resume must name the key this session actually parked on, unless it is an         -- explicit force. Enforcing it here rather than in a read-model precheck is what         -- closes the race: keiro re-runs this edge against the post-conflict state.+        --+        -- Note that 'force' waives the correlation-key check and nothing else. A forced resume+        -- still has to name the session's own memory space; an operator override for a lost key+        -- is not an override for the isolation boundary.+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.requireGuard           ((d.force .== lit True) .|| (d.correlationKey .== B.reg @"awaitedCorrelationKey"))         B.slot @"awaitedCorrelationKey" =: lit Nothing@@ -407,6 +574,8 @@           wireSessionResumed           SessionResumedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               correlationKey = d.correlationKey,               -- Mandatory for replay: the guard reads 'force', and hydration can only               -- recover command fields that the event payload carries.@@ -417,10 +586,13 @@         B.goto Running        B.onCmd inCtorCompleteSession $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireSessionCompleted           SessionCompletedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               completedAt = d.completedAt,               modelUsed = d.modelUsed,               summary = d.summary@@ -428,10 +600,13 @@         B.goto Completed        B.onCmd inCtorFailSession $ \d -> B.do+        B.requireGuard (d.memorySpaceId .== B.reg @"memorySpaceId")         B.emit           wireSessionFailed           SessionFailedTermFields             { sessionId = d.sessionId,+              memorySpaceId = d.memorySpaceId,+              actorPrincipal = d.actorPrincipal,               failedAt = d.failedAt,               errorMessage = d.errorMessage             }
src/Kioku/Session/EventStream.hs view
@@ -17,6 +17,7 @@ import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow) import Keiro.Stream (Stream) import Keiro.Stream qualified as Stream+import Kioku.Api.Access (RecordedPrincipal (..), legacyMemorySpaceId, legacyPrincipalRef) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Id (SessionId, idText, parseIdLenient) import Kioku.Prelude@@ -93,15 +94,23 @@       "interactive_session_recorded" -> InteractiveSessionRecorded <$> parseLegacyInteractiveSessionRecorded payload       other -> fail ("Unknown Rei AgentSessionEvent tag: " <> Text.unpack other) +-- | Rei payloads predate memory spaces, so they take the legacy space; their @agentId@ is the+-- free-text label 'LegacyPrincipal' exists to mark, and is never rewritten into a+-- directory-issued principal id. parseLegacySessionStarted :: Value -> Parser SessionStartedData parseLegacySessionStarted =   withObject "Rei AgentSessionStartedData" $ \o -> do     sessionId <- parseLegacySessionId =<< o .: "sessionId"+    agentId <- o .: "agentId"     intentionId <- o .:? "intentionId"     previousSessionId <- traverse parseLegacySessionId =<< o .:? "previousSessionId"-    SessionStartedData sessionId-      <$> o .: "agentId"-      <*> (normalizeLegacyFocus <$> o .: "focusType")+    SessionStartedData+      sessionId+      legacyMemorySpaceId+      (LegacyPrincipal (legacyPrincipalRef agentId))+      Nothing+      agentId+      <$> (normalizeLegacyFocus <$> o .: "focusType")       <*> pure (sessionScope intentionId)       <*> o .:? "focusTarget"       <*> pure previousSessionId@@ -114,6 +123,8 @@   withObject "Rei AgentSessionCompletedData" $ \o ->     SessionCompletedData       <$> (parseLegacySessionId =<< o .: "sessionId")+      <*> pure legacyMemorySpaceId+      <*> pure UnattributedPrincipal       <*> o .: "completedAt"       <*> o .:? "modelUsed"       <*> o .:? "summary"@@ -123,6 +134,8 @@   withObject "Rei AgentSessionFailedData" $ \o ->     SessionFailedData       <$> (parseLegacySessionId =<< o .: "sessionId")+      <*> pure legacyMemorySpaceId+      <*> pure UnattributedPrincipal       <*> o .: "failedAt"       <*> o .: "errorMessage" @@ -130,10 +143,15 @@ parseLegacyInteractiveSessionRecorded =   withObject "Rei InteractiveSessionRecordedData" $ \o -> do     sessionId <- parseLegacySessionId =<< o .: "sessionId"+    agentId <- o .: "agentId"     intentionId <- o .:? "intentionId"-    InteractiveSessionRecordedData sessionId-      <$> o .: "agentId"-      <*> (normalizeLegacyFocus <$> o .: "focusType")+    InteractiveSessionRecordedData+      sessionId+      legacyMemorySpaceId+      (LegacyPrincipal (legacyPrincipalRef agentId))+      Nothing+      agentId+      <$> (normalizeLegacyFocus <$> o .: "focusType")       <*> pure (sessionScope intentionId)       <*> pure Nothing       <*> o .: "startedAt"
src/Kioku/Session/ReadModel.hs view
@@ -1,3 +1,8 @@+-- | The @kioku.sessions@ and @kioku.turns@ projections and every query over them.+--+-- A session belongs to the memory space it was started in, and a turn belongs to its session's+-- space. Both columns are written by the projection from the event payload rather than looked+-- up, and every query below filters on the space as well as on whatever it is keyed by. module Kioku.Session.ReadModel   ( sessionInlineProjection,     SessionRow (..),@@ -23,23 +28,33 @@   ) where -import Contravariant.Extras (contrazip2, contrazip3, contrazip4) import Data.Functor.Contravariant ((>$<)) import Data.Int (Int32)+import Data.Text qualified as Text import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.Projection (InlineProjection (..)) import Keiro.ReadModel (ConsistencyMode (..), ReadModel (..), StrongScope (..))+import Kioku.Api.Access (MemorySpaceId) import Kioku.Api.Scope (scopeKindText, scopeNamespaceText, scopeRefText)+import Kioku.Database.Schema+  ( kiokuSchema,+    sessionsRelation,+    sessionsTable,+    turnsRelation,+    turnsTable,+  ) import Kioku.Id (idText)+import Kioku.Partition (memorySpaceColumn, memorySpaceParam) import Kioku.Prelude import Kioku.Session.Domain import Kiroku.Store.Types (RecordedEvent)  data SessionRow = SessionRow-  { sessionId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text,     agentId :: !Text,     focus :: !Text,     namespace :: !Text,@@ -63,7 +78,8 @@   deriving stock (Generic, Eq, Show)  data TurnRow = TurnRow-  { turnId :: !Text,+  { memorySpaceId :: !MemorySpaceId,+    turnId :: !Text,     sessionId :: !Text,     turnIndex :: !Int,     role :: !Text,@@ -75,23 +91,57 @@   }   deriving stock (Generic, Eq, Show) -newtype SessionByIdQuery = SessionByIdQuery Text+data SessionByIdQuery = SessionByIdQuery+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text+  } -data SessionsByNamespaceQuery = SessionsByNamespaceQuery Text Int+data SessionsByNamespaceQuery = SessionsByNamespaceQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    limit :: !Int+  } -data SessionsByScopeQuery = SessionsByScopeQuery Text (Maybe Text) (Maybe Text)+data SessionsByScopeQuery = SessionsByScopeQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    scopeKind :: !(Maybe Text),+    scopeRef :: !(Maybe Text)+  } -data SessionsByFocusQuery = SessionsByFocusQuery Text Text+data SessionsByFocusQuery = SessionsByFocusQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    focus :: !Text+  } -data SessionsByStartedRangeQuery = SessionsByStartedRangeQuery Text UTCTime UTCTime+data SessionsByStartedRangeQuery = SessionsByStartedRangeQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    startedAfter :: !UTCTime,+    startedBefore :: !UTCTime+  } -newtype SessionChainQuery = SessionChainQuery Text+data SessionChainQuery = SessionChainQuery+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text+  } -newtype SessionDelegationChildrenQuery = SessionDelegationChildrenQuery Text+data SessionDelegationChildrenQuery = SessionDelegationChildrenQuery+  { memorySpaceId :: !MemorySpaceId,+    parentSessionId :: !Text+  } -data AwaitingSessionsByCorrelationKeyQuery = AwaitingSessionsByCorrelationKeyQuery Text Text+data AwaitingSessionsByCorrelationKeyQuery = AwaitingSessionsByCorrelationKeyQuery+  { memorySpaceId :: !MemorySpaceId,+    namespace :: !Text,+    correlationKey :: !Text+  } -newtype TurnsBySessionQuery = TurnsBySessionQuery Text+data TurnsBySessionQuery = TurnsBySessionQuery+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text+  }  sessionInlineProjection :: InlineProjection SessionEvent sessionInlineProjection =@@ -107,26 +157,80 @@     InteractiveSessionRecorded d -> Tx.statement (interactiveRow d) upsertSessionStmt     SessionCompleted d ->       Tx.statement-        (idText d.sessionId, d.completedAt, d.modelUsed, d.summary)+        SessionCompletion+          { memorySpaceId = d.memorySpaceId,+            sessionId = idText d.sessionId,+            completedAt = d.completedAt,+            modelUsed = d.modelUsed,+            summary = d.summary+          }         updateSessionCompletedStmt     SessionFailed d ->       Tx.statement-        (idText d.sessionId, d.failedAt, d.errorMessage)+        SessionFailure+          { memorySpaceId = d.memorySpaceId,+            sessionId = idText d.sessionId,+            failedAt = d.failedAt,+            errorMessage = d.errorMessage+          }         updateSessionFailedStmt     SessionAwaiting d ->       Tx.statement-        (idText d.sessionId, d.reason, d.correlationKey, d.deadline)+        SessionPark+          { memorySpaceId = d.memorySpaceId,+            sessionId = idText d.sessionId,+            reason = d.reason,+            correlationKey = d.correlationKey,+            deadline = d.deadline+          }         updateSessionAwaitingStmt     SessionResumed d ->       Tx.statement-        (idText d.sessionId, d.input)+        SessionResumption+          { memorySpaceId = d.memorySpaceId,+            sessionId = idText d.sessionId,+            input = d.input+          }         updateSessionResumedStmt     TurnRecorded d -> Tx.statement (turnRow d) insertTurnStmt +-- | The parameters of each lifecycle update, as records rather than tuples, so that the+-- partition is named at every call site instead of being the first of five positional+-- arguments.+data SessionCompletion = SessionCompletion+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text,+    completedAt :: !UTCTime,+    modelUsed :: !(Maybe Text),+    summary :: !(Maybe Text)+  }++data SessionFailure = SessionFailure+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text,+    failedAt :: !UTCTime,+    errorMessage :: !Text+  }++data SessionPark = SessionPark+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text,+    reason :: !Text,+    correlationKey :: !(Maybe Text),+    deadline :: !(Maybe UTCTime)+  }++data SessionResumption = SessionResumption+  { memorySpaceId :: !MemorySpaceId,+    sessionId :: !Text,+    input :: !Text+  }+ startedRow :: SessionStartedData -> SessionRow startedRow d =   SessionRow-    { sessionId = idText d.sessionId,+    { memorySpaceId = d.memorySpaceId,+      sessionId = idText d.sessionId,       agentId = d.agentId,       focus = d.focus,       namespace = scopeNamespaceText d.scope,@@ -151,7 +255,8 @@ interactiveRow :: InteractiveSessionRecordedData -> SessionRow interactiveRow d =   SessionRow-    { sessionId = idText d.sessionId,+    { memorySpaceId = d.memorySpaceId,+      sessionId = idText d.sessionId,       agentId = d.agentId,       focus = d.focus,       namespace = scopeNamespaceText d.scope,@@ -176,7 +281,8 @@ turnRow :: TurnRecordedData -> TurnRow turnRow d =   TurnRow-    { turnId = d.turnId,+    { memorySpaceId = d.memorySpaceId,+      turnId = d.turnId,       sessionId = idText d.sessionId,       turnIndex = d.turnIndex,       role = d.role,@@ -191,25 +297,25 @@ sessionByIdReadModel =   ReadModel     { name = "kioku-session-by-id",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(SessionByIdQuery sid) -> Tx.statement sid selectSessionByIdStmt+      query = \q -> Tx.statement q selectSessionByIdStmt     }  sessionsByNamespaceReadModel :: ReadModel SessionsByNamespaceQuery [SessionRow] sessionsByNamespaceReadModel =   ReadModel     { name = "kioku-sessions-by-namespace",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,       query = \q -> Tx.statement q selectSessionsByNamespaceStmt@@ -219,11 +325,11 @@ sessionsByScopeReadModel =   ReadModel     { name = "kioku-sessions-by-scope",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,       query = \q -> Tx.statement q selectSessionsByScopeStmt@@ -233,11 +339,11 @@ sessionsByFocusReadModel =   ReadModel     { name = "kioku-sessions-by-focus",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,       query = \q -> Tx.statement q selectSessionsByFocusStmt@@ -247,11 +353,11 @@ sessionsByStartedRangeReadModel =   ReadModel     { name = "kioku-sessions-by-started-range",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,       query = \q -> Tx.statement q selectSessionsByStartedRangeStmt@@ -261,39 +367,39 @@ sessionChainReadModel =   ReadModel     { name = "kioku-session-chain",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(SessionChainQuery sid) -> Tx.statement sid selectSessionChainStmt+      query = \q -> Tx.statement q selectSessionChainStmt     }  sessionDelegationChildrenReadModel :: ReadModel SessionDelegationChildrenQuery [SessionRow] sessionDelegationChildrenReadModel =   ReadModel     { name = "kioku-session-delegation-children",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(SessionDelegationChildrenQuery sid) -> Tx.statement sid selectDelegationChildrenStmt+      query = \q -> Tx.statement q selectDelegationChildrenStmt     }  awaitingSessionsByCorrelationKeyReadModel :: ReadModel AwaitingSessionsByCorrelationKeyQuery [SessionRow] awaitingSessionsByCorrelationKeyReadModel =   ReadModel     { name = "kioku-sessions-awaiting-by-correlation-key",-      schema = "kiroku",-      tableName = "kioku_sessions",+      schema = kiokuSchema,+      tableName = sessionsRelation,       subscriptionName = "kioku-session-inline",-      version = 3,-      shapeHash = "kioku-session-v3",+      version = sessionReadModelVersion,+      shapeHash = sessionReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,       query = \q -> Tx.statement q selectAwaitingByCorrelationKeyStmt@@ -303,23 +409,48 @@ turnsBySessionReadModel =   ReadModel     { name = "kioku-turns-by-session",-      schema = "kiroku",-      tableName = "kioku_turns",+      schema = kiokuSchema,+      tableName = turnsRelation,       subscriptionName = "kioku-session-inline",-      version = 1,-      shapeHash = "kioku-turn-v1",+      version = turnReadModelVersion,+      shapeHash = turnReadModelShapeHash,       defaultConsistency = Eventual,       strongScope = EntireLog,-      query = \(TurnsBySessionQuery sid) -> Tx.statement sid selectTurnsBySessionStmt+      query = \q -> Tx.statement q selectTurnsBySessionStmt     } +-- | The registry identity of every session read model.+--+-- The session model reshaped v1 -> v2 (delegation lineage) -> v3 (awaiting park/resume) -> v4+-- (the memory-space partition) -> v5 (the move from @kiroku.kioku_sessions@ to+-- @kioku.sessions@). Turns followed the same last step, v2 -> v3.+--+-- Each step was a migration that left the table data correct for the newer version, so+-- @Kioku.ReadModel.reconcileReadModelRegistry@ advances the guard rather than rebuilding. The+-- last one is not additive in the usual sense — no column changed, the relation moved — but the+-- version still has to advance, because Keiro's registry records no physical relation name and+-- the version is therefore the only thing that can stop a binary compiled before the move from+-- querying a table that is no longer where it thinks it is.+sessionReadModelVersion :: Int+sessionReadModelVersion = 5++sessionReadModelShapeHash :: Text+sessionReadModelShapeHash = "kioku-session-v5"++turnReadModelVersion :: Int+turnReadModelVersion = 3++turnReadModelShapeHash :: Text+turnReadModelShapeHash = "kioku-turn-v3"+ sessionRowDecoder :: D.Row SessionRow sessionRowDecoder =   SessionRow-    <$> D.column (D.nonNullable D.text)+    <$> memorySpaceColumn     <*> D.column (D.nonNullable D.text)     <*> D.column (D.nonNullable D.text)     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nullable D.text)     <*> D.column (D.nullable D.text)@@ -340,8 +471,9 @@ turnRowDecoder :: D.Row TurnRow turnRowDecoder =   TurnRow-    <$> D.column (D.nonNullable D.text)+    <$> memorySpaceColumn     <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.text)     <*> (fromIntegral @Int32 @Int <$> D.column (D.nonNullable D.int4))     <*> D.column (D.nonNullable D.text)     <*> D.column (D.nonNullable D.text)@@ -350,97 +482,122 @@     <*> (fmap (fromIntegral @Int32 @Int) <$> D.column (D.nullable D.int4))     <*> D.column (D.nonNullable D.timestamptz) -selectSessionByIdStmt :: Statement Text (Maybe SessionRow)+-- | The projection 'sessionRowDecoder' expects, in its exact order. The partition comes first,+-- so a reviewer reading any session query sees the space before anything it is keyed by.+sessionRowColumnNames :: [Text]+sessionRowColumnNames =+  [ "memory_space_id",+    "session_id",+    "agent_id",+    "focus",+    "namespace",+    "scope_kind",+    "scope_ref",+    "subject_ref",+    "previous_session_id",+    "parent_session_id",+    "delegation_depth",+    "status",+    "started_at",+    "completed_at",+    "model_used",+    "summary",+    "error_message",+    "awaiting_reason",+    "awaiting_correlation_key",+    "awaiting_deadline",+    "resume_input"+  ]++sessionRowColumns :: Text+sessionRowColumns = Text.intercalate ", " sessionRowColumnNames++qualifiedSessionRowColumns :: Text -> Text+qualifiedSessionRowColumns prefix =+  Text.intercalate ", " ((\column -> prefix <> "." <> column) <$> sessionRowColumnNames)++selectSessionByIdStmt :: Statement SessionByIdQuery (Maybe SessionRow) selectSessionByIdStmt =   preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE session_id = $1-    """-    (E.param (E.nonNullable E.text))+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND session_id = $2"+    )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.sessionId) >$< E.param (E.nonNullable E.text))+    )     (D.rowMaybe sessionRowDecoder)  selectSessionsByNamespaceStmt :: Statement SessionsByNamespaceQuery [SessionRow] selectSessionsByNamespaceStmt =   preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE namespace = $1-    ORDER BY started_at DESC-    LIMIT $2-    """-    ( ((\(SessionsByNamespaceQuery ns _) -> ns) >$< E.param (E.nonNullable E.text))-        <> ((\(SessionsByNamespaceQuery _ limit) -> fromIntegral @Int @Int32 limit) >$< E.param (E.nonNullable E.int4))+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND namespace = $2 ORDER BY started_at DESC LIMIT $3"     )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+        <> ((\q -> fromIntegral @Int @Int32 q.limit) >$< E.param (E.nonNullable E.int4))+    )     (D.rowList sessionRowDecoder)  selectSessionsByScopeStmt :: Statement SessionsByScopeQuery [SessionRow] selectSessionsByScopeStmt =   preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE namespace = $1-      AND scope_kind IS NOT DISTINCT FROM $2-      AND scope_ref IS NOT DISTINCT FROM $3-    ORDER BY started_at DESC-    """-    ( ((\(SessionsByScopeQuery ns _ _) -> ns) >$< E.param (E.nonNullable E.text))-        <> ((\(SessionsByScopeQuery _ sk _) -> sk) >$< E.param (E.nullable E.text))-        <> ((\(SessionsByScopeQuery _ _ sr) -> sr) >$< E.param (E.nullable E.text))+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND namespace = $2\+           \ AND scope_kind IS NOT DISTINCT FROM $3 AND scope_ref IS NOT DISTINCT FROM $4\+           \ ORDER BY started_at DESC"     )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+        <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))+        <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))+    )     (D.rowList sessionRowDecoder)  selectSessionsByFocusStmt :: Statement SessionsByFocusQuery [SessionRow] selectSessionsByFocusStmt =   preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE namespace = $1-      AND focus = $2-    ORDER BY started_at DESC-    """-    ( ((\(SessionsByFocusQuery ns _) -> ns) >$< E.param (E.nonNullable E.text))-        <> ((\(SessionsByFocusQuery _ focus) -> focus) >$< E.param (E.nonNullable E.text))+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND namespace = $2 AND focus = $3\+           \ ORDER BY started_at DESC"     )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+        <> ((\q -> q.focus) >$< E.param (E.nonNullable E.text))+    )     (D.rowList sessionRowDecoder)  selectSessionsByStartedRangeStmt :: Statement SessionsByStartedRangeQuery [SessionRow] selectSessionsByStartedRangeStmt =   preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE namespace = $1-      AND started_at >= $2-      AND started_at < $3-    ORDER BY started_at DESC-    """-    ( ((\(SessionsByStartedRangeQuery ns _ _) -> ns) >$< E.param (E.nonNullable E.text))-        <> ((\(SessionsByStartedRangeQuery _ start _) -> start) >$< E.param (E.nonNullable E.timestamptz))-        <> ((\(SessionsByStartedRangeQuery _ _ end) -> end) >$< E.param (E.nonNullable E.timestamptz))+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND namespace = $2\+           \ AND started_at >= $3 AND started_at < $4 ORDER BY started_at DESC"     )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+        <> ((\q -> q.startedAfter) >$< E.param (E.nonNullable E.timestamptz))+        <> ((\q -> q.startedBefore) >$< E.param (E.nonNullable E.timestamptz))+    )     (D.rowList sessionRowDecoder) --- | Walk a session's continuation chain backwards through @previous_session_id@.+-- | Walk a session's continuation chain backwards through @previous_session_id@, without+-- leaving the memory space it started in. -- -- The @path@ array makes revisiting a session impossible, so the walk terminates on any -- data — including a cycle, which 'Kioku.Session.start' now refuses to create but which@@ -448,106 +605,135 @@ -- With a plain @UNION ALL@ and no guard, a cycle loops until timeout or OOM. The depth cap -- is defense in depth, far above any legitimate chain. --+-- @previous_session_id@ is a bare id with no foreign key, so the recursive arm carries the+-- space predicate too: without it a chain could be walked out of its space by any id written+-- before the partition existed.+-- -- The final @SELECT@ omits @depth@/@path@, so 'sessionRowDecoder' is unchanged.-selectSessionChainStmt :: Statement Text [SessionRow]+selectSessionChainStmt :: Statement SessionChainQuery [SessionRow] selectSessionChainStmt =   preparable-    """-    WITH RECURSIVE chain AS (-      SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-             previous_session_id, parent_session_id, delegation_depth, status, started_at,-             completed_at, model_used, summary, error_message,-             awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input,-             1 AS depth, ARRAY[session_id] AS path-      FROM kioku_sessions-      WHERE session_id = $1-      UNION ALL-      SELECT s.session_id, s.agent_id, s.focus, s.namespace, s.scope_kind, s.scope_ref, s.subject_ref,-             s.previous_session_id, s.parent_session_id, s.delegation_depth, s.status, s.started_at,-             s.completed_at, s.model_used, s.summary, s.error_message,-             s.awaiting_reason, s.awaiting_correlation_key, s.awaiting_deadline, s.resume_input,-             c.depth + 1, c.path || s.session_id-      FROM kioku_sessions s-      INNER JOIN chain c ON s.session_id = c.previous_session_id-      WHERE NOT s.session_id = ANY (c.path)-        AND c.depth < 10000+    ( "WITH RECURSIVE chain AS ("+        <> "SELECT "+        <> sessionRowColumns+        <> ", 1 AS depth, ARRAY[session_id] AS path"+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND session_id = $2"+        <> " UNION ALL "+        <> "SELECT "+        <> qualifiedSessionRowColumns "s"+        <> ", c.depth + 1, c.path || s.session_id"+        <> " FROM "+        <> sessionsTable+        <> " s INNER JOIN chain c"+        <> " ON s.session_id = c.previous_session_id AND s.memory_space_id = c.memory_space_id"+        <> " WHERE NOT s.session_id = ANY (c.path) AND c.depth < 10000"+        <> ") SELECT "+        <> sessionRowColumns+        <> " FROM chain ORDER BY started_at ASC"     )-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM chain-    ORDER BY started_at ASC-    """-    (E.param (E.nonNullable E.text))+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.sessionId) >$< E.param (E.nonNullable E.text))+    )     (D.rowList sessionRowDecoder) -selectDelegationChildrenStmt :: Statement Text [SessionRow]+selectDelegationChildrenStmt :: Statement SessionDelegationChildrenQuery [SessionRow] selectDelegationChildrenStmt =   preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE parent_session_id = $1-    ORDER BY started_at ASC, session_id ASC-    """-    (E.param (E.nonNullable E.text))+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND parent_session_id = $2\+           \ ORDER BY started_at ASC, session_id ASC"+    )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.parentSessionId) >$< E.param (E.nonNullable E.text))+    )     (D.rowList sessionRowDecoder) -selectTurnsBySessionStmt :: Statement Text [TurnRow]+selectAwaitingByCorrelationKeyStmt :: Statement AwaitingSessionsByCorrelationKeyQuery [SessionRow]+selectAwaitingByCorrelationKeyStmt =+  preparable+    ( "SELECT "+        <> sessionRowColumns+        <> " FROM "+        <> sessionsTable+        <> " WHERE memory_space_id = $1 AND namespace = $2\+           \ AND status = 'awaiting' AND awaiting_correlation_key = $3 ORDER BY started_at DESC"+    )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))+        <> ((\q -> q.correlationKey) >$< E.param (E.nonNullable E.text))+    )+    (D.rowList sessionRowDecoder)++selectTurnsBySessionStmt :: Statement TurnsBySessionQuery [TurnRow] selectTurnsBySessionStmt =   preparable-    """-    SELECT turn_id, session_id, turn_index, role, content, tool_summary, prompt_tokens,-           output_tokens, recorded_at-    FROM kioku_turns-    WHERE session_id = $1-    ORDER BY turn_index ASC-    """-    (E.param (E.nonNullable E.text))+    ( """+      SELECT memory_space_id, turn_id, session_id, turn_index, role, content, tool_summary,+             prompt_tokens, output_tokens, recorded_at+      FROM+      """+        <> " "+        <> turnsTable+        <> " "+        <> """+           WHERE memory_space_id = $1+             AND session_id = $2+           ORDER BY turn_index ASC+           """+    )+    ( ((\q -> q.memorySpaceId) >$< memorySpaceParam)+        <> ((\q -> q.sessionId) >$< E.param (E.nonNullable E.text))+    )     (D.rowList turnRowDecoder)  upsertSessionStmt :: Statement SessionRow () upsertSessionStmt =   preparable-    """-    INSERT INTO kioku_sessions-      (session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-       previous_session_id, parent_session_id, delegation_depth, status, started_at,-       completed_at, model_used, summary, error_message,-       awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input, updated_at)-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, NOW())-    ON CONFLICT (session_id) DO UPDATE SET-      agent_id = EXCLUDED.agent_id,-      focus = EXCLUDED.focus,-      namespace = EXCLUDED.namespace,-      scope_kind = EXCLUDED.scope_kind,-      scope_ref = EXCLUDED.scope_ref,-      subject_ref = EXCLUDED.subject_ref,-      previous_session_id = EXCLUDED.previous_session_id,-      parent_session_id = EXCLUDED.parent_session_id,-      delegation_depth = EXCLUDED.delegation_depth,-      status = EXCLUDED.status,-      started_at = EXCLUDED.started_at,-      completed_at = EXCLUDED.completed_at,-      model_used = EXCLUDED.model_used,-      summary = EXCLUDED.summary,-      error_message = EXCLUDED.error_message,-      awaiting_reason = EXCLUDED.awaiting_reason,-      awaiting_correlation_key = EXCLUDED.awaiting_correlation_key,-      awaiting_deadline = EXCLUDED.awaiting_deadline,-      resume_input = EXCLUDED.resume_input,-      updated_at = EXCLUDED.updated_at-    """+    ( "INSERT INTO "+        <> sessionsTable+        <> "\n"+        <> """+             (memory_space_id, session_id, agent_id, focus, namespace, scope_kind, scope_ref,+              subject_ref, previous_session_id, parent_session_id, delegation_depth, status, started_at,+              completed_at, model_used, summary, error_message,+              awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input, updated_at)+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, NOW())+           ON CONFLICT (session_id) DO UPDATE SET+             memory_space_id = EXCLUDED.memory_space_id,+             agent_id = EXCLUDED.agent_id,+             focus = EXCLUDED.focus,+             namespace = EXCLUDED.namespace,+             scope_kind = EXCLUDED.scope_kind,+             scope_ref = EXCLUDED.scope_ref,+             subject_ref = EXCLUDED.subject_ref,+             previous_session_id = EXCLUDED.previous_session_id,+             parent_session_id = EXCLUDED.parent_session_id,+             delegation_depth = EXCLUDED.delegation_depth,+             status = EXCLUDED.status,+             started_at = EXCLUDED.started_at,+             completed_at = EXCLUDED.completed_at,+             model_used = EXCLUDED.model_used,+             summary = EXCLUDED.summary,+             error_message = EXCLUDED.error_message,+             awaiting_reason = EXCLUDED.awaiting_reason,+             awaiting_correlation_key = EXCLUDED.awaiting_correlation_key,+             awaiting_deadline = EXCLUDED.awaiting_deadline,+             resume_input = EXCLUDED.resume_input,+             updated_at = EXCLUDED.updated_at+           """+    )     sessionRowEncoder     D.noResult  sessionRowEncoder :: E.Params SessionRow sessionRowEncoder =-  ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.agentId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.focus) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))@@ -568,26 +754,26 @@     <> ((\row -> row.awaitingDeadline) >$< E.param (E.nullable E.timestamptz))     <> ((\row -> row.resumeInput) >$< E.param (E.nullable E.text)) -updateSessionCompletedStmt :: Statement (Text, UTCTime, Maybe Text, Maybe Text) ()+updateSessionCompletedStmt :: Statement SessionCompletion () updateSessionCompletedStmt =   preparable-    "UPDATE kioku_sessions SET status = 'completed', completed_at = $2, model_used = $3, summary = $4, awaiting_reason = NULL, awaiting_correlation_key = NULL, awaiting_deadline = NULL, updated_at = NOW() WHERE session_id = $1"-    ( contrazip4-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.timestamptz))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.text))+    ("UPDATE " <> sessionsTable <> " SET status = 'completed', completed_at = $3, model_used = $4, summary = $5, awaiting_reason = NULL, awaiting_correlation_key = NULL, awaiting_deadline = NULL, updated_at = NOW() WHERE memory_space_id = $1 AND session_id = $2")+    ( ((\c -> c.memorySpaceId) >$< memorySpaceParam)+        <> ((\c -> c.sessionId) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.completedAt) >$< E.param (E.nonNullable E.timestamptz))+        <> ((\c -> c.modelUsed) >$< E.param (E.nullable E.text))+        <> ((\c -> c.summary) >$< E.param (E.nullable E.text))     )     D.noResult -updateSessionFailedStmt :: Statement (Text, UTCTime, Text) ()+updateSessionFailedStmt :: Statement SessionFailure () updateSessionFailedStmt =   preparable-    "UPDATE kioku_sessions SET status = 'failed', completed_at = $2, error_message = $3, awaiting_reason = NULL, awaiting_correlation_key = NULL, awaiting_deadline = NULL, updated_at = NOW() WHERE session_id = $1"-    ( contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.timestamptz))-        (E.param (E.nonNullable E.text))+    ("UPDATE " <> sessionsTable <> " SET status = 'failed', completed_at = $3, error_message = $4, awaiting_reason = NULL, awaiting_correlation_key = NULL, awaiting_deadline = NULL, updated_at = NOW() WHERE memory_space_id = $1 AND session_id = $2")+    ( ((\c -> c.memorySpaceId) >$< memorySpaceParam)+        <> ((\c -> c.sessionId) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.failedAt) >$< E.param (E.nonNullable E.timestamptz))+        <> ((\c -> c.errorMessage) >$< E.param (E.nonNullable E.text))     )     D.noResult @@ -596,70 +782,65 @@ -- -- @awaiting_deadline@ is advisory only: it is stored for hosts, and kioku does not enforce -- it (no timer fires, nothing expires). See MasterPlan 2's Decision Log (2026-07-07).-updateSessionAwaitingStmt :: Statement (Text, Text, Maybe Text, Maybe UTCTime) ()+updateSessionAwaitingStmt :: Statement SessionPark () updateSessionAwaitingStmt =   preparable-    "UPDATE kioku_sessions SET status = 'awaiting', awaiting_reason = $2, awaiting_correlation_key = $3, awaiting_deadline = $4, resume_input = NULL, updated_at = NOW() WHERE session_id = $1"-    ( contrazip4-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.text))-        (E.param (E.nullable E.text))-        (E.param (E.nullable E.timestamptz))+    ("UPDATE " <> sessionsTable <> " SET status = 'awaiting', awaiting_reason = $3, awaiting_correlation_key = $4, awaiting_deadline = $5, resume_input = NULL, updated_at = NOW() WHERE memory_space_id = $1 AND session_id = $2")+    ( ((\c -> c.memorySpaceId) >$< memorySpaceParam)+        <> ((\c -> c.sessionId) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.reason) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.correlationKey) >$< E.param (E.nullable E.text))+        <> ((\c -> c.deadline) >$< E.param (E.nullable E.timestamptz))     )     D.noResult -updateSessionResumedStmt :: Statement (Text, Text) ()+updateSessionResumedStmt :: Statement SessionResumption () updateSessionResumedStmt =   preparable-    "UPDATE kioku_sessions SET status = 'running', resume_input = $2, awaiting_reason = NULL, awaiting_correlation_key = NULL, awaiting_deadline = NULL, updated_at = NOW() WHERE session_id = $1"-    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)))-    D.noResult--selectAwaitingByCorrelationKeyStmt :: Statement AwaitingSessionsByCorrelationKeyQuery [SessionRow]-selectAwaitingByCorrelationKeyStmt =-  preparable-    """-    SELECT session_id, agent_id, focus, namespace, scope_kind, scope_ref, subject_ref,-           previous_session_id, parent_session_id, delegation_depth, status, started_at,-           completed_at, model_used, summary, error_message,-           awaiting_reason, awaiting_correlation_key, awaiting_deadline, resume_input-    FROM kioku_sessions-    WHERE namespace = $1-      AND status = 'awaiting'-      AND awaiting_correlation_key = $2-    ORDER BY started_at DESC-    """-    ( ((\(AwaitingSessionsByCorrelationKeyQuery ns _) -> ns) >$< E.param (E.nonNullable E.text))-        <> ((\(AwaitingSessionsByCorrelationKeyQuery _ k) -> k) >$< E.param (E.nonNullable E.text))+    ("UPDATE " <> sessionsTable <> " SET status = 'running', resume_input = $3, awaiting_reason = NULL, awaiting_correlation_key = NULL, awaiting_deadline = NULL, updated_at = NOW() WHERE memory_space_id = $1 AND session_id = $2")+    ( ((\c -> c.memorySpaceId) >$< memorySpaceParam)+        <> ((\c -> c.sessionId) >$< E.param (E.nonNullable E.text))+        <> ((\c -> c.input) >$< E.param (E.nonNullable E.text))     )-    (D.rowList sessionRowDecoder)+    D.noResult  -- | @(session_id, turn_index)@ is the turn's identity; @turn_id@ is an idempotency token -- that travels with it. The conflict clause updates @turn_id@ as well, so rebuilding the -- projection from the event stream cannot leave a superseded turn's id attached to the -- winning event's content.+--+-- The conflict target stays @(session_id, turn_index)@ rather than gaining the space. A+-- session id is globally unique and the aggregate pins each session to exactly one space, so+-- two spaces cannot produce the same pair; widening the key would /weaken/ it by letting one+-- session's turn index be written twice. insertTurnStmt :: Statement TurnRow () insertTurnStmt =   preparable-    """-    INSERT INTO kioku_turns-      (turn_id, session_id, turn_index, role, content, tool_summary, prompt_tokens, output_tokens, recorded_at)-    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)-    ON CONFLICT (session_id, turn_index) DO UPDATE SET-      turn_id = EXCLUDED.turn_id,-      role = EXCLUDED.role,-      content = EXCLUDED.content,-      tool_summary = EXCLUDED.tool_summary,-      prompt_tokens = EXCLUDED.prompt_tokens,-      output_tokens = EXCLUDED.output_tokens,-      recorded_at = EXCLUDED.recorded_at-    """+    ( "INSERT INTO "+        <> turnsTable+        <> "\n"+        <> """+             (memory_space_id, turn_id, session_id, turn_index, role, content, tool_summary,+              prompt_tokens, output_tokens, recorded_at)+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)+           ON CONFLICT (session_id, turn_index) DO UPDATE SET+             memory_space_id = EXCLUDED.memory_space_id,+             turn_id = EXCLUDED.turn_id,+             role = EXCLUDED.role,+             content = EXCLUDED.content,+             tool_summary = EXCLUDED.tool_summary,+             prompt_tokens = EXCLUDED.prompt_tokens,+             output_tokens = EXCLUDED.output_tokens,+             recorded_at = EXCLUDED.recorded_at+           """+    )     turnRowEncoder     D.noResult  turnRowEncoder :: E.Params TurnRow turnRowEncoder =-  ((\row -> row.turnId) >$< E.param (E.nonNullable E.text))+  ((\row -> row.memorySpaceId) >$< memorySpaceParam)+    <> ((\row -> row.turnId) >$< E.param (E.nonNullable E.text))     <> ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))     <> ((\row -> fromIntegral @Int @Int32 row.turnIndex) >$< E.param (E.nonNullable E.int4))     <> ((\row -> row.role) >$< E.param (E.nonNullable E.text))
src/Kioku/Worker/Failure.hs view
@@ -32,9 +32,21 @@   PoolAcquisitionTimeout -> True   ConnectionLost _ -> True   ConnectionError _ -> True+  -- PostgreSQL's class-40 rollback codes (40001 serialization_failure, 40P01+  -- deadlock_detected). Kiroku documents these as retryable: the transaction+  -- rolled back completely and nothing was committed. Before kiroku-store+  -- 0.8.0.0 they arrived as 'UnexpectedServerError' and this function called+  -- them permanent, which halted the worker on a conflict it should have+  -- retried.+  TransientTransactionFailure {} -> True   WrongExpectedVersion {} -> False   EmptyAppendBatch _ -> False   StreamNotFound _ -> False+  -- An operator-policy refusal, not an infrastructure fault: a supported hard+  -- delete found an active replay-history lease and committed nothing. It fails+  -- identically until the lease is released or expires, so redelivery cannot+  -- clear it. Kioku's workers never hard-delete, so this is unreachable today.+  HistoryRetentionActive {} -> False   ReservedStreamName _ -> False   StreamNameTooLong _ _ -> False   StreamAlreadyExists _ -> False
+ src/Kioku/Workspace.hs view
@@ -0,0 +1,222 @@+-- | Where a memory space's plaintext artifacts live on disk, and how to move the pre-partition+-- ones there.+--+-- Kioku mirrors each scene and persona to a Markdown file so a host agent can read it without a+-- database. Until memory spaces existed those files were keyed by scope alone:+--+-- > .kioku/scenes/<scope-slug>.md+-- > .kioku/persona/<scope-slug>.md+--+-- Two spaces are allowed to hold the same namespace and the same scope — that is the whole point+-- of the partition — so those two paths are the same path, and one space's scene would overwrite+-- the other's the moment both regenerated. The database stopped colliding when+-- @docs\/plans\/26-…@ gave the rows a composite key; the filesystem did not.+--+-- The layout is now rooted per space:+--+-- > .kioku/spaces/<space-dir>/scenes/<scope-slug>.md+-- > .kioku/spaces/<space-dir>/persona/<scope-slug>.md+--+-- == Why the directory name is not the space id+--+-- A 'MemorySpaceId' rejects @:@, @#@, @%@, @\/@, whitespace and control characters+-- ('Kioku.Api.Access.mkMemorySpaceId'), which is enough for a database column and nowhere near+-- enough for a path component. @..@ is a perfectly legal space id and would walk out of+-- @.kioku\/spaces@ entirely. @.@ names the directory itself. And on the case-insensitive+-- filesystems this project is developed on, @space_A@ and @space_a@ are two spaces and one+-- directory.+--+-- So 'spaceDirectoryName' does what 'Kioku.Distill.ScopeIdentity.scopeSlugFromColumns' already+-- does for scopes: a sanitised readable prefix, which is only there so a human can tell the+-- directories apart, plus a digest of the exact id, which is what actually separates them. Every+-- character outside @[A-Za-z0-9_-]@ becomes @-@, so no encoding of any space id can contain a+-- path separator or a dot segment, and the digest is over the true bytes, so no two distinct ids+-- share a directory whatever the filesystem thinks of their case.+--+-- == The historical tree+--+-- Nothing writes to @.kioku\/scenes@ or @.kioku\/persona@ any more. Those files are history: an+-- upgraded deployment still has them, still readable, and 'planArtifactMigration' reports+-- exactly what would move where. 'applyArtifactMigration' copies rather than moves, so a failed+-- verification leaves the originals to fall back on; removing the old tree afterwards is the+-- operator's call and is deliberately not something this module will do.+--+-- The one exception is deletion, in 'Kioku.Distill.L2' and 'Kioku.Distill.L3': when every memory+-- in a scope is forgotten, the legacy space's historical mirror is unlinked along with the+-- partitioned one. A merely out-of-date file is visible to the operator through the migration+-- plan; forgotten content surviving on disk is not out of date, it is a retention failure.+module Kioku.Workspace+  ( -- * Layout+    spaceArtifactRoot,+    spaceDirectoryName,+    sceneArtifactDir,+    personaArtifactDir,+    legacySceneArtifactDir,+    legacyPersonaArtifactDir,++    -- * Migrating the pre-partition tree+    ArtifactMove (..),+    MoveVerdict (..),+    planArtifactMigration,+    applyArtifactMigration,+  )+where++import Crypto.Hash (Digest, SHA256)+import Crypto.Hash qualified as Hash+import Data.ByteString qualified as BS+import Data.List (sort)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Kioku.Api.Access (MemorySpaceId, memorySpaceIdText)+import Kioku.Prelude+import System.Directory+  ( copyFile,+    createDirectoryIfMissing,+    doesDirectoryExist,+    doesFileExist,+    listDirectory,+  )+import System.FilePath (takeDirectory, takeExtension, (</>))++-- | The directory holding every artifact of one memory space.+--+-- @\<workspace\>\/.kioku\/spaces\/\<space-dir\>@. See the module header for why the last+-- component is not the space id itself.+spaceArtifactRoot :: FilePath -> MemorySpaceId -> FilePath+spaceArtifactRoot workspace space =+  spacesRoot workspace </> Text.unpack (spaceDirectoryName space)++-- | Where a space's scene mirrors go.+sceneArtifactDir :: FilePath -> MemorySpaceId -> FilePath+sceneArtifactDir workspace space = spaceArtifactRoot workspace space </> "scenes"++-- | Where a space's persona mirrors go.+personaArtifactDir :: FilePath -> MemorySpaceId -> FilePath+personaArtifactDir workspace space = spaceArtifactRoot workspace space </> "persona"++-- | The pre-partition scene directory. Read for migration, never written.+legacySceneArtifactDir :: FilePath -> FilePath+legacySceneArtifactDir workspace = kiokuRoot workspace </> "scenes"++-- | The pre-partition persona directory. Read for migration, never written.+legacyPersonaArtifactDir :: FilePath -> FilePath+legacyPersonaArtifactDir workspace = kiokuRoot workspace </> "persona"++-- | A path-safe, collision-free directory name for one memory space.+--+-- The readable half cannot be trusted for identity and the digest cannot be read by a human, so+-- the name is both. This is the same shape as a scope slug, for the same reasons.+spaceDirectoryName :: MemorySpaceId -> Text+spaceDirectoryName space =+  sanitize raw <> "-" <> digest+  where+    raw = memorySpaceIdText space++    digest =+      Text.take 10 . Text.pack . show $+        (Hash.hash (TE.encodeUtf8 raw) :: Digest SHA256)++-- | Map every character outside @[A-Za-z0-9_-]@ to @-@.+--+-- Dots included, and that is the load-bearing part: it is what makes @..@ and @.@ ordinary+-- directory names rather than traversal.+sanitize :: Text -> Text+sanitize =+  Text.map \ch ->+    if isSafeChar ch then ch else '-'++isSafeChar :: Char -> Bool+isSafeChar ch =+  (ch >= 'a' && ch <= 'z')+    || (ch >= 'A' && ch <= 'Z')+    || (ch >= '0' && ch <= '9')+    || ch == '-'+    || ch == '_'++kiokuRoot :: FilePath -> FilePath+kiokuRoot workspace = workspace </> ".kioku"++spacesRoot :: FilePath -> FilePath+spacesRoot workspace = kiokuRoot workspace </> "spaces"++-- | What would happen to one historical artifact file.+data ArtifactMove = ArtifactMove+  { source :: !FilePath,+    destination :: !FilePath,+    verdict :: !MoveVerdict+  }+  deriving stock (Generic, Eq, Show)++-- | Why a move will or will not happen.+data MoveVerdict+  = -- | Nothing is at the destination; the file will be copied.+    MoveReady+  | -- | The destination already holds byte-identical content. This is what makes a second run a+    -- no-op rather than an error.+    MoveAlreadyMigrated+  | -- | The destination exists with different content. Refused: the partitioned file is the one+    -- the running system writes, and overwriting it with a pre-partition snapshot would replace+    -- current content with older content.+    MoveCollision+  deriving stock (Generic, Eq, Show)++-- | Work out what migrating the historical tree into one memory space would do, without touching+-- anything.+--+-- Only @.md@ files are considered, because those are the only files Kioku ever wrote there; a+-- @README@ or an editor swap file an operator left behind is not Kioku's to relocate. The result+-- is sorted by source path so that a dry run and the run that follows it read the same.+planArtifactMigration :: FilePath -> MemorySpaceId -> IO [ArtifactMove]+planArtifactMigration workspace space = do+  scenes <- planDirectory (legacySceneArtifactDir workspace) (sceneArtifactDir workspace space)+  personas <- planDirectory (legacyPersonaArtifactDir workspace) (personaArtifactDir workspace space)+  pure (scenes <> personas)++planDirectory :: FilePath -> FilePath -> IO [ArtifactMove]+planDirectory sourceDir destinationDir = do+  present <- doesDirectoryExist sourceDir+  if not present+    then pure []+    else do+      entries <- sort . filter isMarkdown <$> listDirectory sourceDir+      traverse (planOne sourceDir destinationDir) entries+  where+    isMarkdown = (== ".md") . takeExtension++planOne :: FilePath -> FilePath -> FilePath -> IO ArtifactMove+planOne sourceDir destinationDir name = do+  let source = sourceDir </> name+      destination = destinationDir </> name+  occupied <- doesFileExist destination+  verdict <-+    if not occupied+      then pure MoveReady+      else do+        same <- sameContent source destination+        pure (if same then MoveAlreadyMigrated else MoveCollision)+  pure ArtifactMove {source, destination, verdict}++-- | Compared by content hash rather than by size or mtime: a copy made by an earlier run has a+-- different mtime and must still count as already migrated.+sameContent :: FilePath -> FilePath -> IO Bool+sameContent left right = do+  leftBytes <- BS.readFile left+  rightBytes <- BS.readFile right+  pure (digestOf leftBytes == digestOf rightBytes)+  where+    digestOf bytes = Hash.hash bytes :: Digest SHA256++-- | Carry out a plan, copying every 'MoveReady' file and touching nothing else.+--+-- Copy, not move: the historical file stays where it is until an operator has verified the new+-- layout and removed the old tree themselves. A 'MoveCollision' is skipped here rather than+-- overwritten — the caller is expected to report it and exit non-zero, which is what makes a+-- refusal visible instead of silent. Re-running is safe: every file copied by the previous run+-- plans as 'MoveAlreadyMigrated'.+applyArtifactMigration :: [ArtifactMove] -> IO ()+applyArtifactMigration moves =+  forM_ moves \move ->+    when (move.verdict == MoveReady) do+      createDirectoryIfMissing True (takeDirectory move.destination)+      copyFile move.source move.destination
test/Kioku/AwaitingSpec.hs view
@@ -20,6 +20,7 @@   ) import Kioku.Session.EventStream (parseSessionEvent, sessionStream) import Kioku.Session.ReadModel (SessionRow (..))+import Kioku.SpaceFixtures (testActorPrincipal, testContext, testSpace) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -78,7 +79,7 @@     sid2 <- startFixture     parkFixture sid1 "approval_req_1"     parkFixture sid2 "approval_req_2"-    found <- Session.getAwaitingByCorrelationKey testNamespace "approval_req_1" >>= liftEither "getAwaitingByCorrelationKey"+    found <- Session.getAwaitingByCorrelationKey testSpace testNamespace "approval_req_1" >>= liftEither "getAwaitingByCorrelationKey"     liftIO $       assertEqual "only matching parked session is returned" [idText sid1] (map (.sessionId) found) @@ -113,9 +114,12 @@     parkFixture sid "k1"     now <- liftIO getCurrentTime     result <--      Session.resume+      Session.resumeWithContext+        testContext         ResumeSessionData           { sessionId = sid,+            memorySpaceId = testSpace,+            actorPrincipal = testActorPrincipal,             correlationKey = Just "k2",             force = False,             input = "approved",@@ -136,14 +140,17 @@     parkFixture sid "approval_req_1"     now <- liftIO getCurrentTime     completeResult <--      Session.complete+      Session.completeWithContext+        testContext         CompleteSessionData           { sessionId = sid,+            memorySpaceId = testSpace,+            actorPrincipal = testActorPrincipal,             completedAt = now,             modelUsed = Just "test-model",             summary = Just "completed while parked"           }-    void (liftEither "Session.complete" completeResult)+    void (liftEither "Session.completeWithContext" completeResult)     completed <- getExisting sid     events <- readSessionEvents sid     liftIO do@@ -160,13 +167,16 @@     parkFixture sid "approval_req_1"     now <- liftIO getCurrentTime     failResult <--      Session.failSession+      Session.failSessionWithContext+        testContext         FailSessionData           { sessionId = sid,+            memorySpaceId = testSpace,+            actorPrincipal = testActorPrincipal,             failedAt = now,             errorMessage = "timed out"           }-    void (liftEither "Session.failSession" failResult)+    void (liftEither "Session.failSessionWithContext" failResult)     failed <- getExisting sid     events <- readSessionEvents sid     liftIO do@@ -195,9 +205,13 @@   sid <- liftIO genSessionId   now <- liftIO getCurrentTime   result <--    Session.start+    Session.startWithContext+      testContext       StartSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,+          ownerPrincipal = Nothing,           agentId = "test-agent",           focus = "awaiting lifecycle",           scope = testScope,@@ -207,7 +221,7 @@           delegationDepth = 0,           startedAt = now         }-  void (liftEither "Session.start" result)+  void (liftEither "Session.startWithContext" result)   pure sid  parkFixture ::@@ -218,15 +232,18 @@ parkFixture sid key = do   now <- liftIO getCurrentTime   result <--    Session.awaitInput+    Session.awaitInputWithContext+      testContext       AwaitInputData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,           reason = "approval",           correlationKey = Just key,           deadline = Nothing,           awaitedAt = now         }-  void (liftEither "Session.awaitInput" result)+  void (liftEither "Session.awaitInputWithContext" result)  resumeFixture ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>@@ -237,22 +254,25 @@ resumeFixture sid key input = do   now <- liftIO getCurrentTime   result <--    Session.resume+    Session.resumeWithContext+      testContext       ResumeSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,           correlationKey = key,           force = False,           input,           resumedAt = now         }-  void (liftEither "Session.resume" result)+  void (liftEither "Session.resumeWithContext" result)  getExisting ::   (IOE :> es, Store :> es) =>   SessionId ->   Eff es SessionRow getExisting sid = do-  result <- Session.getById sid >>= liftEither "Session.getById"+  result <- Session.getById testSpace sid >>= liftEither "Session.getById"   case result of     Nothing -> liftIO (assertFailure ("missing session row " <> show (idText sid)))     Just row -> pure row
test/Kioku/CodecCompatSpec.hs view
@@ -17,17 +17,28 @@ where  import Control.Monad ((<=<))-import Data.Aeson (Value, eitherDecode)+import Data.Aeson (Value, eitherDecode, encode) import Data.Bifunctor (first) import Data.ByteString.Lazy (ByteString)+import Data.ByteString.Lazy.Char8 qualified as LBS8 import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text import Data.Time (UTCTime) import Data.Time.Format.ISO8601 (iso8601ParseM)+import Kioku.Api.Access+  ( LegacyPrincipalRef,+    MemorySpaceId,+    PrincipalRef,+    RecordedPrincipal (..),+    legacyMemorySpaceId,+    legacyPrincipalRef,+    mkPrincipalRef,+    recordedPrincipalText,+  ) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Api.Types (Confidence (..), MemoryType (..))-import Kioku.Id (idText)+import Kioku.Id (MemoryId, SessionId, idText, parseId) import Kioku.Memory.Domain   ( MemoryArchivedData (..),     MemoryConfidenceUpdatedData (..),@@ -37,7 +48,9 @@     MemorySupersededData (..),     MemoryTagsUpdatedData (..),   )+import Kioku.Memory.Domain qualified as MemoryDomain import Kioku.Memory.EventStream (parseMemoryEvent)+import Kioku.Prelude (toJSON) import Kioku.Session.Domain   ( InteractiveSessionRecordedData (..),     SessionAwaitingData (..),@@ -48,9 +61,11 @@     SessionStartedData (..),     TurnRecordedData (..),   )+import Kioku.Session.Domain qualified as SessionDomain import Kioku.Session.EventStream (parseSessionEvent)+import Kioku.SpaceFixtures (testActorPrincipal, testSpace) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertFailure, testCase, (@?=))+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))  tests :: TestTree tests =@@ -58,8 +73,170 @@     "pre-upgrade event payloads still decode"     [ testGroup "memory events" memoryTests,       testGroup "session events" sessionTests,-      testGroup "the legacy fallback still exists" fallbackTests+      testGroup "the legacy fallback still exists" fallbackTests,+      testGroup "pre-partition payloads land in the legacy space" partitionTests,+      testGroup "partitioned payloads round-trip" roundTripTests     ]++-- * The memory-space partition++--+-- Every fixture above was captured before memory spaces existed, which makes them the exact+-- input the compatibility rules were written for. These assert what those rules produce.+--+-- Two things are being pinned. First, that a payload with no partition decodes into one+-- explicit space rather than into "no space" — absence of a partition must never read as+-- unrestricted access. Second, that a historical @agentId@ stays marked as the free-text label+-- it is: promoting @agent-1@ to a directory principal would put an identity nobody issued into+-- an audit trail, and every later authorization decision about it would be a decision about a+-- string somebody typed.++partitionTests :: [TestTree]+partitionTests =+  [ testCase "a pre-partition memory_recorded lands in the legacy space" do+      decodeMemory memoryRecordedJson >>= \case+        MemoryRecorded d -> do+          d.memorySpaceId @?= legacyMemorySpaceId+          d.actorPrincipal @?= LegacyPrincipal (legacyPrincipalRef "agent-1")+          d.ownerPrincipal @?= Nothing+        other -> unexpected "MemoryRecorded" other,+    testCase "a legacy agent label is not a directory principal" do+      -- The rendering carries the marker, so nothing downstream can mistake it for one.+      decodeMemory memoryRecordedJson >>= \case+        MemoryRecorded d -> recordedPrincipalText d.actorPrincipal @?= "kioku:legacy:agent-1"+        other -> unexpected "MemoryRecorded" other,+    testCase "a pre-partition memory_archived records no actor at all" do+      -- Archiving never carried an agent, so there is nothing to attribute it to. Inventing+      -- one would be worse than saying so.+      decodeMemory memoryArchivedJson >>= \case+        MemoryArchived d -> do+          d.memorySpaceId @?= legacyMemorySpaceId+          d.actorPrincipal @?= UnattributedPrincipal+        other -> unexpected "MemoryArchived" other,+    testCase "every other pre-partition memory event lands in the legacy space" do+      spaces <- traverse (fmap memoryEventSpace . decodeMemory) allMemoryFixtures+      spaces @?= replicate (length allMemoryFixtures) legacyMemorySpaceId,+    testCase "a pre-partition session_started lands in the legacy space" do+      decodeSession sessionStartedJson >>= \case+        SessionStarted d -> do+          d.memorySpaceId @?= legacyMemorySpaceId+          d.actorPrincipal @?= LegacyPrincipal (legacyPrincipalRef "agent-1")+          d.ownerPrincipal @?= Nothing+        other -> unexpected "SessionStarted" other,+    testCase "a pre-partition turn_recorded records no actor at all" do+      decodeSession turnRecordedJson >>= \case+        TurnRecorded d -> do+          d.memorySpaceId @?= legacyMemorySpaceId+          d.actorPrincipal @?= UnattributedPrincipal+        other -> unexpected "TurnRecorded" other,+    testCase "every other pre-partition session event lands in the legacy space" do+      spaces <- traverse (fmap sessionEventSpace . decodeSession) allSessionFixtures+      spaces @?= replicate (length allSessionFixtures) legacyMemorySpaceId,+    testCase "a legacy Rei payload lands in the legacy space too" do+      decodeMemory legacyReiMemoryRecordedJson >>= \case+        MemoryRecorded d -> do+          d.memorySpaceId @?= legacyMemorySpaceId+          d.actorPrincipal @?= LegacyPrincipal (legacyPrincipalRef "agent-1")+        other -> unexpected "MemoryRecorded" other+  ]++-- | Encoding emits only the new form, so a value written today has to survive the same+-- decoder the fixtures above go through — with its space and actor intact rather than+-- defaulted.+roundTripTests :: [TestTree]+roundTripTests =+  [ testCase "a partitioned memory event survives encode and decode" do+      let event = MemoryRecorded partitionedRecord+      decoded <- either (assertFailure . Text.unpack) pure (parseMemoryEvent (toJSON event))+      decoded @?= event,+    testCase "the encoded form actually contains the partition" do+      -- Without this, the round-trip above would still pass if both sides defaulted.+      let encoded = toJSON (MemoryRecorded partitionedRecord)+      assertBool "names the space" (encodedContains "space_test" encoded)+      assertBool "names the actor" (encodedContains "agent_01h9xk3v7hf8b9c0d1e2f3g4h5" encoded),+    testCase "a partitioned session event survives encode and decode" do+      let event = SessionStarted partitionedStart+      decoded <- either (assertFailure . Text.unpack) pure (parseSessionEvent (toJSON event))+      decoded @?= event,+    testCase "a legacy-marked actor survives encode and decode" do+      -- A stream rebuilt and re-encoded must not launder its legacy actor into a real one.+      let event = MemoryRecorded partitionedRecord {actorPrincipal = LegacyPrincipal (legacyPrincipalRef "demo-agent")}+      decoded <- either (assertFailure . Text.unpack) pure (parseMemoryEvent (toJSON event))+      decoded @?= event+  ]++partitionedRecord :: MemoryRecordedData+partitionedRecord =+  MemoryRecordedData+    { memoryId = fixtureMemoryId,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,+      ownerPrincipal = Just ownerRef,+      agentId = "agent-1",+      sessionId = Just fixtureSessionId,+      scope = fixtureScope,+      memoryType = MemoryFact,+      content = "the build is green",+      priority = 3,+      confidence = HighConfidence,+      tags = Set.fromList ["build", "ci"],+      supersedes = Nothing,+      recordedAt = at "2026-06-24T21:30:00Z"+    }++partitionedStart :: SessionStartedData+partitionedStart =+  SessionStartedData+    { sessionId = fixtureSessionId,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,+      ownerPrincipal = Just ownerRef,+      agentId = "agent-1",+      focus = "ship the release",+      scope = fixtureScope,+      subjectRef = Just "kioku",+      previousSessionId = Nothing,+      parentSessionId = Nothing,+      delegationDepth = 0,+      startedAt = at "2026-06-24T21:30:00Z"+    }++ownerRef :: PrincipalRef+ownerRef = either (error . Text.unpack) id (mkPrincipalRef "person_01h9xk3v7hf8b9c0d1e2f3g4h9")++memoryEventSpace :: MemoryEvent -> MemorySpaceId+memoryEventSpace = MemoryDomain.eventMemorySpaceId++sessionEventSpace :: SessionEvent -> MemorySpaceId+sessionEventSpace = SessionDomain.eventMemorySpaceId++fixtureMemoryId :: MemoryId+fixtureMemoryId = either (error . Text.unpack) id (parseId memoryIdText)++fixtureSessionId :: SessionId+fixtureSessionId = either (error . Text.unpack) id (parseId sessionIdText)++encodedContains :: Text -> Value -> Bool+encodedContains needle = Text.isInfixOf needle . Text.pack . LBS8.unpack . encode++allMemoryFixtures :: [ByteString]+allMemoryFixtures =+  [ memorySupersededJson,+    memoryArchivedJson,+    memoryTagsUpdatedJson,+    memoryConfidenceUpdatedJson,+    memoryMergedJson+  ]++allSessionFixtures :: [ByteString]+allSessionFixtures =+  [ sessionCompletedJson,+    sessionFailedJson,+    sessionAwaitingJson,+    sessionResumedJson,+    interactiveSessionRecordedJson,+    turnRecordedJson+  ]  -- * Memory events 
test/Kioku/DistillSpec.hs view
@@ -30,8 +30,9 @@ import Hasql.Transaction qualified as Tx import Keiro.Stream qualified as Stream import Keiro.Timer (countDueTimers)+import Kioku.Api.Access (MemoryAccessContext, memoryContextRecordedActor, memoryContextSpace) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..), scopeKindText, scopeNamespaceText, scopeRefText)-import Kioku.Api.Types (Confidence (..), MemoryType (..))+import Kioku.Api.Types (Confidence (..), MemoryRecord (..), MemoryType (..)) import Kioku.App (AppEnv, runAppIO, withNoopAppEnv) import Kioku.Distill.Consolidate (ConsolidateInput (..), ConsolidationAction (..), ConsolidationDecision (..), ExistingMemory (..), consolidateProgram) import Kioku.Distill.Extract (ExtractOutput (..), ExtractedAtom (..), extractProgram)@@ -56,9 +57,18 @@ import Kioku.Memory.EventStream (memoryStream) import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase) import Kioku.Prelude+import Kioku.Recall qualified as Recall import Kioku.Recall.Capability (VectorCapability (..)) import Kioku.Session qualified as Session import Kioku.Session.Domain (CompleteSessionData (..), RecordTurnData (..), StartSessionData (..))+import Kioku.SpaceFixtures+  ( otherContext,+    otherSpace,+    testActorPrincipal,+    testContext,+    testContextProvider,+    testSpace,+  ) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -66,6 +76,7 @@ import Kiroku.Store.Read (readStreamForward) import Kiroku.Store.Transaction (runTransaction) import Kiroku.Store.Types (EventType (..), RecordedEvent (..), StreamVersion (..))+import Shibuya.Telemetry.Effect (Tracing) import Shikumi.Effect.Time (runTime) import Shikumi.Error (ShikumiError (..)) import Shikumi.LLM (LLM (..))@@ -85,12 +96,13 @@   testGroup     "Distillation pyramid"     [ testCase "replay distills duplicate turns into merged atom scene and persona" testReplayDistillation,-      testCase "re-running distillSessionL1 creates no new memories or audit rows" testRerunIdempotent,+      testCase "re-running distillSessionL1 testContext creates no new memories or audit rows" testRerunIdempotent,       testCase "consolidation failure stores nothing and fails the pass" testConsolidationFailure,       testCase "merge with a missing target drops it and stays convergent" testMergeMissingTarget,       testCase "watermark skips re-extraction until a new turn arrives" testWatermarkSkip,       testCase "a session accumulates one idle timer however many turns" testIdleTimerCollapse,       testCase "recall candidates find a duplicate outside the scan window" testRecallCandidateWindow,+      testCase "recall candidates stay inside the session's own scope" testRecallCandidateBreadth,       forgetPropagationTests,       confidencePropagationTests,       validationTests@@ -105,7 +117,8 @@     [ testCase "forget operations schedule scene timers" testForgetSchedulesSceneTimers,       testCase "an emptied scope deletes its scene, persona, and mirrors" testEmptyScopeDeletesArtifacts,       testCase "the timer worker propagates an archive to every artifact" testWorkerPropagatesArchive,-      testCase "supersede and merge propagate like archive" testWorkerPropagatesSupersedeAndMerge+      testCase "supersede and merge propagate like archive" testWorkerPropagatesSupersedeAndMerge,+      testCase "one worker serving two spaces keeps their artifacts disjoint" testWorkerKeepsSpacesApart     ]  -- | A memory's confidence is part of what its scope's scene is built from: it is@@ -131,7 +144,7 @@ -- the only thing standing between that mistake and production, so it asserts the -- count rises on *both* changes, not just the first. ----- The walk is genuinely @high -> medium -> low@ on purpose: 'Memory.updateConfidence'+-- The walk is genuinely @high -> medium -> low@ on purpose: 'Memory.updateConfidenceWithContext' -- refuses to emit an event when the confidence is unchanged, so re-applying the same -- value would schedule nothing and pass this test for the wrong reason. --@@ -150,15 +163,17 @@       afterRecord <- countDueTimers horizon        lowered <--        Memory.updateConfidence-          UpdateMemoryConfidenceData {memoryId, confidence = MediumConfidence, updatedAt = now}-      void (liftIO (expectRight "Memory.updateConfidence to medium" lowered))+        Memory.updateConfidenceWithContext+          testContext+          UpdateMemoryConfidenceData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId, confidence = MediumConfidence, updatedAt = now}+      void (liftIO (expectRight "Memory.updateConfidenceWithContext testContext to medium" lowered))       afterFirst <- countDueTimers horizon        loweredAgain <--        Memory.updateConfidence-          UpdateMemoryConfidenceData {memoryId, confidence = LowConfidence, updatedAt = now}-      void (liftIO (expectRight "Memory.updateConfidence to low" loweredAgain))+        Memory.updateConfidenceWithContext+          testContext+          UpdateMemoryConfidenceData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId, confidence = LowConfidence, updatedAt = now}+      void (liftIO (expectRight "Memory.updateConfidenceWithContext testContext to low" loweredAgain))       afterSecond <- countDueTimers horizon       pure (afterRecord, afterFirst, afterSecond)   case result of@@ -184,12 +199,13 @@       recordForgetFixture memoryId scope "Content for the tags timer test" now       afterRecord <- countDueTimers horizon -      -- A genuinely different tag set: 'Memory.updateTags' short-circuits on an+      -- A genuinely different tag set: 'Memory.updateTagsWithContext' short-circuits on an       -- unchanged one, which would make this pass for the wrong reason.       retagResult <--        Memory.updateTags-          UpdateMemoryTagsData {memoryId, tags = Set.fromList ["retagged"], updatedAt = now}-      void (liftIO (expectRight "Memory.updateTags" retagResult))+        Memory.updateTagsWithContext+          testContext+          UpdateMemoryTagsData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId, tags = Set.fromList ["retagged"], updatedAt = now}+      void (liftIO (expectRight "Memory.updateTagsWithContext" retagResult))       afterRetag <- countDueTimers horizon       pure (afterRecord, afterRetag)   case result of@@ -214,21 +230,22 @@       -- 'recordForgetFixture' records at 'HighConfidence'.       recordForgetFixture memoryId scope confidenceContent now       void (drainTimers runtime)-      builtScene <- getScenesByScope scope >>= liftIO . expectOneScene "the initial distillation"+      builtScene <- getScenesByScope testSpace scope >>= liftIO . expectOneScene "the initial distillation"       builtPersona <--        getPersonaByScope scope >>= liftIO . expectJust "a persona after the initial distillation"+        getPersonaByScope testSpace scope >>= liftIO . expectJust "a persona after the initial distillation"       personaRunsBefore <- liftIO (readIORef calls.personaCalls)        lowered <--        Memory.updateConfidence-          UpdateMemoryConfidenceData {memoryId, confidence = LowConfidence, updatedAt = now}-      void (liftIO (expectRight "Memory.updateConfidence" lowered))+        Memory.updateConfidenceWithContext+          testContext+          UpdateMemoryConfidenceData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId, confidence = LowConfidence, updatedAt = now}+      void (liftIO (expectRight "Memory.updateConfidenceWithContext" lowered))       void (drainTimers runtime)        refreshedScene <--        getScenesByScope scope >>= liftIO . expectOneScene "after lowering the confidence"+        getScenesByScope testSpace scope >>= liftIO . expectOneScene "after lowering the confidence"       refreshedPersona <--        getPersonaByScope scope >>= liftIO . expectJust "a persona after lowering the confidence"+        getPersonaByScope testSpace scope >>= liftIO . expectJust "a persona after lowering the confidence"       personaRunsAfter <- liftIO (readIORef calls.personaCalls)       mirror <- liftIO (TextIO.readFile (sceneMirrorPath workspace refreshedScene)) @@ -345,9 +362,9 @@       recordForgetFixture alphaId scope alphaContent now       recordForgetFixture betaId scope betaContent now -      firstScene <- regenerateScene runtime scope+      firstScene <- regenerateScene runtime testSpace scope       sceneRow <- liftIO (expectJustRow "the first regeneration writes a scene" firstScene)-      firstPersona <- regeneratePersona runtime scope+      firstPersona <- regeneratePersona runtime testSpace scope       personaRow <- liftIO (expectJustRow "the first regeneration writes a persona" firstPersona)        -- Both mirrors have to be observed here, while they still exist: the whole@@ -358,19 +375,19 @@         liftIO ((&&) <$> doesFileExist scenePath <*> doesFileExist personaPath)        -- Forget one of the two: the scene must rebuild from the survivor alone.-      archivedAlpha <- Memory.archive ArchiveMemoryData {memoryId = alphaId, archivedAt = now}-      void (liftIO (expectRight "Memory.archive alpha" archivedAlpha))-      afterOne <- regenerateScene runtime scope+      archivedAlpha <- Memory.archiveWithContext testContext ArchiveMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = alphaId, archivedAt = now}+      void (liftIO (expectRight "Memory.archiveWithContext testContext alpha" archivedAlpha))+      afterOne <- regenerateScene runtime testSpace scope       survivorScene <- liftIO (expectJustRow "the scene survives one forget" afterOne)        -- Forget the last one: nothing may be left to regenerate from.-      archivedBeta <- Memory.archive ArchiveMemoryData {memoryId = betaId, archivedAt = now}-      void (liftIO (expectRight "Memory.archive beta" archivedBeta))-      emptyScene <- regenerateScene runtime scope-      emptyPersona <- regeneratePersona runtime scope+      archivedBeta <- Memory.archiveWithContext testContext ArchiveMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = betaId, archivedAt = now}+      void (liftIO (expectRight "Memory.archiveWithContext testContext beta" archivedBeta))+      emptyScene <- regenerateScene runtime testSpace scope+      emptyPersona <- regeneratePersona runtime testSpace scope -      scenes <- getScenesByScope scope-      persona <- getPersonaByScope scope+      scenes <- getScenesByScope testSpace scope+      persona <- getPersonaByScope testSpace scope       pure (mirrorsWritten, scenePath, personaPath, survivorScene, emptyScene, emptyPersona, scenes, persona)   case result of     Left storeErr -> assertFailure ("store error: " <> show storeErr)@@ -425,22 +442,25 @@         [archivedId, supersededId, supersederId, loserId, winnerId]       afterRecords <- countDueTimers horizon -      archived <- Memory.archive ArchiveMemoryData {memoryId = archivedId, archivedAt = now}-      void (liftIO (expectRight "Memory.archive" archived))+      archived <- Memory.archiveWithContext testContext ArchiveMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = archivedId, archivedAt = now}+      void (liftIO (expectRight "Memory.archiveWithContext" archived))       afterArchive <- countDueTimers horizon        superseded <--        Memory.supersede+        Memory.supersedeWithContext+          testContext           SupersedeMemoryData             { memoryId = supersededId,+              memorySpaceId = testSpace,+              actorPrincipal = testActorPrincipal,               supersededBy = supersederId,               supersededAt = now             }-      void (liftIO (expectRight "Memory.supersede" superseded))+      void (liftIO (expectRight "Memory.supersedeWithContext" superseded))       afterSupersede <- countDueTimers horizon -      merged <- Memory.merge loserId winnerId-      void (liftIO (expectRight "Memory.merge" merged))+      merged <- Memory.mergeWithContext testContext loserId winnerId+      void (liftIO (expectRight "Memory.mergeWithContext" merged))       afterMerge <- countDueTimers horizon       pure (afterRecords, afterArchive, afterSupersede, afterMerge)   case result of@@ -468,26 +488,26 @@       recordForgetFixture betaId scope betaContent now       void (drainTimers runtime) -      builtScene <- getScenesByScope scope >>= liftIO . expectOneScene "the initial distillation"+      builtScene <- getScenesByScope testSpace scope >>= liftIO . expectOneScene "the initial distillation"       builtPersona <--        getPersonaByScope scope >>= liftIO . expectJust "a persona after the initial distillation"+        getPersonaByScope testSpace scope >>= liftIO . expectJust "a persona after the initial distillation"       let scenePath = sceneMirrorPath workspace builtScene           personaPath = personaMirrorPath workspace builtPersona       builtMirrors <- liftIO ((&&) <$> doesFileExist scenePath <*> doesFileExist personaPath)        -- Forget alpha. The worker has to rebuild the scene from the survivor.-      archivedAlpha <- Memory.archive ArchiveMemoryData {memoryId = alphaId, archivedAt = now}-      void (liftIO (expectRight "Memory.archive alpha" archivedAlpha))+      archivedAlpha <- Memory.archiveWithContext testContext ArchiveMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = alphaId, archivedAt = now}+      void (liftIO (expectRight "Memory.archiveWithContext testContext alpha" archivedAlpha))       void (drainTimers runtime)-      survivorScene <- getScenesByScope scope >>= liftIO . expectOneScene "after forgetting alpha"+      survivorScene <- getScenesByScope testSpace scope >>= liftIO . expectOneScene "after forgetting alpha"       survivorMirror <- liftIO (TextIO.readFile scenePath)        -- Forget beta, the last one. Every artifact has to go with it.-      archivedBeta <- Memory.archive ArchiveMemoryData {memoryId = betaId, archivedAt = now}-      void (liftIO (expectRight "Memory.archive beta" archivedBeta))+      archivedBeta <- Memory.archiveWithContext testContext ArchiveMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = betaId, archivedAt = now}+      void (liftIO (expectRight "Memory.archiveWithContext testContext beta" archivedBeta))       void (drainTimers runtime)-      emptyScenes <- getScenesByScope scope-      emptyPersona <- getPersonaByScope scope+      emptyScenes <- getScenesByScope testSpace scope+      emptyPersona <- getPersonaByScope testSpace scope       survivingMirrors <- liftIO ((||) <$> doesFileExist scenePath <*> doesFileExist personaPath)        -- Timer firing is at-least-once, so another pass must change nothing.@@ -528,6 +548,125 @@        refired @?= 0 +-- | What one two-space drain produced, so the assertions can be read without a nine-tuple.+data TwoSpaceRun = TwoSpaceRun+  { mineScenePath :: !FilePath,+    theirsScenePath :: !FilePath,+    mineMirror :: !Text,+    theirsMirror :: !Text,+    mineScenesAfter :: ![SceneRow],+    theirsScenesAfter :: ![SceneRow],+    minePersonaAfter :: !(Maybe PersonaRow),+    theirsPersonaAfter :: !(Maybe PersonaRow),+    mineFilesSurvived :: !Bool,+    theirsFilesSurvived :: !Bool,+    theirsMirrorAfter :: !Text,+    refired :: !Int+  }++-- | One worker, two memory spaces, one namespace and one scope shared between them.+--+-- This is the shape the whole partition exists for and the one every layer can get wrong+-- independently: the timer ids are derived from a scope both spaces hold, the scene and persona+-- row ids are derived from that same scope, and the mirror filename is a slug of it. If any of+-- those three still ignored the space, one tenant's regeneration would land on the other's+-- artifact — and because the scene body here echoes the atoms it was built from, that shows up+-- as the wrong tenant's content in the file rather than as a metadata mismatch.+--+-- The second half is the harder half. Forgetting my space's only memory empties my scope, which+-- deletes my scene, my persona, and both my mirrors. Their space must come through untouched:+-- rows, files, and content. A final drain asserts the at-least-once contract — nothing left to+-- fire, so nothing to redo.+testWorkerKeepsSpacesApart :: Assertion+testWorkerKeepsSpacesApart = withDistillWorkspaceEnv \env workspace -> do+  calls <- newDistillCalls+  runtime <- echoingRuntime calls <$> replayRuntimeIn workspace+  mineId <- genMemoryId+  theirsId <- genMemoryId+  now <- getCurrentTime+  let scope = forgetScope "intention_two_space_worker"+  result <-+    runAppIO env do+      recordFixtureIn testContext mineId scope alphaContent now+      recordFixtureIn otherContext theirsId scope betaContent now+      void (drainTimers runtime)++      mineScene <- getScenesByScope testSpace scope >>= liftIO . expectOneScene "my space's scene"+      theirsScene <- getScenesByScope otherSpace scope >>= liftIO . expectOneScene "their space's scene"+      minePersona <- getPersonaByScope testSpace scope >>= liftIO . expectJust "my space's persona"+      theirsPersona <- getPersonaByScope otherSpace scope >>= liftIO . expectJust "their space's persona"++      let mineScenePath = sceneMirrorPath workspace mineScene+          theirsScenePath = sceneMirrorPath workspace theirsScene+          minePersonaPath = personaMirrorPath workspace minePersona+          theirsPersonaPath = personaMirrorPath workspace theirsPersona+      mineMirror <- liftIO (TextIO.readFile mineScenePath)+      theirsMirror <- liftIO (TextIO.readFile theirsScenePath)++      archived <-+        Memory.archiveWithContext+          testContext+          ArchiveMemoryData+            { memorySpaceId = testSpace,+              actorPrincipal = testActorPrincipal,+              memoryId = mineId,+              archivedAt = now+            }+      void (liftIO (expectRight "Memory.archiveWithContext testContext mine" archived))+      void (drainTimers runtime)++      mineScenesAfter <- getScenesByScope testSpace scope+      theirsScenesAfter <- getScenesByScope otherSpace scope+      minePersonaAfter <- getPersonaByScope testSpace scope+      theirsPersonaAfter <- getPersonaByScope otherSpace scope+      mineFilesSurvived <- liftIO ((||) <$> doesFileExist mineScenePath <*> doesFileExist minePersonaPath)+      theirsFilesSurvived <- liftIO ((&&) <$> doesFileExist theirsScenePath <*> doesFileExist theirsPersonaPath)+      theirsMirrorAfter <- liftIO (TextIO.readFile theirsScenePath)++      refired <- drainTimers runtime+      pure+        TwoSpaceRun+          { mineScenePath,+            theirsScenePath,+            mineMirror,+            theirsMirror,+            mineScenesAfter,+            theirsScenesAfter,+            minePersonaAfter,+            theirsPersonaAfter,+            mineFilesSurvived,+            theirsFilesSurvived,+            theirsMirrorAfter,+            refired+          }+  case result of+    Left storeErr -> assertFailure ("store error: " <> show storeErr)+    Right run -> do+      assertBool+        ("both spaces wrote the same scene mirror: " <> run.mineScenePath)+        (run.mineScenePath /= run.theirsScenePath)+      assertBool+        ("their content is in my mirror: " <> Text.unpack run.mineMirror)+        (alphaNeedle `Text.isInfixOf` run.mineMirror && not (betaNeedle `Text.isInfixOf` run.mineMirror))+      assertBool+        ("my content is in their mirror: " <> Text.unpack run.theirsMirror)+        (betaNeedle `Text.isInfixOf` run.theirsMirror && not (alphaNeedle `Text.isInfixOf` run.theirsMirror))++      run.mineScenesAfter @?= []+      run.minePersonaAfter @?= Nothing+      assertBool "a mirror survived my emptied scope" (not run.mineFilesSurvived)++      assertBool+        "forgetting in my space deleted their scene"+        (length run.theirsScenesAfter == 1)+      assertBool "forgetting in my space deleted their persona" (isJust run.theirsPersonaAfter)+      assertBool "forgetting in my space deleted their mirrors" run.theirsFilesSurvived+      assertBool+        ("their mirror changed when I forgot: " <> Text.unpack run.theirsMirrorAfter)+        (run.theirsMirrorAfter == run.theirsMirror)++      run.refired @?= 0+ -- | Archive is not a special case: superseding and merging retire a memory the -- same way, and must reach the scene the same way. testWorkerPropagatesSupersedeAndMerge :: Assertion@@ -551,21 +690,22 @@       recordForgetFixture newId supersedeScope newAddressContent now       void (drainTimers supersedeRuntime)       superseded <--        Memory.supersede-          SupersedeMemoryData {memoryId = oldId, supersededBy = newId, supersededAt = now}-      void (liftIO (expectRight "Memory.supersede" superseded))+        Memory.supersedeWithContext+          testContext+          SupersedeMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = oldId, supersededBy = newId, supersededAt = now}+      void (liftIO (expectRight "Memory.supersedeWithContext" superseded))       void (drainTimers supersedeRuntime)       supersedeScene <--        getScenesByScope supersedeScope >>= liftIO . expectOneScene "after superseding"+        getScenesByScope testSpace supersedeScope >>= liftIO . expectOneScene "after superseding"        -- Merge, in a second scope.       recordForgetFixture loserId mergeScope loserContent now       recordForgetFixture winnerId mergeScope winnerContent now       void (drainTimers mergeRuntime)-      merged <- Memory.merge loserId winnerId-      void (liftIO (expectRight "Memory.merge" merged))+      merged <- Memory.mergeWithContext testContext loserId winnerId+      void (liftIO (expectRight "Memory.mergeWithContext" merged))       void (drainTimers mergeRuntime)-      mergeScene <- getScenesByScope mergeScope >>= liftIO . expectOneScene "after merging"+      mergeScene <- getScenesByScope testSpace mergeScope >>= liftIO . expectOneScene "after merging"        pure (supersedeScene, mergeScene)   case result of@@ -591,7 +731,7 @@ -- due. Bounded on purpose: a timer that rescheduled itself would otherwise spin -- here forever, and a hung test is worse than a failed one. drainTimers ::-  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>   DistillRuntime ->   Eff es Int drainTimers rt = go (50 :: Int) 0@@ -603,6 +743,7 @@           claimed <-             runKiokuTimerWorkerOnce               Nothing+              testContextProvider               rt               (scopedScanCandidates 5)               (addUTCTime 3600 realNow)@@ -630,11 +771,26 @@   Text ->   UTCTime ->   Eff es ()-recordForgetFixture memoryId scope content now = do+recordForgetFixture = recordFixtureIn testContext++-- | The same fixture in a named space, for the two-space cases.+recordFixtureIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  MemoryId ->+  MemoryScope ->+  Text ->+  UTCTime ->+  Eff es ()+recordFixtureIn context memoryId scope content now = do   recorded <--    Memory.record+    Memory.recordWithContext+      context       RecordMemoryData-        { memoryId,+        { memorySpaceId = memoryContextSpace context,+          actorPrincipal = memoryContextRecordedActor context,+          ownerPrincipal = Nothing,+          memoryId,           agentId = "test-agent",           sessionId = Nothing,           scope,@@ -646,7 +802,7 @@           supersedes = Nothing,           recordedAt = now         }-  void (liftIO (expectRight "Memory.record" recorded))+  void (liftIO (expectRight "Memory.recordWithContext" recorded))  -- | Pure tests over the 'Validatable' instances; no database, no LLM. validationTests :: TestTree@@ -777,16 +933,16 @@   result <-     runAppIO env do       writeFixtureSession sid scope now-      distillResult <- distillSessionL1 RespectWatermark runtime (scopedScanCandidates 5) sid-      summary <- liftIO (expectDistilled "distillSessionL1" distillResult)-      sceneResult <- regenerateScene runtime scope+      distillResult <- distillSessionL1 testContext RespectWatermark runtime (scopedScanCandidates 5) sid+      summary <- liftIO (expectDistilled "distillSessionL1 testContext" distillResult)+      sceneResult <- regenerateScene runtime testSpace scope       _scene <- liftIO (expectRight "regenerateScene" sceneResult)-      personaResult <- regeneratePersona runtime scope+      personaResult <- regeneratePersona runtime testSpace scope       _persona <- liftIO (expectRight "regeneratePersona" personaResult)       memories <- loadMemoryStatuses scope       mergeAuditCount <- loadMergeAuditCount scope-      scenes <- getScenesByScope scope-      persona <- getPersonaByScope scope+      scenes <- getScenesByScope testSpace scope+      persona <- getPersonaByScope testSpace scope       loserEvents <- loadLoserEvents memories       pure DistillResult {summary, memories, scenes, persona, mergeAuditCount, loserEvents}   case result of@@ -817,11 +973,11 @@   result <-     runAppIO env do       writeFixtureSession sid fixtureScope now-      first <- distillSessionL1 RespectWatermark runtime (scopedScanCandidates 5) sid+      first <- distillSessionL1 testContext RespectWatermark runtime (scopedScanCandidates 5) sid       summary1 <- liftIO (expectDistilled "first pass" first)       memories1 <- loadMemoryStatuses fixtureScope       audits1 <- loadAuditCount fixtureScope-      second <- distillSessionL1 IgnoreWatermark runtime (scopedScanCandidates 5) sid+      second <- distillSessionL1 testContext IgnoreWatermark runtime (scopedScanCandidates 5) sid       summary2 <- liftIO (expectDistilled "second pass" second)       memories2 <- loadMemoryStatuses fixtureScope       audits2 <- loadAuditCount fixtureScope@@ -851,7 +1007,7 @@   result <-     runAppIO env do       writeFixtureSession sid fixtureScope now-      distilled <- distillSessionL1 RespectWatermark runtime (scopedScanCandidates 5) sid+      distilled <- distillSessionL1 testContext RespectWatermark runtime (scopedScanCandidates 5) sid       memories <- loadMemoryStatuses fixtureScope       audits <- loadAuditCount fixtureScope       pure (distilled, memories, audits)@@ -883,11 +1039,11 @@     runAppIO env do       writeFixtureSession sid fixtureScope now       seedMemory existingId sid fixtureScope "The user likes concise answers." now-      first <- distillSessionL1 RespectWatermark runtime (scopedScanCandidates 5) sid+      first <- distillSessionL1 testContext RespectWatermark runtime (scopedScanCandidates 5) sid       summary1 <- liftIO (expectDistilled "first pass" first)       memories1 <- loadMemoryStatuses fixtureScope       audits1 <- loadAuditRows fixtureScope-      second <- distillSessionL1 IgnoreWatermark runtime (scopedScanCandidates 5) sid+      second <- distillSessionL1 testContext IgnoreWatermark runtime (scopedScanCandidates 5) sid       void (liftIO (expectDistilled "second pass" second))       memories2 <- loadMemoryStatuses fixtureScope       audits2 <- loadAuditRows fixtureScope@@ -921,11 +1077,11 @@   result <-     runAppIO env do       writeRunningFixtureSession sid fixtureScope now-      first <- distillSessionL1 RespectWatermark working (scopedScanCandidates 5) sid+      first <- distillSessionL1 testContext RespectWatermark working (scopedScanCandidates 5) sid       void (liftIO (expectDistilled "first pass" first))-      skipped <- distillSessionL1 RespectWatermark exploding (scopedScanCandidates 5) sid+      skipped <- distillSessionL1 testContext RespectWatermark exploding (scopedScanCandidates 5) sid       recordFixtureTurn sid now 4 "Never deploy on a Friday."-      afterNewTurn <- distillSessionL1 RespectWatermark exploding (scopedScanCandidates 5) sid+      afterNewTurn <- distillSessionL1 testContext RespectWatermark exploding (scopedScanCandidates 5) sid       pure (skipped, afterNewTurn)   case result of     Left storeErr -> assertFailure ("store error: " <> show storeErr)@@ -1009,6 +1165,7 @@       seedCandidateWindow recallSid recallScope now recallDuplicateId       recallOutcome <-         distillSessionL1+          testContext           RespectWatermark           (runtimeFor recallDuplicateId)           (recallCandidates dummyEmbeddingModel capability 8)@@ -1020,6 +1177,7 @@       seedCandidateWindow scanSid scanScope now scanDuplicateId       scanOutcome <-         distillSessionL1+          testContext           RespectWatermark           (runtimeFor scanDuplicateId)           (scopedScanCandidates 5)@@ -1045,6 +1203,80 @@           (\row -> row.memoryId == idText scanDuplicateId && row.status == "active")           scanMemories +-- | The recall finder searches the session's own scope, not its whole namespace.+--+-- A globally-scoped session used to draw merge candidates from every entity scope beside it,+-- because 'recallCandidates' mapped the scope through @legacyRecallTarget@ and a global scope+-- means /namespace-wide/ to recall. The consolidator could then merge an atom into a memory+-- belonging to a sibling entity — rewriting content that feeds a scene the session has nothing to+-- do with, and one that 'scopedScanCandidates' would never have offered.+--+-- The control matters as much as the assertion: the sibling is proven /findable/ by a+-- namespace-wide recall over the same text, so what excludes it from the pass is the target and+-- not a missing full-text match.+testRecallCandidateBreadth :: Assertion+testRecallCandidateBreadth = withDistillEnv \env -> do+  base <- replayRuntime+  sid <- genSessionId+  siblingId <- genMemoryId+  now <- getCurrentTime+  let namespace = Namespace "rei_breadth"+      sessionScope = ScopeGlobal namespace+      siblingScope = ScopeEntity namespace (ScopeKind "intention") "intention_sibling"+      -- Identical to the atom the extractor produces, so nothing but the scope predicate can+      -- keep it out of the candidate set.+      siblingContent = "The user prefers concise answers."+      runtime =+        base+          { runExtract = replayProgram singleAtomExtractResponse extractProgram,+            runConsolidate = \input ->+              if any (\existing -> unField existing.memoryId == idText siblingId) input.existing+                then replayProgram (mergeTargetsResponse [idText siblingId]) consolidateProgram input+                else replayProgram storeAtomResponse consolidateProgram input+          }+      -- Keyword only, so dummyEmbeddingModel is never called.+      recallWith target =+        case Recall.mkRecallQuery target siblingContent Recall.Keyword 8 of+          Left err -> liftIO (assertFailure ("mkRecallQuery: " <> Text.unpack err))+          Right request -> do+            hits <- Recall.recall dummyEmbeddingModel VectorExtensionUnavailable testContext request+            case hits of+              Left recallErr -> liftIO (assertFailure ("recall: " <> show recallErr))+              Right found -> pure (fmap (\hit -> hit.memory.content) found)+  result <-+    runAppIO env do+      writeRunningFixtureSession sid sessionScope now+      seedMemoryWith siblingId sid siblingScope siblingContent 50 now+      wide <- recallWith (Recall.NamespaceWide namespace)+      exact <- recallWith (Recall.ExactScope sessionScope)+      outcome <-+        distillSessionL1+          testContext+          RespectWatermark+          runtime+          (recallCandidates dummyEmbeddingModel VectorExtensionUnavailable 8)+          sid+      summary <- liftIO (expectDistilled "global-scoped pass" outcome)+      siblingMemories <- loadMemoryStatuses siblingScope+      bucketMemories <- loadMemoryStatuses sessionScope+      pure (wide, exact, summary, siblingMemories, bucketMemories)+  case result of+    Left storeErr -> assertFailure ("store error: " <> show storeErr)+    Right (wide, exact, summary, siblingMemories, bucketMemories) -> do+      -- The control: the sibling is reachable from this namespace, and only namespace-wide.+      wide @?= [siblingContent]+      exact @?= []++      summary.stored @?= 1+      summary.merged @?= 0+      assertBool+        ("the sibling entity scope must be untouched, got: " <> show siblingMemories)+        (all (\row -> row.status == "active") siblingMemories)+      length siblingMemories @?= 1+      assertBool+        ("the global bucket should hold the newly stored atom, got: " <> show bucketMemories)+        (any (\row -> row.status == "active") bucketMemories)+ -- | Six low-priority fillers ahead of the duplicate in a @priority ASC@ scan, -- with content that shares no stem with the extracted atom so full-text search -- passes over them.@@ -1102,9 +1334,13 @@   Eff es () seedMemoryWith memoryId sid scope content priority now = do   recorded <--    Memory.record+    Memory.recordWithContext+      testContext       RecordMemoryData-        { memoryId,+        { memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,+          ownerPrincipal = Nothing,+          memoryId,           agentId = "test-agent",           sessionId = Just sid,           scope,@@ -1116,7 +1352,7 @@           supersedes = Nothing,           recordedAt = now         }-  void (liftIO (expectRight "Memory.record" recorded))+  void (liftIO (expectRight "Memory.recordWithContext" recorded))  -- | Turn @n@ is recorded one minute after turn @n-1@, so the single debounced -- idle timer's @fire_at@ is observably re-armed forward by each turn.@@ -1141,9 +1377,13 @@   Eff es () writeRunningFixtureSession sid scope now = do   startResult <--    Session.start+    Session.startWithContext+      testContext       StartSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,+          ownerPrincipal = Nothing,           agentId = "test-agent",           focus = "style preference capture",           scope,@@ -1153,7 +1393,7 @@           delegationDepth = 0,           startedAt = now         }-  void (liftIO (expectRight "Session.start" startResult))+  void (liftIO (expectRight "Session.startWithContext" startResult))   traverse_ (uncurry (recordFixtureTurn sid now)) fixtureTurns  recordFixtureTurn ::@@ -1165,9 +1405,12 @@   Eff es () recordFixtureTurn sid now turnIndex content = do   turnResult <--    Session.recordTurn+    Session.recordTurnWithContext+      testContext       RecordTurnData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,           turnId = idText sid <> "-turn-" <> Text.pack (show turnIndex),           turnIndex,           role = "user",@@ -1177,7 +1420,7 @@           outputTokens = Nothing,           recordedAt = turnRecordedAt now turnIndex         }-  void (liftIO (expectRight "Session.recordTurn" turnResult))+  void (liftIO (expectRight "Session.recordTurnWithContext" turnResult))  writeFixtureSession ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>@@ -1188,14 +1431,17 @@ writeFixtureSession sid scope now = do   writeRunningFixtureSession sid scope now   completeResult <--    Session.complete+    Session.completeWithContext+      testContext       CompleteSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,           completedAt = turnRecordedAt now (length fixtureTurns),           modelUsed = Just "test-model",           summary = Just "Captured style preferences."         }-  void (liftIO (expectRight "Session.complete" completeResult))+  void (liftIO (expectRight "Session.completeWithContext" completeResult))  replayRuntime :: IO DistillRuntime replayRuntime = do@@ -1499,7 +1745,7 @@   preparable     """     SELECT memory_id, content, status-    FROM kioku_memories+    FROM kioku.memories     WHERE namespace = $1       AND ((scope_kind = $2 AND scope_ref = $3)            OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))@@ -1513,7 +1759,7 @@   preparable     """     SELECT count(*)-    FROM kioku_consolidation_decisions+    FROM kioku.consolidation_decisions     WHERE namespace = $1       AND ((scope_kind = $2 AND scope_ref = $3)            OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))@@ -1526,7 +1772,7 @@   preparable     """     SELECT decision, target_ids::text, result_memory_id-    FROM kioku_consolidation_decisions+    FROM kioku.consolidation_decisions     WHERE namespace = $1       AND ((scope_kind = $2 AND scope_ref = $3)            OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))@@ -1547,7 +1793,7 @@   preparable     """     SELECT count(*)-    FROM kioku_consolidation_decisions+    FROM kioku.consolidation_decisions     WHERE namespace = $1       AND ((scope_kind = $2 AND scope_ref = $3)            OR ($2 IS NULL AND scope_kind IS NULL AND $3 IS NULL AND scope_ref IS NULL))
test/Kioku/EmbeddingWorkerSpec.hs view
@@ -7,6 +7,7 @@  import Baikai.Embedding (EmbeddingModel) import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap import Data.HashMap.Strict qualified as HashMap import Data.Set qualified as Set import Data.Vector qualified as Vector@@ -17,6 +18,15 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.Stream qualified as Stream+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryAccessDenial (..),+    MemoryContextProvider (..),+    MemoryPermission (..),+    MemorySpaceId,+    memoryContextRecordedActor,+    memoryContextSpace,+  ) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Api.Types (Confidence (..), MemoryType (..)) import Kioku.App (AppEffects, AppEnv, runAppIO, withNoopAppEnv)@@ -25,7 +35,9 @@ import Kioku.Memory.Domain (RecordMemoryData (..)) import Kioku.Memory.Embedding (EmbedError (..), EmbeddingConfig (..), toEmbeddingModel) import Kioku.Memory.Embedding.Worker-  ( EmbeddingWorkerEnv (..),+  ( EmbeddingBackfillScope (..),+    EmbeddingWorkerEnv (..),+    backfillMissingEmbeddings,     embeddingHandler,     shouldSkipEmbedding,   )@@ -33,6 +45,7 @@ import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase) import Kioku.Prelude import Kioku.Recall.Capability (VectorCapability (..), detectVectorCapability)+import Kioku.SpaceFixtures (otherContext, otherSpace, testContext, testContextProvider, testSpace) import Kioku.Worker.Failure (embeddingRetryDelay, isTransientStoreError) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store)@@ -62,7 +75,10 @@       testCase "provider failure acks retry" testProviderFailureRetries,       testCase "undecodable payload acks dead-letter" testUndecodablePayloadDeadLetters,       testCase "successful embedding acks ok and stores the vector" testSuccessStoresEmbedding,-      testCase "dimension mismatch halts the processor" testDimensionMismatchHalts+      testCase "dimension mismatch halts the processor" testDimensionMismatchHalts,+      testCase "a refused memory space acks dead-letter" testRefusedSpaceDeadLetters,+      testCase "an envelope naming another space acks dead-letter and writes nothing" testForgedSpaceDeadLetters,+      testCase "a one-space backfill leaves every other space alone" testBackfillHonorsSpace     ]  -- | Every constructor kiroku documents as retryable is transient; every@@ -110,9 +126,9 @@   withEmbeddingEnv \appEnv -> do     capability <- runOrFail appEnv (detectVectorCapability embeddingDims)     decision <- runOrFail appEnv do-      (_, recorded) <- recordFixtureMemory "corrupt payload memory"+      (_, recorded) <- recordFixtureMemory testContext "corrupt payload memory"       let env = mkTestEnv (failingEmbed EmbedEmpty)-      embeddingHandler capability env (mkIngested (corruptPayload recorded) (Just 0))+      embeddingHandler testContextProvider capability env (mkIngested (corruptPayload recorded) (Just 0))     case decision of       AckDeadLetter (InvalidPayload _) -> pure ()       other -> assertFailure ("expected a dead-letter for an undecodable payload, got: " <> show other)@@ -124,9 +140,9 @@   withVectorEnv "successful embedding" \env capability -> do     (decision, stored) <-       runOrFail env do-        (memoryId, recorded) <- recordFixtureMemory "a memory worth embedding"+        (memoryId, recorded) <- recordFixtureMemory testContext "a memory worth embedding"         let workerEnv = mkTestEnv (\_ -> pure (Right (Vector.replicate embeddingDims 0.1)))-        decision <- embeddingHandler capability workerEnv (mkIngested recorded (Just 0))+        decision <- embeddingHandler testContextProvider capability workerEnv (mkIngested recorded (Just 0))         stored <- loadEmbeddingState (idText memoryId)         pure (decision, stored)     decision @?= AckOk@@ -144,19 +160,93 @@       AckHalt (HaltFatal _) -> pure ()       other -> assertFailure ("expected a fatal halt on a dimension mismatch, got: " <> show other) +-- | A provider that refuses every space, as a host with a real authorization engine would when+-- the worker is not allowed to touch this tenant.+refusingContextProvider :: (Applicative m) => MemoryContextProvider m+refusingContextProvider =+  MemoryContextProvider \space -> pure (Left (MemoryPermissionDenied space MemoryDistill))++-- | A worker that may not act in a space must not quietly retry forever. The refusal is a+-- configuration fact: it goes to the dead-letter table where an operator can see it, and the+-- memory row is never read, let alone written.+testRefusedSpaceDeadLetters :: Assertion+testRefusedSpaceDeadLetters =+  withEmbeddingEnv \appEnv -> do+    capability <- runOrFail appEnv (detectVectorCapability embeddingDims)+    decision <- runOrFail appEnv do+      (_, recorded) <- recordFixtureMemory testContext "a memory in a refused space"+      embeddingHandler+        refusingContextProvider+        capability+        (mkTestEnv (\_ -> pure (Right (Vector.replicate embeddingDims 0.1))))+        (mkIngested recorded (Just 0))+    case decision of+      AckDeadLetter (InvalidPayload _) -> pure ()+      other -> assertFailure ("expected a dead-letter for a refused memory space, got: " <> show other)++-- | The case a partition predicate alone cannot catch.+--+-- The event names 'otherSpace'; the memory it names is in 'testSpace'. Had the state read been+-- scoped by the envelope's space, this would have come back as "no such memory" and acked as a+-- success — a forged envelope silently swallowed. Instead the row's own space is read and+-- compared, so the disagreement is visible and nothing is written.+testForgedSpaceDeadLetters :: Assertion+testForgedSpaceDeadLetters =+  withVectorEnv "forged memory space" \appEnv capability -> do+    (decision, embedded) <- runOrFail appEnv do+      (memoryId, recorded) <- recordFixtureMemory testContext "a memory in its real space"+      verdict <-+        embeddingHandler+          testContextProvider+          capability+          (mkTestEnv (\_ -> pure (Right (Vector.replicate embeddingDims 0.1))))+          (mkIngested (retargetSpace otherSpace recorded) (Just 0))+      stored <- loadEmbeddingState (idText memoryId)+      pure (verdict, stored)+    case decision of+      AckDeadLetter (InvalidPayload _) -> pure ()+      other -> assertFailure ("expected a dead-letter for a forged memory space, got: " <> show other)+    assertBool "the forged envelope must not have embedded the row" (not embedded)++-- | A backfill bounded to one space embeds that space and nothing else.+--+-- Both memories are unembedded when the pass starts, so a scan that ignored the predicate would+-- return two rows and the count alone would give it away; the per-row assertions are what prove+-- the /other/ space's row was not merely counted but genuinely left alone.+testBackfillHonorsSpace :: Assertion+testBackfillHonorsSpace =+  withVectorEnv "one-space backfill" \appEnv capability -> do+    (count, mineEmbedded, theirsEmbedded) <- runOrFail appEnv do+      (mine, _) <- recordFixtureMemory testContext "a memory in the backfilled space"+      (theirs, _) <- recordFixtureMemory otherContext "a memory in the untouched space"+      embedded <-+        backfillMissingEmbeddings+          capability+          (mkTestEnv (\_ -> pure (Right (Vector.replicate embeddingDims 0.1))))+          (BackfillOneSpace testSpace)+      (embedded,,) <$> loadEmbeddingState (idText mine) <*> loadEmbeddingState (idText theirs)+    assertBool ("the backfill embedded nothing (count " <> show count <> ")") (count >= 1)+    assertBool "the backfilled space's memory has no embedding" mineEmbedded+    assertBool "the untouched space's memory was embedded anyway" (not theirsEmbedded)+ -- | Record a memory, then hand back its id and the @MemoryRecorded@ event at -- the head of its stream — a real recorded event, not a hand-built one. recordFixtureMemory ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->   Text ->   Eff es (MemoryId, RecordedEvent)-recordFixtureMemory content = do+recordFixtureMemory context content = do   memoryId <- liftIO genMemoryId   now <- liftIO getCurrentTime   recorded <--    Memory.record+    Memory.recordWithContext+      context       RecordMemoryData-        { memoryId,+        { memorySpaceId = memoryContextSpace context,+          actorPrincipal = memoryContextRecordedActor context,+          ownerPrincipal = Nothing,+          memoryId,           agentId = "test-agent",           sessionId = Nothing,           scope = fixtureScope,@@ -168,7 +258,7 @@           supersedes = Nothing,           recordedAt = now         }-  void (liftIO (expectRight "Memory.record" recorded))+  void (liftIO (expectRight "Memory.recordWithContext" recorded))   events <- readStreamForward (Stream.streamName (memoryStream memoryId)) (StreamVersion 0) 10   case Vector.toList events of     event : _ -> pure (memoryId, event)@@ -178,7 +268,26 @@ -- Spelled out field by field rather than as a record update because @payload@ -- is also an 'Envelope' field, and GHC will not guess which one is meant. corruptPayload :: RecordedEvent -> RecordedEvent-corruptPayload e =+corruptPayload e = withPayload e (Aeson.String "garbage")++-- | Rewrite a recorded event's @memorySpaceId@, leaving everything else — including the memory+-- id — exactly as it was written. This is what a stale or forged envelope looks like.+--+-- The event encoding is a tagged object (@Kioku.Prelude.eventAesonOptions@), so the field lives+-- under @data@ rather than at the top level. An unexpected shape is fatal rather than a no-op:+-- silently returning the event unchanged would make this test pass by testing nothing.+retargetSpace :: MemorySpaceId -> RecordedEvent -> RecordedEvent+retargetSpace space e =+  withPayload e $+    case e.payload of+      Aeson.Object o+        | Just (Aeson.Object d) <- KeyMap.lookup "data" o ->+            Aeson.Object+              (KeyMap.insert "data" (Aeson.Object (KeyMap.insert "memorySpaceId" (Aeson.toJSON space) d)) o)+      other -> error ("retargetSpace: unexpected event payload shape: " <> show other)++withPayload :: RecordedEvent -> Aeson.Value -> RecordedEvent+withPayload e payload =   RecordedEvent     { eventId = e.eventId,       eventType = e.eventType,@@ -186,7 +295,7 @@       globalPosition = e.globalPosition,       originalStreamId = e.originalStreamId,       originalVersion = e.originalVersion,-      payload = Aeson.String "garbage",+      payload,       metadata = e.metadata,       causationId = e.causationId,       correlationId = e.correlationId,@@ -201,8 +310,8 @@   IO AckDecision runHandler appEnv capability embed attemptN =   runOrFail appEnv do-    (_, recorded) <- recordFixtureMemory "a memory to embed"-    embeddingHandler capability (mkTestEnv embed) (mkIngested recorded attemptN)+    (_, recorded) <- recordFixtureMemory testContext "a memory to embed"+    embeddingHandler testContextProvider capability (mkTestEnv embed) (mkIngested recorded attemptN)  mkTestEnv :: (Text -> IO (Either EmbedError (Vector.Vector Double))) -> EmbeddingWorkerEnv mkTestEnv embed =@@ -265,7 +374,7 @@   preparable     """     SELECT embedding IS NOT NULL AND content_hash IS NOT NULL-    FROM kiroku.kioku_memories+    FROM kioku.memories     WHERE memory_id = $1     """     (E.param (E.nonNullable E.text))
test/Kioku/IdempotencySpec.hs view
@@ -32,6 +32,7 @@     StartSessionData (..),   ) import Kioku.Session.EventStream (sessionStream)+import Kioku.SpaceFixtures (testActorPrincipal, testContext, testSpace) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -78,8 +79,8 @@     sid <- liftIO genSessionId     now <- liftIO getCurrentTime     let cmd = startData sid now-    void (expectRight "first start" =<< Session.start cmd)-    void (expectRight "duplicate start" =<< Session.start cmd)+    void (expectRight "first start" =<< Session.startWithContext testContext cmd)+    void (expectRight "duplicate start" =<< Session.startWithContext testContext cmd)     assertSessionEvents sid 1  testStartConflict :: Assertion@@ -88,9 +89,9 @@     sid <- liftIO genSessionId     now <- liftIO getCurrentTime     let cmd = startData sid now-    void (expectRight "first start" =<< Session.start cmd)+    void (expectRight "first start" =<< Session.startWithContext testContext cmd)     expectConflict "start with a different focus"-      =<< Session.start cmd {focus = "a different focus"}+      =<< Session.startWithContext testContext cmd {focus = "a different focus"}     assertSessionEvents sid 1  testAwaitDuplicate :: Assertion@@ -99,8 +100,8 @@     sid <- startedSession     now <- liftIO getCurrentTime     let cmd = awaitData sid now-    void (expectRight "first awaitInput" =<< Session.awaitInput cmd)-    void (expectRight "duplicate awaitInput" =<< Session.awaitInput cmd)+    void (expectRight "first awaitInput" =<< Session.awaitInputWithContext testContext cmd)+    void (expectRight "duplicate awaitInput" =<< Session.awaitInputWithContext testContext cmd)     assertSessionEvents sid 2  testAwaitConflict :: Assertion@@ -109,9 +110,9 @@     sid <- startedSession     now <- liftIO getCurrentTime     let cmd = awaitData sid now-    void (expectRight "first awaitInput" =<< Session.awaitInput cmd)+    void (expectRight "first awaitInput" =<< Session.awaitInputWithContext testContext cmd)     expectConflict "awaitInput with a different reason"-      =<< Session.awaitInput cmd {reason = "a different reason"}+      =<< Session.awaitInputWithContext testContext cmd {reason = "a different reason"}     assertSessionEvents sid 2  testResumeDuplicate :: Assertion@@ -119,10 +120,10 @@   withApp do     sid <- startedSession     now <- liftIO getCurrentTime-    void (expectRight "awaitInput" =<< Session.awaitInput (awaitData sid now))+    void (expectRight "awaitInput" =<< Session.awaitInputWithContext testContext (awaitData sid now))     let cmd = resumeData sid now "approved"-    void (expectRight "first resume" =<< Session.resume cmd)-    void (expectRight "duplicate resume" =<< Session.resume cmd)+    void (expectRight "first resume" =<< Session.resumeWithContext testContext cmd)+    void (expectRight "duplicate resume" =<< Session.resumeWithContext testContext cmd)     assertSessionEvents sid 3  testResumeConflict :: Assertion@@ -130,12 +131,12 @@   withApp do     sid <- startedSession     now <- liftIO getCurrentTime-    void (expectRight "awaitInput" =<< Session.awaitInput (awaitData sid now))-    void (expectRight "first resume" =<< Session.resume (resumeData sid now "approved"))+    void (expectRight "awaitInput" =<< Session.awaitInputWithContext testContext (awaitData sid now))+    void (expectRight "first resume" =<< Session.resumeWithContext testContext (resumeData sid now "approved"))     -- The session is running again; a re-delivery carrying a *different* answer is not this     -- request's own echo.     expectConflict "resume with different input"-      =<< Session.resume (resumeData sid now "rejected")+      =<< Session.resumeWithContext testContext (resumeData sid now "rejected")     assertSessionEvents sid 3  testCompleteDuplicate :: Assertion@@ -144,8 +145,8 @@     sid <- startedSession     now <- liftIO getCurrentTime     let cmd = completeData sid now-    void (expectRight "first complete" =<< Session.complete cmd)-    void (expectRight "duplicate complete" =<< Session.complete cmd)+    void (expectRight "first complete" =<< Session.completeWithContext testContext cmd)+    void (expectRight "duplicate complete" =<< Session.completeWithContext testContext cmd)     assertSessionEvents sid 2  -- | The headline regression: this used to return @Right@ and report success for a session@@ -155,8 +156,8 @@   withApp do     sid <- startedSession     now <- liftIO getCurrentTime-    void (expectRight "failSession" =<< Session.failSession (failData sid now))-    expectConflict "complete after fail" =<< Session.complete (completeData sid now)+    void (expectRight "failSession" =<< Session.failSessionWithContext testContext (failData sid now))+    expectConflict "complete after fail" =<< Session.completeWithContext testContext (completeData sid now)     assertSessionEvents sid 2  testFailAfterComplete :: Assertion@@ -164,8 +165,8 @@   withApp do     sid <- startedSession     now <- liftIO getCurrentTime-    void (expectRight "complete" =<< Session.complete (completeData sid now))-    expectConflict "fail after complete" =<< Session.failSession (failData sid now)+    void (expectRight "complete" =<< Session.completeWithContext testContext (completeData sid now))+    expectConflict "fail after complete" =<< Session.failSessionWithContext testContext (failData sid now)     assertSessionEvents sid 2  -- * Memories@@ -176,8 +177,8 @@     mid <- liftIO genMemoryId     now <- liftIO getCurrentTime     let cmd = recordData mid now "the original content"-    void (expectRightM "first record" =<< Memory.record cmd)-    void (expectRightM "duplicate record" =<< Memory.record cmd)+    void (expectRightM "first record" =<< Memory.recordWithContext testContext cmd)+    void (expectRightM "duplicate record" =<< Memory.recordWithContext testContext cmd)     assertMemoryEvents mid 1  -- | @recordedAt@ must not participate in conflict detection. Distillation depends on this:@@ -191,11 +192,11 @@     mid <- liftIO genMemoryId     firstAt <- liftIO getCurrentTime     let content = "identical content, later clock"-    void (expectRightM "first record" =<< Memory.record (recordData mid firstAt content))+    void (expectRightM "first record" =<< Memory.recordWithContext testContext (recordData mid firstAt content))     laterAt <- liftIO getCurrentTime-    void (expectRightM "retry with a fresh clock" =<< Memory.record (recordData mid laterAt content))+    void (expectRightM "retry with a fresh clock" =<< Memory.recordWithContext testContext (recordData mid laterAt content))     assertMemoryEvents mid 1-    lookedUp <- Memory.getMemoryRowById mid+    lookedUp <- Memory.getMemoryRowById testSpace mid     liftIO case lookedUp of       Left err -> assertFailure ("lookup: " <> show err)       Right Nothing -> assertFailure "the memory row vanished"@@ -206,9 +207,9 @@   withApp do     mid <- liftIO genMemoryId     now <- liftIO getCurrentTime-    void (expectRightM "first record" =<< Memory.record (recordData mid now "the original content"))+    void (expectRightM "first record" =<< Memory.recordWithContext testContext (recordData mid now "the original content"))     expectConflictM "record with different content"-      =<< Memory.record (recordData mid now "something else entirely")+      =<< Memory.recordWithContext testContext (recordData mid now "something else entirely")     assertMemoryEvents mid 1  testSupersedeDuplicate :: Assertion@@ -217,9 +218,9 @@     loser <- recordedMemory "loser"     winner <- recordedMemory "winner"     now <- liftIO getCurrentTime-    let cmd = SupersedeMemoryData {memoryId = loser, supersededBy = winner, supersededAt = now}-    void (expectRightM "first supersede" =<< Memory.supersede cmd)-    void (expectRightM "duplicate supersede" =<< Memory.supersede cmd)+    let cmd = SupersedeMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = loser, supersededBy = winner, supersededAt = now}+    void (expectRightM "first supersede" =<< Memory.supersedeWithContext testContext cmd)+    void (expectRightM "duplicate supersede" =<< Memory.supersedeWithContext testContext cmd)     assertMemoryEvents loser 2  -- | The other headline regression: supersede by X, then by Y, used to report success for Y@@ -233,9 +234,9 @@     now <- liftIO getCurrentTime     void $       expectRightM "supersede by X"-        =<< Memory.supersede SupersedeMemoryData {memoryId = loser, supersededBy = winnerX, supersededAt = now}+        =<< Memory.supersedeWithContext testContext SupersedeMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = loser, supersededBy = winnerX, supersededAt = now}     expectConflictM "supersede by Y after X"-      =<< Memory.supersede SupersedeMemoryData {memoryId = loser, supersededBy = winnerY, supersededAt = now}+      =<< Memory.supersedeWithContext testContext SupersedeMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = loser, supersededBy = winnerY, supersededAt = now}     assertMemoryEvents loser 2  testArchiveAfterSupersede :: Assertion@@ -246,9 +247,9 @@     now <- liftIO getCurrentTime     void $       expectRightM "supersede"-        =<< Memory.supersede SupersedeMemoryData {memoryId = loser, supersededBy = winner, supersededAt = now}+        =<< Memory.supersedeWithContext testContext SupersedeMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = loser, supersededBy = winner, supersededAt = now}     expectConflictM "archive after supersede"-      =<< Memory.archive ArchiveMemoryData {memoryId = loser, archivedAt = now}+      =<< Memory.archiveWithContext testContext ArchiveMemoryData {memorySpaceId = testSpace, actorPrincipal = testActorPrincipal, memoryId = loser, archivedAt = now}     assertMemoryEvents loser 2  testMergeDuplicate :: Assertion@@ -256,8 +257,8 @@   withApp do     loser <- recordedMemory "loser"     winner <- recordedMemory "winner"-    void (expectRightM "first merge" =<< Memory.merge loser winner)-    void (expectRightM "duplicate merge" =<< Memory.merge loser winner)+    void (expectRightM "first merge" =<< Memory.mergeWithContext testContext loser winner)+    void (expectRightM "duplicate merge" =<< Memory.mergeWithContext testContext loser winner)     assertMemoryEvents loser 2  testMergeConflict :: Assertion@@ -266,8 +267,8 @@     loser <- recordedMemory "loser"     winnerX <- recordedMemory "winner x"     winnerY <- recordedMemory "winner y"-    void (expectRightM "merge into X" =<< Memory.merge loser winnerX)-    expectConflictM "merge into Y after X" =<< Memory.merge loser winnerY+    void (expectRightM "merge into X" =<< Memory.mergeWithContext testContext loser winnerX)+    expectConflictM "merge into Y after X" =<< Memory.mergeWithContext testContext loser winnerY     assertMemoryEvents loser 2  -- * Fixtures@@ -276,6 +277,9 @@ startData sid startedAt =   StartSessionData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,+      ownerPrincipal = Nothing,       agentId = "test-agent",       focus = "idempotency",       scope = testScope,@@ -290,6 +294,8 @@ awaitData sid awaitedAt =   AwaitInputData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,       reason = "approval",       correlationKey = Just "k1",       deadline = Nothing,@@ -300,6 +306,8 @@ resumeData sid resumedAt input =   ResumeSessionData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,       correlationKey = Just "k1",       force = False,       input,@@ -310,6 +318,8 @@ completeData sid completedAt =   CompleteSessionData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,       completedAt,       modelUsed = Just "test-model",       summary = Just "done"@@ -319,6 +329,8 @@ failData sid failedAt =   FailSessionData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,       failedAt,       errorMessage = "boom"     }@@ -327,6 +339,9 @@ recordData mid recordedAt content =   RecordMemoryData     { memoryId = mid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,+      ownerPrincipal = Nothing,       agentId = "test-agent",       sessionId = Nothing,       scope = testScope,@@ -345,7 +360,7 @@ startedSession = do   sid <- liftIO genSessionId   now <- liftIO getCurrentTime-  void (expectRight "Session.start" =<< Session.start (startData sid now))+  void (expectRight "Session.startWithContext" =<< Session.startWithContext testContext (startData sid now))   pure sid  recordedMemory ::@@ -355,7 +370,7 @@ recordedMemory content = do   mid <- liftIO genMemoryId   now <- liftIO getCurrentTime-  void (expectRightM "Memory.record" =<< Memory.record (recordData mid now content))+  void (expectRightM "Memory.recordWithContext" =<< Memory.recordWithContext testContext (recordData mid now content))   pure mid  -- * Assertions
+ test/Kioku/MemorySpaceSpec.hs view
@@ -0,0 +1,461 @@+{-# OPTIONS_GHC -Wno-deprecations #-}++-- | The isolation boundary itself: what a caller authorized for one memory space can and+-- cannot do to another one's data.+--+-- Everything here drives the real write path against a real database, because the property+-- being tested is not "the function returns Left" but "no event was appended". A guard that+-- rejects the command and an aggregate that quietly accepted it look identical if you only+-- inspect the return value.+--+-- The module deliberately calls the deprecated compatibility wrappers, so it turns their+-- warning off. That is the point of those tests: the wrappers still exist for one release, and+-- what has to be proved about them is that they cannot reach anything outside the legacy space.+module Kioku.MemorySpaceSpec (tests) where++import Data.Set qualified as Set+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, (:>))+import Effectful.Error.Static (Error)+import Keiro.Stream qualified as Stream+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryPermission (..),+    MemorySpaceId,+    RecordedPrincipal,+    legacyMemorySpaceId,+    memoryContextRecordedActor,+    memoryContextSpace,+  )+import Kioku.Api.Access.Internal qualified as Internal+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Kioku.Api.Types (Confidence (..), MemoryType (..))+import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)+import Kioku.Distill.L1 (L1Error (..), L1RunMode (..), distillSessionL1, scopedScanCandidates)+import Kioku.Distill.Runtime (DistillRuntime (..), newDistillRuntime)+import Kioku.Id (MemoryId, SessionId, genMemoryId, genSessionId, idText)+import Kioku.Memory qualified as Memory+import Kioku.Memory.Domain (ArchiveMemoryData (..), MemoryEvent (..), MemoryRecordedData (..), RecordMemoryData (..))+import Kioku.Memory.EventStream (memoryStream, parseMemoryEvent)+import Kioku.Memory.ReadModel (MemoryRow (..))+import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)+import Kioku.Prelude+import Kioku.Session qualified as Session+import Kioku.Session.Domain (RecordTurnData (..), SessionEvent (..), StartSessionData (..))+import Kioku.Session.EventStream (parseSessionEvent, sessionStream)+import Kioku.SpaceFixtures+  ( legacyContext,+    otherActorPrincipal,+    otherContext,+    otherSpace,+    testActor,+    testActorPrincipal,+    testContext,+    testSpace,+  )+import Kiroku.Store.Connection (defaultConnectionSettings)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Read (readStreamForward)+import Kiroku.Store.Types (RecordedEvent (..), StreamVersion (..))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertEqual, assertFailure, testCase)++tests :: TestTree+tests =+  testGroup+    "Memory space isolation"+    [ testGroup+        "a command from another space cannot see, let alone change, the row"+        [ testCase "a memory cannot be archived from another space" testCrossSpaceArchiveRejected,+          testCase "a turn cannot be appended from another space" testCrossSpaceTurnRejected,+          testCase "an id in another space is indistinguishable from one that does not exist" testCrossSpaceIsNotAnOracle+        ],+      testGroup+        "the context and the command must agree"+        [ testCase "a payload naming another space is refused" testPayloadSpaceMismatch,+          testCase "a payload naming another principal is refused" testPayloadActorMismatch,+          testCase "a read-only context cannot record" testReadContextCannotRecord,+          testCase "a context without distill cannot distill" testContextWithoutDistill+        ],+      testGroup+        "the deprecated wrappers reach only the legacy space"+        [ testCase "record refuses a non-legacy payload" testWrapperRefusesNonLegacy,+          testCase "record accepts a legacy payload" testWrapperAcceptsLegacy,+          testCase "the wrapper cannot mutate another space's memory" testWrapperCannotTouchOtherSpace+        ],+      testCase "the same scope in two spaces is two independent memories" testSameScopeTwoSpaces+    ]++-- | The load-bearing case. A caller legitimately authorized for @space_other@ presents the id+-- of a memory that lives in @space_test@.+--+-- The refusal now arrives before the aggregate is reached at all: the read-model precheck is+-- scoped to the caller's own space, so the row is simply not there and the write fails with+-- 'Memory.MemoryNotFound'. The aggregate's guard is still behind it and would refuse the+-- command too — see 'testPayloadSpaceMismatch' for the context check and the plan's Decision+-- Log for why the guard lives in the state machine — but nothing gets that far.+--+-- The proof is unchanged: the memory's event stream still contains exactly one event and the+-- row is still active.+testCrossSpaceArchiveRejected :: Assertion+testCrossSpaceArchiveRejected =+  withApp do+    mid <- recordFixture testContext "cross-space archive"+    now <- liftIO getCurrentTime+    result <-+      Memory.archiveWithContext+        otherContext+        ArchiveMemoryData+          { memoryId = mid,+            memorySpaceId = otherSpace,+            actorPrincipal = otherActorPrincipal,+            archivedAt = now+          }+    liftIO case result of+      Left Memory.MemoryNotFound -> pure ()+      other -> assertFailure ("expected the archive to be refused, got " <> show other)+    events <- readMemoryEvents mid+    row <- getMemory mid+    liftIO do+      assertEqual "no event was appended" ["MemoryRecorded"] (memoryEventName <$> events)+      assertEqual "the memory is still active" "active" row.status++-- | The residual this plan closed.+--+-- Before the read models carried a memory space, the write-path precheck compared against a+-- row it could see whatever space that row was in, so an idempotent answer told a caller in+-- another space that the id existed and whether it was still active. Now the two cases are+-- byte-identical, which is the only way \"you may not look here\" and \"there is nothing here\"+-- can stay indistinguishable.+testCrossSpaceIsNotAnOracle :: Assertion+testCrossSpaceIsNotAnOracle =+  withApp do+    real <- recordFixture testContext "cross-space oracle"+    absent <- liftIO genMemoryId+    now <- liftIO getCurrentTime+    let archiveFrom mid =+          Memory.archiveWithContext+            otherContext+            ArchiveMemoryData+              { memoryId = mid,+                memorySpaceId = otherSpace,+                actorPrincipal = otherActorPrincipal,+                archivedAt = now+              }+    existsElsewhere <- archiveFrom real+    doesNotExist <- archiveFrom absent+    liftIO $+      assertEqual+        "an id in another space answers exactly as an id that does not exist"+        (show doesNotExist)+        (show existsElsewhere)++testCrossSpaceTurnRejected :: Assertion+testCrossSpaceTurnRejected =+  withApp do+    sid <- startFixture testContext+    now <- liftIO getCurrentTime+    result <-+      Session.recordTurnWithContext+        otherContext+        RecordTurnData+          { sessionId = sid,+            memorySpaceId = otherSpace,+            actorPrincipal = otherActorPrincipal,+            turnId = idText sid <> "-turn-1",+            turnIndex = 0,+            role = "user",+            content = "not yours",+            toolSummary = Nothing,+            promptTokens = Nothing,+            outputTokens = Nothing,+            recordedAt = now+          }+    liftIO case result of+      Left Session.SessionNotFound -> pure ()+      other -> assertFailure ("expected the turn to be refused, got " <> show other)+    events <- readSessionEvents sid+    liftIO $+      assertEqual "no turn was appended" ["SessionStarted"] (sessionEventName <$> events)++-- | Before any of that, the payload has to agree with the decision that authorized it. A+-- mismatch is refused without touching the store at all.+testPayloadSpaceMismatch :: Assertion+testPayloadSpaceMismatch =+  withApp do+    mid <- liftIO genMemoryId+    now <- liftIO getCurrentTime+    result <-+      Memory.recordWithContext+        testContext+        (recordData mid testContext now) {memorySpaceId = otherSpace}+    liftIO case result of+      Left (Memory.MemorySpaceMismatch requested authorized) -> do+        assertEqual "names the requested space" otherSpace requested+        assertEqual "names the authorized space" testSpace authorized+      other -> assertFailure ("expected MemorySpaceMismatch, got " <> show other)+    stored <- Memory.getMemoryRowById testSpace mid+    liftIO case stored of+      Right Nothing -> pure ()+      other -> assertFailure ("nothing should have been written, got " <> show other)++-- | Attribution is not caller-supplied. A context authorized as one principal cannot be used+-- to write an event saying somebody else acted.+testPayloadActorMismatch :: Assertion+testPayloadActorMismatch =+  withApp do+    mid <- liftIO genMemoryId+    now <- liftIO getCurrentTime+    result <-+      Memory.recordWithContext+        testContext+        (recordData mid testContext now) {actorPrincipal = otherActorPrincipal}+    liftIO case result of+      Left (Memory.MemoryActorMismatch claimed authorized) -> do+        assertEqual "names the claimed actor" otherActorPrincipal claimed+        assertEqual "names the authorized actor" testActorPrincipal authorized+      other -> assertFailure ("expected MemoryActorMismatch, got " <> show other)++-- | A context is minted for specific actions. One obtained by checking @read@ must not spend+-- itself on a write.+testReadContextCannotRecord :: Assertion+testReadContextCannotRecord =+  withApp do+    mid <- liftIO genMemoryId+    now <- liftIO getCurrentTime+    let readOnly = narrowContext testSpace [MemoryRead]+    result <- Memory.recordWithContext readOnly (recordData mid readOnly now)+    liftIO case result of+      Left (Memory.MemoryNotPermitted MemoryRecord) -> pure ()+      other -> assertFailure ("expected MemoryNotPermitted MemoryRecord, got " <> show other)++-- | Distillation asks for its own permission, and asks before spending an LLM call rather than+-- after, at the first write.+testContextWithoutDistill :: Assertion+testContextWithoutDistill =+  withApp do+    sid <- startFixture testContext+    runtime <- liftIO newDistillRuntime+    let refuse = runtime {runExtract = \_ -> liftIO (assertFailure "the extractor must not run")}+    result <- distillSessionL1 (narrowContext testSpace [MemoryRecord]) RespectWatermark refuse (scopedScanCandidates 5) sid+    liftIO case result of+      Left (L1NotPermitted MemoryDistill) -> pure ()+      other -> assertFailure ("expected L1NotPermitted MemoryDistill, got " <> show other)++testWrapperRefusesNonLegacy :: Assertion+testWrapperRefusesNonLegacy =+  withApp do+    mid <- liftIO genMemoryId+    now <- liftIO getCurrentTime+    result <- Memory.record (recordData mid testContext now)+    liftIO case result of+      Left (Memory.MemorySpaceMismatch requested authorized) -> do+        assertEqual "names the requested space" testSpace requested+        assertEqual "the wrapper only reaches the legacy space" legacyMemorySpaceId authorized+      other -> assertFailure ("expected MemorySpaceMismatch, got " <> show other)++testWrapperAcceptsLegacy :: Assertion+testWrapperAcceptsLegacy =+  withApp do+    mid <- liftIO genMemoryId+    now <- liftIO getCurrentTime+    result <- Memory.record (recordData mid legacyContext now)+    liftIO case result of+      Right written -> assertEqual "wrote the memory it was asked to" mid written+      other -> assertFailure ("expected the legacy wrapper to write, got " <> show other)+    events <- readMemoryEvents mid+    liftIO case events of+      [MemoryRecorded d] -> assertEqual "in the legacy space" legacyMemorySpaceId d.memorySpaceId+      other -> assertFailure ("expected one MemoryRecorded, got " <> show other)++    -- The projection has to agree with the event, which is what makes an upgraded+    -- single-space deployment keep working: everything it writes through the old names lands+    -- in the one space its backfilled rows are already in, and is visible from there.+    inLegacy <- Memory.getMemoryRowById legacyMemorySpaceId mid+    elsewhere <- Memory.getMemoryRowById testSpace mid+    liftIO do+      assertEqual+        "the projected row is readable from the legacy space"+        (Just (idText mid))+        (either (const Nothing) (fmap (\row -> row.memoryId)) inLegacy)+      assertEqual+        "and from nowhere else"+        Nothing+        (either (const Nothing) (fmap (\row -> row.memoryId)) elsewhere)++-- | The wrapper is confined by its payload, and its payload is confined to the legacy space, so+-- there is no argument it can be given that reaches a memory living anywhere else.+testWrapperCannotTouchOtherSpace :: Assertion+testWrapperCannotTouchOtherSpace =+  withApp do+    mid <- recordFixture testContext "not reachable from the wrapper"+    now <- liftIO getCurrentTime+    -- Named honestly: the only payload the wrapper accepts claims the legacy space, and this+    -- memory is not in it — so the wrapper's own lookup, scoped to the legacy space, does not+    -- find it and the write is refused before the aggregate is consulted.+    result <-+      Memory.archive+        ArchiveMemoryData+          { memoryId = mid,+            memorySpaceId = legacyMemorySpaceId,+            actorPrincipal = Internal.UnattributedPrincipal,+            archivedAt = now+          }+    liftIO case result of+      Left Memory.MemoryNotFound -> pure ()+      other -> assertFailure ("expected the archive to be refused, got " <> show other)+    row <- getMemory mid+    liftIO $ assertEqual "the memory is still active" "active" row.status++-- | Scopes organize; spaces isolate. The same namespace and scope in two spaces are two+-- unrelated memories, and neither write interferes with the other.+testSameScopeTwoSpaces :: Assertion+testSameScopeTwoSpaces =+  withApp do+    mine <- recordFixture testContext "shared scope, my space"+    theirs <- recordFixture otherContext "shared scope, their space"+    mineEvents <- readMemoryEvents mine+    theirsEvents <- readMemoryEvents theirs+    liftIO do+      assertEqual "my memory is in my space" [testSpace] (recordedSpaces mineEvents)+      assertEqual "their memory is in theirs" [otherSpace] (recordedSpaces theirsEvents)+  where+    recordedSpaces events = [d.memorySpaceId | MemoryRecorded d <- events]++-- * Fixtures++recordFixture ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  Text ->+  Eff es MemoryId+recordFixture context content = do+  mid <- liftIO genMemoryId+  now <- liftIO getCurrentTime+  result <- Memory.recordWithContext context (recordData mid context now) {content}+  case result of+    Left err -> liftIO (assertFailure ("Memory.recordWithContext: " <> show err))+    Right written -> pure written++recordData :: MemoryId -> MemoryAccessContext -> UTCTime -> RecordMemoryData+recordData memoryId context recordedAt =+  RecordMemoryData+    { memoryId,+      memorySpaceId = memoryContextSpace context,+      actorPrincipal = memoryContextRecordedActor context,+      ownerPrincipal = Nothing,+      agentId = "test-agent",+      sessionId = Nothing,+      scope = testScope,+      memoryType = MemoryFact,+      content = "a memory",+      priority = 100,+      confidence = HighConfidence,+      tags = Set.empty,+      supersedes = Nothing,+      recordedAt+    }++startFixture ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  Eff es SessionId+startFixture context = do+  sid <- liftIO genSessionId+  now <- liftIO getCurrentTime+  result <-+    Session.startWithContext+      context+      StartSessionData+        { sessionId = sid,+          memorySpaceId = memoryContextSpace context,+          actorPrincipal = memoryContextRecordedActor context,+          ownerPrincipal = Nothing,+          agentId = "test-agent",+          focus = "isolation",+          scope = testScope,+          subjectRef = Nothing,+          previousSessionId = Nothing,+          parentSessionId = Nothing,+          delegationDepth = 0,+          startedAt = now+        }+  case result of+    Left err -> liftIO (assertFailure ("Session.startWithContext: " <> show err))+    Right written -> pure written++-- | A context minted for exactly the listed actions.+--+-- 'Kioku.Api.Access.assumeAuthorizedMemoryContext' grants everything, so it cannot demonstrate+-- that the granted set is consulted at all. Building one through the internal constructor is+-- the only way to express "authorized to read, and nothing else".+narrowContext :: MemorySpaceId -> [MemoryPermission] -> MemoryAccessContext+narrowContext space permissions =+  Internal.MemoryAccessContext+    { Internal.memorySpaceId = space,+      Internal.actor = testActor,+      Internal.grantedPermissions = Set.fromList permissions,+      Internal.decisionToken = Nothing+    }++testScope :: MemoryScope+testScope = ScopeEntity (Namespace "kioku_test") (ScopeKind "space") "isolation"++-- * Store helpers++withApp :: Eff AppEffects a -> IO a+withApp action =+  withKiokuMigratedDatabase \connStr ->+    withNoopAppEnv (defaultConnectionSettings connStr) \env -> do+      result <- runAppIO env action+      case result of+        Left storeErr -> assertFailure ("store error: " <> show storeErr)+        Right value -> pure value++getMemory :: (IOE :> es, Store :> es) => MemoryId -> Eff es MemoryRow+getMemory mid = do+  result <- Memory.getMemoryRowById testSpace mid+  case result of+    Right (Just row) -> pure row+    other -> liftIO (assertFailure ("missing memory row: " <> show other))++readMemoryEvents :: (IOE :> es, Store :> es) => MemoryId -> Eff es [MemoryEvent]+readMemoryEvents mid = do+  recorded <- Vector.toList <$> readStreamForward (Stream.streamName (memoryStream mid)) (StreamVersion 0) 100+  traverse decode recorded+  where+    decode recorded =+      case parseMemoryEvent recorded.payload of+        Left err -> liftIO (assertFailure ("parseMemoryEvent: " <> show err))+        Right event -> pure event++readSessionEvents :: (IOE :> es, Store :> es) => SessionId -> Eff es [SessionEvent]+readSessionEvents sid = do+  recorded <- Vector.toList <$> readStreamForward (Stream.streamName (sessionStream sid)) (StreamVersion 0) 100+  traverse decode recorded+  where+    decode recorded =+      case parseSessionEvent recorded.payload of+        Left err -> liftIO (assertFailure ("parseSessionEvent: " <> show err))+        Right event -> pure event++memoryEventName :: MemoryEvent -> Text+memoryEventName = \case+  MemoryRecorded {} -> "MemoryRecorded"+  MemorySuperseded {} -> "MemorySuperseded"+  MemoryArchived {} -> "MemoryArchived"+  MemoryTagsUpdated {} -> "MemoryTagsUpdated"+  MemoryConfidenceUpdated {} -> "MemoryConfidenceUpdated"+  MemoryMerged {} -> "MemoryMerged"++sessionEventName :: SessionEvent -> Text+sessionEventName = \case+  SessionStarted {} -> "SessionStarted"+  SessionCompleted {} -> "SessionCompleted"+  SessionFailed {} -> "SessionFailed"+  SessionAwaiting {} -> "SessionAwaiting"+  SessionResumed {} -> "SessionResumed"+  InteractiveSessionRecorded {} -> "InteractiveSessionRecorded"+  TurnRecorded {} -> "TurnRecorded"
+ test/Kioku/PortfolioAccessSpec.hs view
@@ -0,0 +1,523 @@+-- | Conformance fixtures for the access contract in "Kioku.Api.Access".+--+-- Kioku takes no dependency on any identity service, so this suite stands in for one. It models+-- the two seams Kioku consumes — a directory that maps an authenticated credential subject to a+-- principal, and a relationship-based authorizer with grants, team-derived access, and a lagging+-- replica — and pins what Kioku expects of any real implementation.+--+-- The point of a model rather than a mock is the replica. Several of the rules below (a+-- just-written grant that a stale read cannot see, a retry that forwards a freshness token) only+-- have observable behaviour if the fake actually has revisions, and those are exactly the rules+-- a naive integration gets wrong.+module Kioku.PortfolioAccessSpec (tests) where++import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Kioku.Api.Access+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..), scopeNamespaceText)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Portfolio access contract"+    [ directAccessTests,+      derivedAccessTests,+      isolationTests,+      failClosedTests,+      freshnessTests+    ]++-- ---------------------------------------------------------------------------+-- Fixtures+-- ---------------------------------------------------------------------------++-- Rendered principal identifiers in the shape a directory produces. Kioku never parses these;+-- they are here so the fixtures exercise the real wire form rather than a placeholder.+personAlice, personBob, teamPlatform, agentWatcher, servicePipeline :: Text+personAlice = "person_01h9xk3v7hf8b9c0d1e2f3g4h5"+personBob = "person_01h9xk3v7hf8b9c0d1e2f3g4h6"+teamPlatform = "team_01h9xk3v7hf8b9c0d1e2f3g4h7"+agentWatcher = "agent_01h9xk3v7hf8b9c0d1e2f3g4h8"+servicePipeline = "service_01h9xk3v7hf8b9c0d1e2f3g4h9"++spaceAlpha, spaceBeta :: MemorySpaceId+spaceAlpha = expectRight (mkMemorySpaceId "space_alpha")+spaceBeta = expectRight (mkMemorySpaceId "space_beta")++-- | The scope both spaces use. Identical on purpose: a scope organizes memory /inside/ a space+-- and contributes nothing to the space's identity as an authorization object.+sharedScope :: MemoryScope+sharedScope = ScopeEntity (Namespace "rei") (ScopeKind "intention") "intention_01h9xk"++-- | The host-supplied translation from Kioku's actions to a schema's names. Kioku ships no+-- default, so every caller — including this suite — writes its own.+binding :: MemoryAuthorizationBinding+binding =+  expectRight+    ( mkMemoryAuthorizationBinding+        (expectRight (mkMemoryObjectType "memory_space"))+        [ bind MemoryRead "kioku:read" "can_read",+          bind MemoryRecord "kioku:record" "can_record",+          bind MemoryDistill "kioku:distill" "can_distill",+          bind MemoryForget "kioku:forget" "can_forget",+          bind MemoryAdmin "kioku:admin" "can_administer"+        ]+    )+  where+    bind permission scope name =+      ( permission,+        MemoryPermissionBinding+          { coarseScope = expectRight (mkMemoryCoarseScope scope),+            objectPermission = expectRight (mkMemoryPermissionName name)+          }+      )++-- | A caller whose credential carries every coarse claim. Passing the coarse gate is necessary+-- and nowhere near sufficient — everything interesting below happens after it.+fullyScoped :: Text -> AuthenticatedSubject+fullyScoped subjectId =+  AuthenticatedSubject+    { subjectId,+      grantedScopes =+        Set.fromList+          [ (memoryPermissionBinding binding permission).coarseScope+          | permission <- allMemoryPermissions+          ]+    }++scopedFor :: Text -> [MemoryPermission] -> AuthenticatedSubject+scopedFor subjectId permissions =+  AuthenticatedSubject+    { subjectId,+      grantedScopes =+        Set.fromList [(memoryPermissionBinding binding permission).coarseScope | permission <- permissions]+    }++-- ---------------------------------------------------------------------------+-- The modelled identity stack+-- ---------------------------------------------------------------------------++-- | Who a grant is written for: a named principal, or every member of a team.+data GrantSubject+  = GrantPrincipal Text+  | GrantTeamMember Text+  deriving stock (Eq, Show)++data Grant = Grant+  { grantObject :: Text,+    grantPermission :: Text,+    grantSubject :: GrantSubject,+    -- | the revision at which this grant became visible+    grantVisibleAt :: Int+  }+  deriving stock (Eq, Show)++data World = World+  { -- | credential subject → principal reference. A principal that has been paused, removed, or+    -- never linked is simply absent, which is all Kioku is entitled to know.+    credentials :: Map Text Text,+    -- | team principal → its members+    teams :: Map Text (Set Text),+    grants :: [Grant],+    -- | what an ordinary read sees: a replica that may lag+    replicaRevision :: Int,+    headRevision :: Int+  }++-- | A world where the replica is fully caught up.+world :: [(Text, Text)] -> [(Text, [Text])] -> [Grant] -> World+world credentialPairs teamPairs grantList =+  World+    { credentials = Map.fromList credentialPairs,+      teams = Map.fromList [(team, Set.fromList members) | (team, members) <- teamPairs],+      grants = grantList,+      replicaRevision = 1,+      headRevision = 1+    }++-- | A grant visible from the start of the world.+grantNow :: MemorySpaceId -> Text -> GrantSubject -> Grant+grantNow spaceId permission subject =+  Grant+    { grantObject = memoryObjectRefText (memorySpaceObjectRef binding spaceId),+      grantPermission = permission,+      grantSubject = subject,+      grantVisibleAt = 1+    }++data Harness = Harness+  { directory :: PrincipalDirectory IO,+    checker :: PermissionChecker IO,+    directoryCalls :: IORef Int,+    checkCalls :: IORef Int,+    worldRef :: IORef World+  }++newHarness :: World -> IO Harness+newHarness initial = do+  worldRef <- newIORef initial+  directoryCalls <- newIORef (0 :: Int)+  checkCalls <- newIORef (0 :: Int)+  let directory = PrincipalDirectory \subjectId -> do+        modifyIORef' directoryCalls (+ 1)+        current <- readIORef worldRef+        pure (either (const Nothing) Just . mkPrincipalRef =<< Map.lookup subjectId current.credentials)++      checker = PermissionChecker \freshness principal permissionName object -> do+        modifyIORef' checkCalls (+ 1)+        current <- readIORef worldRef+        let readAt = case freshness of+              MemoryFreshnessDefault -> current.replicaRevision+              MemoryFreshnessAtLeast token -> max current.replicaRevision (revisionOfToken token)+            visible =+              [ grant+              | grant <- current.grants,+                grant.grantVisibleAt <= readAt,+                grant.grantObject == memoryObjectRefText object,+                grant.grantPermission == memoryPermissionNameText permissionName+              ]+            subject = principalRefText principal+            matches grant = case grant.grantSubject of+              GrantPrincipal named -> named == subject+              GrantTeamMember team ->+                Set.member subject (fromMaybe Set.empty (Map.lookup team current.teams))+        pure+          MemoryDecision+            { outcome = if any matches visible then MemoryAllowed else MemoryDenied,+              checkedAt = tokenForRevision readAt+            }+  pure Harness {directory, checker, directoryCalls, checkCalls, worldRef}++-- | Write a grant at a new head revision and return the token naming it. The replica is left+-- behind on purpose: this is the situation a caller is in immediately after granting access.+writeGrantAheadOfReplica :: Harness -> MemorySpaceId -> Text -> GrantSubject -> IO MemoryDecisionToken+writeGrantAheadOfReplica harness spaceId permission subject =+  atomicModifyIORef' harness.worldRef \current ->+    let next = current.headRevision + 1+        grant =+          Grant+            { grantObject = memoryObjectRefText (memorySpaceObjectRef binding spaceId),+              grantPermission = permission,+              grantSubject = subject,+              grantVisibleAt = next+            }+     in (current {grants = grant : current.grants, headRevision = next}, tokenForRevision next)++-- The token is opaque to Kioku; the model gives it a readable encoding so a test can assert+-- which revision a read landed on.+tokenForRevision :: Int -> MemoryDecisionToken+tokenForRevision revision = expectRight (mkMemoryDecisionToken ("rev-" <> Text.pack (show revision)))++revisionOfToken :: MemoryDecisionToken -> Int+revisionOfToken token =+  case Text.stripPrefix "rev-" (memoryDecisionTokenText token) >>= readInt of+    Just revision -> revision+    Nothing -> error ("unrecognised decision token: " <> Text.unpack (memoryDecisionTokenText token))+  where+    readInt text = case reads (Text.unpack text) of+      [(value, "")] -> Just value+      _ -> Nothing++authorize ::+  Harness ->+  AuthenticatedSubject ->+  MemorySpaceId ->+  NonEmpty MemoryPermission ->+  IO (Either MemoryAccessDenial MemoryAccessContext)+authorize harness = authorizeAt harness MemoryFreshnessDefault++authorizeAt ::+  Harness ->+  MemoryFreshness ->+  AuthenticatedSubject ->+  MemorySpaceId ->+  NonEmpty MemoryPermission ->+  IO (Either MemoryAccessDenial MemoryAccessContext)+authorizeAt harness freshness subject spaceId permissions =+  authorizeMemoryAccess binding harness.directory harness.checker freshness subject spaceId permissions++-- ---------------------------------------------------------------------------+-- Fixtures: access that should be granted+-- ---------------------------------------------------------------------------++directAccessTests :: TestTree+directAccessTests =+  testGroup+    "authorized access"+    [ testCase "a person with a direct grant may read their own space" do+        harness <-+          newHarness+            ( world+                [("sub-alice", personAlice)]+                []+                [grantNow spaceAlpha "can_read" (GrantPrincipal personAlice)]+            )+        result <- authorize harness (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        context <- expectAuthorized result+        memoryContextSpace context @?= spaceAlpha+        memoryContextActor context @?= MemoryActor (expectRight (mkPrincipalRef personAlice))+        assertBool "read granted" (memoryContextAllows MemoryRead context),+      testCase "an agent that owns a space may record into it" do+        harness <-+          newHarness+            ( world+                [("sub-watcher", agentWatcher)]+                []+                [grantNow spaceAlpha "can_record" (GrantPrincipal agentWatcher)]+            )+        result <- authorize harness (fullyScoped "sub-watcher") spaceAlpha (MemoryRecord :| [])+        context <- expectAuthorized result+        memoryContextActor context @?= MemoryActor (expectRight (mkPrincipalRef agentWatcher))+        assertBool "record granted" (memoryContextAllows MemoryRecord context),+      testCase "a service principal is authorized exactly like any other principal" do+        -- Kioku holds no principal-kind vocabulary, so a service must need no special case. If+        -- this test ever required different setup from the person case above, that would be the+        -- symptom of a kind vocabulary leaking in.+        harness <-+          newHarness+            ( world+                [("sub-pipeline", servicePipeline)]+                []+                [grantNow spaceAlpha "can_distill" (GrantPrincipal servicePipeline)]+            )+        result <- authorize harness (fullyScoped "sub-pipeline") spaceAlpha (MemoryDistill :| [])+        context <- expectAuthorized result+        memoryContextActor context @?= MemoryActor (expectRight (mkPrincipalRef servicePipeline)),+      testCase "a context authorizes every action it was minted for" do+        harness <-+          newHarness+            ( world+                [("sub-alice", personAlice)]+                []+                [ grantNow spaceAlpha "can_read" (GrantPrincipal personAlice),+                  grantNow spaceAlpha "can_record" (GrantPrincipal personAlice)+                ]+            )+        result <- authorize harness (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [MemoryRecord])+        context <- expectAuthorized result+        memoryContextPermissions context @?= Set.fromList [MemoryRead, MemoryRecord]+        assertBool "forget withheld" (not (memoryContextAllows MemoryForget context))+        checks <- readIORef harness.checkCalls+        assertEqual "one check per requested action" 2 checks+    ]++derivedAccessTests :: TestTree+derivedAccessTests =+  testGroup+    "relationship-derived access"+    [ testCase "a person reaches a space through team membership" do+        -- The grant names the team, never the person. Kioku stores no membership of its own and+        -- could not compute this answer; it exists entirely on the other side of the seam.+        harness <-+          newHarness+            ( world+                [("sub-bob", personBob)]+                [(teamPlatform, [personBob])]+                [grantNow spaceAlpha "can_read" (GrantTeamMember teamPlatform)]+            )+        result <- authorize harness (fullyScoped "sub-bob") spaceAlpha (MemoryRead :| [])+        context <- expectAuthorized result+        memoryContextActor context @?= MemoryActor (expectRight (mkPrincipalRef personBob)),+      testCase "leaving the team removes the derived access" do+        harness <-+          newHarness+            ( world+                [("sub-bob", personBob)]+                [(teamPlatform, [])]+                [grantNow spaceAlpha "can_read" (GrantTeamMember teamPlatform)]+            )+        result <- authorize harness (fullyScoped "sub-bob") spaceAlpha (MemoryRead :| [])+        result @?= Left (MemoryPermissionDenied spaceAlpha MemoryRead)+    ]++-- ---------------------------------------------------------------------------+-- Fixtures: isolation between spaces+-- ---------------------------------------------------------------------------++isolationTests :: TestTree+isolationTests =+  testGroup+    "memory spaces are isolated"+    [ testCase "a grant on one space never authorizes another" do+        harness <-+          newHarness+            ( world+                [("sub-alice", personAlice)]+                []+                [grantNow spaceAlpha "can_read" (GrantPrincipal personAlice)]+            )+        allowed <- authorize harness (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        _ <- expectAuthorized allowed+        denied <- authorize harness (fullyScoped "sub-alice") spaceBeta (MemoryRead :| [])+        denied @?= Left (MemoryPermissionDenied spaceBeta MemoryRead),+      testCase "the same namespace and scope in two spaces are two different questions" do+        -- Both spaces are asked about under one identical scope. What separates them is the+        -- space alone, which is why a namespace can never be pressed into service as a tenancy+        -- boundary.+        scopeNamespaceText sharedScope @?= "rei"+        assertBool+          "distinct authorization objects"+          ( memoryObjectRefText (memorySpaceObjectRef binding spaceAlpha)+              /= memoryObjectRefText (memorySpaceObjectRef binding spaceBeta)+          ),+      testCase "a denial names the space that was refused" do+        harness <- newHarness (world [("sub-alice", personAlice)] [] [])+        result <- authorize harness (fullyScoped "sub-alice") spaceBeta (MemoryForget :| [])+        result @?= Left (MemoryPermissionDenied spaceBeta MemoryForget)+    ]++-- ---------------------------------------------------------------------------+-- Fixtures: the ways access is refused+-- ---------------------------------------------------------------------------++failClosedTests :: TestTree+failClosedTests =+  testGroup+    "refusals stay distinct and fail closed"+    [ testCase "a missing coarse claim is refused before anyone is looked up" do+        harness <-+          newHarness+            ( world+                [("sub-alice", personAlice)]+                []+                [grantNow spaceAlpha "can_forget" (GrantPrincipal personAlice)]+            )+        -- The credential carries kioku:read but not kioku:forget, even though the grant exists.+        result <- authorize harness (scopedFor "sub-alice" [MemoryRead]) spaceAlpha (MemoryForget :| [])+        result+          @?= Left+            (MemoryCoarseScopeMissing (memoryPermissionBinding binding MemoryForget).coarseScope)+        lookups <- readIORef harness.directoryCalls+        checks <- readIORef harness.checkCalls+        assertEqual "directory not consulted" 0 lookups+        assertEqual "authorizer not consulted" 0 checks,+      testCase "a paused agent's credential resolves to nothing and is refused" do+        -- Agent lifecycle belongs to the directory. Kioku holds no paused/active state, so a+        -- paused agent reaches it as a subject that no longer resolves — and that must fail+        -- closed rather than fall through to an unauthenticated path.+        harness <- newHarness (world [] [] [grantNow spaceAlpha "can_record" (GrantPrincipal agentWatcher)])+        result <- authorize harness (fullyScoped "sub-watcher") spaceAlpha (MemoryRecord :| [])+        result @?= Left (MemoryPrincipalUnresolved "sub-watcher")+        checks <- readIORef harness.checkCalls+        assertEqual "no permission check on an unresolved subject" 0 checks,+      testCase "an unresolved subject is not confused with a denial" do+        harness <- newHarness (world [] [] [])+        unresolved <- authorize harness (fullyScoped "sub-nobody") spaceAlpha (MemoryRead :| [])+        harnessWithPrincipal <- newHarness (world [("sub-alice", personAlice)] [] [])+        denied <- authorize harnessWithPrincipal (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        assertBool "different refusals" (unresolved /= denied),+      testCase "a conditional decision is a refusal, not an allow" do+        -- A relationship exists but is gated on context this request did not supply. Promoting+        -- that to an allow is how a time-limited grant silently becomes permanent.+        conditionalHarness <- newConditionalHarness ["within_autonomy"]+        result <-+          authorizeMemoryAccess+            binding+            conditionalHarness.directory+            conditionalHarness.checker+            MemoryFreshnessDefault+            (fullyScoped "sub-alice")+            spaceAlpha+            (MemoryRead :| [])+        result @?= Left (MemoryDecisionConditional spaceAlpha MemoryRead ["within_autonomy"]),+      testCase "one denied action refuses the whole request" do+        harness <-+          newHarness+            ( world+                [("sub-alice", personAlice)]+                []+                [grantNow spaceAlpha "can_read" (GrantPrincipal personAlice)]+            )+        result <- authorize harness (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [MemoryForget])+        result @?= Left (MemoryPermissionDenied spaceAlpha MemoryForget)+    ]++-- | A harness whose authorizer always answers conditionally.+newConditionalHarness :: [Text] -> IO Harness+newConditionalHarness obligations = do+  base <- newHarness (world [("sub-alice", personAlice)] [] [])+  pure+    base+      { checker =+          PermissionChecker \_ _ _ _ ->+            pure+              MemoryDecision+                { outcome = MemoryConditional obligations,+                  checkedAt = tokenForRevision 1+                }+      }++-- ---------------------------------------------------------------------------+-- Fixtures: freshness+-- ---------------------------------------------------------------------------++freshnessTests :: TestTree+freshnessTests =+  testGroup+    "freshness and the stale-decision retry"+    [ testCase "a lagging replica denies a just-written grant" do+        harness <- newHarness (world [("sub-alice", personAlice)] [] [])+        _ <- writeGrantAheadOfReplica harness spaceAlpha "can_read" (GrantPrincipal personAlice)+        result <- authorize harness (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        result @?= Left (MemoryPermissionDenied spaceAlpha MemoryRead),+      testCase "retrying with the write's token observes the new grant" do+        harness <- newHarness (world [("sub-alice", personAlice)] [] [])+        token <- writeGrantAheadOfReplica harness spaceAlpha "can_read" (GrantPrincipal personAlice)+        stale <- authorize harness (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        stale @?= Left (MemoryPermissionDenied spaceAlpha MemoryRead)+        retried <-+          authorizeAt harness (atLeastAsFresh token) (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        context <- expectAuthorized retried+        assertBool "read granted after retry" (memoryContextAllows MemoryRead context),+      testCase "the minted context carries the revision it was decided at" do+        harness <- newHarness (world [("sub-alice", personAlice)] [] [])+        token <- writeGrantAheadOfReplica harness spaceAlpha "can_read" (GrantPrincipal personAlice)+        retried <-+          authorizeAt harness (atLeastAsFresh token) (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        context <- expectAuthorized retried+        memoryContextDecisionToken context @?= Just token,+      testCase "a follow-up read chains from the context, never falling back to stale" do+        -- This is the forwarding rule EP-2 and later plans depend on: once a caller has observed+        -- a revision, everything downstream of that decision observes it too.+        harness <- newHarness (world [("sub-alice", personAlice)] [] [])+        token <- writeGrantAheadOfReplica harness spaceAlpha "can_read" (GrantPrincipal personAlice)+        _ <- writeGrantAheadOfReplica harness spaceAlpha "can_record" (GrantPrincipal personAlice)+        first <-+          authorizeAt harness (atLeastAsFresh token) (fullyScoped "sub-alice") spaceAlpha (MemoryRead :| [])+        firstContext <- expectAuthorized first+        memoryContextFreshness firstContext @?= atLeastAsFresh token+        second <-+          authorizeAt+            harness+            (memoryContextFreshness firstContext)+            (fullyScoped "sub-alice")+            spaceAlpha+            (MemoryRead :| [])+        _ <- expectAuthorized second+        pure (),+      testCase "an assumed context observed nothing and pins nothing" do+        memoryContextFreshness+          (assumeAuthorizedMemoryContext spaceAlpha (MemoryActor (expectRight (mkPrincipalRef personAlice))))+          @?= MemoryFreshnessDefault+    ]++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++expectAuthorized :: Either MemoryAccessDenial MemoryAccessContext -> IO MemoryAccessContext+expectAuthorized = \case+  Right context -> pure context+  Left denial -> fail ("expected authorization, got: " <> show denial)++expectRight :: Either Text a -> a+expectRight = either (error . Text.unpack) id
test/Kioku/ReadModelReconcileSpec.hs view
@@ -11,6 +11,12 @@ -- 'reconcileReadModelRegistry' is the repair, and this spec walks the whole arc: startup -- registration, a healthy query, a downgraded registry row, the resulting outage, the -- reconcile, and the query working again.+--+-- The relocation of the projections into the @kioku@ schema (migration 0012) leans on the same+-- guard for a second purpose. Nothing in the registry records /where/ a projection physically+-- lives, so the only way to stop a binary from the wrong side of that migration serving traffic+-- is to advance the declared version — memory v2 -> v3, session v4 -> v5, turn v2 -> v3 — and+-- let the check refuse the disagreement in both directions. module Kioku.ReadModelReconcileSpec (tests) where  import Data.Text qualified as Text@@ -19,10 +25,16 @@ import Effectful (Eff, IOE, liftIO, (:>)) import Effectful.Error.Static (Error) import Hasql.Transaction qualified as Tx-import Keiro.ReadModel (ReadModelError (..))+import Keiro.ReadModel+  ( ConsistencyMode (Eventual),+    ReadModel (..),+    ReadModelError (..),+    runQueryWith,+  ) import Kioku.Api.Scope (MemoryScope (..), Namespace (..)) import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)-import Kioku.Id (SessionId, genSessionId)+import Kioku.Id (SessionId, genMemoryId, genSessionId, idText)+import Kioku.Memory qualified as Memory import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase) import Kioku.Prelude import Kioku.ReadModel@@ -33,6 +45,8 @@   ) import Kioku.Session qualified as Session import Kioku.Session.Domain (StartSessionData (..))+import Kioku.Session.ReadModel qualified as Session+import Kioku.SpaceFixtures (testActorPrincipal, testContext, testSpace) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -45,64 +59,112 @@ tests =   testGroup     "ReadModel.Reconcile"-    [ testCase "a stale registry row fails every query closed, then reconciles" testStaleThenReconcile,-      testCase "reconciliation is idempotent" testIdempotent+    [ testCase "the pre-relocation registry fails every query closed, then reconciles" testStaleThenReconcile,+      testCase "reconciliation is idempotent" testIdempotent,+      testCase "a binary declaring a pre-relocation identity fails closed" testOldBinaryFailsClosed     ]  -- | The whole arc. Each step is asserted, including the outage itself — without that -- assertion the test could pass against a build where the guard never fires at all.+--+-- The registry state it starts from is not invented: it is exactly what a database looks like+-- in the instant after migration 0012 commits. The seven projections have moved to the @kioku@+-- schema, the binary declares memory v3 \/ session v5 \/ turn v3, and every registry row still+-- says v2 \/ v4 \/ v2. Keiro records no physical relation name, so this version disagreement is+-- the only thing standing between an unreconciled deployment and SQL aimed at relations that+-- are no longer where the old code thinks they are. testStaleThenReconcile :: Assertion testStaleThenReconcile =   withApp \sid -> do     -- Application startup registered every model at the identity the code declares.-    healthy <- Session.getById sid+    healthy <- Session.getById testSpace sid     liftIO $ assertBool "a fresh database serves session queries" (isRight healthy) -    downgradeSessionByIdTo 2 "kioku-session-v2"+    downgradeToPreRelocationIdentities -    stale <- Session.getById sid+    stale <- Session.getById testSpace sid     liftIO case stale of       Left (ReadModelStaleSchema name expectedVersion foundVersion expectedHash foundHash) -> do         assertEqual "the stale model" "kioku-session-by-id" name-        assertEqual "expected version" 3 expectedVersion-        assertEqual "found version" 2 foundVersion-        assertEqual "expected hash" "kioku-session-v3" expectedHash-        assertEqual "found hash" "kioku-session-v2" foundHash+        assertEqual "expected version" 5 expectedVersion+        assertEqual "found version" 4 foundVersion+        assertEqual "expected hash" "kioku-session-v5" expectedHash+        assertEqual "found hash" "kioku-session-v4" foundHash       other ->         assertFailure-          ("expected the query to fail closed on the stale row, got " <> show (() <$ other))+          ("expected the session query to fail closed on the stale row, got " <> show (() <$ other)) +    -- The memory family moved too, so it must be just as closed. A relocation that bumped only+    -- the session identity would leave memory reads running against a vanished relation.+    -- Any id at all: the guard runs before the query, and on the repaired path finding no row+    -- is a success.+    probeMemoryId <- genMemoryId+    staleMemory <- Memory.getMemoryRowById testSpace probeMemoryId+    liftIO case staleMemory of+      Left (ReadModelStaleSchema name _ _ expectedHash foundHash) -> do+        assertEqual "the stale model" "kioku-memory-by-id" name+        assertEqual "expected hash" "kioku-memory-v3" expectedHash+        assertEqual "found hash" "kioku-memory-v2" foundHash+      other ->+        assertFailure+          ("expected the memory query to fail closed on the stale row, got " <> show (() <$ other))+     outcomes <- reconcileReadModelRegistry     liftIO do       assertEqual-        "the downgraded row was bumped back"-        (Just Reconciled)-        (outcomeFor "kioku-session-by-id" outcomes)-      -- Reconciliation must cover every model the code declares, not just the one this-      -- test downgraded. Startup registered the rest, so they stay current.-      assertEqual         "every declared model was accounted for"         (map (.readModelName) kiokuReadModelSchemas)         (map ((.readModelName) . fst) outcomes)+      -- Every Kioku read model reads one of the three relocated projections, so the relocation+      -- leaves none of them current and reconciliation has to advance all of them.       assertEqual-        "the models that were not downgraded stayed current"+        "every model was advanced"         []-        [ schema.readModelName-        | (schema, outcome) <- outcomes,-          schema.readModelName /= "kioku-session-by-id",-          outcome /= AlreadyCurrent-        ]+        [schema.readModelName | (schema, outcome) <- outcomes, outcome /= Reconciled] -    repaired <- Session.getById sid-    liftIO $ assertBool "the query works again" (isRight repaired)+    repaired <- Session.getById testSpace sid+    liftIO $ assertBool "the session query works again" (isRight repaired)+    repairedMemory <- Memory.getMemoryRowById testSpace probeMemoryId+    liftIO $ assertBool "the memory query works again" (isRight repairedMemory) +-- | The other direction of the same guard, and the one that makes the deployment order safe:+-- against a reconciled registry, code still declaring the pre-relocation identity is refused.+--+-- That is what stops an old binary — one whose SQL still says @kiroku.kioku_sessions@ — from+-- serving traffic after the migration. It fails closed on the registry check before it ever+-- reaches a relation that has moved.+testOldBinaryFailsClosed :: Assertion+testOldBinaryFailsClosed =+  withApp \sid -> do+    _ <- reconcileReadModelRegistry+    result <-+      runQueryWith+        Nothing+        Eventual+        preRelocationSessionByIdReadModel+        Session.SessionByIdQuery {memorySpaceId = testSpace, sessionId = idText sid}+    liftIO case result of+      Left (ReadModelStaleSchema name expectedVersion foundVersion _ _) -> do+        assertEqual "the stale model" "kioku-session-by-id" name+        assertEqual "the old binary's declared version" 4 expectedVersion+        assertEqual "the reconciled row" 5 foundVersion+      other ->+        assertFailure+          ("expected the pre-relocation read model to be refused, got " <> show (() <$ other))++-- | The session read model exactly as the previous release declared it: same name, same query,+-- the identity it carried before the projections moved.+preRelocationSessionByIdReadModel :: ReadModel Session.SessionByIdQuery (Maybe Session.SessionRow)+preRelocationSessionByIdReadModel =+  Session.sessionByIdReadModel {version = 4, shapeHash = "kioku-session-v4"}+ -- | A second pass must write nothing. If it reported 'Reconciled' again, the reconciler -- would be rewriting @last_built_at@ on every @just migrate@ — and, worse, would be lying -- about what it changed. testIdempotent :: Assertion testIdempotent =   withApp \sid -> do-    void (Session.getById sid)+    void (Session.getById testSpace sid)     _ <- reconcileReadModelRegistry     second <- reconcileReadModelRegistry     liftIO $@@ -111,21 +173,25 @@         []         [schema.readModelName | (schema, outcome) <- second, outcome /= AlreadyCurrent] --- | Pin the registry row back to an older identity, exactly as a database that missed the--- v3 bump would have it. The name is unqualified so it resolves through the store's--- @search_path@, precisely as keiro's own registry statements do.-downgradeSessionByIdTo :: (Store :> es) => Int -> Text -> Eff es ()-downgradeSessionByIdTo version shapeHash =-  runTransaction . Tx.sql . encodeUtf8 $-    "UPDATE keiro.keiro_read_models SET version = "-      <> Text.pack (show version)-      <> ", shape_hash = '"-      <> shapeHash-      <> "' WHERE name = 'kioku-session-by-id'"--outcomeFor :: Text -> [(ReadModelSchema, ReconcileOutcome)] -> Maybe ReconcileOutcome-outcomeFor name outcomes =-  lookup name [(schema.readModelName, outcome) | (schema, outcome) <- outcomes]+-- | Pin every registry row back to the identity it carried before the projections moved into+-- the @kioku@ schema. Written as three targeted updates keyed on the /current/ shape hash, so a+-- future family this test does not know about is left alone rather than silently swept along.+downgradeToPreRelocationIdentities :: (Store :> es) => Eff es ()+downgradeToPreRelocationIdentities =+  runTransaction . Tx.sql . encodeUtf8 . Text.concat $+    [ downgrade 4 "kioku-session-v4" "kioku-session-v5",+      downgrade 2 "kioku-memory-v2" "kioku-memory-v3",+      downgrade 2 "kioku-turn-v2" "kioku-turn-v3"+    ]+  where+    downgrade previousVersion previousHash currentHash =+      "UPDATE keiro.keiro_read_models SET version = "+        <> Text.pack (show (previousVersion :: Int))+        <> ", shape_hash = '"+        <> previousHash+        <> "' WHERE shape_hash = '"+        <> currentHash+        <> "';"  isRight :: Either e a -> Bool isRight = \case@@ -151,9 +217,13 @@ startFixture sid = do   now <- liftIO getCurrentTime   result <--    Session.start+    Session.startWithContext+      testContext       StartSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,+          ownerPrincipal = Nothing,           agentId = "test-agent",           focus = "read-model reconciliation",           scope = ScopeGlobal (Namespace "kioku-test"),@@ -164,5 +234,5 @@           startedAt = now         }   case result of-    Left err -> liftIO (assertFailure ("Session.start: " <> show err))+    Left err -> liftIO (assertFailure ("Session.startWithContext testContext: " <> show err))     Right _ -> pure ()
+ test/Kioku/RecallCompatSpec.hs view
@@ -0,0 +1,234 @@+-- Every test here is /about/ the deprecated entry point, so the deprecation is suppressed for+-- this module and nowhere else. A caller that has not migrated must still get the warning, and a+-- future deprecated use anywhere outside this file must still be visible.+{-# OPTIONS_GHC -Wno-deprecations #-}++-- | The compatibility promise, executed against a real database.+--+-- 'Kioku.Recall.legacyRecall' exists so that a caller holding a pre-'RecallTarget'+-- 'RecallRequest' keeps the rows it has today while the compiler tells it to migrate. That+-- promise is only worth anything if it is measured, because the mistake it guards against —+-- mapping a global scope to the exact global bucket instead of to the whole namespace — compiles,+-- runs, and returns a plausible subset of the right answer with no error at all.+--+-- So each case here runs the legacy request and the explicit request side by side over the same+-- seeded rows and asserts they return the same memory ids. Everything runs keyword-only under+-- 'VectorExtensionUnavailable', so no embedding endpoint is involved and the embedding model is+-- never forced.+module Kioku.RecallCompatSpec (tests) where++import Data.List (sort)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding (encodeUtf8)+import Effectful (Eff, IOE, (:>))+import Hasql.Transaction qualified as Tx+import Kioku.Api.Access (memorySpaceIdText)+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Kioku.Api.Types (MemoryRecord (..))+import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)+import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)+import Kioku.Recall+  ( RecallError (..),+    RecallHit (..),+    RecallLimit,+    RecallQuery (..),+    RecallRequest (..),+    RecallStrategy (..),+    RecallTarget (..),+    legacyRecall,+    mkRecallLimit,+    recall,+  )+import Kioku.Recall.Capability (VectorCapability (..))+import Kioku.SpaceFixtures (otherSpace, testContext, testSpace)+import Kiroku.Store.Connection (defaultConnectionSettings)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)++-- Each 'withRecallFixture' spins an ephemeral PostgreSQL cluster and applies every migration, so+-- the scenarios are grouped into two of them rather than one per assertion.+tests :: TestTree+tests =+  testGroup+    "Recall.Compat"+    [ testCase "a legacy scope returns exactly what its explicit target returns" testLegacyMatchesExplicit,+      testCase "the legacy request's edges are preserved" testLegacyEdges+    ]++-- | The case the whole compatibility layer exists for.+--+-- A legacy @ScopeGlobal ns@ returns every active row in @ns@, entity-scoped rows included, and an+-- entity scope returns only itself. If 'Kioku.Api.Recall.legacyRecallTarget' were ever+-- "corrected" to send a global scope to the exact global bucket, the first assertion returns+-- @["m_global"]@ instead of both ids — a silent narrowing, which is exactly the kind of failure+-- that reaches a downstream months later as "recall got worse".+testLegacyMatchesExplicit :: IO ()+testLegacyMatchesExplicit =+  withRecallFixture \runEff -> do+    result <- runEff do+      seedCorpus+      legacyWide <- runLegacy (legacyRequest ns1Global)+      explicitWide <- runExplicit (explicitRequest (NamespaceWide ns1))+      legacyExact <- runLegacy (legacyRequest ns1Entity)+      explicitExact <- runExplicit (explicitRequest (ExactScope ns1Entity))+      pure (legacyWide, explicitWide, legacyExact, explicitExact)+    case result of+      Left err -> assertFailure ("store error: " <> show err)+      Right (legacyWide, explicitWide, legacyExact, explicitExact) -> do+        assertEqual+          "a legacy global scope returns the global bucket and the entity scope alike"+          (Right ["m_entity", "m_global"])+          (hitIds legacyWide)+        assertEqual+          "and returns exactly what the explicit namespace-wide target returns"+          (hitIds explicitWide)+          (hitIds legacyWide)+        assertEqual+          "a legacy entity scope returns only that scope"+          (Right ["m_entity"])+          (hitIds legacyExact)+        assertEqual+          "and returns exactly what the explicit exact target returns"+          (hitIds explicitExact)+          (hitIds legacyExact)++-- | The three edges of the legacy request that 'RecallQuery' does not have.+--+-- __The space.__ The legacy request is the only recall input carrying a memory space of its own,+-- so it is the only one that can disagree with the context that authorized it. It is refused+-- rather than retargeted, for the same reason the deprecated write wrappers in "Kioku.Memory"+-- refuse: quietly rewriting the space would make the compatibility layer a way to reach another+-- space's data.+--+-- __A zero limit.__ @take (max 0 n)@ returned nothing, so this returns nothing, rather than+-- becoming a validation error an unmigrated caller meets as a crash.+--+-- __An oversized limit.__ 'Kioku.Api.Recall.mkRecallLimit' refuses anything above+-- 'Kioku.Api.Recall.maxRecallLimit', but a legacy caller passing 5000 was never getting 5000 rows+-- — each channel contributes at most 50 candidates — so clamping is invisible where refusing+-- would be a new failure.+testLegacyEdges :: IO ()+testLegacyEdges =+  withRecallFixture \runEff -> do+    result <- runEff do+      seedCorpus+      mismatched <- runLegacy (legacyRequest ns1Global) {memorySpaceId = otherSpace}+      zeroLimit <- runLegacy (legacyRequestLimited ns1Global 0)+      oversize <- runLegacy (legacyRequestLimited ns1Global 5000)+      pure (mismatched, zeroLimit, oversize)+    case result of+      Left err -> assertFailure ("store error: " <> show err)+      Right (mismatched, zeroLimit, oversize) -> do+        case mismatched of+          Left (RecallSpaceMismatch requested authorized) -> do+            assertEqual "names the space that was asked for" otherSpace requested+            assertEqual "names the space the context authorizes" testSpace authorized+          other -> assertFailure ("expected a space mismatch, got " <> show (hitIds other))+        assertEqual "a zero limit returns no hits" (Right []) (hitIds zeroLimit)+        assertEqual+          "an oversized limit still returns every match"+          (Right ["m_entity", "m_global"])+          (hitIds oversize)++runLegacy :: (Store :> es, IOE :> es) => RecallRequest -> Eff es (Either RecallError [RecallHit])+runLegacy = legacyRecall undefinedModel VectorExtensionUnavailable testContext++runExplicit :: (Store :> es, IOE :> es) => RecallQuery -> Eff es (Either RecallError [RecallHit])+runExplicit = recall undefinedModel VectorExtensionUnavailable testContext++-- * Fixture++ns1 :: Namespace+ns1 = Namespace "ns1"++ns1Global, ns1Entity, ns2Global :: MemoryScope+ns1Global = ScopeGlobal ns1+ns1Entity = ScopeEntity ns1 (ScopeKind "repo") "web"+ns2Global = ScopeGlobal (Namespace "ns2")++-- | The keyword channel never embeds, and 'VectorExtensionUnavailable' makes that a guarantee+-- rather than a hope: the execution plan is keyword-only, so this is never forced.+undefinedModel :: a+undefinedModel = error "the keyword channel must not embed"++legacyRequest :: MemoryScope -> RecallRequest+legacyRequest scope = legacyRequestLimited scope 10++-- | Built rather than record-updated: 'RecallRequest' and 'RecallQuery' both have a @maxResults@+-- field, and a record update over an ambiguous field is a warning GHC intends to stop supporting.+legacyRequestLimited :: MemoryScope -> Int -> RecallRequest+legacyRequestLimited scope maxResults =+  RecallRequest+    { memorySpaceId = testSpace,+      scope,+      query = searchText,+      strategy = Keyword,+      maxResults+    }++explicitRequest :: RecallTarget -> RecallQuery+explicitRequest target =+  RecallQuery {target, query = searchText, strategy = Keyword, maxResults = limitOf 10}++searchText :: Text+searchText = "deployment pipeline"++limitOf :: Int -> RecallLimit+limitOf = either (error . Text.unpack) id . mkRecallLimit++hitIds :: Either RecallError [RecallHit] -> Either RecallError [Text]+hitIds = fmap (sort . fmap (\hit -> hit.memory.memoryId))++-- | One row in the global bucket, one under an entity scope in the same namespace, and one in a+-- different namespace that no target may ever return.+seedCorpus :: (Store :> es) => Eff es ()+seedCorpus =+  seedMemories+    [ ("m_global", ns1Global),+      ("m_entity", ns1Entity),+      ("m_other_ns", ns2Global)+    ]++seedMemories :: (Store :> es) => [(Text, MemoryScope)] -> Eff es ()+seedMemories rows =+  runTransaction . Tx.sql . encodeUtf8 $+    "INSERT INTO kioku.memories (memory_space_id, memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at) VALUES "+      <> Text.intercalate ", " (row <$> rows)+  where+    row (memoryId, scope) =+      "('"+        <> memorySpaceIdText testSpace+        <> "', '"+        <> memoryId+        <> "', 'agent', '"+        <> namespaceOf scope+        <> "', "+        <> sqlText (kindOf scope)+        <> ", "+        <> sqlText (refOf scope)+        <> ", 'fact', 'the deployment pipeline runs on nix flakes', 'active', now(), now())"++    namespaceOf = \case+      ScopeGlobal (Namespace ns) -> ns+      ScopeEntity (Namespace ns) _ _ -> ns++    kindOf = \case+      ScopeGlobal _ -> Nothing+      ScopeEntity _ (ScopeKind kind) _ -> Just kind++    refOf = \case+      ScopeGlobal _ -> Nothing+      ScopeEntity _ _ ref -> Just ref++    sqlText Nothing = "NULL"+    sqlText (Just value) = "'" <> value <> "'"++withRecallFixture :: ((forall a. Eff AppEffects a -> IO (Either StoreError a)) -> IO ()) -> IO ()+withRecallFixture use =+  withKiokuMigratedDatabase \connStr ->+    withNoopAppEnv (defaultConnectionSettings connStr) \env ->+      use (runAppIO env)
test/Kioku/RecallHarness.hs view
@@ -35,11 +35,15 @@ --    committed transaction ('runTransaction' commits). Do not "optimise" 'seedCorpus' by --    wrapping the whole corpus in one transaction — the rows would be invisible to the index --    and every number downstream would be fiction.--- 2. __A partial index needs its predicate restated in the query__, or the planner cannot prove---    the index applies and falls back to a sequential scan. The @embedding IS NOT NULL@ in---    'explainVectorStmt' is load-bearing, not decoration.+-- 2. __An @EXPLAIN@ that restates the query is not measuring the query.__ This module used to+--    keep its own copy of the vector SQL, and the copy was wrong twice — once by selecting one+--    column instead of thirteen, once by omitting the memory-space predicate — each time+--    reporting a plan no live query could produce. There is no copy now:+--    'Kioku.Recall.explainVectorAnnCandidates' explains the shipping statement itself, from the+--    same SQL text and the same parameters. ----- See docs/plans/18-build-a-recall-quality-harness-that-reproduces-filtered-ann-starvation.md.+-- See docs/plans/18-build-a-recall-quality-harness-that-reproduces-filtered-ann-starvation.md+-- and docs/plans/29-enforce-exact-and-namespace-wide-recall-in-postgresql.md. module Kioku.RecallHarness   ( -- * Geometry     vectorAtAngle,@@ -50,6 +54,8 @@     -- * Seeding     CorpusConfig (..),     defaultStarvationCorpus,+    exactEntityStarvationCorpus,+    inScopeScopeFor,     SeededCorpus (..),     seedCorpus, @@ -68,8 +74,6 @@  import Data.Char (isDigit) import Data.Foldable (traverse_)-import Data.Functor.Contravariant ((>$<))-import Data.Int (Int32) import Data.List (isInfixOf, sortOn) import Data.Set qualified as Set import Data.Text (Text)@@ -78,29 +82,33 @@ import Data.Vector (Vector) import Data.Vector qualified as Vector import Effectful (Eff, (:>))-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx-import Kioku.Api.Scope (MemoryScope (..), Namespace (..), scopeKindText, scopeNamespaceText, scopeRefText)+import Kioku.Api.Access (memorySpaceIdText)+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Api.Types (MemoryRecord (..)) import Kioku.Recall-  ( RecallRequest (..),+  ( RecallLimit,+    RecallQuery (..),     RecallStrategy (..),+    RecallTarget (..),+    ResolvedRecall,+    VectorCandidateSql,     VectorChannelOutcome (..),-    candidatePoolSize,-    memoryRecordColumns,+    explainVectorAnnCandidates,+    mkRecallLimit,+    resolveRecall,+    runVectorAnnCandidates,     selectVectorCandidatesDiagnosed,-    selectVectorCandidatesStmt,-    vectorCandidateQuery,+    vectorCandidateSql,   )+import Kioku.SpaceFixtures (testSpace) import Kiroku.Store.Effect (Store) import Kiroku.Store.Transaction (runTransaction) import Text.Read (readMaybe)  -- * Geometry --- | The width of @kioku_memories.embedding@, which is @vector(1536)@. A seeded vector must+-- | The width of @kioku.memories.embedding@, which is @vector(1536)@. A seeded vector must -- match it exactly or the @::vector@ cast fails. embeddingDimensions :: Int embeddingDimensions = 1536@@ -131,55 +139,105 @@  -- | The knobs that make a corpus starve. ----- Starvation needs the filter to /correlate with distance/: the rows the scope filter throws--- away must be the ones the index reaches for first. So the decoys live in a different--- namespace and sit /nearer/ the query than any in-scope row.+-- Starvation needs the filter to /correlate with distance/: the rows the query's bound throws+-- away must be the ones the index reaches for first. So the decoys sit /nearer/ the query than+-- any in-scope row, in a scope the target excludes. data CorpusConfig = CorpusConfig-  { -- | Memories in the namespace and scope the query asks for. These are the true answers.+  { -- | Memories inside the target's bound. These are the true answers.     inScopeCount :: !Int,-    -- | Memories in a /different/ namespace, which the query must never return.+    -- | Memories outside it, which the query must never return.     decoyCount :: !Int,     -- | The angular band (radians) the in-scope rows are spread evenly across.     inScopeAngles :: !(Double, Double),     -- | The angular band the decoys occupy. Make it strictly nearer the query than     -- 'inScopeAngles' — that is, smaller angles — or nothing starves.-    decoyAngles :: !(Double, Double)+    decoyAngles :: !(Double, Double),+    -- | The target the measurement aims at the corpus, which decides both the statement family+    -- under test and the scope the in-scope rows are seeded with ('inScopeScopeFor').+    target :: !RecallTarget,+    -- | The scope the decoys carry. The target must exclude it, or the \"decoys\" are answers+    -- and nothing is being measured.+    decoyScope :: !MemoryScope   }   deriving stock (Eq, Show) +-- | The scope the in-scope rows carry, given the target aimed at them.+--+-- An exact target admits exactly one scope, so there is no choice. A namespace-wide target+-- admits every scope in its namespace; the global bucket is the simplest of them and is what the+-- corpus uses.+inScopeScopeFor :: RecallTarget -> MemoryScope+inScopeScopeFor = \case+  ExactScope scope -> scope+  NamespaceWide ns -> ScopeGlobal ns+ -- | The probe the previous initiative recorded as "1648 rows removed by filter, zero returned":--- 2000 in-scope memories, 2000 nearer decoys in another namespace.+-- in-scope memories in one namespace, nearer decoys in another. -- -- Every decoy is strictly nearer the query than every in-scope row. In cosine distance the -- decoys span roughly 0.001 to 0.12 and the in-scope rows roughly 0.30 to 0.64, so the index, -- descending towards the query, meets all 2000 decoys before the first true answer.+--+-- __@inScopeCount@ was 2000 until the memory-space partition landed, and 2000 stopped+-- starving.__ Not because anything about the ANN scan changed, but because the partition-first+-- rebuild of @kioku_memories_scope_idx@ — now @kioku_memories_space_scope_idx@, leading with+-- @memory_space_id@ — made the planner prefer an ordinary index scan of the in-scope rows plus+-- a top-N sort over the HNSW scan entirely. Measured: at 2000 in-scope rows the exact plan cost+-- 213.97 and won; dropping that one index restored the HNSW plan and the starvation with it,+-- while dropping the other two new partition-first indexes changed nothing.+--+-- The sort's cost grows with the in-scope row count and the HNSW scan's does not, so the fix is+-- the one this file's starvation case asks for: a harsher corpus, not a relaxed assertion. 4000+-- is the first power-of-two step at which the planner goes back to HNSW; it was measured+-- starving at 4000, 8000, 16000 and 32000, and it is the smallest of those. defaultStarvationCorpus :: CorpusConfig defaultStarvationCorpus =   CorpusConfig-    { inScopeCount = 2000,+    { inScopeCount = 4000,       decoyCount = 2000,       inScopeAngles = (0.8, 1.2),-      decoyAngles = (0.05, 0.5)+      decoyAngles = (0.05, 0.5),+      target = NamespaceWide targetNamespace,+      decoyScope = ScopeGlobal decoyNamespace     } +-- | The same starvation, aimed at an /exact entity scope/ rather than a whole namespace.+--+-- This is the shape the filtered-ANN work was really about: a small scope inside a large+-- namespace, where the nearest rows belong to a sibling scope. Both scopes live in the same+-- namespace here, so the memory-space and namespace predicates match every row in the table and+-- only the scope comparison separates the answers from the decoys — which is the narrowest the+-- exact-entity statement family can be pushed.+--+-- It exists because the split into three statement families means the fallback is now dispatched+-- per family: a regression that dropped the @OFFSET 0@ fence from the exact family alone would+-- leave the namespace-wide case above passing.+exactEntityStarvationCorpus :: CorpusConfig+exactEntityStarvationCorpus =+  defaultStarvationCorpus+    { target = ExactScope (ScopeEntity targetNamespace repoKind "in-scope"),+      decoyScope = ScopeEntity targetNamespace repoKind "decoy"+    }++repoKind :: ScopeKind+repoKind = ScopeKind "repo"+ -- | What was seeded, including the ground truth. data SeededCorpus = SeededCorpus   { config :: !CorpusConfig,-    -- | The scope the query asks for. Its namespace holds only the in-scope rows.-    targetScope :: !MemoryScope,     -- | The in-scope memory ids ordered by true cosine distance, nearest first. Computed from     -- the seed angles, never read back from the database.     trueNearestInScope :: ![Text]   }   deriving stock (Eq, Show) --- | The namespace the query asks for. Only in-scope rows live here.-targetNamespace :: Text-targetNamespace = "harness_target"+-- | The namespace the query asks for.+targetNamespace :: Namespace+targetNamespace = Namespace "harness_target" --- | The namespace the decoys live in. The query must never return one of these.-decoyNamespace :: Text-decoyNamespace = "harness_decoy"+-- | The namespace the default corpus's decoys live in. The query must never return one of these.+decoyNamespace :: Namespace+decoyNamespace = Namespace "harness_decoy"  -- | Spread @n@ points evenly across @[lo, hi]@, inclusive at both ends. anglesAcross :: Int -> (Double, Double) -> [Double]@@ -200,19 +258,18 @@ seedCorpus :: (Store :> es) => CorpusConfig -> Eff es SeededCorpus seedCorpus cfg = do   let inScope =-        [ (inScopeId i, targetNamespace, t)+        [ (inScopeId i, inScopeScopeFor cfg.target, t)         | (i, t) <- zip [0 :: Int ..] (anglesAcross cfg.inScopeCount cfg.inScopeAngles)         ]       decoys =-        [ (decoyId i, decoyNamespace, t)+        [ (decoyId i, cfg.decoyScope, t)         | (i, t) <- zip [0 :: Int ..] (anglesAcross cfg.decoyCount cfg.decoyAngles)         ]   traverse_ insertBatch (chunksOf seedBatchSize (inScope <> decoys))-  runTransaction (Tx.sql "ANALYZE kioku_memories")+  runTransaction (Tx.sql "ANALYZE kioku.memories")   pure     SeededCorpus       { config = cfg,-        targetScope = ScopeGlobal (Namespace targetNamespace),         -- Distance is @1 - cos t@, which increases monotonically with @t@ on @[0, pi]@, so         -- ordering by angle *is* ordering by distance. 'anglesAcross' already emits ascending         -- angles; sorting explicitly keeps that from being a silent assumption.@@ -227,25 +284,49 @@ seedBatchSize :: Int seedBatchSize = 500 -insertBatch :: (Store :> es) => [(Text, Text, Double)] -> Eff es ()+insertBatch :: (Store :> es) => [(Text, MemoryScope, Double)] -> Eff es () insertBatch [] = pure () insertBatch rows =   runTransaction . Tx.sql . encodeUtf8 $-    "INSERT INTO kioku_memories \-    \(memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at, embedding) VALUES "+    "INSERT INTO kioku.memories \+    \(memory_space_id, memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at, embedding) VALUES "       <> Text.intercalate ", " (row <$> rows)   where-    row (memoryId, namespace, t) =+    row (memoryId, scope, t) =       "('"+        <> memorySpaceIdText testSpace+        <> "', '"         <> memoryId         <> "', 'agent', '"-        <> namespace-        <> "', NULL, NULL, 'fact', 'seeded corpus row "+        <> namespaceTextOf scope+        <> "', "+        <> sqlText (kindTextOf scope)+        <> ", "+        <> sqlText (refTextOf scope)+        <> ", 'fact', 'seeded corpus row "         <> memoryId         <> "', 'active', now(), now(), "         <> sparseVectorSql t         <> ")" +namespaceTextOf :: MemoryScope -> Text+namespaceTextOf = \case+  ScopeGlobal (Namespace ns) -> ns+  ScopeEntity (Namespace ns) _ _ -> ns++kindTextOf :: MemoryScope -> Maybe Text+kindTextOf = \case+  ScopeGlobal _ -> Nothing+  ScopeEntity _ (ScopeKind kind) _ -> Just kind++refTextOf :: MemoryScope -> Maybe Text+refTextOf = \case+  ScopeGlobal _ -> Nothing+  ScopeEntity _ _ ref -> Just ref++sqlText :: Maybe Text -> Text+sqlText = maybe "NULL" (\value -> "'" <> value <> "'")+ -- | The seeded vector, built as SQL rather than as a 1536-element text literal. -- -- 'Kioku.Recall.vectorLiteral' would render all 1536 components, of which 1534 are zero: about@@ -349,6 +430,8 @@ measureRecallQuality :: (Store :> es) => SeededCorpus -> Int -> Eff es RecallQuality measureRecallQuality corpus k = do   (outcome, rows) <- selectVectorCandidatesDiagnosed (vectorRequest corpus) queryVector+  -- Both passes and the plan below run against the same statement family, because they are all+  -- compiled from the corpus's own target.   plan <- explainVectorQuery corpus   pure (scoreRecallQuality corpus k rows plan outcome.annRows outcome.exactFallbackFired) @@ -363,9 +446,10 @@ -- is a no-op that silently measures the baseline while claiming to measure the candidate — and -- reports a confident number either way. Both go in the single transaction below. ----- Second, the query is 'Kioku.Recall.selectVectorCandidatesStmt' itself, not a copy, and the--- @EXPLAIN@ runs under the same settings in its own transaction. 'planAgreesWithQuery' still--- guards the pair.+-- Second, the query is the shipping approximate pass itself, not a copy: both it and the+-- @EXPLAIN@ are driven from the one 'Kioku.Recall.VectorCandidateSql' the corpus's target+-- compiles to, and the @EXPLAIN@ runs under the same settings in its own transaction.+-- 'planAgreesWithQuery' still guards the pair. measureRecallQualityWith ::   (Store :> es) =>   -- | @SET LOCAL@ statements, e.g. @["SET LOCAL hnsw.iterative_scan = 'strict_order'"]@.@@ -376,7 +460,7 @@ measureRecallQualityWith settings corpus k = do   rows <- runTransaction do     traverse_ (Tx.sql . encodeUtf8) settings-    Tx.statement (vectorCandidateQuery (vectorRequest corpus) queryVector) selectVectorCandidatesStmt+    runVectorAnnCandidates (vectorCandidates corpus)   plan <- explainVectorQueryWith settings corpus   -- This drives the raw ANN statement, with no fallback, so the ANN pass *is* the whole channel.   pure (scoreRecallQuality corpus k rows plan (length rows) False)@@ -403,18 +487,31 @@     found = length (filter (`Set.member` returnedSet) truth)  -- | The request the vector channel is driven with. The query /text/ is irrelevant — the vector--- statement never reads it — but 'RecallRequest' requires one.-vectorRequest :: SeededCorpus -> RecallRequest+-- statement never reads it — but a 'RecallQuery' requires one.+--+-- It goes through 'resolveRecall' rather than being assembled by hand, so the harness measures+-- the same target-to-statement mapping recall itself uses, whichever of the three families the+-- corpus's target selects.+vectorRequest :: SeededCorpus -> ResolvedRecall vectorRequest corpus =-  RecallRequest-    { scope = corpus.targetScope,-      query = "seeded corpus row",-      strategy = Embedding,-      maxResults = 10-    }+  resolveRecall+    testSpace+    RecallQuery+      { target = corpus.config.target,+        query = "seeded corpus row",+        strategy = Embedding,+        maxResults = expectValidLimit 10+      } --- | @EXPLAIN (ANALYZE, BUFFERS)@ for the vector candidate query, run with the same five--- parameters recall passes, so Postgres plans it the way it plans the real one.+-- | The compiled vector query the measurement, the plan capture, and recall itself all share.+vectorCandidates :: SeededCorpus -> VectorCandidateSql+vectorCandidates corpus = vectorCandidateSql (vectorRequest corpus) queryVector++expectValidLimit :: Int -> RecallLimit+expectValidLimit = either (error . Text.unpack) id . mkRecallLimit++-- | @EXPLAIN (ANALYZE, BUFFERS)@ for the vector candidate query, over exactly the statement and+-- parameters recall issues, so Postgres plans it the way it plans the real one. explainVectorQuery :: (Store :> es) => SeededCorpus -> Eff es Text explainVectorQuery = explainVectorQueryWith [] @@ -425,55 +522,7 @@ explainVectorQueryWith settings corpus =   Text.unlines <$> runTransaction do     traverse_ (Tx.sql . encodeUtf8) settings-    Tx.statement-      ( Text.pack (show (Vector.toList queryVector)),-        scopeNamespaceText corpus.targetScope,-        scopeKindText corpus.targetScope,-        scopeRefText corpus.targetScope,-        candidatePoolSize-      )-      explainVectorStmt---- | Everything here is copied verbatim from 'Kioku.Recall.selectVectorCandidatesStmt' — the--- select list, the predicates, the @ORDER BY@, and the @LIMIT@ — and every part of it earns--- its place.------ @embedding IS NOT NULL@ is not decoration: the HNSW index is partial on exactly that--- predicate, and without it restated here the planner cannot prove the index applies and falls--- back to a sequential scan — which would look like "the index is broken" and be entirely an--- artifact of the measurement.------ __The select list is load-bearing too, which is not obvious and was learned the hard way.__--- An earlier version of this function selected @memory_id@ alone, on the theory that Postgres--- chooses the plan from the @WHERE@, the @ORDER BY@ and the @LIMIT@, and that the projection--- could not turn an HNSW scan into anything else. That is false. The projection sets the row--- width, the width sets the cost of the top-N sort that the /exact/ plan needs, and that cost--- is exactly what the planner weighs against the HNSW scan. On the 2000-in-scope, 2000-decoy--- corpus the narrow projection made the sort look cheap, the planner took the exact plan, and--- the EXPLAIN reported 50 happy rows — while the real query, with its 13 real columns, took--- the HNSW plan and returned zero. The instrument was describing a query nobody runs.-explainVectorStmt :: Statement (Text, Text, Maybe Text, Maybe Text, Int32) [Text]-explainVectorStmt =-  preparable-    ( "EXPLAIN (ANALYZE, BUFFERS) SELECT "-        <> memoryRecordColumns-        <> " FROM kiroku.kioku_memories \-           \ WHERE status = 'active' \-           \   AND namespace = $2 \-           \   AND (($3 IS NULL AND $4 IS NULL) OR (scope_kind = $3 AND scope_ref = $4)) \-           \   AND embedding IS NOT NULL \-           \ ORDER BY embedding <=> $1::vector \-           \ LIMIT $5"-    )-    encoder-    (D.rowList (D.column (D.nonNullable D.text)))-  where-    encoder =-      ((\(v, _, _, _, _) -> v) >$< E.param (E.nonNullable E.text))-        <> ((\(_, n, _, _, _) -> n) >$< E.param (E.nonNullable E.text))-        <> ((\(_, _, sk, _, _) -> sk) >$< E.param (E.nullable E.text))-        <> ((\(_, _, _, sr, _) -> sr) >$< E.param (E.nullable E.text))-        <> ((\(_, _, _, _, l) -> l) >$< E.param (E.nonNullable E.int4))+    explainVectorAnnCandidates (vectorCandidates corpus)  -- | A failure message that reads as a diagnosis rather than an assertion. --@@ -505,7 +554,7 @@     ]  -- | Whether a captured plan used the HNSW index — the approximate path — as opposed to the--- exact plan over @kioku_memories_scope_idx@. Which one Postgres picked is the load-bearing+-- exact plan over @kioku_memories_space_scope_idx@. Which one Postgres picked is the load-bearing -- observation: the previous initiative found it returning 50 correct rows on the exact plan and -- zero on the HNSW one, and a measurement that does not record the plan has not measured the -- thing that matters.@@ -519,4 +568,4 @@ runDdl :: (Store :> es) => [Text] -> Eff es () runDdl statements = do   traverse_ (runTransaction . Tx.sql . encodeUtf8) statements-  runTransaction (Tx.sql "ANALYZE kioku_memories")+  runTransaction (Tx.sql "ANALYZE kioku.memories")
test/Kioku/RecallSqlSpec.hs view
@@ -15,11 +15,22 @@ import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx+import Kioku.Api.Access (memorySpaceIdText) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.Api.Types (MemoryRecord (..)) import Kioku.App (AppEffects, runAppIO, withNoopAppEnv) import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)-import Kioku.Recall (RecallRequest (..), RecallStrategy (..), selectFtsCandidates, selectVectorCandidates, vectorLiteral)+import Kioku.Recall+  ( RecallQuery (..),+    RecallStrategy (..),+    RecallTarget (..),+    ResolvedRecall,+    mkRecallLimit,+    resolveRecall,+    selectFtsCandidates,+    selectVectorCandidates,+    vectorLiteral,+  ) import Kioku.Recall.Capability (VectorCapability (..), detectVectorCapability) import Kioku.RecallHarness   ( CorpusConfig (..),@@ -28,11 +39,13 @@     cosineDistanceAtAngle,     defaultStarvationCorpus,     describeRecallQuality,+    exactEntityStarvationCorpus,     measureRecallQuality,     planAgreesWithQuery,     queryVector,     seedCorpus,   )+import Kioku.SpaceFixtures (testSpace) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Error (StoreError)@@ -44,7 +57,7 @@ tests =   testGroup     "Recall.Sql"-    [ testCase "a global scope searches the whole namespace; an entity scope is exact" testScopePredicate,+    [ testCase "each target compiles to the rows it names and no others" testScopePredicate,       testCase "archived memories are never candidates" testStatusFilter,       testGroup         "websearch_to_tsquery survives whatever the user typed"@@ -57,6 +70,7 @@       testCase "the harness seeds the geometry it claims" testHarnessGeometry,       testCase "the captured plan describes the query that was measured" testPlanCaptureIsFaithful,       testCase "the vector channel does not starve on a selective scope" testVectorChannelDoesNotStarve,+      testCase "nor on an exact entity scope inside a busy namespace" testExactScopeChannelDoesNotStarve,       testCase "a healthy scope never pays for the exact fallback" testHealthyScopeSkipsFallback     ] @@ -102,6 +116,50 @@         "the exact fallback did not fire, so recall survived this corpus by luck rather than by mechanism"         q.exactFallbackFired +-- | The same defect, on the statement family that answers an exact entity scope.+--+-- The two passes are now dispatched per statement family, so a regression can be family-local: a+-- rewrite that dropped the @OFFSET 0@ optimisation fence from the exact-scope statement alone+-- would leave 'testVectorChannelDoesNotStarve' above passing, because that case drives the+-- namespace-wide family. This corpus puts the answers and the decoys in the /same/ namespace+-- under sibling entity scopes, so the memory-space and namespace predicates match every row in+-- the table and the scope comparison is the only thing separating them — which is both the+-- narrowest this family can be pushed and the shape ("a small scope inside a large namespace")+-- that filtered-ANN starvation was originally found in.+testExactScopeChannelDoesNotStarve :: IO ()+testExactScopeChannelDoesNotStarve =+  withRecallFixture \runEff -> do+    result <- runEff do+      available <- vectorTypeIsReachable+      if not available+        then pure Nothing+        else do+          corpus <- seedCorpus exactEntityStarvationCorpus+          Just <$> measureRecallQuality corpus 10+    case result of+      Left err -> assertFailure ("store error: " <> show err)+      Right Nothing ->+        putStrLn "  [skipped] no reachable pgvector on this cluster; re-enter the dev shell to exercise the vector path"+      Right (Just q) -> do+        assertBool+          ("the vector channel starved on an exact entity scope.\n" <> describeRecallQuality q)+          (q.rowsReturned > 0)+        assertBool+          ( "the vector channel returned candidates for an exact entity scope, but missed most of \+            \the true nearest.\n"+              <> describeRecallQuality q+          )+          (q.recallAtK >= 0.5)+        assertEqual+          "a sibling entity scope's memory reached the caller: the scope filter is a correctness \+          \boundary, not a ranking hint"+          0+          q.decoysReturned+        assertBool+          "the exact fallback did not fire, so the exact-scope family survived this corpus by luck \+          \rather than by mechanism"+          q.exactFallbackFired+ -- | Bar (b) of the bake-off, as a test: a fix that trades a rare failure for a common one is not -- a fix. On a corpus with nothing out of scope there is nothing for the filter to discard, the -- approximate pass fills its pool, and the exact fallback must not run — because the fallback@@ -294,7 +352,7 @@     stmt =       preparable         "SELECT memory_id, (embedding <=> $1::vector)::float8 \-        \  FROM kiroku.kioku_memories \+        \  FROM kioku.memories \         \ WHERE embedding IS NOT NULL \         \ ORDER BY 2"         (E.param (E.nonNullable E.text))@@ -327,11 +385,15 @@         assertEqual "a 512-dimension config against a vector(1536) column is a mismatch" (VectorDimensionMismatch 512 1536) mismatched         assertEqual "the configured width matching the column is simply available" VectorAvailable matched --- | Recall's scope predicate is @(($3 IS NULL AND $4 IS NULL) OR (scope_kind = $3 AND--- scope_ref = $4))@: for a global scope both parameters are NULL, the first disjunct is--- always true, and the query searches the entire namespace -- entity-scoped rows included.--- That is deliberate and is *not* what the scoped read-model queries do with the same--- 'MemoryScope' value. See docs/user/recall.md.+-- | The three targets, the three statement families they compile to, and the rows each one is+-- supposed to admit.+--+-- Each family spells its own scope clause: the exact global bucket asks for+-- @scope_kind IS NULL AND scope_ref IS NULL@, an exact entity compares both columns, and a+-- namespace-wide target carries no scope comparison at all. The representation this replaced+-- spelled all three with one nullable pair, in which NULL meant /omit the scope filter/ — so the+-- exact global bucket could not be asked for and was refused rather than answered wrongly. This+-- case is what proves it can now be asked for and answered correctly. See docs/user/recall.md. testScopePredicate :: IO () testScopePredicate =   withRecallFixture \runEff -> do@@ -341,23 +403,28 @@           ("m_entity", ns1Entity, "the deployment pipeline runs on nix flakes", "active"),           ("m_other_ns", ns2Global, "the deployment pipeline runs on nix flakes", "active")         ]-      wide <- selectFtsCandidates (request ns1Global "deployment pipeline")-      exact <- selectFtsCandidates (request ns1Entity "deployment pipeline")-      pure (memoryIds wide, memoryIds exact)+      wide <- selectFtsCandidates (request ns1Wide "deployment pipeline")+      exactEntity <- selectFtsCandidates (request ns1Exact "deployment pipeline")+      exactGlobal <- selectFtsCandidates (request ns1ExactGlobal "deployment pipeline")+      pure (memoryIds wide, memoryIds exactEntity, memoryIds exactGlobal)     case result of       Left err -> assertFailure ("store error: " <> show err)-      Right (wide, exact) -> do+      Right (wide, exactEntity, exactGlobal) -> do         assertEqual-          "a global scope recalls every active row in the namespace, entity-scoped rows included"+          "a namespace-wide target recalls every active row in the namespace, entity-scoped rows included"           ["m_entity", "m_global"]           wide         assertEqual-          "an entity scope recalls only rows carrying exactly that scope"+          "an exact entity target recalls only rows carrying exactly that scope"           ["m_entity"]-          exact+          exactEntity+        assertEqual+          "an exact global target recalls the global bucket only, not the whole namespace"+          ["m_global"]+          exactGlobal         assertBool           "no query ever crosses a namespace boundary"-          ("m_other_ns" `notElem` (wide <> exact))+          ("m_other_ns" `notElem` (wide <> exactEntity <> exactGlobal))  testStatusFilter :: IO () testStatusFilter =@@ -367,7 +434,7 @@         [ ("m_active", ns1Global, "the deployment pipeline runs on nix flakes", "active"),           ("m_archived", ns1Global, "the deployment pipeline runs on nix flakes", "archived")         ]-      memoryIds <$> selectFtsCandidates (request ns1Global "deployment pipeline")+      memoryIds <$> selectFtsCandidates (request ns1Wide "deployment pipeline")     case result of       Left err -> assertFailure ("store error: " <> show err)       Right ids -> assertEqual "an archived memory matching the query is not a candidate" ["m_active"] ids@@ -379,7 +446,7 @@   withRecallFixture \runEff -> do     result <- runEff do       seedMemories [("m_active", ns1Global, "the deployment pipeline runs on nix flakes", "active")]-      selectFtsCandidates (request ns1Global query)+      selectFtsCandidates (request ns1Wide query)     case result of       Left err -> assertFailure ("recall raised on query " <> show query <> ": " <> show err)       Right _ -> pure ()@@ -401,7 +468,7 @@             ]           setEmbedding "m_near" (unitVector 0)           setEmbedding "m_far" (unitVector 1)-          Just . memoryIdsInOrder <$> selectVectorCandidates (request ns1Global "orbital") (unitVector 0)+          Just . memoryIdsInOrder <$> selectVectorCandidates (request ns1Wide "orbital") (unitVector 0)     case result of       Left err -> assertFailure ("store error: " <> show err)       Right Nothing ->@@ -422,10 +489,25 @@ ns1Entity = ScopeEntity (Namespace "ns1") (ScopeKind "repo") "web" ns2Global = ScopeGlobal (Namespace "ns2") -request :: MemoryScope -> Text -> RecallRequest-request scope query =-  RecallRequest {scope, query, strategy = Keyword, maxResults = 10}+ns1Wide, ns1Exact, ns1ExactGlobal :: RecallTarget+ns1Wide = NamespaceWide (Namespace "ns1")+ns1Exact = ExactScope ns1Entity+ns1ExactGlobal = ExactScope ns1Global +-- | A request resolved against the test space, the way 'Kioku.Recall.recall' resolves one. The+-- candidate statements are reached only through 'resolveRecall', so a test cannot hand the SQL a+-- statement family that the target mapping would never choose.+request :: RecallTarget -> Text -> ResolvedRecall+request target query =+  resolveRecall+    testSpace+    RecallQuery+      { target,+        query,+        strategy = Keyword,+        maxResults = either (error . Text.unpack) id (mkRecallLimit 10)+      }+ memoryIds :: [MemoryRecord] -> [Text] memoryIds = sort . fmap (\row -> row.memoryId) @@ -444,11 +526,13 @@   Eff es () seedMemories rows =   runTransaction . Tx.sql . encodeUtf8 $-    "INSERT INTO kioku_memories (memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at) VALUES "+    "INSERT INTO kioku.memories (memory_space_id, memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at) VALUES "       <> Text.intercalate ", " (row <$> rows)   where     row (memoryId, scope, content, status) =       "('"+        <> memorySpaceIdText testSpace+        <> "', '"         <> memoryId         <> "', 'agent', '"         <> namespaceOf scope@@ -478,7 +562,7 @@ setEmbedding :: (Store :> es) => Text -> Vector Double -> Eff es () setEmbedding memoryId embedding =   runTransaction . Tx.sql . encodeUtf8 $-    "UPDATE kioku_memories SET embedding = '"+    "UPDATE kioku.memories SET embedding = '"       <> vectorLiteral embedding       <> "'::vector WHERE memory_id = '"       <> memoryId
+ test/Kioku/RecallTargetSpec.hs view
@@ -0,0 +1,458 @@+-- | The recall target matrix, against a real database: three targets, three strategies, two+-- memory spaces holding identical rows under identical names.+--+-- == Why identical rows in two spaces+--+-- Every fixture row in @space_test@ has a twin in @space_other@ with the same namespace, the same+-- scope, and the same content. Nothing but the @memory_space_id@ predicate can tell them apart,+-- so a statement that lost that predicate — or that put it after a scope comparison it then got+-- wrong — returns six rows where it should return one, and every case here fails at once. A+-- fixture whose spaces differed in namespace or content would pass on the namespace filter alone+-- and prove nothing about the partition.+--+-- == Why the strategies are exercised at the channel level+--+-- @embedding@ and @hybrid@ recall embed the query through+-- 'Baikai.Embedding.EmbeddingModel', which is an HTTP endpoint; running them through+-- 'Kioku.Recall.recall' would need a live embedding service. The target predicate lives in the+-- channels, not above them, so the matrix drives 'Kioku.Recall.selectFtsCandidates' and+-- 'Kioku.Recall.selectVectorCandidates' directly and fuses their results with+-- 'Kioku.Recall.fuseRecallCandidates' for the hybrid row — which is exactly what recall does with+-- them. Fusion is pure and set-union-like, so it cannot introduce a row neither channel returned.+--+-- The keyword row is /also/ run through the public 'Kioku.Recall.recall', which needs no+-- embedding, so the whole entry point is proven for all three targets and not just the SQL under+-- it.+module Kioku.RecallTargetSpec (tests) where++import Data.Foldable (traverse_)+import Data.List (sort)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding (encodeUtf8)+import Data.Time (UTCTime)+import Data.Vector (Vector)+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, (:>))+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Kioku.Api.Access (MemoryAccessContext, MemorySpaceId, memorySpaceIdText)+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Kioku.Api.Types (MemoryRecord (..))+import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)+import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)+import Kioku.Recall+  ( RecallError,+    RecallHit (..),+    RecallLimit,+    RecallQuery (..),+    RecallStrategy (..),+    RecallTarget (..),+    ResolvedRecall,+    explainFtsCandidates,+    explainVectorExactCandidates,+    ftsCandidateSql,+    fuseRecallCandidates,+    mkRecallLimit,+    recall,+    resolveRecall,+    selectFtsCandidates,+    selectVectorCandidates,+    vectorCandidateSql,+    vectorLiteral,+  )+import Kioku.Recall.Capability (VectorCapability (..))+import Kioku.SpaceFixtures (otherContext, otherSpace, testContext, testSpace)+import Kiroku.Store.Connection (defaultConnectionSettings)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++tests :: TestTree+tests =+  testGroup+    "Recall.Target"+    [ testCase "every target and strategy returns its own rows, in its own space" testTargetMatrix,+      testCase "the public entry point answers all three targets" testRecallEntryPoint,+      testCase "each target's plan is bounded by the partition and its own scope clause" testBoundedPlans+    ]++-- * The matrix++-- | The nine target/strategy combinations, plus the same nine seen from the second space.+--+-- One fixture, one migrated cluster: seeding is the expensive part and the assertions are not.+testTargetMatrix :: IO ()+testTargetMatrix =+  withTargetFixture \runEff -> do+    result <- runEff do+      available <- vectorTypeIsReachable+      keyword <- traverse (channelIds Keyword) targetsUnderTest+      mirrored <- traverse (mirroredIds Keyword) targetsUnderTest+      embedding <- ifAvailable available (traverse (channelIds Embedding) targetsUnderTest)+      hybrid <- ifAvailable available (traverse (channelIds Hybrid) targetsUnderTest)+      pure (keyword, mirrored, embedding, hybrid)+    case result of+      Left err -> assertFailure ("store error: " <> show err)+      Right (keyword, mirrored, embedding, hybrid) -> do+        assertMatrix "keyword" testSpaceExpectations keyword+        assertMatrix "keyword, from the second space" otherSpaceExpectations mirrored+        case (embedding, hybrid) of+          (Just embeddingIds, Just hybridIds) -> do+            assertMatrix "embedding" testSpaceExpectations embeddingIds+            assertMatrix "hybrid" testSpaceExpectations hybridIds+          _ -> putStrLn skipMessage++-- | The rows each target must return in the space its context authorizes.+--+-- The exact global bucket is the row this whole initiative existed to make reachable: before the+-- statements were split it was refused, because the only rows the shared predicate could give it+-- were the namespace-wide ones.+testSpaceExpectations :: [(String, [Text])]+testSpaceExpectations =+  [ ("the exact global bucket, and nothing else in the namespace", ["t_global"]),+    ("exactly one entity scope", ["t_web"]),+    ("every scope in the namespace", ["t_api", "t_global", "t_web"])+  ]++-- | The same three targets, run under the context that authorizes the /other/ space. Identical+-- targets, identical rows, different partition — so this is what fails if a statement ever+-- resolves its space from anywhere but the context.+otherSpaceExpectations :: [(String, [Text])]+otherSpaceExpectations =+  [ ("the exact global bucket, and nothing else in the namespace", ["o_global"]),+    ("exactly one entity scope", ["o_web"]),+    ("every scope in the namespace", ["o_api", "o_global", "o_web"])+  ]++assertMatrix :: String -> [(String, [Text])] -> [[Text]] -> IO ()+assertMatrix label expectations actual =+  traverse_ assertRow (zip3 [0 :: Int ..] expectations actual)+  where+    assertRow (index, (what, expected), got) = do+      assertEqual (label <> ": " <> what) expected got+      assertBool+        ( label+            <> ": target "+            <> show index+            <> " returned a row from another memory space: "+            <> show got+        )+        (not (any (`elem` foreignIdsFor expected) got))++    -- Whichever space the expectations belong to, the other space's ids are the ones that must+    -- never appear.+    foreignIdsFor expected+      | any ("t_" `Text.isPrefixOf`) expected = otherSpaceIds+      | otherwise = testSpaceIds++-- | The three targets, in the order the expectation tables list them.+targetsUnderTest :: [RecallTarget]+targetsUnderTest =+  [ ExactScope (ScopeGlobal fixtureNamespace),+    ExactScope (ScopeEntity fixtureNamespace repoKind "web"),+    NamespaceWide fixtureNamespace+  ]++-- | Run one strategy's channels for one target, in the test space, and return the ids it found.+channelIds :: (Store :> es) => RecallStrategy -> RecallTarget -> Eff es [Text]+channelIds = channelIdsIn testSpace++-- | The same, in the second space.+mirroredIds :: (Store :> es) => RecallStrategy -> RecallTarget -> Eff es [Text]+mirroredIds = channelIdsIn otherSpace++channelIdsIn :: (Store :> es) => MemorySpaceId -> RecallStrategy -> RecallTarget -> Eff es [Text]+channelIdsIn space strategy target = do+  let resolved = request space target strategy+  case strategy of+    Keyword -> sortedIds <$> selectFtsCandidates resolved+    Embedding -> sortedIds <$> selectVectorCandidates resolved fixtureQueryVector+    Hybrid -> do+      ftsRows <- selectFtsCandidates resolved+      vecRows <- selectVectorCandidates resolved fixtureQueryVector+      -- The instant is irrelevant here: it only feeds the recency term of the blended score, and+      -- this case asserts which rows came back rather than in what order.+      pure (sort (fmap hitId (fuseRecallCandidates fixtureInstant ftsRows vecRows)))+  where+    hitId hit = hit.memory.memoryId++-- * The public entry point++-- | The same three targets through 'Kioku.Recall.recall' itself, keyword-only so that no+-- embedding endpoint is involved.+--+-- 'VectorExtensionUnavailable' makes the keyword plan a guarantee rather than a hope, which is+-- what lets the embedding model be 'undefined': the execution plan never asks for it.+testRecallEntryPoint :: IO ()+testRecallEntryPoint =+  withTargetFixture \runEff -> do+    result <- runEff do+      authorized <- traverse (runKeywordRecall testContext) targetsUnderTest+      -- The same three targets under a context that authorizes the other space. Identical+      -- requests, identical rows, different partition: nothing in a RecallQuery can choose it.+      mirrored <- traverse (runKeywordRecall otherContext) targetsUnderTest+      pure (authorized, mirrored)+    case result of+      Left err -> assertFailure ("store error: " <> show err)+      Right (authorized, mirrored) -> do+        assertMatrix "recall" testSpaceExpectations (fmap expectHits authorized)+        assertMatrix "recall, in the second space" otherSpaceExpectations (fmap expectHits mirrored)+  where+    expectHits =+      either (\err -> error ("recall refused: " <> show err)) id++runKeywordRecall ::+  (Store :> es, IOE :> es) =>+  MemoryAccessContext ->+  RecallTarget ->+  Eff es (Either RecallError [Text])+runKeywordRecall context target =+  fmap (sort . fmap (\hit -> hit.memory.memoryId))+    <$> recall+      undefinedModel+      VectorExtensionUnavailable+      context+      RecallQuery+        { target,+          query = fixtureContent,+          strategy = Keyword,+          maxResults = limitOf 10+        }++-- | The keyword channel never embeds, and 'VectorExtensionUnavailable' makes that a guarantee.+undefinedModel :: a+undefinedModel = error "the keyword channel must not embed"++-- * Plan evidence++-- | What the three bounds compile to, read back out of PostgreSQL rather than asserted about the+-- Haskell.+--+-- Two properties, and they are the two the split exists for:+--+-- 1. __Every plan is bounded by the partition.__ @memory_space_id@ appears in every plan, on+--    every channel, for every target. A statement that lost it would still pass every row-level+--    case above in a single-space fixture; this is what makes losing it loud.+-- 2. __The three bounds are three plans.__ The exact global bucket's plan names+--    @scope_kind IS NULL@, the exact entity's names an equality on @scope_kind@, and the+--    namespace-wide plan names neither. Before the split all three produced one plan with one+--    parameterised predicate, and no artifact anywhere could tell a reviewer which meaning had+--    been asked for.+--+-- @enable_seqscan = off@ is set for the same reason the corpus is small: the fixture holds six+-- rows, so an unconstrained planner would sequentially scan whatever it was asked and the plan+-- would say nothing about which access paths are /available/. Turning the sequential scan off+-- asks the question this case actually means — can this query be answered through a+-- partition-leading index? — and the answer is asserted below.+testBoundedPlans :: IO ()+testBoundedPlans =+  withTargetFixture \runEff -> do+    result <- runEff do+      available <- vectorTypeIsReachable+      keywordPlans <- traverse (planFor explainFtsCandidates . ftsPlan) targetsUnderTest+      vectorPlans <-+        ifAvailable+          available+          (traverse (planFor explainVectorExactCandidates . vectorPlan) targetsUnderTest)+      pure (keywordPlans, vectorPlans)+    case result of+      Left err -> assertFailure ("store error: " <> show err)+      Right (keywordPlans, vectorPlans) -> do+        assertPlans "keyword" keywordPlans+        case vectorPlans of+          Just plans -> assertPlans "vector (exact pass)" plans+          Nothing -> putStrLn skipMessage+  where+    ftsPlan target = ftsCandidateSql (request testSpace target Keyword)+    vectorPlan target =+      vectorCandidateSql (request testSpace target Embedding) fixtureQueryVector++    planFor explain compiled =+      Text.unlines+        <$> runTransaction do+          Tx.sql "SET LOCAL enable_seqscan = off"+          explain compiled++assertPlans :: String -> [Text] -> IO ()+assertPlans label plans =+  case plans of+    [globalPlan, entityPlan, widePlan] -> do+      traverse_ (assertPartitionBound label) plans+      assertBool+        (label <> ": the exact global plan does not test scope_kind for NULL\n" <> Text.unpack globalPlan)+        ("scope_kind IS NULL" `Text.isInfixOf` globalPlan)+      assertBool+        (label <> ": the exact entity plan does not compare scope_kind\n" <> Text.unpack entityPlan)+        ("scope_kind = " `Text.isInfixOf` entityPlan)+      assertBool+        ( label+            <> ": the namespace-wide plan constrains a scope, so it is not namespace-wide\n"+            <> Text.unpack widePlan+        )+        (not ("scope_kind" `Text.isInfixOf` widePlan))+      assertBool+        ( label+            <> ": the exact global and namespace-wide targets produced the same plan, which is \+               \the ambiguity this split removes\n"+            <> Text.unpack globalPlan+        )+        (globalPlan /= widePlan)+      -- "Partition-leading" is not a claim about which index was named; it is a claim about the+      -- shape of the access path, so it is asserted against the index condition itself.+      assertBool+        ( label+            <> ": a bound cannot be answered through an index whose condition leads with the \+               \memory space\n"+            <> Text.unpack (Text.unlines plans)+        )+        (all (Text.isInfixOf "Index Cond: ((memory_space_id") plans)+    other -> assertFailure (label <> ": expected three plans, got " <> show (length other))++assertPartitionBound :: String -> Text -> IO ()+assertPartitionBound label plan =+  assertBool+    (label <> ": a plan does not mention memory_space_id at all\n" <> Text.unpack plan)+    ("memory_space_id" `Text.isInfixOf` plan)++-- * Fixture++fixtureNamespace :: Namespace+fixtureNamespace = Namespace "mori"++repoKind :: ScopeKind+repoKind = ScopeKind "repo"++-- | Identical in both spaces and under every scope, so content can never be what separates the+-- rows a target returns.+fixtureContent :: Text+fixtureContent = "the deployment pipeline runs on nix flakes"++fixtureInstant :: UTCTime+fixtureInstant = read "2026-08-07 00:00:00 UTC"++testSpaceIds, otherSpaceIds :: [Text]+testSpaceIds = ["t_global", "t_web", "t_api"]+otherSpaceIds = ["o_global", "o_web", "o_api"]++-- | Three scopes in one namespace: the global bucket and two entity scopes under the same kind.+fixtureScopes :: [(Text, Text, MemoryScope)]+fixtureScopes =+  [ ("t_global", "o_global", ScopeGlobal fixtureNamespace),+    ("t_web", "o_web", ScopeEntity fixtureNamespace repoKind "web"),+    ("t_api", "o_api", ScopeEntity fixtureNamespace repoKind "api")+  ]++-- | The query vector the embedding and hybrid rows search with. It is one of the seeded vectors,+-- so the nearest row is exact — but every seeded row is within the pool, and the matrix asserts+-- which rows came back rather than their order.+fixtureQueryVector :: Vector Double+fixtureQueryVector = basisVector 0++request :: MemorySpaceId -> RecallTarget -> RecallStrategy -> ResolvedRecall+request space target strategy =+  resolveRecall+    space+    RecallQuery {target, query = fixtureContent, strategy, maxResults = limitOf 10}++limitOf :: Int -> RecallLimit+limitOf = either (error . Text.unpack) id . mkRecallLimit++sortedIds :: [MemoryRecord] -> [Text]+sortedIds = sort . fmap (\row -> row.memoryId)++ifAvailable :: (Applicative f) => Bool -> f a -> f (Maybe a)+ifAvailable False _ = pure Nothing+ifAvailable True action = Just <$> action++-- | A silent skip is indistinguishable from a pass, so the vector rows say so out loud.+skipMessage :: String+skipMessage =+  "  [skipped] no reachable pgvector on this cluster; re-enter the dev shell to exercise the \+  \vector rows of the target matrix"++-- | Six rows: three scopes, twice, one set per memory space. Each carries a distinct embedding so+-- the vector channel has something to rank, and identical content so the keyword channel matches+-- all six.+seedFixture :: (Store :> es) => Bool -> Eff es ()+seedFixture withEmbeddings = do+  runTransaction . Tx.sql . encodeUtf8 $+    "INSERT INTO kioku.memories \+    \(memory_space_id, memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at) VALUES "+      <> Text.intercalate ", " (concatMap rowsFor fixtureScopes)+  if withEmbeddings+    then traverse_ setEmbedding (zip [0 ..] (testSpaceIds <> otherSpaceIds))+    else pure ()+  where+    rowsFor (testId, otherId, scope) =+      [row testSpace testId scope, row otherSpace otherId scope]++    row space memoryId scope =+      "('"+        <> memorySpaceIdText space+        <> "', '"+        <> memoryId+        <> "', 'agent', '"+        <> namespaceOf scope+        <> "', "+        <> sqlText (kindOf scope)+        <> ", "+        <> sqlText (refOf scope)+        <> ", 'fact', '"+        <> fixtureContent+        <> "', 'active', now(), now())"++    namespaceOf = \case+      ScopeGlobal (Namespace ns) -> ns+      ScopeEntity (Namespace ns) _ _ -> ns+    kindOf = \case+      ScopeGlobal _ -> Nothing+      ScopeEntity _ (ScopeKind kind) _ -> Just kind+    refOf = \case+      ScopeGlobal _ -> Nothing+      ScopeEntity _ _ ref -> Just ref++    sqlText = maybe "NULL" (\value -> "'" <> value <> "'")++setEmbedding :: (Store :> es) => (Int, Text) -> Eff es ()+setEmbedding (i, memoryId) =+  runTransaction . Tx.sql . encodeUtf8 $+    "UPDATE kioku.memories SET embedding = '"+      <> vectorLiteral (basisVector i)+      <> "'::vector WHERE memory_id = '"+      <> memoryId+      <> "'"++-- | A 1536-dimension basis vector. Distinct basis vectors are cosine-orthogonal, so the ranking+-- among them is unambiguous and none of them is ever equidistant from the query.+basisVector :: Int -> Vector Double+basisVector i = Vector.generate 1536 (\j -> if j == i then 1 else 0)++-- | Whether /this connection/ can name the @vector@ type, which is what the @$1::vector@ cast in+-- the vector statements needs.+vectorTypeIsReachable :: (Store :> es) => Eff es Bool+vectorTypeIsReachable =+  runTransaction (Tx.statement () stmt)+  where+    stmt :: Statement () Bool+    stmt =+      preparable+        "SELECT to_regtype('vector') IS NOT NULL"+        E.noParams+        (D.singleRow (D.column (D.nonNullable D.bool)))++-- | A migrated throwaway database, seeded once, handed to the case as a runner.+withTargetFixture :: ((forall a. Eff AppEffects a -> IO (Either StoreError a)) -> IO ()) -> IO ()+withTargetFixture use =+  withKiokuMigratedDatabase \connStr ->+    withNoopAppEnv (defaultConnectionSettings connStr) \env -> do+      seeded <- runAppIO env do+        available <- vectorTypeIsReachable+        seedFixture available+      case seeded of+        Left err -> assertFailure ("seeding failed: " <> show err)+        Right () -> use (runAppIO env)
test/Kioku/SchemaSpec.hs view
@@ -29,11 +29,21 @@           testCase "two global personas in one namespace collide" (assertSqlState "23505" duplicateGlobalPersonas)         ],       testGroup+        "the same scope in two memory spaces is two rows, not a collision"+        [ testCase "two spaces may hold the same global scene key" (assertAccepted crossSpaceGlobalScenes),+          testCase "two spaces may hold the same global persona" (assertAccepted crossSpaceGlobalPersonas)+        ],+      testGroup         "a scope is global or an entity scope, never half of one"         [ testCase "a memory with a kind and no ref is rejected" (assertSqlState "23514" (halfScopedMemory "'kind-without-ref'" "NULL")),           testCase "a memory with a ref and no kind is rejected" (assertSqlState "23514" (halfScopedMemory "NULL" "'ref-without-kind'"))         ],-      testCase "the chain and session-list indexes exist and the redundant turns index is gone" testIndexes+      testGroup+        "every partitioned row names a memory space"+        [ testCase "a memory with no memory space is rejected" (assertSqlState "23502" unpartitionedMemory),+          testCase "a memory with an empty memory space is rejected" (assertSqlState "23514" emptySpaceMemory)+        ],+      testCase "the chain and session-list indexes exist and the redundant ones are gone" testIndexes     ]  -- * Constraint cases@@ -45,33 +55,72 @@ duplicateGlobalScenes :: Text duplicateGlobalScenes =   """-  INSERT INTO kiroku.kioku_scenes (scene_id, namespace, scene_key, title, body_md, source_hash)-  VALUES ('scene-a', 'ns', 'default', 't', 'b', 'h'),-         ('scene-b', 'ns', 'default', 't', 'b', 'h')+  INSERT INTO kioku.scenes (memory_space_id, scene_id, namespace, scene_key, title, body_md, source_hash)+  VALUES ('space_a', 'scene-a', 'ns', 'default', 't', 'b', 'h'),+         ('space_a', 'scene-b', 'ns', 'default', 't', 'b', 'h')   """  duplicateGlobalPersonas :: Text duplicateGlobalPersonas =   """-  INSERT INTO kiroku.kioku_personas (persona_id, namespace, body_md, source_hash)-  VALUES ('persona-a', 'ns', 'b', 'h'),-         ('persona-b', 'ns', 'b', 'h')+  INSERT INTO kioku.personas (memory_space_id, persona_id, namespace, body_md, source_hash)+  VALUES ('space_a', 'persona-a', 'ns', 'b', 'h'),+         ('space_a', 'persona-b', 'ns', 'b', 'h')   """ +-- | The other half of the same constraint. Scene and persona ids are derived from the scope+-- alone, so two spaces using the same namespace produce the same id — which is exactly why the+-- primary key had to become composite. Both of these rows must be accepted.+crossSpaceGlobalScenes :: Text+crossSpaceGlobalScenes =+  """+  INSERT INTO kioku.scenes (memory_space_id, scene_id, namespace, scene_key, title, body_md, source_hash)+  VALUES ('space_a', 'kioku_scene:ns:default', 'ns', 'default', 't', 'b', 'h'),+         ('space_b', 'kioku_scene:ns:default', 'ns', 'default', 't', 'b', 'h')+  """++crossSpaceGlobalPersonas :: Text+crossSpaceGlobalPersonas =+  """+  INSERT INTO kioku.personas (memory_space_id, persona_id, namespace, body_md, source_hash)+  VALUES ('space_a', 'kioku_persona:ns', 'ns', 'b', 'h'),+         ('space_b', 'kioku_persona:ns', 'ns', 'b', 'h')+  """+ -- | A row with exactly one of the two scope columns set. 'Kioku.Api.Scope.scopeFromColumns' -- reads it back as a global scope, yet no exact-scope query matches it -- so the row is -- invisible to both halves of the API. The CHECK makes it unwritable. Both arguments are -- SQL literals, so a case can pass @NULL@ for the column it wants to leave unset. halfScopedMemory :: Text -> Text -> Text halfScopedMemory scopeKind scopeRef =-  "INSERT INTO kiroku.kioku_memories"-    <> " (memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, created_at, updated_at)"-    <> " VALUES ('m-half', 'agent', 'ns', "+  "INSERT INTO kioku.memories"+    <> " (memory_space_id, memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, created_at, updated_at)"+    <> " VALUES ('space_a', 'm-half', 'agent', 'ns', "     <> scopeKind     <> ", "     <> scopeRef     <> ", 'fact', 'content', now(), now())" +-- | No space at all. A NULL would have to be interpreted by every query, and the only two+-- available interpretations -- invisible, or visible everywhere -- are both wrong.+unpartitionedMemory :: Text+unpartitionedMemory =+  """+  INSERT INTO kioku.memories+    (memory_id, agent_id, namespace, memory_type, content, created_at, updated_at)+  VALUES ('m-unpartitioned', 'agent', 'ns', 'fact', 'content', now(), now())+  """++-- | The empty string is not a space either. Allowing it would give "no space" a second+-- spelling, and one that satisfies NOT NULL.+emptySpaceMemory :: Text+emptySpaceMemory =+  """+  INSERT INTO kioku.memories+    (memory_space_id, memory_id, agent_id, namespace, memory_type, content, created_at, updated_at)+  VALUES ('', 'm-empty-space', 'agent', 'ns', 'fact', 'content', now(), now())+  """+ assertSqlState :: Text -> Text -> IO () assertSqlState expected sql =   withMigratedConnection \conn -> do@@ -85,6 +134,14 @@           Just actual -> actual @?= expected           Nothing -> assertFailure ("expected SQLSTATE " <> Text.unpack expected <> ", got: " <> show err) +assertAccepted :: Text -> IO ()+assertAccepted sql =+  withMigratedConnection \conn -> do+    result <- Connection.use conn (Session.script sql)+    case result of+      Right () -> pure ()+      Left err -> assertFailure ("expected the statement to be accepted, got: " <> show err)+ sqlState :: SessionError -> Maybe Text sqlState = \case   ScriptSessionError _ (ServerError code _ _ _ _) -> Just code@@ -104,18 +161,36 @@           (\name -> assertBool (Text.unpack name <> " is missing") (name `elem` indexes))           [ "kioku_memories_supersedes_idx",             "kioku_memories_superseded_by_idx",-            "kioku_sessions_namespace_started_idx",-            "kioku_sessions_namespace_focus_idx"+            -- Every index whose leading columns were a namespace or a scope is rebuilt with+            -- the memory space in front of them, because every such query now carries a space+            -- predicate and the space is the most selective column available.+            "kioku_memories_space_scope_idx",+            "kioku_memories_space_type_idx",+            "kioku_memories_space_namespace_idx",+            "kioku_sessions_space_scope_idx",+            "kioku_sessions_space_namespace_started_idx",+            "kioku_sessions_space_namespace_focus_idx",+            "kioku_sessions_space_awaiting_corr_idx",+            "kioku_consolidation_space_scope_idx"           ]-        assertBool-          "kioku_turns_session_idx still exists; it duplicates the index implied by UNIQUE (session_id, turn_index)"-          ("kioku_turns_session_idx" `notElem` indexes)+        mapM_+          (\(name, why) -> assertBool (Text.unpack name <> " still exists; " <> why) (name `notElem` indexes))+          [ ("kioku_turns_session_idx", "it duplicates the index implied by UNIQUE (session_id, turn_index)"),+            ("kioku_scenes_scope_idx", "it duplicates the prefix of kioku_scenes_scope_scene_key_unique"),+            ("kioku_memories_scope_idx", "it was replaced by the partition-leading kioku_memories_space_scope_idx"),+            ("kioku_memories_type_idx", "it was replaced by the partition-leading kioku_memories_space_type_idx"),+            ("kioku_sessions_scope_idx", "it was replaced by the partition-leading kioku_sessions_space_scope_idx"),+            ("kioku_sessions_namespace_started_idx", "it was replaced by its partition-leading rebuild"),+            ("kioku_sessions_namespace_focus_idx", "it was replaced by its partition-leading rebuild"),+            ("kioku_sessions_awaiting_corr_idx", "it was replaced by its partition-leading rebuild"),+            ("kioku_consolidation_scope_idx", "it was replaced by its partition-leading rebuild")+          ]  -- | @pg_indexes.indexname@ is a @name@, not a @text@; the cast is what lets hasql decode it. selectKiokuIndexes :: Statement () [Text] selectKiokuIndexes =   preparable-    "SELECT indexname::text FROM pg_indexes WHERE schemaname = 'kiroku'"+    "SELECT indexname::text FROM pg_indexes WHERE schemaname = 'kioku'"     E.noParams     (D.rowList (D.column (D.nonNullable D.text))) 
test/Kioku/ScopeIdentitySpec.hs view
@@ -8,6 +8,7 @@ import Kioku.Distill.L2 (l2SceneTimerId, sceneRowId) import Kioku.Distill.L3 (l3PersonaTimerId, personaRowId) import Kioku.Distill.ScopeIdentity (escapeScopeComponent, scopeIdentity, scopeSlugFromColumns)+import Kioku.SpaceFixtures (otherSpace, testSpace) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (Assertion, assertBool, testCase, (@?=)) @@ -19,9 +20,29 @@       testCase "well-formed scopes keep their exact legacy ids" testLegacyStability,       testCase "escaping is injective on adversarial components" testEscapeInjective,       testCase "mirror slugs separate scopes the sanitiser cannot" testSlugCollision,-      testCase "namespace and kind reject the reserved characters" testValidators+      testCase "namespace and kind reject the reserved characters" testValidators,+      testCase "one scope in two memory spaces derives different timer ids" testSpaceSeparatesTimers     ] +-- | The scene and persona timers are keyed by a scope, and two memory spaces are allowed to+-- use the same one. Without the space in the id, keiro's @scheduleTimerTx@ upsert would treat+-- one space's regeneration as a re-arming of the other's and only one payload would survive.+testSpaceSeparatesTimers :: Assertion+testSpaceSeparatesTimers = do+  assertAllDistinct+    "scene timer id across spaces"+    [ show (l2SceneTimerId testSpace collidingEntity "src"),+      show (l2SceneTimerId otherSpace collidingEntity "src")+    ]+  assertAllDistinct+    "persona timer id across spaces"+    [ show (l3PersonaTimerId testSpace collidingEntity fireAt),+      show (l3PersonaTimerId otherSpace collidingEntity fireAt)+    ]+  where+    fireAt :: UTCTime+    fireAt = read "2026-07-11 00:00:00 UTC"+ -- | The canonical collision. Both of these used to render @a/b/c@, so they shared one scene -- row, one persona row, one timer id and one mirror file — and the upserts do not update the -- scope columns on conflict, so the second scope's content landed on the first scope's row.@@ -38,10 +59,14 @@   assertAllDistinct "persona row id" [personaRowId collidingGlobal, personaRowId collidingEntity]   assertAllDistinct     "scene timer id"-    [show (l2SceneTimerId collidingGlobal "src"), show (l2SceneTimerId collidingEntity "src")]+    [ show (l2SceneTimerId testSpace collidingGlobal "src"),+      show (l2SceneTimerId testSpace collidingEntity "src")+    ]   assertAllDistinct     "persona timer id"-    [show (l3PersonaTimerId collidingGlobal fireAt), show (l3PersonaTimerId collidingEntity fireAt)]+    [ show (l3PersonaTimerId testSpace collidingGlobal fireAt),+      show (l3PersonaTimerId testSpace collidingEntity fireAt)+    ]   assertAllDistinct     "mirror slug"     [ scopeSlugFromColumns "a/b/c" Nothing Nothing,
test/Kioku/SessionInvariantsSpec.hs view
@@ -5,7 +5,8 @@ module Kioku.SessionInvariantsSpec (tests) where  import Control.Monad (void)-import Data.Aeson (Value, object, toJSON, (.=))+import Data.Aeson (Value (..), object, toJSON, (.=))+import Data.Aeson.KeyMap qualified as KeyMap import Data.Text (Text) import Data.Time (UTCTime (..), fromGregorian, getCurrentTime) import Data.Vector qualified as Vector@@ -14,6 +15,12 @@ import Keiro.Command (CommandError (..), defaultRunCommandOptions) import Keiro.Projection (runCommandWithProjections) import Keiro.Stream qualified as Stream+import Kioku.Api.Access+  ( MemorySpaceId,+    RecordedPrincipal (..),+    legacyMemorySpaceId,+    legacyPrincipalRef,+  ) import Kioku.Api.Scope (MemoryScope (..), Namespace (..)) import Kioku.App (AppEffects, runAppIO, withNoopAppEnv) import Kioku.Distill.Timer (l1TimerScheduleProjection)@@ -33,6 +40,7 @@   ) import Kioku.Session.EventStream (parseSessionEvent, sessionEventStream, sessionStream) import Kioku.Session.ReadModel (SessionRow (..), sessionInlineProjection)+import Kioku.SpaceFixtures (testActorPrincipal, testContext, testSpace) import Kiroku.Store.Append (appendToStream) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store)@@ -85,6 +93,8 @@         ( ResumeSession             ResumeSessionData               { sessionId = sid,+                memorySpaceId = testSpace,+                actorPrincipal = testActorPrincipal,                 correlationKey = Just "k2",                 force = False,                 input = "approved",@@ -112,13 +122,15 @@     let staleResume =           ResumeSessionData             { sessionId = sid,+              memorySpaceId = testSpace,+              actorPrincipal = testActorPrincipal,               correlationKey = Just "k1",               force = False,               input = "stale answer",               resumedAt = now             }     -- Through the public API the precheck catches it early ...-    apiResult <- Session.resume staleResume+    apiResult <- Session.resumeWithContext testContext staleResume     case apiResult of       Left Session.SessionCorrelationMismatch -> pure ()       other -> liftIO (assertFailure ("expected SessionCorrelationMismatch, got " <> show other))@@ -138,8 +150,8 @@     sid <- startFixture     parkFixture sid (Just "k1")     now <- liftIO getCurrentTime-    result <- Session.forceResume sid "forced answer" now-    void (liftEither "Session.forceResume" result)+    result <- Session.forceResumeWithContext testContext sid "forced answer" now+    void (liftEither "Session.forceResumeWithContext" result)     row <- getExisting sid     liftIO do       assertEqual "force resume ran" "running" row.status@@ -180,6 +192,8 @@         ( ResumeSession             ResumeSessionData               { sessionId = sid,+                memorySpaceId = testSpace,+                actorPrincipal = testActorPrincipal,                 correlationKey = Just "unexpected",                 force = False,                 input = "approved",@@ -217,7 +231,7 @@ -- through the new guard: a rejected historical event would surface as -- 'HydrationReplayFailed' instead. ----- The command runs through 'runCommandWithProjections' rather than 'Session.awaitInput'+-- The command runs through 'runCommandWithProjections' rather than 'Session.awaitInputWithContext' -- because a hand-appended stream has no read-model row, and the public API's precheck -- would fail with 'SessionNotFound' before hydration was ever attempted. hydrateLegacyStream :: Maybe Text -> Assertion@@ -228,7 +242,10 @@     let started =           SessionStarted             SessionStartedData-              { sessionId = sid,+              { memorySpaceId = legacyMemorySpaceId,+                actorPrincipal = LegacyPrincipal (legacyPrincipalRef "test-agent"),+                ownerPrincipal = Nothing,+                sessionId = sid,                 agentId = "test-agent",                 focus = "legacy replay",                 scope = testScope,@@ -241,33 +258,38 @@         awaiting =           SessionAwaiting             SessionAwaitingData-              { sessionId = sid,+              { memorySpaceId = legacyMemorySpaceId,+                actorPrincipal = UnattributedPrincipal,+                sessionId = sid,                 reason = "approval",                 correlationKey = Just "k1",                 deadline = Nothing,                 awaitedAt = now               }+    -- Written as an older kioku wrote them: no partition keys at all.     void $       appendToStream         (Stream.streamName (sessionStream sid))         NoStream-        [ rawEvent "SessionStarted" (toJSON started),-          rawEvent "SessionAwaiting" (toJSON awaiting),+        [ rawEvent "SessionStarted" (withoutPartitionKeys (toJSON started)),+          rawEvent "SessionAwaiting" (withoutPartitionKeys (toJSON awaiting)),           rawEvent "SessionResumed" (legacyResumedPayload sid resumedKey now)         ]-    -- The stream now ends in Running. A fresh AwaitInput must hydrate it and be accepted.+    -- A command in some *other* space must not be able to drive this session. The events+    -- carry no partition, so this is the assertion that a missing partition resolves to the+    -- legacy space rather than to "any space".+    crossSpace <-+      runSessionCommandDirect sid (AwaitInput (awaitIn testSpace testActorPrincipal sid now))+    case crossSpace of+      Left _ -> pure ()+      Right _ ->+        liftIO (assertFailure "a command in another memory space drove a legacy session")+    -- The stream ends in Running. A fresh AwaitInput in the legacy space must hydrate it and+    -- be accepted.     result <-       runSessionCommandDirect         sid-        ( AwaitInput-            AwaitInputData-              { sessionId = sid,-                reason = "approval",-                correlationKey = Just "k2",-                deadline = Nothing,-                awaitedAt = now-              }-        )+        (AwaitInput (awaitIn legacyMemorySpaceId UnattributedPrincipal sid now))     case result of       Right _ -> pure ()       Left err ->@@ -279,6 +301,29 @@         ["SessionStarted", "SessionAwaiting", "SessionResumed", "SessionAwaiting"]         (eventName <$> events) +-- | An @AwaitInput@ for a given space, so the two attempts above differ in exactly one thing.+awaitIn :: MemorySpaceId -> RecordedPrincipal -> SessionId -> UTCTime -> AwaitInputData+awaitIn memorySpaceId actorPrincipal sid now =+  AwaitInputData+    { sessionId = sid,+      memorySpaceId,+      actorPrincipal,+      reason = "approval",+      correlationKey = Just "k2",+      deadline = Nothing,+      awaitedAt = now+    }++-- | Strip the partition keys an older kioku never wrote.+--+-- Building the value and then removing the keys keeps the fixture honest in both directions: it+-- is exactly the current encoder's output minus the fields this change added, so it cannot+-- drift away from the real shape and cannot accidentally test the new one.+withoutPartitionKeys :: Value -> Value+withoutPartitionKeys = \case+  Object o -> Object (foldr KeyMap.delete o ["memorySpaceId", "actorPrincipal", "ownerPrincipal"])+  other -> other+ -- * Turn identity  testTurnReRecordIsDuplicate :: Assertion@@ -286,23 +331,23 @@   withApp do     sid <- startFixture     let turn = turnData sid "turn-a" 0 "hello"-    void (liftEither "first recordTurn" =<< Session.recordTurn turn)-    void (liftEither "duplicate recordTurn" =<< Session.recordTurn turn)+    void (liftEither "first recordTurn" =<< Session.recordTurnWithContext testContext turn)+    void (liftEither "duplicate recordTurn" =<< Session.recordTurnWithContext testContext turn)     events <- readSessionEvents sid     liftIO $       assertEqual         "the duplicate appended no second TurnRecorded"         1         (length [() | TurnRecorded _ <- events])-    turns <- liftEither "getTurns" =<< Session.getTurns sid+    turns <- liftEither "getTurns" =<< Session.getTurns testSpace sid     liftIO $ assertEqual "exactly one turn row" 1 (length turns)  testTurnSameIndexConflict :: Assertion testTurnSameIndexConflict =   withApp do     sid <- startFixture-    void (liftEither "first recordTurn" =<< Session.recordTurn (turnData sid "turn-a" 0 "hello"))-    result <- Session.recordTurn (turnData sid "turn-a" 0 "something else")+    void (liftEither "first recordTurn" =<< Session.recordTurnWithContext testContext (turnData sid "turn-a" 0 "hello"))+    result <- Session.recordTurnWithContext testContext (turnData sid "turn-a" 0 "something else")     liftIO $ assertConflict "same index, different content" result     events <- readSessionEvents sid     liftIO $@@ -312,8 +357,8 @@ testTurnIdReuseConflict =   withApp do     sid <- startFixture-    void (liftEither "first recordTurn" =<< Session.recordTurn (turnData sid "turn-a" 0 "hello"))-    result <- Session.recordTurn (turnData sid "turn-a" 1 "a new turn reusing the id")+    void (liftEither "first recordTurn" =<< Session.recordTurnWithContext testContext (turnData sid "turn-a" 0 "hello"))+    result <- Session.recordTurnWithContext testContext (turnData sid "turn-a" 1 "a new turn reusing the id")     liftIO $ assertConflict "turn id reused at a different index" result     events <- readSessionEvents sid     liftIO $@@ -325,8 +370,8 @@ testAggregateRejectsNonIncreasingTurn =   withApp do     sid <- startFixture-    void (liftEither "recordTurn 0" =<< Session.recordTurn (turnData sid "turn-a" 0 "hello"))-    void (liftEither "recordTurn 1" =<< Session.recordTurn (turnData sid "turn-b" 1 "again"))+    void (liftEither "recordTurn 0" =<< Session.recordTurnWithContext testContext (turnData sid "turn-a" 0 "hello"))+    void (liftEither "recordTurn 1" =<< Session.recordTurnWithContext testContext (turnData sid "turn-b" 1 "again"))     now <- liftIO getCurrentTime     result <-       runSessionCommandDirect@@ -334,6 +379,8 @@         ( RecordTurn             RecordTurnData               { sessionId = sid,+                memorySpaceId = testSpace,+                actorPrincipal = testActorPrincipal,                 turnId = "turn-c",                 turnIndex = 1,                 role = "user",@@ -353,6 +400,8 @@ turnData sid turnId turnIndex content =   RecordTurnData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,       turnId,       turnIndex,       role = "user",@@ -436,9 +485,13 @@   sid <- liftIO genSessionId   now <- liftIO getCurrentTime   result <--    Session.start+    Session.startWithContext+      testContext       StartSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,+          ownerPrincipal = Nothing,           agentId = "test-agent",           focus = "session invariants",           scope = testScope,@@ -448,7 +501,7 @@           delegationDepth = 0,           startedAt = now         }-  void (liftEither "Session.start" result)+  void (liftEither "Session.startWithContext" result)   pure sid  parkFixture ::@@ -459,15 +512,18 @@ parkFixture sid key = do   now <- liftIO getCurrentTime   result <--    Session.awaitInput+    Session.awaitInputWithContext+      testContext       AwaitInputData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,           reason = "approval",           correlationKey = key,           deadline = Nothing,           awaitedAt = now         }-  void (liftEither "Session.awaitInput" result)+  void (liftEither "Session.awaitInputWithContext" result)  resumeFixture ::   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>@@ -478,22 +534,25 @@ resumeFixture sid key input = do   now <- liftIO getCurrentTime   result <--    Session.resume+    Session.resumeWithContext+      testContext       ResumeSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,           correlationKey = key,           force = False,           input,           resumedAt = now         }-  void (liftEither "Session.resume" result)+  void (liftEither "Session.resumeWithContext" result)  getExisting ::   (IOE :> es, Store :> es) =>   SessionId ->   Eff es SessionRow getExisting sid = do-  result <- Session.getById sid >>= liftEither "Session.getById"+  result <- Session.getById testSpace sid >>= liftEither "Session.getById"   case result of     Nothing -> liftIO (assertFailure ("missing session row " <> show (idText sid)))     Just row -> pure row
test/Kioku/SessionLineageSpec.hs view
@@ -8,6 +8,7 @@ import Effectful (Eff, IOE, liftIO, (:>)) import Effectful.Error.Static (Error) import Hasql.Transaction qualified as Tx+import Kioku.Api.Access (memorySpaceIdText) import Kioku.Api.Scope (MemoryScope (..), Namespace (..)) import Kioku.App (runAppIO, withNoopAppEnv) import Kioku.Id (SessionId, genSessionId, idText)@@ -15,6 +16,7 @@ import Kioku.Session qualified as Session import Kioku.Session.Domain (StartSessionData (..)) import Kioku.Session.ReadModel (SessionRow (..))+import Kioku.SpaceFixtures (testActorPrincipal, testContext, testSpace) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -63,8 +65,8 @@           startFixture parent "parent" Nothing Nothing 0 now           startFixture child1 "child-1" Nothing (Just parent) 1 (addUTCTime 1 now)           startFixture child2 "child-2" (Just child1) (Just parent) 1 child2Started-          children <- Session.getDelegationChildren parent >>= liftEither "getDelegationChildren"-          childChain <- Session.getChain child2 >>= liftEither "getChain"+          children <- Session.getDelegationChildren testSpace parent >>= liftEither "getDelegationChildren"+          childChain <- Session.getChain testSpace child2 >>= liftEither "getChain"           pure LineageResult {children, childChain}       case result of         Left storeErr -> assertFailure ("store error: " <> show storeErr)@@ -83,6 +85,9 @@ baseStart sid _other startedAt =   StartSessionData     { sessionId = sid,+      memorySpaceId = testSpace,+      actorPrincipal = testActorPrincipal,+      ownerPrincipal = Nothing,       agentId = "test-agent",       focus = "delegation lineage",       scope = ScopeGlobal (Namespace "kioku-test"),@@ -108,7 +113,7 @@       sid <- genSessionId       other <- genSessionId       now <- getCurrentTime-      result <- runAppIO env (Session.start (mkCommand sid other now))+      result <- runAppIO env (Session.startWithContext testContext (mkCommand sid other now))       case result of         Left storeErr -> assertFailure ("store error: " <> show storeErr)         Right (Left (Session.SessionInvalidLineage _)) -> pure ()@@ -126,9 +131,13 @@   Eff es () startFixture sid agent previous parent depth startedAt = do   result <--    Session.start+    Session.startWithContext+      testContext       StartSessionData         { sessionId = sid,+          memorySpaceId = testSpace,+          actorPrincipal = testActorPrincipal,+          ownerPrincipal = Nothing,           agentId = agent,           focus = "delegation lineage",           scope = ScopeGlobal (Namespace "kioku-test"),@@ -138,9 +147,9 @@           delegationDepth = depth,           startedAt         }-  void (liftEither "Session.start" result)+  void (liftEither "Session.startWithContext" result) --- | Insert two sessions that name each other as predecessor, bypassing 'Session.start'+-- | Insert two sessions that name each other as predecessor, bypassing 'Session.startWithContext' -- (which now refuses to create a cycle), then walk the chain. The assertion is simply that -- the query returns at all — and returns each session exactly once, rather than looping. testChainTerminatesOnCycle :: IO ()@@ -152,7 +161,7 @@       result <-         runAppIO env do           insertCyclicPair a b-          Session.getChain a >>= liftEither "getChain"+          Session.getChain testSpace a >>= liftEither "getChain"       case result of         Left storeErr -> assertFailure ("store error: " <> show storeErr)         Right chain -> do@@ -169,16 +178,22 @@   Eff es () insertCyclicPair a b =   runTransaction . Tx.sql . encodeUtf8 $-    "INSERT INTO kioku_sessions (session_id, agent_id, focus, namespace, delegation_depth, status, started_at, previous_session_id) VALUES "+    "INSERT INTO kioku.sessions (memory_space_id, session_id, agent_id, focus, namespace, delegation_depth, status, started_at, previous_session_id) VALUES "       <> "('"+      <> space+      <> "','"       <> idText a       <> "','t','f','kioku-test',0,'completed',NOW(),'"       <> idText b       <> "'),('"+      <> space+      <> "','"       <> idText b       <> "','t','f','kioku-test',0,'completed',NOW() - interval '1 second','"       <> idText a       <> "')"+  where+    space = memorySpaceIdText testSpace  liftEither :: (Show e, IOE :> es) => String -> Either e a -> Eff es a liftEither label = \case
+ test/Kioku/SpaceFixtures.hs view
@@ -0,0 +1,89 @@+-- | Shared memory-space and principal fixtures for the test suite.+--+-- Every write in Kioku now needs a 'MemoryAccessContext', and most tests do not care which one —+-- they care about turns, watermarks, or recall. Those use 'testContext'.+--+-- It deliberately names a space that is /not/ 'Kioku.Api.Access.legacyMemorySpaceId'. If the+-- suite ran entirely in the legacy space, every test would keep passing on the day some path+-- started silently defaulting to it, which is the exact failure this work exists to prevent.+--+-- 'otherContext' is for the tests that need a second space to be refused from.+module Kioku.SpaceFixtures+  ( testSpace,+    otherSpace,+    testActor,+    otherActor,+    testContext,+    otherContext,+    legacyContext,+    testActorPrincipal,+    otherActorPrincipal,+    testContextProvider,+    contextFor,+    spaceNamed,+  )+where++import Data.Text (Text)+import Data.Text qualified as Text+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryActor (..),+    MemoryContextProvider,+    MemorySpaceId,+    RecordedPrincipal,+    assumeAuthorizedContextProvider,+    assumeAuthorizedMemoryContext,+    legacyMemorySpaceId,+    memoryContextRecordedActor,+    mkMemorySpaceId,+    mkPrincipalRef,+  )++testSpace :: MemorySpaceId+testSpace = spaceNamed "space_test"++otherSpace :: MemorySpaceId+otherSpace = spaceNamed "space_other"++testActor :: MemoryActor+testActor = actorNamed "agent_01h9xk3v7hf8b9c0d1e2f3g4h5"++otherActor :: MemoryActor+otherActor = actorNamed "agent_01h9xk3v7hf8b9c0d1e2f3g4h6"++testContext :: MemoryAccessContext+testContext = contextFor testSpace testActor++otherContext :: MemoryAccessContext+otherContext = contextFor otherSpace otherActor++-- | The space every row written before memory spaces existed was backfilled into. Tests that+-- rehearse an upgrade — a pre-partition event stream, a pre-partition timer payload — act in+-- this one, because it is the only space such data can be in.+legacyContext :: MemoryAccessContext+legacyContext = contextFor legacyMemorySpaceId testActor++-- | The provider a background worker under test uses: authorized for whatever space the work+-- names, as 'testActor'.+testContextProvider :: (Applicative m) => MemoryContextProvider m+testContextProvider = assumeAuthorizedContextProvider testActor++-- | The actor as an event payload records it.+testActorPrincipal :: RecordedPrincipal+testActorPrincipal = memoryContextRecordedActor testContext++otherActorPrincipal :: RecordedPrincipal+otherActorPrincipal = memoryContextRecordedActor otherContext++contextFor :: MemorySpaceId -> MemoryActor -> MemoryAccessContext+contextFor = assumeAuthorizedMemoryContext++spaceNamed :: Text -> MemorySpaceId+spaceNamed = expectRight . mkMemorySpaceId++actorNamed :: Text -> MemoryActor+actorNamed = MemoryActor . expectRight . mkPrincipalRef++expectRight :: Either Text a -> a+expectRight = either (error . Text.unpack) id
+ test/Kioku/SpaceIsolationSpec.hs view
@@ -0,0 +1,537 @@+-- | Two memory spaces holding the same namespace, the same scope, the same content, and the+-- same derived artifact keys — and every public read returning only the one it was asked for.+--+-- "Kioku.MemorySpaceSpec" proves the write side: a command naming another space cannot change+-- anything. This module proves the read side, which is the half the schema had to grow a column+-- for. The fixture is deliberately maximal: if any query anywhere still ignored the partition,+-- these rows are indistinguishable by every other column and it would show up immediately.+--+-- The last group is about plans rather than rows. A predicate that is correct but unindexed+-- degrades into a scan of every space's data, which is a correctness-preserving way to leak+-- one tenant's load onto another; asserting the partition-leading index is chosen is what keeps+-- the boundary cheap as well as real.+module Kioku.SpaceIsolationSpec (tests) where++import Data.Foldable (traverse_)+import Data.Functor.Contravariant ((>$<))+import Data.List (sort)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding (encodeUtf8)+import Data.Text.IO qualified as TextIO+import Data.Text.Read qualified as Text.Read+import Data.Time (addUTCTime)+import Effectful (Eff, IOE, (:>))+import Effectful.Error.Static (Error)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemorySpaceId,+    memoryContextRecordedActor,+    memoryContextSpace,+    memorySpaceIdText,+  )+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Kioku.Api.Types (Confidence (..), MemoryRecord (..), MemoryType (..))+import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)+import Kioku.Distill.L2 (SceneRow (..), getScenesByScope, mirrorSceneToWorkspace)+import Kioku.Distill.L3 (PersonaRow (..), getPersonaByScope, mirrorPersonaToWorkspace)+import Kioku.Id (MemoryId, SessionId, genMemoryId, genSessionId, idText)+import Kioku.Memory qualified as Memory+import Kioku.Memory.Domain (RecordMemoryData (..))+import Kioku.Memory.ReadModel (MemoryRow (..))+import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)+import Kioku.Prelude+import Kioku.ReadModel (ReadModelSchema (..), ReconcileOutcome (..), reconcileReadModelRegistry)+import Kioku.Recall qualified as Recall+import Kioku.Recall.Capability (VectorCapability (..))+import Kioku.Session qualified as Session+import Kioku.Session.Domain (AwaitInputData (..), RecordTurnData (..), StartSessionData (..))+import Kioku.Session.ReadModel (SessionRow (..), TurnRow (..))+import Kioku.SpaceFixtures (otherContext, otherSpace, testContext, testSpace)+import Kiroku.Store.Connection (defaultConnectionSettings)+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Transaction (runTransaction)+import System.IO.Temp (withSystemTempDirectory)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, testCase)++tests :: TestTree+tests =+  testGroup+    "Memory space read isolation"+    [ testCase "every memory read returns only the requested space" testMemoryReads,+      testCase "every session read returns only the requested space" testSessionReads,+      testCase "recall never crosses a space, however wide its scope" testRecallReads,+      testCase "scenes and personas with identical scope keys stay apart" testDerivedArtifacts,+      testCase "reconciliation leaves both spaces readable" testReconcileKeepsBothSpaces,+      testCase "every partitioned lookup has a partition-leading index" testPartitionLeadingPlans+    ]++-- * The fixture++-- | Both spaces get the same namespace, the same entity scope, and the same content. Only the+-- ids differ, and they have to: memory and session ids are globally unique by construction.+sharedNamespace :: Namespace+sharedNamespace = Namespace "kioku_shared"++sharedScope :: MemoryScope+sharedScope = ScopeEntity sharedNamespace (ScopeKind "repo") "web"++sharedContent :: Text+sharedContent = "identical in both spaces"++sharedFocus :: Text+sharedFocus = "identical focus"++sharedCorrelationKey :: Text+sharedCorrelationKey = "approval_req_shared"++-- | What one space's fixture produced, so an assertion can name the row it expects and the row+-- it must not see.+data SpaceFixture = SpaceFixture+  { space :: !MemorySpaceId,+    memoryId :: !MemoryId,+    sessionId :: !SessionId+  }++-- | Record one memory and one session, with a turn and a park, in the context's space.+seedSpace ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  Eff es SpaceFixture+seedSpace context = do+  mid <- liftIO genMemoryId+  sid <- liftIO genSessionId+  now <- liftIO getCurrentTime+  let space = memoryContextSpace context+      actor = memoryContextRecordedActor context++  started <-+    Session.startWithContext+      context+      StartSessionData+        { sessionId = sid,+          memorySpaceId = space,+          actorPrincipal = actor,+          ownerPrincipal = Nothing,+          agentId = "shared-agent",+          focus = sharedFocus,+          scope = sharedScope,+          subjectRef = Nothing,+          previousSessionId = Nothing,+          parentSessionId = Nothing,+          delegationDepth = 0,+          startedAt = now+        }+  expectRight "Session.startWithContext" started++  turned <-+    Session.recordTurnWithContext+      context+      RecordTurnData+        { sessionId = sid,+          memorySpaceId = space,+          actorPrincipal = actor,+          turnId = idText sid <> "-turn-1",+          turnIndex = 1,+          role = "user",+          content = sharedContent,+          toolSummary = Nothing,+          promptTokens = Nothing,+          outputTokens = Nothing,+          recordedAt = now+        }+  expectRight "Session.recordTurnWithContext" turned++  parked <-+    Session.awaitInputWithContext+      context+      AwaitInputData+        { sessionId = sid,+          memorySpaceId = space,+          actorPrincipal = actor,+          reason = "waiting",+          correlationKey = Just sharedCorrelationKey,+          deadline = Nothing,+          awaitedAt = now+        }+  expectRight "Session.awaitInputWithContext" parked++  recorded <-+    Memory.recordWithContext+      context+      RecordMemoryData+        { memoryId = mid,+          memorySpaceId = space,+          actorPrincipal = actor,+          ownerPrincipal = Nothing,+          agentId = "shared-agent",+          sessionId = Just sid,+          scope = sharedScope,+          memoryType = MemoryFact,+          content = sharedContent,+          priority = 100,+          confidence = HighConfidence,+          tags = Set.empty,+          supersedes = Nothing,+          recordedAt = now+        }+  expectRight "Memory.recordWithContext" recorded++  pure SpaceFixture {space, memoryId = mid, sessionId = sid}++expectRight :: (IOE :> es, Show e) => String -> Either e a -> Eff es ()+expectRight label = \case+  Left err -> liftIO (assertFailure (label <> ": " <> show err))+  Right _ -> pure ()++withBothSpaces :: ((SpaceFixture, SpaceFixture) -> Eff AppEffects ()) -> Assertion+withBothSpaces action =+  withApp do+    mine <- seedSpace testContext+    theirs <- seedSpace otherContext+    action (mine, theirs)++-- * Reads++testMemoryReads :: Assertion+testMemoryReads =+  withBothSpaces \(mine, theirs) -> do+    -- By id, both directions. The id is globally unique, so this is the case where only the+    -- predicate can be doing the work.+    Memory.getMemoryRowById mine.space mine.memoryId+      >>= expectRows "my memory by id, in my space" [idText mine.memoryId] (fmap (\row -> row.memoryId) . maybe [] pure)+    Memory.getMemoryRowById mine.space theirs.memoryId+      >>= expectRows "their memory by id, in my space" [] (fmap (\row -> row.memoryId) . maybe [] pure)++    Memory.getActiveRowsInNamespace mine.space sharedNamespace+      >>= expectRows "namespace read" [idText mine.memoryId] (fmap (\row -> row.memoryId))+    Memory.getActiveRowsByScope mine.space sharedScope+      >>= expectRows "scope read" [idText mine.memoryId] (fmap (\row -> row.memoryId))+    Memory.getRowsBySession mine.space mine.sessionId+      >>= expectRows "session read" [idText mine.memoryId] (fmap (\row -> row.memoryId))+    Memory.getActiveRowsByType mine.space sharedNamespace MemoryFact+      >>= expectRows "type read" [idText mine.memoryId] (fmap (\row -> row.memoryId))+    Memory.getSupersessionChain mine.space mine.memoryId+      >>= expectRows "supersession chain" [idText mine.memoryId] (fmap (\row -> row.memoryId))++    -- The other space's session id names a session that exists, in another space. A memory+    -- read keyed by it must return nothing rather than the other space's memory.+    Memory.getRowsBySession mine.space theirs.sessionId+      >>= expectRows "their session, in my space" [] (fmap (\row -> row.memoryId))+    Memory.getSupersessionChain mine.space theirs.memoryId+      >>= expectRows "their chain, in my space" [] (fmap (\row -> row.memoryId))++testSessionReads :: Assertion+testSessionReads =+  withBothSpaces \(mine, theirs) -> do+    Session.getById mine.space mine.sessionId+      >>= expectRows "my session by id" [idText mine.sessionId] (fmap (\row -> row.sessionId) . maybe [] pure)+    Session.getById mine.space theirs.sessionId+      >>= expectRows "their session by id, in my space" [] (fmap (\row -> row.sessionId) . maybe [] pure)++    Session.getRecentInNamespace mine.space sharedNamespace 10+      >>= expectRows "namespace list" [idText mine.sessionId] (fmap (\row -> row.sessionId))+    Session.getByScope mine.space sharedScope+      >>= expectRows "scope list" [idText mine.sessionId] (fmap (\row -> row.sessionId))+    Session.getByFocus mine.space sharedNamespace sharedFocus+      >>= expectRows "focus list" [idText mine.sessionId] (fmap (\row -> row.sessionId))+    Session.getAwaitingByCorrelationKey mine.space sharedNamespace sharedCorrelationKey+      >>= expectRows "awaiting by correlation key" [idText mine.sessionId] (fmap (\row -> row.sessionId))+    Session.getChain mine.space mine.sessionId+      >>= expectRows "chain" [idText mine.sessionId] (fmap (\row -> row.sessionId))+    Session.getTurns mine.space mine.sessionId+      >>= expectRows "turns" [idText mine.sessionId <> "-turn-1"] (fmap (\row -> row.turnId))+    Session.getTurns mine.space theirs.sessionId+      >>= expectRows "their turns, in my space" [] (fmap (\row -> row.turnId))++    now <- liftIO getCurrentTime+    Session.getByStartedRange mine.space sharedNamespace (addHours (-1) now) (addHours 1 now)+      >>= expectRows "started range" [idText mine.sessionId] (fmap (\row -> row.sessionId))++-- | Recall's global scope means \"every scope in this namespace\", which is the widest target+-- the current API can express. It must still stop at the space boundary — a target that widens+-- the scope must never widen the tenancy.+testRecallReads :: Assertion+testRecallReads =+  withBothSpaces \(mine, theirs) -> do+    Recall.getActiveByScope mine.space sharedScope+      >>= expectRows "exact scope" [idText mine.memoryId] (fmap (\r -> r.memoryId))+    Recall.getActiveInNamespace mine.space sharedNamespace+      >>= expectRows "whole namespace" [idText mine.memoryId] (fmap (\r -> r.memoryId))+    Recall.getGlobal mine.space sharedNamespace+      >>= expectRows "global bucket" [] (fmap (\r -> r.memoryId))+    Recall.getById mine.space theirs.memoryId+      >>= expectRows "their memory by id" [] (fmap (\r -> r.memoryId) . maybe [] pure)+    Recall.getBySession mine.space mine.sessionId+      >>= expectRows "by session" [idText mine.memoryId] (fmap (\r -> r.memoryId))+    Recall.getByType mine.space sharedNamespace MemoryFact+      >>= expectRows "by type" [idText mine.memoryId] (fmap (\r -> r.memoryId))++    -- The keyword channel, which is the one that runs without an embedding endpoint. Its+    -- namespace-wide form is the widest read in the codebase, and the space it searches comes+    -- from the context rather than from the request -- so there is no argument a caller could+    -- pass here that would reach the other space.+    hits <-+      Recall.recall+        undefinedModel+        VectorExtensionUnavailable+        testContext+        Recall.RecallQuery+          { target = Recall.NamespaceWide sharedNamespace,+            query = "identical",+            strategy = Recall.Keyword,+            maxResults = expectValidLimit 10+          }+    liftIO $+      case hits of+        Left err -> assertFailure ("recall refused: " <> show err)+        Right rows ->+          assertEqual+            "namespace-wide keyword recall stays in its space"+            [idText mine.memoryId]+            (sort ((\hit -> hit.memory.memoryId) <$> rows))+  where+    -- The keyword channel never embeds, and 'VectorExtensionUnavailable' makes that a+    -- guarantee rather than a hope: 'planRecallExecution' returns a keyword-only plan, so the+    -- model is never forced.+    undefinedModel = error "the keyword channel must not embed"++    expectValidLimit =+      either (error . Text.unpack) id . Recall.mkRecallLimit++-- | Scene and persona ids are derived from the scope alone, so both spaces derive the same+-- ones. They are inserted directly rather than distilled, because what is under test is the+-- read: an LLM is not needed to prove that two rows with one id stay apart.+testDerivedArtifacts :: Assertion+testDerivedArtifacts =+  withApp do+    runTransaction (Tx.sql (encodeUtf8 (derivedArtifactRows testSpace "mine")))+    runTransaction (Tx.sql (encodeUtf8 (derivedArtifactRows otherSpace "theirs")))++    mineScenes <- getScenesByScope testSpace sharedScope+    theirsScenes <- getScenesByScope otherSpace sharedScope+    minePersona <- getPersonaByScope testSpace sharedScope+    theirsPersona <- getPersonaByScope otherSpace sharedScope++    liftIO do+      assertEqual "my scene" ["mine"] ((\row -> row.title) <$> mineScenes)+      assertEqual "their scene" ["theirs"] ((\row -> row.title) <$> theirsScenes)+      assertEqual "my persona" (Just "mine") ((\row -> row.bodyMd) <$> minePersona)+      assertEqual "their persona" (Just "theirs") ((\row -> row.bodyMd) <$> theirsPersona)+      -- Same derived id, two rows. Before the primary key became composite, the second insert+      -- would have replaced the first through the upsert's ON CONFLICT clause.+      assertEqual+        "both spaces derived the same scene id"+        ((\row -> row.sceneId) <$> mineScenes)+        ((\row -> row.sceneId) <$> theirsScenes)++    -- The same collision, one layer out. The scope slug in the filename is derived from the+    -- namespace, kind, and ref alone, so these four rows produce two filenames; only the+    -- per-space directory keeps them apart. Written for real rather than compared as strings,+    -- because a path that differs but resolves to the same file on a case-folding filesystem+    -- would still lose one space's mirror.+    liftIO $ withSystemTempDirectory "kioku-space-mirrors" \workspace -> do+      mineScenePaths <- traverse (mirrorSceneToWorkspace workspace) mineScenes+      theirsScenePaths <- traverse (mirrorSceneToWorkspace workspace) theirsScenes+      minePersonaPath <- traverse (mirrorPersonaToWorkspace workspace) minePersona+      theirsPersonaPath <- traverse (mirrorPersonaToWorkspace workspace) theirsPersona++      assertBool+        ("the two spaces' scene mirrors are the same file: " <> show mineScenePaths)+        (mineScenePaths /= theirsScenePaths)+      assertBool+        ("the two spaces' persona mirrors are the same file: " <> show minePersonaPath)+        (minePersonaPath /= theirsPersonaPath)++      traverse_ (assertFileContains "my scene mirror" "mine") mineScenePaths+      traverse_ (assertFileContains "their scene mirror" "theirs") theirsScenePaths+      traverse_ (assertFileContains "my persona mirror" "mine") minePersonaPath+      traverse_ (assertFileContains "their persona mirror" "theirs") theirsPersonaPath++assertFileContains :: String -> Text -> FilePath -> Assertion+assertFileContains label needle path = do+  body <- TextIO.readFile path+  assertBool+    (label <> " at " <> path <> " does not contain " <> Text.unpack needle <> ": " <> Text.unpack body)+    (needle `Text.isInfixOf` body)++derivedArtifactRows :: MemorySpaceId -> Text -> Text+derivedArtifactRows space marker =+  "INSERT INTO kioku.scenes\+  \ (memory_space_id, scene_id, namespace, scope_kind, scope_ref, scene_key, title, body_md, source_hash)\+  \ VALUES ('"+    <> memorySpaceIdText space+    <> "', 'kioku_scene:kioku_shared/repo/web:default', 'kioku_shared', 'repo', 'web', 'default', '"+    <> marker+    <> "', 'body', 'hash');\+       \ INSERT INTO kioku.personas\+       \ (memory_space_id, persona_id, namespace, scope_kind, scope_ref, body_md, source_hash)\+       \ VALUES ('"+    <> memorySpaceIdText space+    <> "', 'kioku_persona:kioku_shared/repo/web', 'kioku_shared', 'repo', 'web', '"+    <> marker+    <> "', 'hash')"++-- | The registry guard moved with the schema: memory models to v2, sessions to v4, turns to v2.+-- A database that has just been migrated must therefore report every model already current, and+-- both spaces must still be readable afterwards — the version bump is a guard advance, not a+-- rebuild, and a rebuild would be the thing that could lose a space.+testReconcileKeepsBothSpaces :: Assertion+testReconcileKeepsBothSpaces =+  withBothSpaces \(mine, theirs) -> do+    outcomes <- reconcileReadModelRegistry+    liftIO $+      assertEqual+        "a freshly migrated database has nothing to reconcile"+        ([] :: [Text])+        [schema.readModelName | (schema, outcome) <- outcomes, outcome /= AlreadyCurrent]++    Memory.getActiveRowsByScope mine.space sharedScope+      >>= expectRows "my space still reads" [idText mine.memoryId] (fmap (\row -> row.memoryId))+    Memory.getActiveRowsByScope theirs.space sharedScope+      >>= expectRows "their space still reads" [idText theirs.memoryId] (fmap (\row -> row.memoryId))++-- * Plans++-- | Every plan below must reach its rows through an index whose leading column is+-- @memory_space_id@, and must not fall back to a scan of the whole table.+--+-- The SQL here is a copy of the shipped statements, which is a real hazard — the recall+-- harness's copy silently drifted out of date the moment this partition landed and started+-- reporting a plan no live query could produce. So each case also runs the public read it+-- claims to describe and asserts the plan's own row count matches what that read returned. A+-- copy that has drifted apart from the statement it mirrors fails there.+testPartitionLeadingPlans :: Assertion+testPartitionLeadingPlans =+  withBothSpaces \(mine, _theirs) -> do+    -- @enable_seqscan = off@ does not make the planner use the index we want; it removes the+    -- alternative that a four-row test table would otherwise always make cheapest, so that the+    -- question being asked is "is a partition-leading index available and applicable" rather+    -- than "is this table big enough to bother".+    memoriesByScope <-+      explainPartitioned+        mine.space+        "SELECT memory_id FROM kioku.memories WHERE status = 'active' AND memory_space_id = $1 \+        \AND namespace = 'kioku_shared' AND ((scope_kind = 'repo' AND scope_ref = 'web') \+        \OR (NULL IS NULL AND scope_kind IS NULL AND NULL IS NULL AND scope_ref IS NULL)) \+        \ORDER BY priority ASC, created_at DESC"+    memoriesByType <-+      explainPartitioned+        mine.space+        "SELECT memory_id FROM kioku.memories WHERE status = 'active' AND memory_space_id = $1 \+        \AND namespace = 'kioku_shared' AND memory_type = 'fact' ORDER BY priority ASC, created_at DESC"+    sessionsByNamespace <-+      explainPartitioned+        mine.space+        "SELECT session_id FROM kioku.sessions WHERE memory_space_id = $1 \+        \AND namespace = 'kioku_shared' ORDER BY started_at DESC LIMIT 10"+    sessionsAwaiting <-+      explainPartitioned+        mine.space+        "SELECT session_id FROM kioku.sessions WHERE memory_space_id = $1 \+        \AND namespace = 'kioku_shared' AND status = 'awaiting' \+        \AND awaiting_correlation_key = 'approval_req_shared' ORDER BY started_at DESC"++    scopeRows <- rowCount <$> Memory.getActiveRowsByScope mine.space sharedScope+    typeRows <- rowCount <$> Memory.getActiveRowsByType mine.space sharedNamespace MemoryFact+    namespaceSessions <- rowCount <$> Session.getRecentInNamespace mine.space sharedNamespace 10+    awaitingSessions <-+      rowCount <$> Session.getAwaitingByCorrelationKey mine.space sharedNamespace sharedCorrelationKey++    liftIO $+      mapM_+        assertPartitionLeading+        [ ("memories by scope", "kioku_memories_space_", memoriesByScope, scopeRows),+          ("memories by type", "kioku_memories_space_", memoriesByType, typeRows),+          ("sessions by namespace", "kioku_sessions_space_", sessionsByNamespace, namespaceSessions),+          ("sessions awaiting", "kioku_sessions_space_", sessionsAwaiting, awaitingSessions)+        ]+  where+    rowCount = either (const (-1)) length++-- | What is asserted is the plan /shape/, not which particular index won.+--+-- The migration installs several partition-first indexes on each table and they overlap on+-- their @(memory_space_id, namespace)@ prefix, so the planner is free to choose between them —+-- and it does: the by-scope query is served by @kioku_memories_space_namespace_idx@ rather than+-- @…_space_scope_idx@, because the scope predicate is a disjunction that no index can answer+-- and the namespace index also supplies the @ORDER BY@. Pinning the winner would make this case+-- fail on a better plan. What must hold is that /some/ partition-first index on the right table+-- was used, that the space was an index condition rather than a filter applied afterwards, and+-- that nothing fell back to reading every space's rows.+assertPartitionLeading :: (String, Text, Text, Int) -> Assertion+assertPartitionLeading (label, indexPrefix, plan, expectedRows) = do+  assertBool+    ( label+        <> ": no index named "+        <> Text.unpack indexPrefix+        <> "* appears in the plan:\n"+        <> Text.unpack plan+    )+    (indexPrefix `Text.isInfixOf` plan)+  assertBool+    (label <> ": the index condition does not mention memory_space_id:\n" <> Text.unpack plan)+    (any (\line -> "Index Cond" `Text.isInfixOf` line && "memory_space_id" `Text.isInfixOf` line) (Text.lines plan))+  assertBool+    (label <> ": the plan falls back to a scan of every space:\n" <> Text.unpack plan)+    (not ("Seq Scan" `Text.isInfixOf` plan))+  assertEqual+    (label <> ": the explained query is not the one the read function runs:\n" <> Text.unpack plan)+    expectedRows+    (planTopRows plan)++-- | The @rows=N@ of the plan's outermost node, as @EXPLAIN ANALYZE@ actually observed it —+-- the @rows=@ that follows @actual time=@, not the planner's estimate that precedes it.+planTopRows :: Text -> Int+planTopRows plan =+  case Text.lines plan of+    line : _ ->+      case Text.splitOn "rows=" (snd (Text.breakOn "actual time=" line)) of+        _ : rest : _ -> either (const (-1)) fst (Text.Read.decimal rest)+        _ -> -1+    [] -> -1++explainPartitioned :: (Store :> es) => MemorySpaceId -> Text -> Eff es Text+explainPartitioned space sql =+  Text.unlines <$> runTransaction do+    Tx.sql "SET LOCAL enable_seqscan = off"+    Tx.statement space (explainStmt sql)++explainStmt :: Text -> Statement MemorySpaceId [Text]+explainStmt sql =+  preparable+    ("EXPLAIN (ANALYZE, BUFFERS) " <> sql)+    (memorySpaceIdText >$< E.param (E.nonNullable E.text))+    (D.rowList (D.column (D.nonNullable D.text)))++-- * Helpers++expectRows ::+  (IOE :> es, Show e) =>+  String ->+  [Text] ->+  (a -> [Text]) ->+  Either e a ->+  Eff es ()+expectRows label expected project = \case+  Left err -> liftIO (assertFailure (label <> ": " <> show err))+  Right value -> liftIO (assertEqual label (sort expected) (sort (project value)))++addHours :: Int -> UTCTime -> UTCTime+addHours hours = addUTCTime (fromIntegral (hours * 3600))++withApp :: Eff AppEffects a -> IO a+withApp action =+  withKiokuMigratedDatabase \connStr ->+    withNoopAppEnv (defaultConnectionSettings connStr) \env -> do+      result <- runAppIO env action+      case result of+        Left storeErr -> assertFailure ("store error: " <> show storeErr)+        Right value -> pure value
test/Kioku/TimerWorkerSpec.hs view
@@ -19,23 +19,36 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Keiro.Timer (TimerId (..), TimerRequest (..), scheduleTimerTx)+import Kioku.Api.Access+  ( MemoryAccessContext,+    MemoryAccessDenial (..),+    MemoryContextProvider (..),+    MemoryPermission (..),+    MemorySpaceId,+    legacyMemorySpaceId,+    memoryContextRecordedActor,+    memorySpaceIdText,+  ) import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..)) import Kioku.App (AppEffects, AppEnv, runAppIO, withNoopAppEnv) import Kioku.Distill.L1 (scopedScanCandidates)-import Kioku.Distill.L2 (l2SceneProcessManagerName)+import Kioku.Distill.L2 (SceneTimerPayload (..), l2SceneProcessManagerName, l2SceneTimerId)+import Kioku.Distill.L3 (partitionedCorrelationId) import Kioku.Distill.Runtime (DistillRuntime (..), newDistillRuntime)-import Kioku.Distill.Timer (l1ExtractProcessManagerName)+import Kioku.Distill.Timer (L1TimerPayload (..), l1ExtractProcessManagerName) import Kioku.Distill.Timer.Worker (drainKiokuTimers, runKiokuTimerWorkerOnce) import Kioku.Id (SessionId, genSessionId, idText) import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase) import Kioku.Prelude import Kioku.Session qualified as Session import Kioku.Session.Domain (StartSessionData (..))+import Kioku.SpaceFixtures (legacyContext, otherSpace, testContext, testContextProvider, testSpace) import Kiroku.Store.Connection (defaultConnectionSettings) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource) import Kiroku.Store.Error (StoreError) import Kiroku.Store.Transaction (runTransaction)+import Shibuya.Telemetry.Effect (Tracing) import Shikumi.Error (ShikumiError (..)) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))@@ -46,10 +59,16 @@     "Timer worker"     [ testCase "permanent failure dead-letters the timer" testPermanentFailureDeadLetters,       testCase "transient failure reschedules with backoff" testTransientFailureReschedules,+      testCase "a timer scheduled before memory spaces fires in the legacy space" testPrePartitionPayloadFiresInLegacySpace,+      testCase "a pre-partition timer cannot reach a session in another space" testPrePartitionPayloadCannotReachAnotherSpace,+      testCase "a malformed L1 payload dead-letters" testMalformedL1PayloadDeadLetters,       testCase "unknown process manager requeues with a long delay" testUnknownProcessManagerRequeues,       testCase "the attempt ceiling dead-letters" testAttemptCeilingDeadLetters,       testCase "success marks the timer fired" testSuccessMarksFired,-      testCase "drain processes every due timer in one pass" testDrainProcessesAllDueTimers+      testCase "drain processes every due timer in one pass" testDrainProcessesAllDueTimers,+      testCase "two spaces sharing a scope schedule two timers, and both fire" testTwoSpacesTwoTimers,+      testCase "a refused memory space dead-letters" testRefusedSpaceDeadLetters,+      testCase "every dead-letter row names the memory space" testDeadLetterNamesTheSpace     ]  -- | A correlation id that is not a session id can never become one. It used to@@ -79,7 +98,7 @@     before <- getCurrentTime     row <- runOrFail env do       startFixtureSession sid-      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) Aeson.Null (-1)+      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) (l1Payload testSpace) (-1)       fireOnce failing       fetchTimer timerId     row.status @?= "scheduled"@@ -88,6 +107,75 @@     row.attempts @?= 1     assertDelayNear "first retry" before 30 row.fireAt +-- | An L1 timer written before memory spaces existed has no @memorySpaceId@ in its payload.+--+-- It must still fire, in the legacy space, exactly like a stored event with no partition. The+-- proof is indirect but decisive: the pass runs (and here fails on the stubbed extractor, so the+-- timer is rescheduled) rather than dead-lettering, which is what an unreadable payload or a+-- refused space would do.+--+-- The session lives in the legacy space too, because that is the only arrangement a+-- pre-partition database can produce. The companion case below is what happens when it does+-- not.+testPrePartitionPayloadFiresInLegacySpace :: Assertion+testPrePartitionPayloadFiresInLegacySpace =+  withTimerEnv \env rt -> do+    timerId <- freshTimerId+    sid <- genSessionId+    let failing = rt {runExtract = \_ -> pure (Left (ProviderFailure "the model is down"))}+    row <- runOrFail env do+      startFixtureSessionIn legacyContext legacyMemorySpaceId sid+      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) prePartitionL1Payload (-1)+      fireOnce failing+      fetchTimer timerId+    row.status @?= "scheduled"++-- | The same pre-partition timer, against a session that belongs to another space.+--+-- The pass looks the session up in the legacy space, does not find it, and treats it as a+-- session that is gone — which marks the timer fired. That is the right outcome and the+-- important one: a timer defaulted into the legacy space must never reach into a space that+-- was created after it.+testPrePartitionPayloadCannotReachAnotherSpace :: Assertion+testPrePartitionPayloadCannotReachAnotherSpace =+  withTimerEnv \env rt -> do+    timerId <- freshTimerId+    sid <- genSessionId+    let failing = rt {runExtract = \_ -> liftIO (assertFailure "the extractor must not run")}+    row <- runOrFail env do+      startFixtureSession sid+      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) prePartitionL1Payload (-1)+      fireOnce failing+      fetchTimer timerId+    row.status @?= "fired"++-- | An L1 timer payload as it was written before memory spaces existed.+prePartitionL1Payload :: Aeson.Value+prePartitionL1Payload =+  Aeson.object ["kind" Aeson..= ("idle" :: Text), "turnCount" Aeson..= (1 :: Int)]++-- | A payload this handler cannot read will not become readable on the next attempt, so it+-- dead-letters where an operator can see it rather than retrying for an hour first.+testMalformedL1PayloadDeadLetters :: Assertion+testMalformedL1PayloadDeadLetters =+  withTimerEnv \env rt -> do+    timerId <- freshTimerId+    sid <- genSessionId+    row <- runOrFail env do+      startFixtureSession sid+      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) Aeson.Null (-1)+      fireOnce rt+      fetchTimer timerId+    row.status @?= "dead"+    assertBool+      ("last_error names the payload, got: " <> show row.lastError)+      (maybe False (Text.isInfixOf "payload") row.lastError)++-- | An L1 timer payload as the projection writes one today.+l1Payload :: MemorySpaceId -> Aeson.Value+l1Payload space =+  Aeson.toJSON L1TimerPayload {kind = "idle", turnCount = Just 1, memorySpaceId = space}+ -- | keiro's claimDueTimer claims the earliest due timer regardless of process -- manager, so a timer no handler owns cannot be left alone — it must be put -- back, or it starves every other timer behind it forever.@@ -148,19 +236,109 @@     (processed, rows) <- runOrFail env do       forM_ timerIds \timerId ->         scheduleTestTimer timerId l1ExtractProcessManagerName "not-a-session-id" Aeson.Null (-1)-      processed <- drainKiokuTimers Nothing rt (scopedScanCandidates 5)+      processed <- drainKiokuTimers Nothing testContextProvider rt (scopedScanCandidates 5)       rows <- traverse fetchTimer timerIds       pure (processed, rows)     processed @?= 3     fmap (.status) rows @?= ["dead", "dead", "dead"] +-- | Two L2 scene timers for the same namespace and scope, one per memory space.+--+-- The timer id is a UUIDv5 over the process manager, the correlation id, and a source id, and+-- the correlation id is @\<space\>:\<scope identity\>@. Without the space in there both spaces+-- derive one id, and keiro's scheduling upsert would treat the second schedule as a re-arming of+-- the first — one timer, one payload, one space's scene regenerated and the other's silently+-- dropped. Two distinct ids, two rows, and both fired is the whole invariant.+testTwoSpacesTwoTimers :: Assertion+testTwoSpacesTwoTimers =+  withTimerEnv \env rt -> do+    let mineTimer = l2SceneTimerId testSpace emptyScope "shared-source"+        theirsTimer = l2SceneTimerId otherSpace emptyScope "shared-source"+    assertBool "two spaces derived one scene timer id" (mineTimer /= theirsTimer)+    rows <- runOrFail env do+      scheduleTestTimer+        mineTimer+        l2SceneProcessManagerName+        (partitionedCorrelationId testSpace emptyScope)+        (sceneTimerPayload testSpace)+        (-1)+      scheduleTestTimer+        theirsTimer+        l2SceneProcessManagerName+        (partitionedCorrelationId otherSpace emptyScope)+        (sceneTimerPayload otherSpace)+        (-1)+      void (drainKiokuTimers Nothing testContextProvider rt (scopedScanCandidates 5))+      traverse fetchTimer [mineTimer, theirsTimer]+    fmap (.status) rows @?= ["fired", "fired"]++-- | A worker that may not act in a space must say so where an operator can see it.+--+-- Dead-letter rather than retry, for the same reason the embedding worker does: a refusal is a+-- configuration fact, and retrying it every thirty seconds for an hour before giving up would+-- spend an hour hiding it.+testRefusedSpaceDeadLetters :: Assertion+testRefusedSpaceDeadLetters =+  withTimerEnv \env rt -> do+    timerId <- freshTimerId+    row <- runOrFail env do+      scheduleTestTimer+        timerId+        l2SceneProcessManagerName+        (partitionedCorrelationId testSpace emptyScope)+        (sceneTimerPayload testSpace)+        (-1)+      fireOnceWith refusingContextProvider rt+      fetchTimer timerId+    row.status @?= "dead"+    assertBool+      ("last_error should name the refusal, got: " <> show row.lastError)+      (maybe False (Text.isInfixOf "not authorized") row.lastError)++-- | @last_error@ is the column an operator reads when a distillation stops happening, and a+-- dead-lettered timer that does not say which tenant it belongs to is a question, not an answer.+testDeadLetterNamesTheSpace :: Assertion+testDeadLetterNamesTheSpace =+  withTimerEnv \env rt -> do+    timerId <- freshTimerId+    sid <- genSessionId+    row <- runOrFail env do+      startFixtureSession sid+      -- A correlation id that is not a session id: a permanent failure whose own message has no+      -- reason to mention a space, so what shows up can only have come from the annotation.+      scheduleTestTimer timerId l1ExtractProcessManagerName "not-a-session-id" (l1Payload testSpace) (-1)+      fireOnce rt+      fetchTimer timerId+    row.status @?= "dead"+    assertBool+      ("last_error should name the memory space, got: " <> show row.lastError)+      (maybe False (Text.isInfixOf (memorySpaceIdText testSpace)) row.lastError)++-- | An L2 scene timer payload as the projection writes one.+sceneTimerPayload :: MemorySpaceId -> Aeson.Value+sceneTimerPayload space =+  Aeson.toJSON SceneTimerPayload {memorySpaceId = space, scope = emptyScope}++-- | A provider that refuses every space, as a host with a real authorization engine would when+-- this worker is not allowed to touch this tenant.+refusingContextProvider :: (Applicative m) => MemoryContextProvider m+refusingContextProvider =+  MemoryContextProvider \space -> pure (Left (MemoryPermissionDenied space MemoryDistill))+ fireOnce ::-  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>   DistillRuntime ->   Eff es ()-fireOnce rt = do+fireOnce = fireOnceWith testContextProvider++fireOnceWith ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>+  MemoryContextProvider (Eff es) ->+  DistillRuntime ->+  Eff es ()+fireOnceWith provider rt = do   now <- liftIO getCurrentTime-  void (runKiokuTimerWorkerOnce Nothing rt (scopedScanCandidates 5) now)+  void (runKiokuTimerWorkerOnce Nothing provider rt (scopedScanCandidates 5) now)  -- | Schedule a timer @offset@ seconds from now (negative means already due). scheduleTestTimer ::@@ -187,12 +365,27 @@   (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>   SessionId ->   Eff es ()-startFixtureSession sid = do+startFixtureSession = startFixtureSessionIn testContext testSpace++-- | Start a fixture session in a named space. The pre-partition case needs the legacy one:+-- in a genuinely pre-partition database the timer and its session are both there, and a+-- fixture that put them in different spaces would be testing nothing that can happen.+startFixtureSessionIn ::+  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+  MemoryAccessContext ->+  MemorySpaceId ->+  SessionId ->+  Eff es ()+startFixtureSessionIn context space sid = do   now <- liftIO getCurrentTime   started <--    Session.start+    Session.startWithContext+      context       StartSessionData         { sessionId = sid,+          memorySpaceId = space,+          actorPrincipal = memoryContextRecordedActor context,+          ownerPrincipal = Nothing,           agentId = "test-agent",           focus = "timer worker spec",           scope = emptyScope,@@ -202,7 +395,7 @@           delegationDepth = 0,           startedAt = now         }-  void (liftIO (expectRight "Session.start" started))+  void (liftIO (expectRight "Session.startWithContext" started))  -- | Drive the row to the brink of the ceiling so the next claim trips it. forceAttempts :: (Store :> es) => TimerId -> Int -> Eff es ()
+ test/Kioku/WorkspaceSpec.hs view
@@ -0,0 +1,187 @@+-- | The filesystem half of the memory-space partition.+--+-- The database stopped letting two spaces collide when their rows got a composite key. These+-- cases are about the other artifact: a Markdown mirror whose filename is derived from a scope+-- that two spaces are allowed to share.+--+-- The traversal cases are the ones worth reading twice. A 'MemorySpaceId' is validated for a+-- database column, not for a path — @..@ passes 'mkMemorySpaceId' — so nothing but the encoding+-- in "Kioku.Workspace" stands between a hostile space id and the rest of the disk.+module Kioku.WorkspaceSpec (tests) where++import Data.List (isInfixOf)+import Data.Text qualified as Text+import Kioku.Api.Access (MemorySpaceId, mkMemorySpaceId)+import Kioku.Workspace+  ( ArtifactMove (..),+    MoveVerdict (..),+    applyArtifactMigration,+    legacyPersonaArtifactDir,+    legacySceneArtifactDir,+    personaArtifactDir,+    planArtifactMigration,+    sceneArtifactDir,+    spaceArtifactRoot,+    spaceDirectoryName,+  )+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.FilePath (isRelative, joinPath, splitDirectories, (</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Workspace artifact layout"+    [ testCase "two spaces never share an artifact root" testDistinctRoots,+      testCase "the same space always gets the same root" testStableRoot,+      testCase "a case-only difference is still two roots" testCaseOnlyDifference,+      testCase "no space id can escape .kioku/spaces" testNoTraversal,+      testCase "a fresh workspace has nothing to migrate" testEmptyMigration,+      testCase "the historical tree is planned, copied, and left in place" testMigrationCopies,+      testCase "a second run is a no-op" testMigrationIdempotent,+      testCase "a destination with different content is refused" testMigrationCollision,+      testCase "a non-markdown file is not Kioku's to relocate" testMigrationIgnoresOtherFiles+    ]++-- * Layout++-- | Same namespace, same scope, same filename — different directory. Without this the two+-- spaces' mirrors are one file, and whichever regenerated last wins.+testDistinctRoots :: Assertion+testDistinctRoots =+  assertBool+    "two memory spaces resolved to the same artifact root"+    (spaceArtifactRoot "/w" (spaceNamed "space_a") /= spaceArtifactRoot "/w" (spaceNamed "space_b"))++testStableRoot :: Assertion+testStableRoot =+  assertEqual+    "the artifact root must be a function of the space id alone"+    (spaceArtifactRoot "/w" (spaceNamed "space_a"))+    (spaceArtifactRoot "/w" (spaceNamed "space_a"))++-- | macOS and Windows fold case in path components, so a sanitised name alone would merge these+-- two spaces into one directory on the machines this is developed on. The digest is what keeps+-- them apart, and it is over the exact bytes.+testCaseOnlyDifference :: Assertion+testCaseOnlyDifference =+  assertBool+    "space_A and space_a resolved to the same artifact root"+    (spaceDirectoryName (spaceNamed "space_A") /= spaceDirectoryName (spaceNamed "space_a"))++-- | Every one of these is a legal 'MemorySpaceId': 'mkMemorySpaceId' rejects @:@, @#@, @%@, @\/@,+-- whitespace and control characters, and nothing else. @..@ in particular would walk out of+-- @.kioku\/spaces@ if the id were used as a path component directly.+testNoTraversal :: Assertion+testNoTraversal =+  mapM_ check ["..", ".", "...", "..-..", "a.b", "____"]+  where+    check raw = do+      let space = spaceNamed (Text.pack raw)+          name = Text.unpack (spaceDirectoryName space)+          root = spaceArtifactRoot "workspace" space+      assertBool+        (raw <> " encoded to a directory name with a path separator: " <> name)+        (length (splitDirectories name) == 1)+      assertBool+        (raw <> " encoded to a dot segment: " <> name)+        (name /= "." && name /= "..")+      assertBool+        (raw <> " escaped .kioku/spaces: " <> root)+        (joinPath ["workspace", ".kioku", "spaces"] `isInfixOf` root && isRelative root)++-- * Migration++testEmptyMigration :: Assertion+testEmptyMigration =+  withSystemTempDirectory "kioku-workspace-empty" \workspace -> do+    moves <- planArtifactMigration workspace legacyish+    moves @?= []++-- | The plan names both trees, the apply copies them, and — the part that matters — the original+-- is still there afterwards. An operator who applies and then finds the new layout wrong must+-- still have something to fall back on.+testMigrationCopies :: Assertion+testMigrationCopies =+  withSystemTempDirectory "kioku-workspace-copy" \workspace -> do+    writeHistorical workspace "scenes" "web-abc.md" "scene body"+    writeHistorical workspace "persona" "web-abc.md" "persona body"++    planned <- planArtifactMigration workspace legacyish+    map (.verdict) planned @?= [MoveReady, MoveReady]+    assertEqual+      "the plan must name both partitioned destinations, scenes first"+      [ sceneArtifactDir workspace legacyish </> "web-abc.md",+        personaArtifactDir workspace legacyish </> "web-abc.md"+      ]+      (map (.destination) planned)++    applyArtifactMigration planned++    assertFileIs (sceneArtifactDir workspace legacyish </> "web-abc.md") "scene body"+    assertFileIs (personaArtifactDir workspace legacyish </> "web-abc.md") "persona body"+    assertFileIs (legacySceneArtifactDir workspace </> "web-abc.md") "scene body"+    assertFileIs (legacyPersonaArtifactDir workspace </> "web-abc.md") "persona body"++testMigrationIdempotent :: Assertion+testMigrationIdempotent =+  withSystemTempDirectory "kioku-workspace-idempotent" \workspace -> do+    writeHistorical workspace "scenes" "web-abc.md" "scene body"+    planArtifactMigration workspace legacyish >>= applyArtifactMigration++    replanned <- planArtifactMigration workspace legacyish+    map (.verdict) replanned @?= [MoveAlreadyMigrated]+    -- Applying the second plan must still be safe, and must still leave the file alone.+    applyArtifactMigration replanned+    assertFileIs (sceneArtifactDir workspace legacyish </> "web-abc.md") "scene body"++-- | The partitioned file is the one the running system writes. A pre-partition snapshot with the+-- same name is older, so copying over it would replace current content with stale content.+testMigrationCollision :: Assertion+testMigrationCollision =+  withSystemTempDirectory "kioku-workspace-collision" \workspace -> do+    writeHistorical workspace "scenes" "web-abc.md" "the pre-partition snapshot"+    createDirectoryIfMissing True (sceneArtifactDir workspace legacyish)+    writeFile (sceneArtifactDir workspace legacyish </> "web-abc.md") "what the worker wrote today"++    planned <- planArtifactMigration workspace legacyish+    map (.verdict) planned @?= [MoveCollision]++    applyArtifactMigration planned+    assertFileIs (sceneArtifactDir workspace legacyish </> "web-abc.md") "what the worker wrote today"++-- | Only @.md@ files were ever Kioku's. An editor swap file or a README an operator dropped in+-- the directory is theirs, and relocating it would be a surprise.+testMigrationIgnoresOtherFiles :: Assertion+testMigrationIgnoresOtherFiles =+  withSystemTempDirectory "kioku-workspace-other" \workspace -> do+    writeHistorical workspace "scenes" "notes.txt" "not a mirror"+    writeHistorical workspace "scenes" ".web-abc.md.swp" "not a mirror either"+    moves <- planArtifactMigration workspace legacyish+    moves @?= []++-- * Helpers++-- | Not 'legacyMemorySpaceId' itself, because these cases are about the layout rather than about+-- which space the CLI defaults to, and a fixture that happened to be the default would hide a+-- path built from a hard-coded constant.+legacyish :: MemorySpaceId+legacyish = spaceNamed "space_migrated"++writeHistorical :: FilePath -> FilePath -> FilePath -> String -> IO ()+writeHistorical workspace kind name body = do+  let dir = workspace </> ".kioku" </> kind+  createDirectoryIfMissing True dir+  writeFile (dir </> name) body++assertFileIs :: FilePath -> String -> Assertion+assertFileIs path expected = do+  exists <- doesFileExist path+  assertBool ("expected a file at " <> path) exists+  actual <- readFile path+  assertEqual ("contents of " <> path) expected actual++spaceNamed :: Text.Text -> MemorySpaceId+spaceNamed = either (error . Text.unpack) id . mkMemorySpaceId
test/Main.hs view
@@ -5,15 +5,21 @@ import Kioku.DistillSpec qualified as DistillSpec import Kioku.EmbeddingWorkerSpec qualified as EmbeddingWorkerSpec import Kioku.IdempotencySpec qualified as IdempotencySpec+import Kioku.MemorySpaceSpec qualified as MemorySpaceSpec+import Kioku.PortfolioAccessSpec qualified as PortfolioAccessSpec import Kioku.ReadModelReconcileSpec qualified as ReadModelReconcileSpec+import Kioku.RecallCompatSpec qualified as RecallCompatSpec import Kioku.RecallSpec qualified as RecallSpec import Kioku.RecallSqlSpec qualified as RecallSqlSpec+import Kioku.RecallTargetSpec qualified as RecallTargetSpec import Kioku.ReiCompatSpec qualified as ReiCompatSpec import Kioku.SchemaSpec qualified as SchemaSpec import Kioku.ScopeIdentitySpec qualified as ScopeIdentitySpec import Kioku.SessionInvariantsSpec qualified as SessionInvariantsSpec import Kioku.SessionLineageSpec qualified as SessionLineageSpec+import Kioku.SpaceIsolationSpec qualified as SpaceIsolationSpec import Kioku.TimerWorkerSpec qualified as TimerWorkerSpec+import Kioku.WorkspaceSpec qualified as WorkspaceSpec import Test.Tasty (defaultMain, testGroup)  main :: IO ()@@ -25,13 +31,19 @@         CodecCompatSpec.tests,         ReiCompatSpec.tests,         IdempotencySpec.tests,+        MemorySpaceSpec.tests,+        PortfolioAccessSpec.tests,         ReadModelReconcileSpec.tests,         RecallSpec.tests,+        RecallCompatSpec.tests,         RecallSqlSpec.tests,+        RecallTargetSpec.tests,         SchemaSpec.tests,         ScopeIdentitySpec.tests,         SessionInvariantsSpec.tests,         SessionLineageSpec.tests,+        SpaceIsolationSpec.tests,+        WorkspaceSpec.tests,         EmbeddingWorkerSpec.tests,         TimerWorkerSpec.tests,         DistillSpec.tests