kioku-core-0.4.0.0: test/Kioku/MemorySpaceSpec.hs
{-# 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"