keiro-dsl-0.17.0.0: src/Keiro/Dsl/CoordinationImpact.hs
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}
-- | Reviewable coordination consequences for router selection evolution.
--
-- The aggregate replay report deliberately remains separate. Declarative
-- selection metadata describes drain/redelivery coordination around the frozen
-- target-keyed command identity; custom resolvers are reported honestly as
-- unverified rather than assigned invented semantic metadata.
module Keiro.Dsl.CoordinationImpact
( SelectionVerification (..),
CoordinationSeverity (..),
CoordinationReason (..),
RouterSelectionSnapshot (..),
RouterSelectionDrift (..),
ProcessReactionSnapshot (..),
ProcessReactionDrift (..),
CoordinationImpact (..),
routerSelectionSnapshots,
routerSelectionDrift,
renderRouterSelectionDrift,
processReactionSnapshots,
processReactionDrift,
renderProcessReactionDrift,
coordinationImpact,
renderCoordinationImpact,
)
where
import Control.Monad (unless)
import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))
import Data.List (sortOn)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Keiro.Dsl.Grammar
import Keiro.Dsl.ProcessReaction (CheckedProcessReaction (..), checkProcessReaction)
import Keiro.Dsl.RouterSelection
import Keiro.Dsl.SemanticContract (CheckedService, checkedLanguageContract, checkedSpec, checkedTypeGraph)
import Keiro.Dsl.SemanticImpact (MappedConsumer (..), MappedImpactDelta (..))
import Keiro.Dsl.TypeGraph (PathSeg (..), UsePath (..), UseSite (..), renderUsePath, unMappedKey)
import Numeric.Natural (Natural)
data SelectionVerification = DeclarativeVerified | CustomUnverified
deriving stock (Eq, Ord, Show, Generic)
data CoordinationSeverity = CoordinationAdvisory | CoordinationBreaking
deriving stock (Eq, Ord, Show, Generic)
data CoordinationReason
= SelectionIdentityChanged
| SelectionVersionDecreased
| SelectionFingerprintChangedWithoutVersionBump
| SelectionFingerprintChangedWithVersionBump
| SelectionVersionMetadataOnly
| SelectionVerificationBoundaryChanged
| SelectionMappedDependencyChanged
deriving stock (Eq, Ord, Show, Generic)
-- | Durable selection ownership metadata. Locations and query files are absent;
-- the ledger owns only the verification boundary and checked semantic identity.
data RouterSelectionSnapshot = RouterSelectionSnapshot
{ router :: !Name,
verification :: !SelectionVerification,
identity :: !(Maybe Text),
version :: !(Maybe Natural),
fingerprint :: !(Maybe Text)
}
deriving stock (Eq, Ord, Show, Generic)
data CoordinationImpact = CoordinationImpact
{ router :: !Name,
severity :: !CoordinationSeverity,
reason :: !CoordinationReason,
previousVerification :: !SelectionVerification,
currentVerification :: !SelectionVerification,
previousIdentity :: !(Maybe Text),
currentIdentity :: !(Maybe Text),
previousVersion :: !(Maybe Natural),
currentVersion :: !(Maybe Natural),
previousFingerprint :: !(Maybe Text),
currentFingerprint :: !(Maybe Text),
affectedUseSites :: ![UseSite]
}
deriving stock (Eq, Show, Generic)
data RouterSelectionDrift = RouterSelectionDrift
{ router :: !Name,
previousSelection :: !(Maybe RouterSelectionSnapshot),
currentSelection :: !(Maybe RouterSelectionSnapshot)
}
deriving stock (Eq, Show, Generic)
-- | Durable coordination metadata for a generated reaction body. Legacy
-- handle bodies are retained as @custom-unverified@ without assigning a
-- version or fingerprint that their source does not declare.
data ProcessReactionSnapshot = ProcessReactionSnapshot
{ process :: !Name,
verification :: !Text,
version :: !(Maybe Natural),
fingerprint :: !(Maybe Text)
}
deriving stock (Eq, Ord, Show, Generic)
data ProcessReactionDrift = ProcessReactionDrift
{ process :: !Name,
previousReaction :: !(Maybe ProcessReactionSnapshot),
currentReaction :: !(Maybe ProcessReactionSnapshot)
}
deriving stock (Eq, Show, Generic)
instance ToJSON SelectionVerification where
toJSON = toJSON . verificationIdentity
instance FromJSON SelectionVerification where
parseJSON value = do
identity <- parseJSON value
case (identity :: Text) of
"declarative-verified" -> pure DeclarativeVerified
"custom-unverified" -> pure CustomUnverified
_ -> fail "unknown router selection verification"
instance ToJSON RouterSelectionSnapshot where
toJSON snapshot =
object
[ "router" .= (.router) snapshot,
"verification" .= (.verification) snapshot,
"identity" .= (.identity) snapshot,
"version" .= (.version) snapshot,
"fingerprint" .= (.fingerprint) snapshot
]
instance ToJSON ProcessReactionSnapshot where
toJSON snapshot =
object
[ "processName" .= (.process) snapshot,
"verification" .= (.verification) snapshot,
"version" .= (.version) snapshot,
"fingerprint" .= (.fingerprint) snapshot
]
instance FromJSON ProcessReactionSnapshot where
parseJSON = withObject "ProcessReactionSnapshot" $ \fields -> do
snapshot <-
ProcessReactionSnapshot
<$> fields .: "processName"
<*> fields .: "verification"
<*> fields .:? "version"
<*> fields .:? "fingerprint"
unless (processSnapshotValid snapshot) (fail "process reaction metadata does not match its verification boundary")
pure snapshot
instance FromJSON RouterSelectionSnapshot where
parseJSON = withObject "RouterSelectionSnapshot" $ \fields -> do
snapshot <-
RouterSelectionSnapshot
<$> fields .: "router"
<*> fields .: "verification"
<*> fields .:? "identity"
<*> fields .:? "version"
<*> fields .:? "fingerprint"
unless (snapshotValid snapshot) (fail "router selection metadata does not match its verification boundary")
pure snapshot
instance ToJSON CoordinationImpact where
toJSON impact =
object
[ "router" .= (.router) impact,
"severity" .= severityIdentity ((.severity) impact),
"reason" .= reasonIdentity ((.reason) impact),
"previousVerification" .= (.previousVerification) impact,
"currentVerification" .= (.currentVerification) impact,
"previousIdentity" .= (.previousIdentity) impact,
"currentIdentity" .= (.currentIdentity) impact,
"previousVersion" .= (.previousVersion) impact,
"currentVersion" .= (.currentVersion) impact,
"previousFingerprint" .= (.previousFingerprint) impact,
"currentFingerprint" .= (.currentFingerprint) impact,
"affectedUseSites" .= map (renderUsePath . useSitePath) ((.affectedUseSites) impact)
]
-- | Freeze every router's checked coordination metadata in canonical name order.
routerSelectionSnapshots :: CheckedService -> [RouterSelectionSnapshot]
routerSelectionSnapshots = sortOn (.router) . map (.snapshot) . routerSelectionStates
routerSelectionDrift :: [RouterSelectionSnapshot] -> [RouterSelectionSnapshot] -> [RouterSelectionDrift]
routerSelectionDrift previous current =
[ RouterSelectionDrift router old new
| router <- Set.toAscList (Map.keysSet oldByRouter <> Map.keysSet newByRouter),
let old = Map.lookup router oldByRouter,
let new = Map.lookup router newByRouter,
old /= new
]
where
oldByRouter = Map.fromList [((.router) snapshot, snapshot) | snapshot <- previous]
newByRouter = Map.fromList [((.router) snapshot, snapshot) | snapshot <- current]
renderRouterSelectionDrift :: [RouterSelectionDrift] -> [Text]
renderRouterSelectionDrift [] = []
renderRouterSelectionDrift drifts = "router selection coordination metadata:" : concatMap renderDrift drifts
where
renderDrift drift =
[ " " <> (.router) drift,
" previous: " <> maybe "(none)" renderSnapshot ((.previousSelection) drift),
" current: " <> maybe "(none)" renderSnapshot ((.currentSelection) drift)
]
renderSnapshot snapshot =
verificationIdentity ((.verification) snapshot)
<> maybe "" (" identity=" <>) ((.identity) snapshot)
<> maybe "" ((" version=" <>) . T.pack . show) ((.version) snapshot)
<> maybe "" (" fingerprint=" <>) ((.fingerprint) snapshot)
processReactionSnapshots :: CheckedService -> Either (NE.NonEmpty Text) [ProcessReactionSnapshot]
processReactionSnapshots service = sortOn (.process) <$> traverse snapshotFor processes
where
spec = checkedSpec service
processes = [process | NProcess process <- (.nodes) spec]
snapshotFor process = case (.body) process of
LegacyProcessBody {} -> Right (customSnapshot process)
ReactionProcessBody {} -> case checkedTypeGraph service of
Left failures -> Left (T.pack ("checked service type graph did not resolve for process coordination: " <> show failures) NE.:| [])
Right graph -> case checkProcessReaction (checkedLanguageContract service) graph spec process of
Left failures -> Left (T.pack ("validated process reaction did not check for coordination: " <> show failures) NE.:| [])
Right checked ->
Right
ProcessReactionSnapshot
{ process = (.name) process,
verification = (.verification) checked,
version = Just ((.version) checked),
fingerprint = Just ((.fingerprint) checked)
}
customSnapshot process =
ProcessReactionSnapshot
{ process = (.name) process,
verification = "custom-unverified",
version = Nothing,
fingerprint = Nothing
}
processReactionDrift :: [ProcessReactionSnapshot] -> [ProcessReactionSnapshot] -> [ProcessReactionDrift]
processReactionDrift previous current =
[ ProcessReactionDrift process old new
| process <- Set.toAscList (Map.keysSet oldByProcess <> Map.keysSet newByProcess),
let old = Map.lookup process oldByProcess,
let new = Map.lookup process newByProcess,
old /= new
]
where
oldByProcess = Map.fromList [((.process) snapshot, snapshot) | snapshot <- previous]
newByProcess = Map.fromList [((.process) snapshot, snapshot) | snapshot <- current]
renderProcessReactionDrift :: [ProcessReactionDrift] -> [Text]
renderProcessReactionDrift [] = []
renderProcessReactionDrift drifts = "process reaction coordination metadata:" : concatMap renderDrift drifts
where
renderDrift drift =
[ " " <> (.process) drift,
" previous: " <> maybe "(none)" renderSnapshot ((.previousReaction) drift),
" current: " <> maybe "(none)" renderSnapshot ((.currentReaction) drift)
]
renderSnapshot snapshot =
(.verification) snapshot
<> maybe "" ((" version=" <>) . T.pack . show) ((.version) snapshot)
<> maybe "" (" fingerprint=" <>) ((.fingerprint) snapshot)
coordinationImpact :: CheckedService -> CheckedService -> [MappedImpactDelta] -> [CoordinationImpact]
coordinationImpact previous current mappedDeltas =
sortOn impactOrder (directImpacts <> mappedImpacts)
where
previousStates = stateMap previous
currentStates = stateMap current
matchedRouters = Set.toAscList (Map.keysSet previousStates `Set.intersection` Map.keysSet currentStates)
directImpacts =
mapMaybe
(\router -> directImpact (previousStates Map.! router) (currentStates Map.! router))
matchedRouters
affectedRouters =
Set.toAscList . Set.fromList $
[ router
| delta <- mappedDeltas,
consumer <- Set.toList ((.previousConsumers) delta <> (.currentConsumers) delta),
RouterSelectionConsumer router _ <- [consumer]
]
mappedImpacts =
[ mkImpact
CoordinationAdvisory
SelectionMappedDependencyChanged
oldState
newState
((.useSites) oldState <> (.useSites) newState)
| router <- affectedRouters,
Just oldState <- [Map.lookup router previousStates],
Just newState <- [Map.lookup router currentStates]
]
impactOrder impact = ((.router) impact, (.reason) impact)
data RouterSelectionState = RouterSelectionState
{ snapshot :: !RouterSelectionSnapshot,
useSites :: ![UseSite]
}
stateMap :: CheckedService -> Map Name RouterSelectionState
stateMap = Map.fromList . map (\state -> ((.router) ((.snapshot) state), state)) . routerSelectionStates
routerSelectionStates :: CheckedService -> [RouterSelectionState]
routerSelectionStates service = case checkedTypeGraph service of
Left failures -> error ("checked service type graph did not resolve for router coordination: " <> show failures)
Right graph -> map (routerState graph) routers
where
spec = checkedSpec service
routers = [router | NRouter router <- (.nodes) spec]
routerState graph router = case (.source) ((.resolve) router) of
ResolveDeclarative {} -> case checkRouterSelection (checkedLanguageContract service) graph spec router of
Left failures -> error ("validated declarative router selection did not check for coordination: " <> show failures)
Right selection ->
RouterSelectionState
{ snapshot =
RouterSelectionSnapshot
{ router = (.id) router,
verification = DeclarativeVerified,
identity = Just ((.identity) selection),
version = Just ((.version) selection),
fingerprint = Just ((.fingerprint) selection)
},
useSites = (.useSites) selection
}
ResolveReadModel {} -> customState router
ResolveHole -> customState router
customState router =
RouterSelectionState
{ snapshot =
RouterSelectionSnapshot
{ router = (.id) router,
verification = CustomUnverified,
identity = Nothing,
version = Nothing,
fingerprint = Nothing
},
useSites = []
}
directImpact :: RouterSelectionState -> RouterSelectionState -> Maybe CoordinationImpact
directImpact oldState newState
| oldVerification /= newVerification = advisory SelectionVerificationBoundaryChanged
| oldVerification == CustomUnverified = Nothing
| oldIdentity /= newIdentity = breaking SelectionIdentityChanged
| newVersion < oldVersion = breaking SelectionVersionDecreased
| oldFingerprint /= newFingerprint && newVersion == oldVersion = breaking SelectionFingerprintChangedWithoutVersionBump
| oldFingerprint /= newFingerprint && newVersion > oldVersion = advisory SelectionFingerprintChangedWithVersionBump
| oldFingerprint == newFingerprint && newVersion > oldVersion = advisory SelectionVersionMetadataOnly
| otherwise = Nothing
where
old = (.snapshot) oldState
new = (.snapshot) newState
oldVerification = (.verification) old
newVerification = (.verification) new
oldIdentity = (.identity) old
newIdentity = (.identity) new
oldVersion = (.version) old
newVersion = (.version) new
oldFingerprint = (.fingerprint) old
newFingerprint = (.fingerprint) new
useSites = (.useSites) oldState <> (.useSites) newState
advisory reason = Just (mkImpact CoordinationAdvisory reason oldState newState useSites)
breaking reason = Just (mkImpact CoordinationBreaking reason oldState newState useSites)
mkImpact :: CoordinationSeverity -> CoordinationReason -> RouterSelectionState -> RouterSelectionState -> [UseSite] -> CoordinationImpact
mkImpact severity reason oldState newState useSites =
CoordinationImpact
{ router = (.router) new,
severity = severity,
reason = reason,
previousVerification = (.verification) old,
currentVerification = (.verification) new,
previousIdentity = (.identity) old,
currentIdentity = (.identity) new,
previousVersion = (.version) old,
currentVersion = (.version) new,
previousFingerprint = (.fingerprint) old,
currentFingerprint = (.fingerprint) new,
affectedUseSites = Set.toAscList (Set.fromList useSites)
}
where
old = (.snapshot) oldState
new = (.snapshot) newState
renderCoordinationImpact :: [CoordinationImpact] -> [Text]
renderCoordinationImpact [] = []
renderCoordinationImpact impacts = "coordination impact:" : concatMap renderImpact impacts
where
renderImpact impact =
[ " " <> (.router) impact <> ": " <> severityIdentity ((.severity) impact) <> " (" <> reasonIdentity ((.reason) impact) <> ")",
" verification: " <> verificationIdentity ((.previousVerification) impact) <> " -> " <> verificationIdentity ((.currentVerification) impact),
" identity: " <> renderMaybe ((.previousIdentity) impact) <> " -> " <> renderMaybe ((.currentIdentity) impact),
" version: " <> renderMaybeShow ((.previousVersion) impact) <> " -> " <> renderMaybeShow ((.currentVersion) impact),
" fingerprint: " <> renderMaybe ((.previousFingerprint) impact) <> " -> " <> renderMaybe ((.currentFingerprint) impact),
" affected use sites: " <> renderUseSites ((.affectedUseSites) impact)
]
renderMaybe = maybe "(unverified)" id
renderMaybeShow = maybe "(unverified)" (T.pack . show)
renderUseSites [] = "(none)"
renderUseSites values = T.intercalate ", " (map (renderUsePath . useSitePath) values)
useSitePath :: UseSite -> UsePath
useSitePath site = UsePath ((.root) site) [SegDecl (unMappedKey ((.mappedKey) site))]
snapshotValid :: RouterSelectionSnapshot -> Bool
snapshotValid snapshot = case (.verification) snapshot of
DeclarativeVerified -> allPresent
CustomUnverified -> allAbsent
where
fields = [() <$ (.identity) snapshot, () <$ (.version) snapshot, () <$ (.fingerprint) snapshot]
allPresent = all (/= Nothing) fields
allAbsent = all (== Nothing) fields
processSnapshotValid :: ProcessReactionSnapshot -> Bool
processSnapshotValid snapshot = case (.verification) snapshot of
"generated-declarative" -> allPresent
"custom-unverified" -> allPresent || allAbsent
_ -> False
where
fields = [() <$ (.version) snapshot, () <$ (.fingerprint) snapshot]
allPresent = all (/= Nothing) fields
allAbsent = all (== Nothing) fields
verificationIdentity :: SelectionVerification -> Text
verificationIdentity DeclarativeVerified = "declarative-verified"
verificationIdentity CustomUnverified = "custom-unverified"
severityIdentity :: CoordinationSeverity -> Text
severityIdentity CoordinationAdvisory = "advisory"
severityIdentity CoordinationBreaking = "breaking"
reasonIdentity :: CoordinationReason -> Text
reasonIdentity SelectionIdentityChanged = "selection-identity-changed"
reasonIdentity SelectionVersionDecreased = "selection-version-decreased"
reasonIdentity SelectionFingerprintChangedWithoutVersionBump = "selection-fingerprint-changed-without-version-bump"
reasonIdentity SelectionFingerprintChangedWithVersionBump = "selection-fingerprint-changed-with-version-bump"
reasonIdentity SelectionVersionMetadataOnly = "selection-version-metadata-only"
reasonIdentity SelectionVerificationBoundaryChanged = "selection-verification-boundary-changed"
reasonIdentity SelectionMappedDependencyChanged = "selection-mapped-dependency-changed"