packages feed

keiro-dsl-0.17.0.0: src/Keiro/Dsl/ProcessReaction.hs

{-# OPTIONS_GHC -Werror=incomplete-patterns #-}

-- | Checked Language 6 process-reaction semantics.
module Keiro.Dsl.ProcessReaction
  ( CheckedProcessReaction (..),
    CheckedReactionArm (..),
    CheckedReactionGuard (..),
    CheckedFollowUp (..),
    CheckedReactionTimer (..),
    ProcessReactionDiagnosticCode (..),
    ProcessReactionDiagnostic (..),
    checkProcessReaction,
    processReactionFingerprint,
    processReactionFingerprintFrom,
  )
where

import Crypto.Hash.SHA256 qualified as SHA256
import Data.ByteString qualified as BS
import Data.List (find, group, sort)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as Text
import Data.Word (Word8)
import Keiro.Dsl.AggregateType
import Keiro.Dsl.CanonicalEncoding (canonicalReactionSurface)
import Keiro.Dsl.Grammar
import Keiro.Dsl.NominalType (NominalRepresentation (..), ResolvedNominalType (..))
import Keiro.Dsl.SemanticContract (EffectiveLanguageContract)
import Keiro.Dsl.TypeGraph (TypeGraph)
import Numeric (showHex)
import Numeric.Natural (Natural)

data CheckedReactionGuard
  = CheckedUnconditional
  | CheckedWhen !Expr
  | CheckedOtherwise
  deriving stock (Eq, Show)

newtype CheckedFollowUp = CheckedFollowUp {syntax :: FollowUp}
  deriving stock (Eq, Show)

data CheckedReactionArm = CheckedReactionArm
  { input :: !Name,
    ordinal :: !Int,
    guard :: !CheckedReactionGuard,
    body :: !ArmBody
  }
  deriving stock (Eq, Show)

newtype CheckedReactionTimer = CheckedReactionTimer {syntax :: ReactionTimerNode}
  deriving stock (Eq, Show)

data CheckedProcessReaction = CheckedProcessReaction
  { version :: !Natural,
    inputs :: !(NonEmpty InputDecl),
    arms :: ![CheckedReactionArm],
    timers :: ![CheckedReactionTimer],
    fingerprint :: !Text,
    verification :: !Text,
    holeObligations :: ![Text]
  }
  deriving stock (Eq, Show)

data ProcessReactionDiagnosticCode
  = ProcessReactionUnknownInput
  | ProcessInputDuplicateDeclaration
  | ProcessTimerDuplicateName
  | ProcessReactionGuardNotBoolean
  | ProcessStateAccessUnsupported
  | ProcessReactionInputUnhandled
  | ProcessReactionDuplicateInput
  | ProcessReactionOtherwiseMissing
  | ProcessReactionOtherwiseUnreachable
  | ProcessTimerPrefixCollision
  | ProcessScheduleUnknownTimer
  | ProcessCancelUnknownTimer
  | ProcessSchedulePayloadIncomplete
  | ProcessTimerPolicyMissing
  | ProcessTimerPolicyUnused
  | ProcessAcceptedArmRequiresEvent
  | ProcessSilentArmMissing
  | ProcessAcceptedArmUnverified
  | ProcessBindingTypeMismatch
  deriving stock (Eq, Ord, Show, Enum, Bounded)

data ProcessReactionDiagnostic = ProcessReactionDiagnostic
  { loc :: !Loc,
    code :: !ProcessReactionDiagnosticCode,
    message :: !Text
  }
  deriving stock (Eq, Show)

checkProcessReaction :: EffectiveLanguageContract -> TypeGraph -> Spec -> ProcessNode -> Either (NonEmpty ProcessReactionDiagnostic) CheckedProcessReaction
checkProcessReaction _languageContract graph spec process = case (.body) process of
  LegacyProcessBody {} -> failure ((.loc) process) ProcessReactionUnknownInput "legacy process has no checked reaction body"
  ReactionProcessBody reaction -> do
    let symbols = aggregateSymbolsFromGraph graph spec
        inputList = NE.toList ((.inputs) reaction)
        inputNames = map (.name) inputList
        reactionList = NE.toList ((.reactions) reaction)
        reactionNames = map (.on) reactionList
        timerList = (.timers) reaction
        timerNames = map (.name) timerList
    requireNoDuplicate ProcessInputDuplicateDeclaration "input" ((.loc) process) inputNames
    requireNoDuplicate ProcessTimerDuplicateName "timer" ((.loc) process) timerNames
    requireNoDuplicate ProcessReactionDuplicateInput "on block" ((.loc) process) reactionNames
    case [node | node <- reactionList, (.on) node `notElem` inputNames] of
      node : _ -> failure ((.loc) node) ProcessReactionUnknownInput ("reaction names undeclared input '" <> (.on) node <> "'")
      [] -> pure ()
    case [input | input <- inputList, (.name) input `notElem` reactionNames] of
      input : _ -> failure ((.loc) input) ProcessReactionInputUnhandled ("input '" <> (.name) input <> "' has no on block")
      [] -> pure ()
    checkTimerPolicy reaction
    checkPrefixCollisions timerList
    checkedArms <- concat <$> traverse (checkReaction symbols inputList timerList) reactionList
    traverse_ (checkTimer symbols) timerList
    let holes =
          [ (.agg) ((.saga) process) <> "." <> (.command) advance <> " accepted-event proof"
          | arm <- checkedArms,
            ArmActions {advance = Just advance} <- [(.body) arm],
            Just _ <- [(.accepted) advance],
            armUnverified arm
          ]
        initial =
          CheckedProcessReaction
            { version = (.version) reaction,
              inputs = (.inputs) reaction,
              arms = checkedArms,
              timers = map CheckedReactionTimer timerList,
              fingerprint = "",
              verification = if null holes then "generated-declarative" else "custom-unverified",
              holeObligations = holes
            }
    pure initial {fingerprint = processReactionFingerprintFrom reaction}
  where
    aggregates = [aggregate | NAggregate aggregate <- (.nodes) spec]

    checkReaction symbols inputList timerList reactionNode = do
      input <- case find ((== (.on) reactionNode) . (.name)) inputList of
        Nothing -> failure ((.loc) reactionNode) ProcessReactionUnknownInput "reaction input is undeclared"
        Just value -> Right value
      checkTotality reactionNode
      traverse (checkArm symbols input timerList) (zip [0 ..] (NE.toList ((.arms) reactionNode)))

    checkTotality reactionNode = do
      let armList = NE.toList ((.arms) reactionNode)
          guards = map (.guard) armList
          otherwiseIndexes = [index | (index, OtherwiseArm) <- zip [0 ..] guards]
      case otherwiseIndexes of
        index : _ | index /= length guards - 1 -> failure ((.loc) (armList !! index)) ProcessReactionOtherwiseUnreachable "otherwise must be the final arm"
        _ -> pure ()
      if any isWhen guards && null otherwiseIndexes
        then failure ((.loc) reactionNode) ProcessReactionOtherwiseMissing "guarded reactions must end in otherwise"
        else pure ()
      where
        isWhen WhenArm {} = True
        isWhen _ = False

    checkArm symbols input timerList (ordinal, arm) = do
      checkedGuard <- case (.guard) arm of
        UnconditionalArm -> Right CheckedUnconditional
        OtherwiseArm -> Right CheckedOtherwise
        WhenArm expression -> checkGuard symbols input expression >> Right (CheckedWhen expression)
      checkArmBody symbols input timerList ((.body) arm)
      pure CheckedReactionArm {input = (.name) input, ordinal, guard = checkedGuard, body = (.body) arm}

    checkArmBody _ _ _ NoAction = Right ()
    checkArmBody symbols input timerList ArmActions {advance, followUps} = do
      maybe (pure ()) (checkAdvance symbols input) advance
      traverse_ (checkFollowUp symbols input timerList) followUps
      case advance >>= (.accepted) of
        Nothing -> pure ()
        Just acceptedFollowUps -> traverse_ (checkFollowUp symbols input timerList) acceptedFollowUps

    checkAdvance symbols input advance = do
      sagaAggregate <- requireAggregate ((.loc) advance) ((.agg) ((.saga) process))
      command <- requireCommand ((.loc) advance) sagaAggregate ((.command) advance)
      checkBindings symbols input sagaAggregate command ((.loc) advance) ((.fields) advance)
      case (.accepted) advance of
        Nothing -> pure ()
        Just _ -> do
          if (.silentNoAction) advance
            then pure ()
            else failure ((.loc) advance) ProcessSilentArmMissing "accepted follow-ups require silent no-action"
          let matching = [transition | transition <- (.transitions) sagaAggregate, (.command) transition == (.command) advance, (.mode) transition == TmLive]
          case [transition | transition <- matching, (.implementation) transition == HoleImplementation] of
            _ : _ -> failure ((.loc) advance) ProcessAcceptedArmUnverified "accepted follow-ups cannot be verified for a hole-owned saga transition"
            [] -> pure ()
          case [transition | transition <- matching, accepts transition && null ((.emits) transition)] of
            _ : _ -> failure ((.loc) advance) ProcessAcceptedArmRequiresEvent "every accepting saga transition must emit an event before it can guard accepted follow-ups"
            [] -> pure ()

    accepts transition = case (.outcome) transition of
      Just OutcomeRejected {} -> False
      Just OutcomeNoOp {} -> False
      Just OutcomeAccepted {} -> True
      Nothing -> True

    checkFollowUp symbols input timerList = \case
      FollowDispatch dispatch -> do
        aggregate <- requireAggregate ((.loc) dispatch) ((.target) dispatch)
        command <- requireCommand ((.loc) dispatch) aggregate ((.command) dispatch)
        checkBindings symbols input aggregate command ((.loc) dispatch) ((.fields) dispatch)
      FollowSchedule schedule -> case find ((== (.timer) schedule) . (.name)) timerList of
        Nothing -> failure ((.loc) schedule) ProcessScheduleUnknownTimer ("schedule names undeclared timer '" <> (.timer) schedule <> "'")
        Just timer -> do
          checkSchedulePayload symbols input timer schedule
          requireInputType symbols input ((.loc) schedule) ((.field) ((.fireAt) schedule)) >>= requireExactType ((.loc) schedule) AggregateTime
      FollowCancel timerName loc ->
        if timerName `elem` map (.name) timerList
          then Right ()
          else failure loc ProcessCancelUnknownTimer ("cancel names undeclared timer '" <> timerName <> "'")

    checkSchedulePayload symbols input timer schedule = do
      let required = [name | PayloadTyped name _ <- (.payload) timer]
          actual = map (.name) ((.bindings) schedule)
      case [name | name <- required, name `notElem` actual] of
        name : _ -> failure ((.loc) schedule) ProcessSchedulePayloadIncomplete ("schedule omits typed payload field '" <> name <> "'")
        [] -> pure ()
      traverse_ (checkPayloadBinding symbols input timer) ((.bindings) schedule)

    checkPayloadBinding symbols input timer binding = case find ((== (.name) binding) . payloadName) ((.payload) timer) of
      Nothing -> pure ()
      Just PayloadConstant {} -> pure ()
      Just (PayloadTyped _ maybeType) -> do
        actual <- bindingType symbols input ((.loc) timer) binding
        expected <- resolveType symbols ((.loc) timer) (maybe TText nameTypeExpr maybeType)
        requireSame ((.loc) timer) expected actual

    checkTimer symbols timer = do
      aggregate <- requireAggregate ((.loc) timer) ((.target) ((.fire) timer))
      command <- requireCommand ((.loc) timer) aggregate ((.command) ((.fire) timer))
      checkTimerFireBindings symbols timer aggregate command

    checkTimerFireBindings symbols timer aggregate command =
      traverse_ (checkOne command) ((.fields) ((.fire) timer))
      where
        checkOne commandDecl binding = case find ((== (.name) binding) . (.name)) ((.fields) commandDecl) of
          Nothing -> pure ()
          Just field -> do
            expected <- mapType ((.loc) timer) (inferAggregateFieldType symbols aggregate CommandFieldUse field)
            actual <- case (.value) binding of
              Just "timer.id" -> Right AggregateText
              _ -> case find ((== (.name) binding) . payloadName) ((.payload) timer) of
                Just (PayloadTyped _ maybeType) -> resolveType symbols ((.loc) timer) (maybe TText nameTypeExpr maybeType)
                Just PayloadConstant {} -> Right AggregateText
                Nothing -> Right AggregateText
            requireSame ((.loc) timer) expected actual

    checkBindings symbols input aggregate command loc bindings =
      traverse_ checkOne bindings
      where
        checkOne binding = case find ((== (.name) binding) . (.name)) ((.fields) command) of
          Nothing -> pure ()
          Just field -> do
            expected <- mapType loc (inferAggregateFieldType symbols aggregate CommandFieldUse field)
            actual <- bindingType symbols input loc binding
            requireSame loc expected actual

    checkGuard symbols input expression = do
      valueType <- guardType symbols input expression
      requireExactType (exprLoc expression) AggregateBool valueType

    guardType symbols input = \case
      EAnd left right -> booleanPair left right
      EOr left right -> booleanPair left right
      ECmp operator left right -> do
        leftType <- guardType symbols input left
        rightType <- guardType symbols input right
        requireSame (exprLoc left) leftType rightType
        if operator `elem` [OpLt, OpLe, OpGt, OpGe] && leftType `notElem` [AggregateText, AggregateInt, AggregateInteger, AggregateNatural, AggregateTime]
          then failure (exprLoc left) ProcessReactionGuardNotBoolean "ordering comparison requires Text, a number, or Time"
          else Right AggregateBool
      EPath loc UnqualifiedRoot ["input", field] -> requireInputType symbols input loc field
      EPath loc _ _ -> failure loc ProcessStateAccessUnsupported "reaction guards may read only input.<field>; saga/register/command state is unsupported"
      ELiteral _ (LiteralBool _) -> Right AggregateBool
      ELiteral _ (LiteralText _) -> Right AggregateText
      ELiteral _ (LiteralIntegral _) -> Right AggregateInteger
      ELiteral loc (LiteralQualified typeName constructor) -> do
        resolved <- resolveType symbols loc (TRef typeName)
        case resolved of
          AggregateNominal nominal -> case (.representation) nominal of
            EnumRepresentation constructors | constructor `elem` map fst (NE.toList constructors) -> Right resolved
            _ -> failure loc ProcessReactionGuardNotBoolean "qualified literal is not a constructor of the declared enum"
          _ -> failure loc ProcessReactionGuardNotBoolean "qualified literal must name a declared enum"
      ELiteral loc LiteralId {} -> failure loc ProcessReactionGuardNotBoolean "id constructor literals are not supported in reaction guards"
      EAdd loc _ _ -> failure loc ProcessReactionGuardNotBoolean "arithmetic is not supported in reaction guards"
      ESubtract loc _ _ -> failure loc ProcessReactionGuardNotBoolean "arithmetic is not supported in reaction guards"
      EMultiply loc _ _ -> failure loc ProcessReactionGuardNotBoolean "arithmetic is not supported in reaction guards"
      EAtom (ABool _) -> Right AggregateBool
      EAtom (AName _) -> failure noLoc ProcessStateAccessUnsupported "bare names are not supported in reaction guards; use input.<field>"
      where
        booleanPair left right = do
          leftType <- guardType symbols input left
          rightType <- guardType symbols input right
          requireExactType (exprLoc left) AggregateBool leftType
          requireExactType (exprLoc right) AggregateBool rightType
          Right AggregateBool

    requireInputType symbols input loc field = case find ((== field) . (.name)) ((.fields) input) of
      Nothing -> failure loc ProcessStateAccessUnsupported ("input '" <> (.name) input <> "' has no field '" <> field <> "'")
      Just declaration -> resolveType symbols loc (maybe TText nameTypeExpr ((.valueType) declaration))

    bindingType symbols input loc binding = case (.value) binding of
      Nothing -> requireInputType symbols input loc ((.name) binding)
      Just value
        | isQuoted value -> Right AggregateText
        | Just field <- T.stripPrefix "input." value -> requireInputType symbols input loc field
        | value == "timer.id" -> Right AggregateText
        | otherwise -> requireInputType symbols input loc value

    requireAggregate loc name = case [aggregate | aggregate <- aggregates, (.name) aggregate == name] of
      aggregate : _ -> Right aggregate
      [] -> failure loc ProcessBindingTypeMismatch ("aggregate '" <> name <> "' is unavailable for reaction type checking")

    requireCommand loc aggregate name = case [command | command <- (.commands) aggregate, (.name) command == name] of
      command : _ -> Right command
      [] -> failure loc ProcessBindingTypeMismatch ("aggregate '" <> (.name) aggregate <> "' has no command '" <> name <> "'")

    resolveType symbols loc expression = mapType loc (resolveAggregateType symbols loc CommandFieldUse expression)
    mapType loc = either (const (failure loc ProcessBindingTypeMismatch "type is not supported by process reactions")) Right
    requireSame loc expected actual
      | expected == actual = Right ()
      | otherwise = failure loc ProcessBindingTypeMismatch ("binding type mismatch: expected " <> aggregateCanonicalName expected <> ", got " <> aggregateCanonicalName actual)
    requireExactType loc expected actual
      | expected == actual = Right ()
      | otherwise = failure loc ProcessReactionGuardNotBoolean "reaction guard must have Boolean type"
    isQuoted value = T.length value >= 2 && T.head value == '"' && T.last value == '"'
    nameTypeExpr = \case
      "Text" -> TText
      "Int" -> TInt
      "Integer" -> TInteger
      "Bool" -> TBool
      "Natural" -> TNatural
      "Time" -> TTime
      name -> TRef name
    payloadName (PayloadConstant name _) = name
    payloadName (PayloadTyped name _) = name
    armUnverified arm = case (.body) arm of
      ArmActions {advance = Just advance} -> case (.accepted) advance of
        Just _ -> any (\aggregate -> (.agg) ((.saga) process) == (.name) aggregate && any (\transition -> (.command) transition == (.command) advance && (.implementation) transition == HoleImplementation) ((.transitions) aggregate)) aggregates
        Nothing -> False
      _ -> False

checkTimerPolicy :: ReactionBody -> Either (NonEmpty ProcessReactionDiagnostic) ()
checkTimerPolicy reaction = case ((.timerPolicy) reaction, (.timers) reaction) of
  (Nothing, _ : _) -> failure ((.versionLoc) reaction) ProcessTimerPolicyMissing "a process with timers requires a timers policy"
  (Just policy, []) -> failure ((.loc) policy) ProcessTimerPolicyUnused "a timer-free process must not declare a timers policy"
  _ -> Right ()

checkPrefixCollisions :: [ReactionTimerNode] -> Either (NonEmpty ProcessReactionDiagnostic) ()
checkPrefixCollisions timers =
  case duplicates (map ((.prefix) . (.id)) timers <> map ((.prefix) . (.firedEventId) . (.fire)) timers) of
    prefix : _ ->
      failure
        (maybe noLoc (.loc) (find (ownsPrefix prefix) timers))
        ProcessTimerPrefixCollision
        ("timer identity prefix is reused: " <> prefix)
    [] -> Right ()
  where
    ownsPrefix prefix timer = (.prefix) ((.id) timer) == prefix || (.prefix) ((.firedEventId) ((.fire) timer)) == prefix

processReactionFingerprint :: CheckedProcessReaction -> Text
processReactionFingerprint = (.fingerprint)

processReactionFingerprintFrom :: ReactionBody -> Text
processReactionFingerprintFrom reaction = hex (BS.unpack (SHA256.hash (Text.encodeUtf8 canonical)))
  where
    canonical = canonicalReactionSurface reaction

hex :: (Foldable f) => f Word8 -> Text
hex = T.pack . concatMap twoHex . foldr (:) []
  where
    twoHex byte = case showHex byte "" of
      [digit] -> ['0', digit]
      digits -> digits

failure :: Loc -> ProcessReactionDiagnosticCode -> Text -> Either (NonEmpty ProcessReactionDiagnostic) a
failure loc code message = Left (ProcessReactionDiagnostic loc code message :| [])

requireNoDuplicate :: ProcessReactionDiagnosticCode -> Text -> Loc -> [Name] -> Either (NonEmpty ProcessReactionDiagnostic) ()
requireNoDuplicate code label loc names = case duplicates names of
  name : _ -> failure loc code (label <> " '" <> name <> "' is declared more than once")
  [] -> Right ()

duplicates :: (Ord a) => [a] -> [a]
duplicates = foldr collect [] . group . sort
  where
    collect (value : _ : _) rest = value : rest
    collect _ rest = rest

traverse_ :: (Applicative f) => (a -> f b) -> [a] -> f ()
traverse_ action = foldr (\value rest -> action value *> rest) (pure ())