packages feed

keiro-core-0.6.0.0: src/Keiro/EventStream/Validate.hs

-- | Replay-safety validation for keiro 'EventStream's.
--
-- An 'EventStream' pairs a pure keiki 'SymTransducer' with the durable plumbing
-- needed to replay it. keiki can prove, with no SMT solver, that a transducer is
-- /replay-safe/ — each emitted chain is recoverable from its first event, observed
-- events invert to one edge, input reads are guarded by the matching command
-- constructor, and output-free edges do not change durable state. The umbrella
-- also checks guard determinism and structural reachability. This module lifts
-- keiki's umbrella check ('Keiki.validateTransducer') to the 'EventStream'
-- boundary so a service can assert all of its streams are sound before hydration.
-- The boundary also validates the event 'Keiro.Codec.Codec' construction
-- invariants, so malformed schema versions, duplicate event tags or upcaster
-- sources, out-of-range rungs, and incomplete chains fail before the service
-- touches stored streams.
--
-- Every warning enabled by the selected 'ValidationOptions' makes construction
-- fail. This is intentionally stricter than a pure keiki use: events are keiro's
-- only durable state, so accepting an unreplayable shape would lose state or defer
-- the failure to production. Build custom options by updating
-- 'defaultValidationOptions'. The replay-contract checks for head recoverability
-- and state-changing output-free edges are always forced on at this durable
-- boundary; caller-supplied options may only strengthen that contract.
--
-- * 'validateEventStream' \/ 'validateEventStreamWith' run the pure check and
--   return labelled warnings (empty when the stream is sound).
-- * 'mkEventStream' is a fail-fast smart constructor: it returns @Left warnings@
--   for an unsafe stream and @Right validatedStream@ for a sound one. The returned
--   'ValidatedEventStream' is the value command runners accept; the bare
--   'EventStream' record literal remains available only for construction,
--   validation, and low-level internals.
module Keiro.EventStream.Validate
  ( EventStreamWarning (..),
    ValidatedEventStream,
    unvalidated,
    validateEventStream,
    validateEventStreamWith,
    mkEventStream,
    mkEventStreamWith,
    mkEventStreamOrThrow,

    -- * Unchecked escape hatch (tests and emergency forensics only)
    mkEventStreamUnchecked,
  )
where

import Control.DeepSeq (force)
import Control.Exception (ErrorCall, displayException, evaluate, try)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Stack (HasCallStack)
import Keiki.Core
  ( EdgeRef (..),
    HsPred,
    TransducerValidationWarning (..),
    ValidationOptions (..),
    defaultValidationOptions,
    validateTransducer,
  )
import Keiro.Codec qualified as Codec
import Keiro.EventStream (EventStream (..), SnapshotPolicy (..), StateCodec (..))
import System.IO.Unsafe (unsafePerformIO)

-- | A validation warning about one event stream, tagged with the
-- caller-supplied label so a multi-aggregate service can tell which stream is at
-- fault.
data EventStreamWarning = EventStreamWarning
  { eswStreamLabel :: !Text,
    -- | rendered from the keiki warning
    eswReason :: !Text
  }
  deriving stock (Eq, Show)

-- | An 'EventStream' that has passed keiki validation and keiro's stream-level
-- checks. Command runners require this wrapper instead of a bare 'EventStream'.
-- The constructor is intentionally not exported; use 'mkEventStream',
-- 'mkEventStreamWith', or 'mkEventStreamOrThrow' to obtain a validated value.
-- 'mkEventStreamUnchecked' exists only for tests and emergency forensics.
newtype ValidatedEventStream phi rs s ci co
  = ValidatedEventStream (EventStream phi rs s ci co)

-- | Recover the underlying stream for internal runners and low-level helpers.
unvalidated :: ValidatedEventStream phi rs s ci co -> EventStream phi rs s ci co
unvalidated (ValidatedEventStream es) = es

-- | Run keiki's pure umbrella check over a stream's transducer with the default
-- options. This includes hidden-input, head recoverability, inversion ambiguity,
-- guarded input reads, state-changing epsilon edges, determinism, and dead-edge
-- checks, plus the event codec's schema and upcaster-chain construction
-- invariants. An empty list means the stream passed every enabled check. Pure;
-- no solver.
validateEventStream ::
  (Bounded s, Enum s, Ord s, Show s) =>
  -- | caller-supplied stream label
  Text ->
  EventStream (HsPred rs ci) rs s ci co ->
  [EventStreamWarning]
validateEventStream = validateEventStreamWith defaultValidationOptions

-- | As 'validateEventStream', but with caller-chosen 'ValidationOptions'.
-- The head-recoverability and state-changing-epsilon checks are always forced on:
-- events are keiro's only durable state, so callers may narrow only checks with a
-- documented benign override.
validateEventStreamWith ::
  (Bounded s, Enum s, Ord s, Show s) =>
  ValidationOptions ->
  -- | caller-supplied stream label
  Text ->
  EventStream (HsPred rs ci) rs s ci co ->
  [EventStreamWarning]
validateEventStreamWith opts label es =
  codecConfigWarnings label es
    <> snapshotWarnings label es
    <> initialSnapshotEncodeWarnings label es
    <> [ EventStreamWarning {eswStreamLabel = label, eswReason = renderWarning w}
       | w <- validateTransducer (forceReplayContract opts) (transducer es)
       ]

-- | Force the replay-contract checks required at keiro's durable boundary.
forceReplayContract :: ValidationOptions -> ValidationOptions
forceReplayContract opts =
  opts
    { checkStateChangingEpsilon = True,
      checkHeadRecoverability = True
    }

-- | Build a validated event stream with the default validation options.
-- Returns the warnings (@Left@) for an unsafe stream, or a
-- 'ValidatedEventStream' (@Right@) when it passes.
mkEventStream ::
  (Bounded s, Enum s, Ord s, Show s) =>
  -- | caller-supplied stream label
  Text ->
  EventStream (HsPred rs ci) rs s ci co ->
  Either [EventStreamWarning] (ValidatedEventStream (HsPred rs ci) rs s ci co)
mkEventStream = mkEventStreamWith defaultValidationOptions

-- | Build a validated event stream with caller-chosen validation options.
-- The replay-contract checks for head recoverability and state-changing epsilon
-- edges cannot be disabled here: caller options may only strengthen the durable
-- boundary. Only narrow other checks for a documented benign warning.
mkEventStreamWith ::
  (Bounded s, Enum s, Ord s, Show s) =>
  ValidationOptions ->
  -- | caller-supplied stream label
  Text ->
  EventStream (HsPred rs ci) rs s ci co ->
  Either [EventStreamWarning] (ValidatedEventStream (HsPred rs ci) rs s ci co)
mkEventStreamWith opts label es =
  case validateEventStreamWith opts label es of
    [] -> Right (ValidatedEventStream es)
    warns -> Left warns

-- | Partial constructor for generated code and test fixtures that have a
-- sibling validation proof. Hand-authored application wiring should prefer
-- 'mkEventStream' and handle @Left@ explicitly.
mkEventStreamOrThrow ::
  (HasCallStack, Bounded s, Enum s, Ord s, Show s) =>
  -- | caller-supplied stream label
  Text ->
  EventStream (HsPred rs ci) rs s ci co ->
  ValidatedEventStream (HsPred rs ci) rs s ci co
mkEventStreamOrThrow label es =
  case mkEventStream label es of
    Right validated -> validated
    Left warns ->
      error $
        "Keiro.EventStream.Validate.mkEventStreamOrThrow: "
          <> Text.unpack label
          <> " is not replay-safe: "
          <> show warns

-- | Wrap an 'EventStream' /without validation/. This skips every keiki and
-- keiro check, including event-codec construction validation and the
-- replay-contract checks that 'mkEventStream' force-enables. A stream admitted
-- through this function can silently lose state changes, select the wrong
-- upcaster, or fail hydration. Tests and emergency forensics only; never use it
-- for production streams. Prefer 'mkEventStream'.
mkEventStreamUnchecked ::
  EventStream phi rs s ci co ->
  ValidatedEventStream phi rs s ci co
mkEventStreamUnchecked = ValidatedEventStream

codecConfigWarnings :: Text -> EventStream phi rs s ci co -> [EventStreamWarning]
codecConfigWarnings label es =
  case Codec.mkCodec (eventCodec es) of
    Right _ -> []
    Left err ->
      [ EventStreamWarning
          { eswStreamLabel = label,
            eswReason = "event codec misconfigured: " <> renderCodecConfigError err
          }
      ]

renderCodecConfigError :: Codec.CodecConfigError -> Text
renderCodecConfigError = \case
  Codec.CodecSchemaVersionInvalid version ->
    "schema version must be at least 1, got " <> showT version
  Codec.CodecDuplicateEventTypes eventTypes ->
    "duplicate event type tag(s): "
      <> Text.intercalate ", " [tag | Codec.EventType tag <- eventTypes]
  Codec.CodecDuplicateUpcasterSources versions ->
    "duplicate upcaster source version(s): "
      <> renderVersions versions
      <> "; only one rung may own each source version"
  Codec.CodecUpcasterSourceOutOfRange source target ->
    "upcaster source version "
      <> showT source
      <> " is outside the valid range 1.."
      <> showT (target - 1)
      <> " for target schema version "
      <> showT target
  Codec.CodecUpcasterChainIncomplete missing target ->
    "missing upcaster source version(s): "
      <> renderVersions missing
      <> "; stored payloads cannot reach target schema version "
      <> showT target
  where
    showT = Text.pack . show
    renderVersions = Text.intercalate ", " . map showT

-- | Render a keiki warning to a human-readable reason. Every constructor
-- carry @tvwDetail@; the source vertex is @edgeSource . tvwEdge@ (or @tvwSource@
-- for pair warnings).
renderWarning :: (Show s) => TransducerValidationWarning s -> Text
renderWarning w = case w of
  HiddenInput {tvwEdge = e, tvwDetail = d} ->
    "hidden-input @" <> showT (edgeSource e) <> ": " <> Text.pack d
  HeadUnrecoverable {tvwEdge = e, tvwDetail = d} ->
    "head-unrecoverable @" <> showT (edgeSource e) <> ": " <> Text.pack d
  InversionAmbiguity {tvwSource = s, tvwDetail = d} ->
    "inversion-ambiguity @" <> showT s <> ": " <> Text.pack d
  UnguardedInputRead {tvwEdge = e, tvwDetail = d} ->
    "unguarded-input-read @" <> showT (edgeSource e) <> ": " <> Text.pack d
  StateChangingEpsilon {tvwEdge = e, tvwDetail = d} ->
    "state-changing-epsilon @" <> showT (edgeSource e) <> ": " <> Text.pack d
  NondeterministicPair {tvwSource = s, tvwDetail = d} ->
    "nondeterministic @" <> showT s <> ": " <> Text.pack d
  PossiblyDeadEdge {tvwEdge = e, tvwDetail = d} ->
    "possibly-dead @" <> showT (edgeSource e) <> ": " <> Text.pack d
  OpaqueGuard {tvwEdge = e, tvwDetail = d} ->
    "opaque-guard @" <> showT (edgeSource e) <> ": " <> Text.pack d
  ProjectionResultUnsupported
    { tvwEdge = e,
      tvwProjectionPath = path,
      tvwProjectionShape = shape,
      tvwProjectionResultType = resultType,
      tvwDetail = d
    } ->
      "projection-result-unsupported @"
        <> showT (edgeSource e)
        <> " ["
        <> Text.pack path
        <> ", shape "
        <> Text.pack shape
        <> ", result "
        <> Text.pack resultType
        <> "]: "
        <> Text.pack d
  ProjectionOrderingUnsupported
    { tvwEdge = e,
      tvwProjectionPath = path,
      tvwProjectionShape = shape,
      tvwProjectionResultType = resultType,
      tvwDetail = d
    } ->
      "projection-ordering-unsupported @"
        <> showT (edgeSource e)
        <> " ["
        <> Text.pack path
        <> ", shape "
        <> Text.pack shape
        <> ", result "
        <> Text.pack resultType
        <> "]: "
        <> Text.pack d
  ProjectionOutsideGuard
    { tvwEdge = e,
      tvwProjectionPath = path,
      tvwProjectionShape = shape,
      tvwProjectionLocation = location,
      tvwDetail = d
    } ->
      "projection-outside-guard @"
        <> showT (edgeSource e)
        <> " ["
        <> Text.pack path
        <> ", shape "
        <> Text.pack shape
        <> ", location "
        <> Text.pack location
        <> "]: "
        <> Text.pack d
  where
    showT = Text.pack . show

snapshotWarnings :: Text -> EventStream phi rs s ci co -> [EventStreamWarning]
snapshotWarnings label es =
  case (snapshotPolicy es, stateCodec es) of
    (Never, _) -> []
    (_, Just _) -> []
    (_, Nothing) ->
      [ EventStreamWarning
          { eswStreamLabel = label,
            eswReason = "snapshotPolicy is set but stateCodec is Nothing; snapshots would never be written"
          }
      ]

-- | Force the configured codec over the initial aggregate state while the
-- stream is being validated. This catches the labelled @uninit: <slot>@
-- 'ErrorCall' thunks installed by 'Keiki.Generics.emptyRegFile' before a service
-- can accept commands for a snapshot-enabled stream.
--
-- The public validation API remains pure; this narrowly scoped exception spoon
-- observes only 'ErrorCall'. Any other exception remains a programmer-visible
-- failure instead of being converted into a warning.
initialSnapshotEncodeWarnings :: Text -> EventStream phi rs s ci co -> [EventStreamWarning]
initialSnapshotEncodeWarnings label es =
  case stateCodec es of
    Nothing -> []
    Just codec -> unsafePerformIO $ do
      encoded <- try @ErrorCall (evaluate (force (encode codec (initialState es, initialRegisters es))))
      pure $ case encoded of
        Right _ -> []
        Left err ->
          [ EventStreamWarning
              { eswStreamLabel = label,
                eswReason =
                  "stateCodec cannot encode the initial state/registers: "
                    <> Text.pack (displayException err)
              }
          ]