kioku-core-0.4.0.0: test/Kioku/SpaceIsolationSpec.hs
-- | 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