packages feed

kioku-core-0.11.0.0: test/Kioku/ArchiveReceiptSpec.hs

{-# OPTIONS_GHC -Wno-deprecations #-}

-- | Exercise deprecated legacy receipt compatibility through the public facade.
-- Stream inspection is independent evidence, never the source of a receipt.
module Kioku.ArchiveReceiptSpec (tests) where

import Control.Concurrent.Async (concurrently)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Data.Aeson (Value (..))
import Data.Aeson.KeyMap qualified as KeyMap
import Data.List (sort)
import Data.Set qualified as Set
import Data.Time (addUTCTime)
import Data.Vector qualified as Vector
import Effectful (Eff)
import Keiro.Stream qualified as Stream
import Kioku.Api.Access (MemoryAccessContext, RecordedPrincipal (..), legacyMemorySpaceId, legacyPrincipalRef, memoryContextRecordedActor, memoryContextSpace)
import Kioku.Api.Access.Internal qualified as Internal
import Kioku.Api.Scope (MemoryScope (..), Namespace (..))
import Kioku.Api.Types (Confidence (..), MemoryType (..))
import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)
import Kioku.Id (MemoryId, genMemoryId)
import Kioku.Memory qualified as M
import Kioku.Memory.Domain (ArchiveMemoryData (..), MemoryArchivedData (..), MemoryEvent (..), RecordMemoryData (..), SupersedeMemoryData (..))
import Kioku.Memory.Domain qualified as Archive (ArchiveMemoryData (..))
import Kioku.Memory.EventStream (memoryStream, parseMemoryEvent)
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Prelude
import Kioku.SpaceFixtures (legacyContext, otherActorPrincipal, testActor, testContext, testSpace)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Read (readStreamForward)
import Kiroku.Store.Types (RecordedEvent (..), StreamVersion (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)

tests :: TestTree
tests =
  testGroup
    "Archive receipts"
    [ testCase "first and repeat preserve unattributed archive payload" (withApp firstAndRepeat),
      testCase "two legacy commands have one append owner" (archiveRace False),
      testCase "legacy and context commands share one append owner" (archiveRace True),
      testCase "foreign active and archived IDs stay absent" (withApp partitionRefusals),
      testCase "context gates precede work and idempotence" (withApp contextRefusals),
      testCase "missing and terminal errors match the ID-only wrapper" (withApp failureParity),
      testCase "known and legacy attribution and ID-only APIs retain parity" (withApp attributionParity)
    ]

right :: (Show e) => Either e a -> Eff AppEffects a
right = either (liftIO . assertFailure . show) pure

check :: (Eq a, Show a) => String -> a -> a -> Eff AppEffects ()
check label expected actual = liftIO (assertEqual label expected actual)

withApp :: Eff AppEffects a -> IO a
withApp action = withKiokuMigratedDatabase \conn -> withNoopAppEnv (defaultConnectionSettings conn) \env ->
  runAppIO env action >>= either (assertFailure . show) pure

record :: MemoryAccessContext -> UTCTime -> Eff AppEffects MemoryId
record context recordedAt = do
  memoryId <- liftIO genMemoryId
  right
    =<< M.recordWithContext
      context
      RecordMemoryData
        { memoryId,
          memorySpaceId = memoryContextSpace context,
          actorPrincipal = memoryContextRecordedActor context,
          ownerPrincipal = Nothing,
          agentId = "archive-receipt-test",
          sessionId = Nothing,
          scope = ScopeGlobal (Namespace "archive_receipts"),
          memoryType = MemoryFact,
          content = "archive receipt fixture",
          priority = 100,
          confidence = HighConfidence,
          tags = Set.empty,
          supersedes = Nothing,
          recordedAt
        }

archiveData :: MemoryId -> RecordedPrincipal -> UTCTime -> ArchiveMemoryData
archiveData memoryId actorPrincipal archivedAt =
  ArchiveMemoryData {memoryId, memorySpaceId = legacyMemorySpaceId, actorPrincipal, archivedAt}

-- Read exact serialized values as well as decoded canonical events, so a codec
-- default cannot conceal an invented actor in the durable payload.
readEvents :: MemoryId -> Eff AppEffects [(Value, MemoryEvent)]
readEvents mid = do
  stored <- Vector.toList <$> readStreamForward (Stream.streamName (memoryStream mid)) (StreamVersion 0) 100
  forM stored \event -> do
    decoded <- right (parseMemoryEvent event.payload)
    pure (event.payload, decoded)

assertArchive :: MemoryId -> ArchiveMemoryData -> Eff AppEffects ()
assertArchive mid command = do
  events <- readEvents mid
  check "record plus exactly one archive" 2 (length events)
  check
    "one canonical archive, unchanged actor and timestamp"
    [MemoryArchivedData mid legacyMemorySpaceId command.actorPrincipal command.archivedAt]
    [d | (_, MemoryArchived d) <- events]

firstAndRepeat :: Eff AppEffects ()
firstAndRepeat = do
  now <- liftIO getCurrentTime
  mid <- record legacyContext now
  let command = archiveData mid UnattributedPrincipal now
  first <- right =<< M.archiveLegacyChanged command
  again <- right =<< M.archiveLegacyChanged command {Archive.archivedAt = addUTCTime 60 now}
  check "first appended" (mid, True) first
  check "repeat appended nothing" (mid, False) again
  assertArchive mid command
  events <- readEvents mid
  let actors = [KeyMap.lookup "actorPrincipal" dat | (Object payload, MemoryArchived _) <- events, Just (Object dat) <- [KeyMap.lookup "data" payload]]
  check "durable actor is the existing marker" [Just (String "kioku:unattributed")] actors

-- Both workers announce readiness before either may call the facade. Different
-- timestamps/actors let the durable event independently identify the winner.
archiveRace :: Bool -> IO ()
archiveRace mixed = withKiokuMigratedDatabase \conn -> withNoopAppEnv (defaultConnectionSettings conn) \env -> do
  let run :: Eff AppEffects a -> IO a
      run action = runAppIO env action >>= either (assertFailure . show) pure
  now <- getCurrentTime
  mid <- run (record legacyContext now)
  readyA <- newEmptyMVar
  readyB <- newEmptyMVar
  startA <- newEmptyMVar
  startB <- newEmptyMVar
  let a = archiveData mid UnattributedPrincipal now
      b = archiveData mid (if mixed then memoryContextRecordedActor legacyContext else LegacyPrincipal (legacyPrincipalRef "old-host")) (addUTCTime 1 now)
      worker ready start action = putMVar ready () >> takeMVar start >> run (right =<< action)
      second = if mixed then M.archiveWithContextChanged legacyContext b else M.archiveLegacyChanged b
      release = takeMVar readyA >> takeMVar readyB >> putMVar startA () >> putMVar startB ()
  ((receiptA, receiptB), ()) <-
    concurrently
      (concurrently (worker readyA startA (M.archiveLegacyChanged a)) (worker readyB startB second))
      release
  assertEqual "both return the requested ID" [mid, mid] [fst receiptA, fst receiptB]
  assertEqual "exactly one authoritative append" [False, True] (sort [snd receiptA, snd receiptB])
  run (assertArchive mid (if snd receiptA then a else b))

-- Every refused call must preserve both stream and projected row, even if the
-- foreign row is already archived (where an unscoped lookup could leak a no-op).
partitionRefusals :: Eff AppEffects ()
partitionRefusals = do
  now <- liftIO getCurrentTime
  mid <- record testContext now
  let command = (archiveData mid (memoryContextRecordedActor testContext) now) {Archive.memorySpaceId = testSpace}
  before <- snapshot testContext mid
  expectFailure (M.MemorySpaceMismatch testSpace legacyMemorySpaceId) command
  check "wrong-space payload did not change row or stream" before =<< snapshot testContext mid
  missing <- liftIO genMemoryId
  forM_ [False, True] \archived -> do
    when archived $ void (right =<< M.archiveWithContext testContext command)
    stored <- snapshot testContext mid
    let hidden = archiveData mid UnattributedPrincipal now
    expectFailure M.MemoryNotFound hidden
    expectFailure M.MemoryNotFound (archiveData missing UnattributedPrincipal now)
    check "foreign ID is unchanged" stored =<< snapshot testContext mid
  check "missing ID has no stream" [] =<< readEvents missing

snapshot :: MemoryAccessContext -> MemoryId -> Eff AppEffects (String, [(Value, MemoryEvent)])
snapshot context mid = do
  row <- right =<< M.getMemoryRowById (memoryContextSpace context) mid
  events <- readEvents mid
  -- Show covers every projected column, including status and update timestamps.
  pure (show row, events)

expectFailure :: M.MemoryWriteError -> ArchiveMemoryData -> Eff AppEffects ()
expectFailure expected command = do
  result <- M.archiveLegacyChanged command
  old <- M.archive command
  checkFailure "receipt failure matches expected constructor" expected result
  checkFailure "ID-only wrapper returns exactly the same failure" expected old

checkFailure :: String -> M.MemoryWriteError -> Either M.MemoryWriteError a -> Eff AppEffects ()
checkFailure label expected actual =
  check label (show (Left expected :: Either M.MemoryWriteError ())) (show (void actual))

contextRefusals :: Eff AppEffects ()
contextRefusals = do
  now <- liftIO getCurrentTime
  mid <- record legacyContext now
  let command = archiveData mid (memoryContextRecordedActor legacyContext) now
      noForget =
        Internal.MemoryAccessContext
          { Internal.memorySpaceId = legacyMemorySpaceId,
            Internal.actor = testActor,
            Internal.grantedPermissions = Set.singleton Internal.MemoryRead,
            Internal.decisionToken = Nothing
          }
      refused context payload expected = do
        result <- M.archiveWithContextChanged context payload
        checkFailure "context refusal remains typed" expected result
  forM_ [False, True] \archived -> do
    when archived $ void (right =<< M.archiveLegacyChanged command)
    before <- snapshot legacyContext mid
    refused noForget command (M.MemoryNotPermitted Internal.MemoryForget)
    refused legacyContext command {Archive.memorySpaceId = testSpace} (M.MemorySpaceMismatch testSpace legacyMemorySpaceId)
    forM_ [otherActorPrincipal, UnattributedPrincipal] \actor ->
      refused legacyContext command {Archive.actorPrincipal = actor} (M.MemoryActorMismatch actor (memoryContextRecordedActor legacyContext))
    check "gates did not change row or stream" before =<< snapshot legacyContext mid

failureParity :: Eff AppEffects ()
failureParity = do
  now <- liftIO getCurrentTime
  missing <- liftIO genMemoryId
  expectFailure M.MemoryNotFound (archiveData missing UnattributedPrincipal now)
  check "missing has no facts" [] =<< readEvents missing
  winner <- record legacyContext now
  forM_ [False, True] \merged -> do
    mid <- record legacyContext now
    if merged
      then void (right =<< M.mergeWithContext legacyContext mid winner)
      else
        void
          ( right
              =<< M.supersedeWithContext
                legacyContext
                SupersedeMemoryData
                  { memoryId = mid,
                    memorySpaceId = legacyMemorySpaceId,
                    actorPrincipal = memoryContextRecordedActor legacyContext,
                    supersededBy = winner,
                    supersededAt = now
                  }
          )
    before <- snapshot legacyContext mid
    expectFailure (M.MemoryConflict "archive: status differs from the recorded memory") (archiveData mid UnattributedPrincipal now)
    check "terminal failure did not change row or stream" before =<< snapshot legacyContext mid

attributionParity :: Eff AppEffects ()
attributionParity = do
  now <- liftIO getCurrentTime
  forM_ [memoryContextRecordedActor legacyContext, LegacyPrincipal (legacyPrincipalRef "historic host label")] \actor -> do
    mid <- record legacyContext now
    let command = archiveData mid actor now
        retry = command {Archive.actorPrincipal = UnattributedPrincipal, Archive.archivedAt = addUTCTime 30 now}
    check "first receipt" (mid, True) =<< right =<< M.archiveLegacyChanged command
    check "accepted repeat retains ID" (mid, False) =<< right =<< M.archiveLegacyChanged retry
    check "old wrapper retains ID" mid =<< right =<< M.archive retry
    check "context ID-only wrapper retains ID" mid =<< right =<< M.archiveWithContext legacyContext command {Archive.actorPrincipal = memoryContextRecordedActor legacyContext}
    assertArchive mid command
  -- An ID-only first archive also owns the append; the bridge then reports a repeat.
  mid <- record legacyContext now
  let command = archiveData mid UnattributedPrincipal now
  check "old wrapper first ID" mid =<< right =<< M.archive command
  check "bridge observes accepted repeat" (mid, False) =<< right =<< M.archiveLegacyChanged command
  assertArchive mid command