packages feed

keiro-dsl-0.6.0.0: src/Keiro/Dsl/Grammar.hs

-- | The abstract syntax of the keiro DSL (@.keiro@) — the shared engine type that
-- every later vertical (EP-2…EP-6) extends additively. EP-1 defines the shared
-- declarations, the 'Expr' sublanguage, the eight hole-kind types, and the
-- 'Aggregate' node. New node families add a 'Node' constructor here in lockstep
-- with their parser, validator, and scaffold cases.
module Keiro.Dsl.Grammar
  ( -- * Names and source locations
    Name,
    Loc (..),
    noLoc,

    -- * Shared declarations
    IdDecl (..),
    EnumDecl (..),
    RuleDecl (..),

    -- * Consumer-owned mapped types (EP-149)
    TypeExpr (..),
    Presence (..),
    UnknownFields (..),
    OnMissing (..),
    WireField (..),
    wireFieldLoc,
    UnionEncoding (..),
    WireEnum (..),
    WireArm (..),
    MappedShape (..),
    HaskellSource (..),
    NominalBindingDecl (..),
    NominalScalarDecl (..),
    MappedDecl (..),

    -- * The eight hole-kind types
    Derivation (..),
    DerivStrategy (..),
    Disposition (..),
    DispAction (..),
    Mapping (..),
    EnvelopeBinding (..),
    EnvelopeLayer (..),

    -- * The Expr sublanguage
    Expr (..),
    ExprRoot (..),
    ScalarLiteral (..),
    exprLoc,
    CmpOp (..),
    Atom (..),
    complementExpr,

    -- * The aggregate node
    RegInitial (..),
    RegDecl (..),
    StateDecl (..),
    AggregateField (..),
    Field (..),
    Command (..),
    Event (..),
    EventBody (..),
    Hole (..),
    Transition (..),
    TransitionImplementation (..),
    TransitionMode (..),
    WireSpec (..),
    ProjectionSpec (..),
    Consistency (..),
    SnapPolicy (..),
    SnapshotSpec (..),
    Aggregate (..),

    -- * The process + timer nodes (EP-3)
    FieldBinding (..),
    InputDecl (..),
    CorrelateDecl (..),
    SagaRef (..),
    Disp (..),
    DispatchDisposition (..),
    AdvanceNode (..),
    DispatchNode (..),
    HandleNode (..),
    IdExpr (..),
    IdStrategy (..),
    FireAtExpr (..),
    FireOutcome (..),
    FireDisposition (..),
    FireNode (..),
    TimerNode (..),
    PolicyChoice (..),
    ProcessNode (..),

    -- * The router node (EP-108)
    ResolveSource (..),
    ResolveDecl (..),
    RouterDispatchNode (..),
    RouterNode (..),

    -- * The integration contract node (EP-4)
    ContractType (..),
    ContractField (..),
    ContractEvent (..),
    ContractNode (..),

    -- * The integration intake (inbox) node (EP-4)
    WireSource (..),
    BindRow (..),
    InboxAction (..),
    DispositionRow (..),
    DecodeSpec (..),
    InkPersist (..),
    IntakeNode (..),

    -- * The integration emit/publisher nodes (EP-4)
    DeriveSpec (..),
    EmitMapRow (..),
    EmitNode (..),
    BackoffSpec (..),
    PublisherNode (..),

    -- * The pgmq workqueue/dispatch nodes (EP-5)
    WqField (..),
    WqDispRow (..),
    WqOrdering (..),
    WqGroupKey (..),
    WqProvision (..),
    WorkqueueNode (..),
    PgmqDispatchNode (..),

    -- * Read-model nodes (EP-107)
    RmColumn (..),
    RmFeed (..),
    RmScope (..),
    ReadModelNode (..),

    -- * The workflow/operation nodes (EP-6)
    WfBodyItem (..),
    WorkflowNode (..),
    workflowNodeLoc,
    OperationShape (..),
    OperationNode (..),

    -- * Top level
    Placement (..),
    Node (..),
    Spec (..),
  )
where

import Data.Text (Text)
import GHC.Generics (Generic)

-- | An identifier in the notation: a type name, register name, command/event
-- name, state name, enum constructor, etc. Always a non-empty 'Text'.
type Name = Text

-- | A source line number, attached to declarations so the validator can emit
-- line-numbered diagnostics. Its 'Eq' instance deliberately ignores the line
-- value: two ASTs that differ only in source position are considered equal, so
-- the @parse . pretty == id@ round-trip property holds without the
-- pretty-printer having to reproduce exact line numbers.
newtype Loc = Loc {unLoc :: Int}
  deriving stock (Show)

instance Eq Loc where
  _ == _ = True

-- | A placeholder location used by generators and pretty-print round-trips.
noLoc :: Loc
noLoc = Loc 0

-- | @id TransferReservationId prefix=rsv@ — declares an id newtype over 'Text'
-- and its prefix tag.
data IdDecl = IdDecl
  { idName :: !Name,
    idPrefix :: !Text,
    idBinding :: !(Maybe NominalBindingDecl),
    idLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | @enum PatientAcuity { RedTag=red … }@ — a closed enumeration; each
-- constructor carries its wire spelling (the right-hand side of @=@).
data EnumDecl = EnumDecl
  { enumName :: !Name,
    enumCtors :: ![(Name, Text)],
    enumBinding :: !(Maybe NominalBindingDecl),
    enumLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | @rule lifeCriticalOverride : PatientAcuity -> Bool@ with an @ex@ line of
-- @Ctor => bool ; …@ — a total function from an enum to a value, used as a
-- derived atom inside guards.
data RuleDecl = RuleDecl
  { ruleName :: !Name,
    ruleDomain :: !Name,
    ruleCodomain :: !Name,
    ruleCases :: ![(Name, Expr)],
    ruleLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- Consumer-owned mapped types (EP-149). The parser-facing declarations keep
-- required facts optional so `keiro-dsl check` can report stable, located
-- diagnostics for omissions. Keiro.Dsl.TypeGraph turns valid values into a
-- checked representation before downstream consumers inspect them.

data TypeExpr
  = TText
  | TInt
  | TInteger
  | TBool
  | TNatural
  | TTime
  | TJson
  | TOptional !TypeExpr
  | TList !TypeExpr
  | TMap !TypeExpr
  | TRef !Name
  deriving stock (Eq, Show, Generic)

data Presence = PRequired | POptional
  deriving stock (Eq, Show, Generic)

data UnknownFields = RejectUnknown | IgnoreUnknown
  deriving stock (Eq, Show, Generic)

data OnMissing
  = OmNull
  | OmText !Text
  | OmInt !Integer
  | OmBool !Bool
  | OmEmptyList
  | OmEmptyMap
  | OmCtor !Name
  deriving stock (Eq, Show, Generic)

data WireField = WireField
  { wfHaskell :: !Name,
    wfKey :: !Text,
    wfType :: !TypeExpr,
    wfPresence :: !Presence,
    wfOnMissing :: !(Maybe OnMissing),
    wfLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

wireFieldLoc :: WireField -> Loc
wireFieldLoc WireField {wfLoc = loc} = loc

data UnionEncoding = TaggedObject
  { ueTagField :: !Text,
    ueContentsField :: !Text,
    ueUnknownFields :: !UnknownFields
  }
  deriving stock (Eq, Show, Generic)

data WireEnum = WireEnum
  { weCtor :: !Name,
    weTag :: !Text,
    weLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

data WireArm = WireArm
  { waCtor :: !Name,
    waTag :: !Text,
    waPayload :: !(Maybe TypeExpr),
    waLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

data MappedShape
  = ShapeRecord !Name !UnknownFields ![WireField]
  | ShapeEnum ![WireEnum]
  | ShapeUnion !UnionEncoding ![WireArm]
  deriving stock (Eq, Show, Generic)

data HaskellSource = HaskellSource
  { hsPackage :: !Text,
    hsModule :: !Text,
    hsType :: !Name
  }
  deriving stock (Eq, Ord, Show, Generic)

-- | Parser-facing facts for a total consumer-owned nominal binding.
--
-- The fields remain optional only so validation can report every missing fact at
-- the owning declaration. Downstream code consumes the checked nominal registry.
data NominalBindingDecl = NominalBindingDecl
  { nominalHaskell :: !(Maybe HaskellSource),
    nominalBinding :: !(Maybe Text),
    nominalBindingVersion :: !(Maybe Text),
    nominalCanonicalType :: !(Maybe Text),
    nominalFixtures :: !(Maybe Text),
    nominalInitial :: !(Maybe Text),
    nominalLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | A consumer-owned nominal scalar over one declared representation name.
--
-- The raw representation name is retained so @keiro-dsl check@ owns the stable
-- unsupported-representation diagnostic instead of the low-level parser.
data NominalScalarDecl = NominalScalarDecl
  { nominalScalarName :: !Name,
    nominalScalarRepresentation :: !Name,
    nominalScalarBinding :: !NominalBindingDecl,
    nominalScalarLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

data MappedDecl
  = MappedStructural
      { msName :: !Name,
        msHaskell :: !(Maybe HaskellSource),
        msBinding :: !(Maybe Text),
        msBindingVersion :: !(Maybe Text),
        msCanonical :: !(Maybe Text),
        msFixtures :: !(Maybe Text),
        msInitial :: !(Maybe Text),
        msShape :: !MappedShape,
        msLoc :: !Loc
      }
  | MappedOpaque
      { moName :: !Name,
        moHaskell :: !(Maybe HaskellSource),
        moCodecId :: !(Maybe Text),
        moCodecVersion :: !(Maybe Text),
        moFixtures :: !(Maybe Text),
        moInitial :: !(Maybe Text),
        moLoc :: !Loc
      }
  deriving stock (Eq, Show, Generic)

-- The eight hole-kind types. EP-1 only exercises hole-kinds 1–3 against the
-- aggregate vertical; the rest exist so EP-3…EP-6 reuse the same types.

-- | Hole-kind 1: a deterministic id/string derivation. Opaque strategies must
-- carry a captured @fixture@ (not a prose rule) so two agents re-derive them
-- identically.
data Derivation = Derivation
  { derivStrategy :: !DerivStrategy,
    derivFixture :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

data DerivStrategy = UuidV5 | SuffixSplice
  deriving stock (Eq, Show, Generic)

-- | Hole-kind 2: a failure→action table. Carries the two dangerous inversions a
-- @duplicate@/@rejected replay@ being treated as success and a
-- @previously-failed@ being dead-lettered rather than retried (enforced by
-- EP-4's validator rules).
newtype Disposition = Disposition
  { dispCases :: [(Name, DispAction)]
  }
  deriving stock (Eq, Show, Generic)

data DispAction = AckOk | Retry !Int | DeadLetter !Text
  deriving stock (Eq, Show, Generic)

-- | Hole-kind 3: an explicit value→value table that is not an identity echo
-- (e.g. an event name → projection status). @mapPartial@ records whether the
-- spec author explicitly marked the table partial over its domain.
data Mapping = Mapping
  { mapPairs :: ![(Name, Name)],
    mapPartial :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | Hole-kind 4: which layer carries each envelope field, and whether the two
-- are cross-checked. Defined here for reuse; exercised by EP-4.
data EnvelopeBinding = EnvelopeBinding
  { envField :: !Name,
    envLayer :: !EnvelopeLayer,
    envCrossChecked :: !Bool
  }
  deriving stock (Eq, Show, Generic)

data EnvelopeLayer = KafkaHeader | JsonBody
  deriving stock (Eq, Show, Generic)

-- | The @Expr@ sublanguage used by @guard@ clauses and the right-hand side of
-- @write@ clauses. An infix expression over 'Atom's; operators in precedence
-- order are @||@ (lowest), @&&@, then the relational comparisons.
data Expr
  = EOr !Expr !Expr
  | EAnd !Expr !Expr
  | ECmp !CmpOp !Expr !Expr
  | EAdd !Loc !Expr !Expr
  | ESubtract !Loc !Expr !Expr
  | EMultiply !Loc !Expr !Expr
  | EPath !Loc !ExprRoot ![Name]
  | ELiteral !Loc !ScalarLiteral
  | EAtom !Atom
  deriving stock (Eq, Show, Generic)

-- | The provenance of a version-2 scalar path. The first path segment is the
-- register or active command-field name; remaining segments are required
-- structural record fields.
data ExprRoot
  = UnqualifiedRoot
  | RegisterRoot
  | CommandRoot
  deriving stock (Eq, Ord, Show, Generic)

-- | Surface scalar literals whose final type is selected by the resolver.
-- Quoted literals deliberately share one syntax for Text and Time; integral
-- literals share one syntax for Int, Integer, and Natural. No numeric coercion
-- follows from that syntactic sharing.
data ScalarLiteral
  = LiteralText !Text
  | LiteralIntegral !Integer
  | LiteralBool !Bool
  | LiteralQualified !Name !Name
  | LiteralId !Name !Text
  deriving stock (Eq, Show, Generic)

-- | Best available source row for an expression node. Version-2 atoms and
-- arithmetic retain their exact row; legacy nodes fall back through children
-- and ultimately to 'noLoc'.
exprLoc :: Expr -> Loc
exprLoc = \case
  EOr left right -> firstLocated left right
  EAnd left right -> firstLocated left right
  ECmp _ left right -> firstLocated left right
  EAdd loc _ _ -> loc
  ESubtract loc _ _ -> loc
  EMultiply loc _ _ -> loc
  EPath loc _ _ -> loc
  ELiteral loc _ -> loc
  EAtom {} -> noLoc
  where
    firstLocated left right = case exprLoc left of
      Loc 0 -> exprLoc right
      loc -> loc

data CmpOp = OpEq | OpNeq | OpLt | OpLe | OpGt | OpGe
  deriving stock (Eq, Show, Generic)

-- | An atom is either a bare boolean literal (@true@/@false@) or a name. Names
-- are kept syntactically neutral: at parse time an identifier is
-- indistinguishable between a register, a command field, an enum constructor,
-- and a rule, so the validator's scope-check (M2) resolves which one each
-- 'AName' is against the declared sets. This keeps the parser honest and the
-- round-trip exact.
data Atom
  = AName !Name
  | ABool !Bool
  deriving stock (Eq, Show, Generic)

-- | The logical complement of a guard, expressed inside the existing grammar —
-- 'Expr' has no negation constructor, but negation is eliminable: De Morgan over
-- 'EOr'\/'EAnd', comparison-operator flipping, boolean-literal flip, and
-- @x == false@ for a bare name atom (guards are boolean-valued, so a bare name
-- in guard position is a boolean read). Used by @diff@ to compute the
-- replay-only twin of a tightened guard (@old ∧ ¬new@, plan 143): the printed
-- complement re-parses as a valid guard today.
--
-- Caveat: comparison flipping is classical — @¬(a < b) = a >= b@ — which is
-- correct over the DSL's total ordered domains.
complementExpr :: Expr -> Expr
complementExpr = \case
  EOr l r -> EAnd (complementExpr l) (complementExpr r)
  EAnd l r -> EOr (complementExpr l) (complementExpr r)
  ECmp op l r -> ECmp (complementCmp op) l r
  ELiteral loc (LiteralBool value) -> ELiteral loc (LiteralBool (not value))
  EAtom (ABool b) -> EAtom (ABool (not b))
  e@(EAtom _) -> ECmp OpEq e (EAtom (ABool False))
  e -> ECmp OpEq e (ELiteral (exprLoc e) (LiteralBool False))
  where
    complementCmp = \case
      OpEq -> OpNeq
      OpNeq -> OpEq
      OpLt -> OpGe
      OpLe -> OpGt
      OpGt -> OpLe
      OpGe -> OpLt

-- | @name Type = initial@ — a named register with its declared type and the
-- initial value (an identifier: a literal like @placeholder@, an enum
-- constructor, or a state name).
data RegInitial
  = RegInitBare !Text
  | RegInitText !Text
  deriving stock (Eq, Show, Generic)

data RegDecl = RegDecl
  { regName :: !Name,
    regType :: !TypeExpr,
    regInitial :: !RegInitial,
    regLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | One entry in a @states@ list. @stTerminal@ is set when the name carries a
-- trailing @!@ (no outgoing transitions allowed). The first 'StateDecl' in an
-- aggregate's list is its initial state.
data StateDecl = StateDecl
  { stName :: !Name,
    stTerminal :: !Bool,
    stLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | An aggregate command/event field. A bare name reuses the field's inferred
-- aggregate type; @name:Type@ accepts the complete 'TypeExpr' grammar so semantic
-- validation can reject unsupported direct shapes with a located diagnostic.
data AggregateField = AggregateField
  { aggregateFieldName :: !Name,
    aggregateFieldType :: !(Maybe TypeExpr),
    aggregateFieldLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | A generic field used by process and router nodes. Aggregate fields are
-- kept separate so widening aggregate syntax does not widen those node families.
data Field = Field
  { fieldName :: !Name,
    fieldType :: !(Maybe Name)
  }
  deriving stock (Eq, Show, Generic)

-- | @command Name { field … }@ — a command constructor.
data Command = Command
  { cmdName :: !Name,
    cmdFields :: ![AggregateField],
    cmdLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | @event Name { … }@ or @event Name = fields(Command)@. EP-2 (evolution) adds
-- the version/upcaster/retirement fields: an unversioned event is @evVersion = 1@,
-- @evUpcastFrom = Nothing@, @evRetiring = False@, and @evDeprecated = False@,
-- reproducing the EP-1 surface. These fields live on the shared 'Event' so every
-- node family's events inherit schema-versioning for free.
data Event = Event
  { evName :: !Name,
    evBody :: !EventBody,
    -- | The schema version of this event shape. Default 1; written @vN@ for N>1.
    evVersion :: !Int,
    -- | The source version this shape migrates /from/, paired with the upcaster
    --     hole. @Just (n-1, …)@ for a @vN@ shape; 'Nothing' for v1.
    evUpcastFrom :: !(Maybe (Int, Hole)),
    -- | Retirement is in progress. The event must keep at least one live
    --     emitting transition while operators terminalize or truncate affected
    --     streams; cut over to @deprecated@ plus a replay-only emitting transition
    --     afterwards.
    evRetiring :: !Bool,
    -- | Retired from the write path (no live transition may @emit@ it) but
    --     still decodable from the log. A replay-only emitting transition must remain
    --     while live streams can still contain the event.
    evDeprecated :: !Bool,
    evLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

data EventBody
  = EventFields ![AggregateField]
  | EventFromCommand !Name
  deriving stock (Eq, Show, Generic)

-- | A spec hole: an unfilled placeholder ('Hole', written @HOLE@ in the
-- notation) or a value the author supplied inline ('Filled').
data Hole = Hole | Filled !Text
  deriving stock (Eq, Show, Generic)

-- | A transition @Src -- Command --> clauses@. Clauses may be written
-- indentation-stacked or @;@-separated on one line.
data Transition = Transition
  { tSource :: !Name,
    tCommand :: !Name,
    tImplementation :: !TransitionImplementation,
    tGuard :: !(Maybe Expr),
    tWrites :: ![(Name, Expr)],
    tEmits :: ![Name],
    tGoto :: !Name,
    tMode :: !TransitionMode,
    tLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | Exclusive behavior ownership. 'LegacyHoleImplementation' exists only for
-- the frozen version-1 parser and preserves its create-once aggregate-wide
-- transducer. Version 2 produces either generated ownership (the default) or
-- an explicit per-transition Hole implementation.
data TransitionImplementation
  = LegacyHoleImplementation
  | GeneratedImplementation
  | HoleImplementation
  deriving stock (Eq, Ord, Show, Generic)

-- | Whether a transition serves forward execution or replay only (plan 143).
-- A @replay-only@ transition lowers to a keiki 'ReplayOnly' edge: it is never
-- taken by a new command and exists so events emitted under a retired rule keep
-- an inverting edge. Spelled as a @replay-only@ prefix on the transition line:
--
-- @
-- replay-only Held -- ConfirmReservation --> guard … ; emit … ; goto …
-- @
data TransitionMode = TmLive | TmReplayOnly
  deriving stock (Eq, Show, Generic)

-- | @wire kind=ctorName fields=camelCase schemaVersion=1@ — how events
-- serialize.
data WireSpec = WireSpec
  { wireKind :: !Text,
    wireFields :: !Text,
    wireSchemaVersion :: !Int
  }
  deriving stock (Eq, Show, Generic)

-- | @projection table consistency=… key=… status-map { … }@ — the read-model
-- projection and its event→status 'Mapping' (hole-kind 3).
data ProjectionSpec = ProjectionSpec
  { projTable :: !Name,
    projConsistency :: !(Maybe Consistency),
    projKey :: !Name,
    projStatusMap :: !(Maybe Mapping),
    projLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

data Consistency = Strong | Eventual
  deriving stock (Eq, Show, Generic)

-- | A generated aggregate snapshot policy supported by the notation.
data SnapPolicy = SnapEvery !Int | SnapOnTerminal
  deriving stock (Eq, Show, Generic)

-- | Snapshot policy plus the captured live state-codec identity.
data SnapshotSpec = SnapshotSpec
  { snapPolicy :: !SnapPolicy,
    snapCodecVersion :: !Int,
    snapShapeHash :: !Text,
    snapLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | An @aggregate@ node: a consistency boundary whose state is rebuilt by
-- replaying events.
data Aggregate = Aggregate
  { aggName :: !Name,
    aggRegs :: ![RegDecl],
    aggStates :: ![StateDecl],
    aggCommands :: ![Command],
    aggEvents :: ![Event],
    aggTransitions :: ![Transition],
    aggWire :: !(Maybe WireSpec),
    aggProjection :: !(Maybe ProjectionSpec),
    aggSnapshot :: !(Maybe SnapshotSpec),
    aggLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-3: process manager + durable timer nodes.

-- | A @field@ or @field=value@ binding inside a command\/payload field list.
-- A bare field reuses the input field of the same name; @name=value@ binds it to
-- an expression (kept as raw text, e.g. @timerId=timer.id@).
data FieldBinding = FieldBinding
  { fbName :: !Name,
    fbValue :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

-- | @input SurgeInput { hospitalId … observedAt:Time }@ — the process's incoming
-- event shape (one field must be a @:Time@ field used by the timer deadline).
data InputDecl = InputDecl
  { inName :: !Name,
    inFields :: ![Field]
  }
  deriving stock (Eq, Show, Generic)

-- | @correlate input.hospitalId via idText@ — the correlation key (hole-kind 1
-- derivation + hole-kind 4 field-source).
data CorrelateDecl = CorrelateDecl
  { corrField :: !Name,
    corrVia :: !Name
  }
  deriving stock (Eq, Show, Generic)

-- | @saga Surge category \"hospitalSurge\"@ — the saga's own aggregate plus
-- the validated stream category used with @Keiro.Stream.entityStream@.  For a
-- correlation id @c@, the saga stream is @<category>-<c>@.
data SagaRef = SagaRef
  { sagaAgg :: !Name,
    sagaCategory :: !Text
  }
  deriving stock (Eq, Show, Generic)

-- | A command dispatch outcome action.
data Disp = DAckOk | DRetry | DDeadLetter !Text
  deriving stock (Eq, Show, Generic)

-- | The complete dispatch disposition table (every arm mandatory; the
-- @on-duplicate AckOk@ benign inversion is explicit). Named to avoid clashing
-- with the hole-kind 'Disposition'.
data DispatchDisposition = DispatchDisposition
  { onAppended :: !Disp,
    onDuplicate :: !Disp,
    onFailed :: !Disp
  }
  deriving stock (Eq, Show, Generic)

-- | @advance NoteSurgeThreshold { … }@ — the self-command that advances the saga.
data AdvanceNode = AdvanceNode
  { advCommand :: !Name,
    advFields :: ![FieldBinding]
  }
  deriving stock (Eq, Show, Generic)

-- | @dispatch Hospital\@input.hospitalId ActivateSurge { … } on-appended … on-duplicate … on-failed …@.
data DispatchNode = DispatchNode
  { dispTarget :: !Name,
    dispKey :: !Text,
    dispCommand :: !Name,
    dispFields :: ![FieldBinding],
    dispDisposition :: !DispatchDisposition,
    dispLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | The @on <Input>@ reaction: a self-advance, zero or more dispatches, and a
-- @schedule@ of the timer.
data HandleNode = HandleNode
  { hOn :: !Name,
    hAdvance :: !AdvanceNode,
    hDispatch :: ![DispatchNode],
    hSchedule :: !Name
  }
  deriving stock (Eq, Show, Generic)

-- | A deterministic id derivation: @uuidv5 \"prefix:\" <> correlationId@.
data IdExpr = IdExpr
  { ideStrategy :: !IdStrategy,
    idePrefix :: !Text
  }
  deriving stock (Eq, Show, Generic)

data IdStrategy = UuidV5Id
  deriving stock (Eq, Show, Generic)

-- | @fireAt input.observedAt + 5m@ — an injected timestamp field plus a window.
-- There is no clock-sampling constructor, so the no-wall-clock rule holds by
-- construction.
data FireAtExpr = FireAtExpr
  { faField :: !Name,
    faWindow :: !Text
  }
  deriving stock (Eq, Show, Generic)

data FireOutcome = OFired | ORetry
  deriving stock (Eq, Show, Generic)

-- | The complete timer-fire disposition table; @on-reject OFired@ is the benign
-- inversion (a CommandRejected means \"already applied\" = success).
data FireDisposition = FireDisposition
  { onOk :: !FireOutcome,
    onReject :: !FireOutcome,
    onAmbiguous :: !FireOutcome,
    onError :: !FireOutcome,
    notMine :: !FireOutcome
  }
  deriving stock (Eq, Show, Generic)

-- | @fire dispatch Surge\@correlationId MarkSurgeTimerFired { … } fired-event-id … on-ok …@.
data FireNode = FireNode
  { fireTarget :: !Name,
    fireKey :: !Text,
    fireCommand :: !Name,
    fireFields :: ![FieldBinding],
    fireFiredEventId :: !IdExpr,
    fireDisposition :: !FireDisposition
  }
  deriving stock (Eq, Show, Generic)

-- | A nested @timer@ sub-node of a process.
data TimerNode = TimerNode
  { tmName :: !Name,
    tmId :: !IdExpr,
    tmFireAt :: !FireAtExpr,
    tmPayload :: ![FieldBinding],
    tmFire :: !FireNode,
    tmDecodeUnknown :: !Name,
    tmMaxAttempts :: !Int,
    tmDeadLetter :: !Text,
    tmLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | A node-level worker policy lowered to the runtime worker options.
data PolicyChoice = PolHalt | PolDeadLetter | PolSkip
  deriving stock (Eq, Show, Generic)

-- | A @process@ (process manager / saga) node. The dispatch-id strategy is fixed
-- (runtime-owned uuidv5), so it is implicit in the AST and always rendered.
data ProcessNode = ProcessNode
  { -- | The block identifier (@process HospitalSurge@), used for module names.
    procId :: !Name,
    -- | The define-once ProcessManager @name@ (@name \"hospital-surge\"@).
    procName :: !Text,
    procInput :: !InputDecl,
    procCorrelate :: !CorrelateDecl,
    procSaga :: !SagaRef,
    procTarget :: !Name,
    procProjections :: ![Name],
    procHandle :: !HandleNode,
    procRejected :: !PolicyChoice,
    procPoison :: !PolicyChoice,
    procTimer :: !TimerNode,
    procLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-108: stateless, effectful content-based routing.

data ResolveSource = ResolveReadModel !Name | ResolveHole
  deriving stock (Eq, Show, Generic)

data ResolveDecl = ResolveDecl
  { rvSource :: !ResolveSource,
    rvRow :: ![Name],
    rvLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

data RouterDispatchNode = RouterDispatchNode
  { rdCommand :: !Name,
    rdFields :: ![FieldBinding],
    rdDisposition :: !DispatchDisposition,
    rdLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | A stateless router. Its fixed dispatch-id strategy is runtime-owned, and
-- the mandatory @stable@ token on the resolve clause is an author acknowledgement
-- that retry attempts accumulate the union of resolved target identities.
data RouterNode = RouterNode
  { rtId :: !Name,
    rtName :: !Text,
    rtInput :: !InputDecl,
    rtKey :: !CorrelateDecl,
    rtResolve :: !ResolveDecl,
    rtTarget :: !Name,
    rtProjections :: ![Name],
    rtDispatch :: !RouterDispatchNode,
    rtRejected :: !PolicyChoice,
    rtPoison :: !PolicyChoice,
    rtLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-4: the cross-service @contract@ (shared Kafka message schema, define-once).

-- | A contract field type: @typeid \"inc\"@, @text@, or @int@.
data ContractType = CTypeId !Text | CText | CInt
  deriving stock (Eq, Show, Generic)

data ContractField = ContractField
  { cfName :: !Name,
    cfType :: !ContractType
  }
  deriving stock (Eq, Show, Generic)

-- | @event <Name> on <topicAlias> { field: type … }@ within a contract.
data ContractEvent = ContractEvent
  { ceName :: !Name,
    ceTopic :: !Name,
    ceFields :: ![ContractField]
  }
  deriving stock (Eq, Show, Generic)

-- | A @contract@ node: the shared cross-service message schema, declared once
-- and referenced by both producer (@emit@) and consumer (@intake@). EP-5's
-- pgmq @dispatch@ also couples to it.
data ContractNode = ContractNode
  { ctrName :: !Name,
    ctrSchemaVersion :: !Int,
    ctrDiscriminator :: !Name,
    -- | (topic alias, real Kafka topic string)
    ctrTopics :: ![(Name, Text)],
    ctrEvents :: ![ContractEvent],
    ctrLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-4: the @intake@ (Kafka consumer / inbox) node.

-- | Where an envelope field is read from on the wire.
data WireSource
  = SrcHeader !Text
  | SrcBody
  | SrcKafkaKey
  | SrcKafkaCursor
  deriving stock (Eq, Show, Generic)

-- | One envelope-binding row: @bind <field> from <source> [required] [cross-check body]@.
data BindRow = BindRow
  { brField :: !Name,
    brSource :: !WireSource,
    brRequired :: !Bool,
    brCrossCheck :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | An inbox outcome action. The dangerous defaults the validator guards: a
-- @duplicate@\/@previouslyFailed@ must not be 'IRetry'; @decodeFailed@ must not
-- be an unbounded 'IRetry'.
data InboxAction
  = IAckOk
  | -- | @retry <window>@, e.g. @retry 5s@
    IRetry !Text
  | IDeadLetter !(Maybe Text)
  deriving stock (Eq, Show, Generic)

-- | One row of the mandatory, complete inbox disposition table.
data DispositionRow = DispositionRow
  { drOutcome :: !Name,
    drAction :: !InboxAction,
    drLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | The body decode-strictness decision (hole-kind 6).
data DecodeSpec = DecodeSpec
  { -- | the envelope policy text, e.g. @strict-required lenient-optional@
    decEnvelope :: !Text,
    decBodyStrict :: !Bool,
    decBodySchemaVersion :: !Int
  }
  deriving stock (Eq, Show, Generic)

-- | How much of a successfully processed envelope the inbox retains.
data InkPersist = InkPersistFull | InkPersistDedupeOnly
  deriving stock (Eq, Show, Generic)

-- | An @intake@ (Kafka consumer / inbox) node. The runtime-config @consumer@
-- block (brokers/groupId/offsetReset) is hole-kind 8, delegated to deployment
-- and not modelled here.
data IntakeNode = IntakeNode
  { inkName :: !Name,
    inkContract :: !Name,
    inkTopic :: !Name,
    inkAccept :: ![Name],
    inkBinds :: ![BindRow],
    inkDedupeKey :: !Name,
    inkDedupePolicy :: !Name,
    inkPersist :: !InkPersist,
    inkDecode :: !DecodeSpec,
    inkDisposition :: ![DispositionRow],
    inkLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-4: the @emit@ (outbox mapping) and @publisher@ nodes.

-- | A deterministic id derivation hole: @derive [\"prefix\"] hole@.
newtype DeriveSpec = DeriveSpec {dsPrefix :: Maybe Text}
  deriving stock (Eq, Show, Generic)

-- | One @\"value\" => EventType@ row of an emit's status mapping.
data EmitMapRow = EmitMapRow
  { emrValue :: !Text,
    emrEvent :: !Name,
    emrLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | An @emit@ (outbox) node: maps a private status discriminant to contract
-- event types, with a mandatory explicit @_ => skip@ catch-all.
data EmitNode = EmitNode
  { emName :: !Name,
    emContract :: !Name,
    emTopic :: !Name,
    emSource :: !Text,
    emKey :: !Name,
    emDiscriminant :: !Name,
    emMap :: ![EmitMapRow],
    -- | whether the explicit @_ => skip@ catch-all is present
    emSkip :: !Bool,
    emMessageId :: !DeriveSpec,
    emIdempotencyKey :: !DeriveSpec,
    emLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | @backoff <kind> <window>@, e.g. @backoff constant 2s@.
data BackoffSpec = BackoffSpec
  { boKind :: !Name,
    boWindow :: !Text,
    boMax :: !(Maybe Text),
    boMultiplier :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

-- | A @publisher@ node: the at-least-once publishing policy for an emit's topic.
data PublisherNode = PublisherNode
  { pubName :: !Name,
    pubEmit :: !Name,
    pubOrdering :: !Name,
    pubMaxAttempts :: !Int,
    pubBackoff :: !BackoffSpec,
    -- | @outboxId stable from <field>@: retries coalesce on (source, this field)
    pubOutboxField :: !Name,
    pubLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-5: the pgmq @workqueue@ + @dispatch@ nodes.

-- | One @field -> \"wire_name\" type required@ row of a workqueue payload.
data WqField = WqField
  { wqfName :: !Name,
    wqfWire :: !Text,
    wqfType :: !Name,
    wqfRequired :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | One row of a workqueue's consumer @JobOutcome@ disposition (reusing
-- 'InboxAction': @retry <window>@ \/ @deadLetter@).
data WqDispRow = WqDispRow
  { wqdOutcome :: !Name,
    wqdAction :: !InboxAction,
    wqdLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | The queue's semantic delivery-order contract.
data WqOrdering = WqUnordered | WqFifoThroughput | WqFifoRoundRobin
  deriving stock (Eq, Show, Generic)

-- | A FIFO message-group key derived from one payload field.
data WqGroupKey = WqGroupKey
  { gkField :: !Name,
    gkVia :: !Name,
    gkFixture :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

-- | The PostgreSQL storage shape provisioned for a queue.
data WqProvision
  = WqStandard
  | WqUnlogged
  | WqPartitioned !Text !Text
  deriving stock (Eq, Show, Generic)

-- | A pgmq @workqueue@ node. The @derive@ trio (physical\/dlq\/table) is a
-- /captured fixture/ (hole-kind 1): the validator re-derives the physical name
-- from @logical@ and flags any divergence (the drift hazard at the dedup site).
data WorkqueueNode = WorkqueueNode
  { wqName :: !Name,
    wqLogical :: !Text,
    wqPhysical :: !Text,
    wqDlq :: !Text,
    wqTable :: !Text,
    wqOrdering :: !WqOrdering,
    wqGroupKey :: !(Maybe WqGroupKey),
    wqProvision :: !WqProvision,
    wqPayloadName :: !Name,
    wqPayload :: ![WqField],
    wqMaxRetries :: !Int,
    wqDelay :: !Text,
    wqDlqOn :: !Bool,
    wqDisposition :: ![WqDispRow],
    wqLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | A pgmq @dispatch@ node: a read-model→enqueue coupling with a fan-out hole
-- and a dedup check (one arm of which is a raw-SQL hole).
data PgmqDispatchNode = PgmqDispatchNode
  { pdName :: !Name,
    pdSourceReadModel :: !Name,
    pdSourceKey :: !Name,
    pdFanoutBody :: !Name,
    pdDedupKey :: !Name,
    pdDedupReadModel :: !Name,
    pdDedupReadModelField :: !Text,
    pdDedupQueue :: !Name,
    pdDedupQueueField :: !Text,
    pdEnqueueTo :: !Name,
    pdLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-107: first-class read-model declarations.

-- | One declared SQL column. The validator owns the closed type vocabulary.
data RmColumn = RmColumn
  { rmcName :: !Text,
    rmcType :: !Text,
    rmcRequired :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | Whether the model is fed in a command transaction or by a subscription.
data RmFeed = RmInline | RmSubscription
  deriving stock (Eq, Show, Generic)

-- | Which event-log head a strong read waits for.
data RmScope = RmEntireLog | RmCategory !Text
  deriving stock (Eq, Show, Generic)

-- | A registered, versioned SQL read model. Columns define its shape identity;
-- the runtime table remains owned by codd migrations rather than the DSL.
data ReadModelNode = ReadModelNode
  { rmName :: !Name,
    rmTable :: !Text,
    rmSchema :: !Text,
    rmColumns :: ![RmColumn],
    rmVersion :: !Int,
    rmShape :: !Text,
    rmConsistency :: !Consistency,
    rmScope :: !(Maybe RmScope),
    rmFeed :: !RmFeed,
    rmSubscription :: !(Maybe Text),
    rmLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- EP-6: the durable @workflow@ + @operation@ nodes.

-- | One ordered item of a workflow body. Replay matches on the label, not the
-- position. (Positional constructors avoid partial record fields.)
data WfBodyItem
  = -- | @step <label> -> <ResultType>@
    WfStep !Name !Name !Loc
  | -- | @await <label> -> <ResultType>@
    WfAwait !Name !Name !Loc
  | -- | @sleep <label> after <injected-delay-field>@ (TIME INJECTED)
    WfSleep !Name !Name !Loc
  | -- | @child <label> id input via <childIdFn> -> <ResultType>@
    WfChild !Name !Name !Name !Loc
  | -- | @patch <patch-id> { <items> }@ — guard items behind a durable patch.
    WfPatch !Name ![WfBodyItem] !Loc
  | -- | @continueAsNew <SeedType>@ — rotate after the terminal top-level item.
    WfContinueAsNew !Name !Loc
  deriving stock (Eq, Show, Generic)

-- | A durable @workflow@ node.
data WorkflowNode = WorkflowNode
  { -- | block identifier (e.g. @HospitalTransferReservation@)
    wfId :: !Name,
    -- | the stable @name "…"@ (journal stream + every deterministic id)
    wfStable :: !Text,
    wfInput :: !Name,
    wfInputFields :: ![Field],
    wfOutput :: !Name,
    -- | @id from input.<field>@; 'Nothing' for @id from input@
    wfIdField :: !(Maybe Name),
    wfIdVia :: !Name,
    wfBody :: ![WfBodyItem],
    wfLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

workflowNodeLoc :: WorkflowNode -> Loc
workflowNodeLoc WorkflowNode {wfLoc = loc} = loc

-- | The four operation shapes.
data OperationShape
  = -- | @command on <Agg> stream from <field> via <fn> project [ … ]@
    CommandOp !Name !Name !Name ![Name]
  | -- | @query <ReadModel> input <T> result <Type> consistency <C>@
    QueryOp !Name !Name !Text !Name
  | -- | @signal <label> of <Workflow> key from <field> via <fn> value <T>@
    SignalOp !Name !Name !Name !Name !Name
  | -- | @run <Workflow> input <T> outcome -> <Result>@
    RunOp !Name !Name !Name
  deriving stock (Eq, Show, Generic)

data OperationNode = OperationNode
  { opName :: !Name,
    opShape :: !OperationShape,
    opLoc :: !Loc
  }
  deriving stock (Eq, Show, Generic)

-- | A top-level node. EP-1 defines 'NAggregate'; EP-3 adds 'NProcess'; EP-4 adds
-- 'NContract'\/'NIntake'\/'NEmit'\/'NPublisher'; EP-5 adds 'NWorkqueue'\/
-- 'NPgmqDispatch'; EP-6 adds 'NWorkflow'\/'NOperation'; EP-107 adds
-- 'NReadModel'.
data Node
  = NAggregate Aggregate
  | NProcess ProcessNode
  | NRouter RouterNode
  | NContract ContractNode
  | NIntake IntakeNode
  | NEmit EmitNode
  | NPublisher PublisherNode
  | NWorkqueue WorkqueueNode
  | NPgmqDispatch PgmqDispatchNode
  | NReadModel ReadModelNode
  | NWorkflow WorkflowNode
  | NOperation OperationNode
  deriving stock (Eq, Show, Generic)

-- | The module-placement style for a scaffolded service. 'GeneratedPrefix' is
-- the historical default — @\<root\>.Generated.\<Ctx\>.\<Node\>@ for the generated
-- layer, holes at @\<root\>.\<Ctx\>.\<Node\>@. 'CollocatedLeaf' places the
-- generated layer as a leaf under the domain — @\<root\>.\<Ctx\>.\<Node\>.Generated@
-- — so it sits next to hand-written domain code (holes still at
-- @\<root\>.\<Ctx\>.\<Node\>@). Defined here (not in "Keiro.Dsl.Scaffold") so the
-- 'Spec' AST can carry an author's standing choice; 'Keiro.Dsl.Scaffold'
-- re-exports it.
data Placement
  = GeneratedPrefix
  | CollocatedLeaf
  deriving stock (Eq, Show, Generic)

-- | A whole @.keiro@ file: one context name, an optional module-placement
-- override (the @module@/@layout@ clauses), the shared id/enum/rule/mapped declarations,
-- and the list of nodes. 'specModuleRoot' and 'specLayout' are 'Nothing' when the
-- spec omits the clauses, reproducing the historical default.
data Spec = Spec
  { specContext :: !Name,
    specModuleRoot :: !(Maybe Text),
    specLayout :: !(Maybe Placement),
    specIds :: ![IdDecl],
    specEnums :: ![EnumDecl],
    specRules :: ![RuleDecl],
    specNominalScalars :: ![NominalScalarDecl],
    specMapped :: ![MappedDecl],
    specNodes :: ![Node]
  }
  deriving stock (Eq, Show, Generic)