packages feed

kioku-core-0.4.0.0: src/Kioku/Memory/Domain.hs

{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -Wno-unused-top-binds #-}

module Kioku.Memory.Domain
  ( MemoryVertex (..),
    MemoryRegs,
    RecordMemoryData (..),
    SupersedeMemoryData (..),
    ArchiveMemoryData (..),
    UpdateMemoryTagsData (..),
    UpdateMemoryConfidenceData (..),
    MergeMemoryData (..),
    MemoryCommand (..),
    commandMemoryId,
    commandMemorySpaceId,
    MemoryRecordedData (..),
    MemorySupersededData (..),
    MemoryArchivedData (..),
    MemoryTagsUpdatedData (..),
    MemoryConfidenceUpdatedData (..),
    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.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)

-- | 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,
    memoryType :: !MemoryType,
    content :: !Text,
    priority :: !Int,
    confidence :: !Confidence,
    tags :: !(Set Text),
    supersedes :: !(Maybe MemoryId),
    recordedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data SupersedeMemoryData = SupersedeMemoryData
  { memoryId :: !MemoryId,
    memorySpaceId :: !MemorySpaceId,
    actorPrincipal :: !RecordedPrincipal,
    supersededBy :: !MemoryId,
    supersededAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

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
  }
  deriving stock (Generic, Eq, Show)

data UpdateMemoryConfidenceData = UpdateMemoryConfidenceData
  { memoryId :: !MemoryId,
    memorySpaceId :: !MemorySpaceId,
    actorPrincipal :: !RecordedPrincipal,
    confidence :: !Confidence,
    updatedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data MergeMemoryData = MergeMemoryData
  { memoryId :: !MemoryId,
    memorySpaceId :: !MemorySpaceId,
    actorPrincipal :: !RecordedPrincipal,
    mergedInto :: !MemoryId,
    mergedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data MemoryCommand
  = RecordMemory !RecordMemoryData
  | SupersedeMemory !SupersedeMemoryData
  | ArchiveMemory !ArchiveMemoryData
  | UpdateMemoryTags !UpdateMemoryTagsData
  | UpdateMemoryConfidence !UpdateMemoryConfidenceData
  | MergeMemory !MergeMemoryData
  deriving stock (Generic, Eq, Show)

commandMemoryId :: MemoryCommand -> MemoryId
commandMemoryId = \case
  RecordMemory d -> d.memoryId
  SupersedeMemory d -> d.memoryId
  ArchiveMemory d -> d.memoryId
  UpdateMemoryTags d -> d.memoryId
  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,
    memoryType :: !MemoryType,
    content :: !Text,
    priority :: !Int,
    confidence :: !Confidence,
    tags :: !(Set Text),
    supersedes :: !(Maybe MemoryId),
    recordedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  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 (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 (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 (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 (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 (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
  | MemoryArchived !MemoryArchivedData
  | MemoryTagsUpdated !MemoryTagsUpdatedData
  | MemoryConfidenceUpdated !MemoryConfidenceUpdatedData
  | MemoryMerged !MemoryMergedData
  deriving stock (Generic, Eq, Show)

instance FromJSON MemoryEvent where
  parseJSON = genericParseJSON eventAesonOptions

instance ToJSON MemoryEvent where
  toJSON = genericToJSON eventAesonOptions

eventMemoryId :: MemoryEvent -> MemoryId
eventMemoryId = \case
  MemoryRecorded d -> d.memoryId
  MemorySuperseded d -> d.memoryId
  MemoryArchived d -> d.memoryId
  MemoryTagsUpdated d -> d.memoryId
  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 ::
  SymTransducer
    (HsPred MemoryRegs MemoryCommand)
    MemoryRegs
    MemoryVertex
    MemoryCommand
    MemoryEvent
memoryTransducer =
  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,
              memoryType = d.memoryType,
              content = d.content,
              priority = d.priority,
              confidence = d.confidence,
              tags = d.tags,
              supersedes = d.supersedes,
              recordedAt = d.recordedAt
            }
        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
            }
        B.goto Merged
  where
    isTerminal = \case
      Superseded -> True
      Merged -> True
      Archived -> True
      _ -> False