keiro-test-support-0.18.0.0: src/Keiro/Test/ReplayCompatibility.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
-- | Stable, application-neutral evidence for comparing retained history across
-- two independently built versions of a service.
--
-- Capture programs should link only one application version. They emit a
-- 'CaptureReport'; a separate comparator loads the baseline report, candidate
-- report, and independently generated 'EvidenceInventory'. This avoids making
-- old and new domain types coexist in one executable.
module Keiro.Test.ReplayCompatibility
( BuildIdentity (..),
BuildPair (..),
CaptureRole (..),
CaseKind (..),
PersistedSurface (..),
RequiredCase (..),
InventorySource (..),
SourceApplicability (..),
InventoryContribution (..),
EvidenceInventory (..),
HighWaterMark (..),
DeterminismInputs (..),
Observation (..),
EvidenceVerdict (..),
CaseResult (..),
CaptureReport (..),
CompatibilityFailure (..),
NormalizationFailure (..),
inventorySourcesV1,
inventoryVersionV1,
reportVersionV1,
requiredCases,
validateCompatibility,
compareObservation,
releaseReady,
renderCompatibilityFailure,
checkNormalizationLaw,
)
where
import Data.Aeson (FromJSON (..), ToJSON (..), Value)
import Data.Aeson qualified as Aeson
import Data.Aeson.Types (Parser)
import Data.List (group, sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Generics (Generic)
reportVersionV1 :: Text
reportVersionV1 = "keiro.replay-compatibility/report/v1"
inventoryVersionV1 :: Text
inventoryVersionV1 = "keiro.replay-compatibility/inventory/v1"
data BuildIdentity = BuildIdentity
{ sourceRevision :: Text,
languageProfile :: Text,
runtimeProfile :: Text,
dependencyPlanHash :: Text
}
deriving stock (Eq, Show, Generic)
instance ToJSON BuildIdentity
instance FromJSON BuildIdentity
data BuildPair = BuildPair
{ baseline :: BuildIdentity,
candidate :: BuildIdentity
}
deriving stock (Eq, Show, Generic)
instance ToJSON BuildPair
instance FromJSON BuildPair
data CaptureRole = BaselineCapture | CandidateCapture
deriving stock (Eq, Ord, Show)
instance ToJSON CaptureRole where
toJSON = Aeson.String . captureRoleToken
instance FromJSON CaptureRole where
parseJSON = Aeson.withText "CaptureRole" $ parseToken "capture role" captureRoles
captureRoleToken :: CaptureRole -> Text
captureRoleToken = \case
BaselineCapture -> "baseline"
CandidateCapture -> "candidate"
captureRoles :: [(Text, CaptureRole)]
captureRoles = [(captureRoleToken role, role) | role <- [BaselineCapture, CandidateCapture]]
data CaseKind
= HistoricalRead
| SemanticEquivalence
| OldReaderNewWriter
| SnapshotReplay
| ProcessManagerReplay
| WorkflowReplay
deriving stock (Eq, Ord, Show, Enum, Bounded)
instance ToJSON CaseKind where
toJSON = Aeson.String . caseKindToken
instance FromJSON CaseKind where
parseJSON = Aeson.withText "CaseKind" $ parseToken "case kind" caseKinds
caseKindToken :: CaseKind -> Text
caseKindToken = \case
HistoricalRead -> "historical-read"
SemanticEquivalence -> "semantic-equivalence"
OldReaderNewWriter -> "old-reader-new-writer"
SnapshotReplay -> "snapshot"
ProcessManagerReplay -> "process-manager"
WorkflowReplay -> "workflow"
caseKinds :: [(Text, CaseKind)]
caseKinds = [(caseKindToken kind, kind) | kind <- [minBound .. maxBound]]
data PersistedSurface = PersistedSurface
{ kind :: Text,
owner :: Text,
identity :: Text
}
deriving stock (Eq, Ord, Show, Generic)
instance ToJSON PersistedSurface
instance FromJSON PersistedSurface
data RequiredCase = RequiredCase
{ caseId :: Text,
caseKind :: CaseKind,
surface :: PersistedSurface
}
deriving stock (Eq, Ord, Show, Generic)
instance ToJSON RequiredCase
instance FromJSON RequiredCase
-- | Every v1 inventory names all of these inputs. An input may explicitly be
-- not applicable, but it may not disappear merely because a candidate report
-- omitted its cases.
data InventorySource
= BaselinePersistedSurfaces
| CandidatePersistedSurfaces
| OrdinaryCompatibilityFindings
| AggregateReplayImpacts
| MappedConsequences
| CheckedProcessReactions
| ApplicationOwnedObligations
deriving stock (Eq, Ord, Show, Enum, Bounded)
inventorySourcesV1 :: [InventorySource]
inventorySourcesV1 = [minBound .. maxBound]
instance ToJSON InventorySource where
toJSON = Aeson.String . inventorySourceToken
instance FromJSON InventorySource where
parseJSON = Aeson.withText "InventorySource" $ parseToken "inventory source" inventorySources
inventorySourceToken :: InventorySource -> Text
inventorySourceToken = \case
BaselinePersistedSurfaces -> "baseline-persisted-surfaces"
CandidatePersistedSurfaces -> "candidate-persisted-surfaces"
OrdinaryCompatibilityFindings -> "ordinary-compatibility-findings"
AggregateReplayImpacts -> "aggregate-replay-impacts"
MappedConsequences -> "mapped-consequences"
CheckedProcessReactions -> "checked-process-reactions"
ApplicationOwnedObligations -> "application-owned-obligations"
inventorySources :: [(Text, InventorySource)]
inventorySources = [(inventorySourceToken source, source) | source <- inventorySourcesV1]
data SourceApplicability
= Applicable
| NotApplicable Text
| SourceUnverified Text
deriving stock (Eq, Show)
instance ToJSON SourceApplicability where
toJSON = \case
Applicable -> Aeson.object ["status" Aeson..= ("applicable" :: Text)]
NotApplicable reason -> Aeson.object ["status" Aeson..= ("not-applicable" :: Text), "reason" Aeson..= reason]
SourceUnverified reason -> Aeson.object ["status" Aeson..= ("unverified" :: Text), "reason" Aeson..= reason]
instance FromJSON SourceApplicability where
parseJSON = Aeson.withObject "SourceApplicability" $ \object -> do
status <- object Aeson..: "status"
case status :: Text of
"applicable" -> pure Applicable
"not-applicable" -> NotApplicable <$> object Aeson..: "reason"
"unverified" -> SourceUnverified <$> object Aeson..: "reason"
other -> fail ("unsupported inventory applicability: " <> Text.unpack other)
data InventoryContribution = InventoryContribution
{ source :: InventorySource,
applicability :: SourceApplicability,
cases :: [RequiredCase]
}
deriving stock (Eq, Show, Generic)
instance ToJSON InventoryContribution
instance FromJSON InventoryContribution
data EvidenceInventory = EvidenceInventory
{ inventoryVersion :: Text,
inventoryId :: Text,
buildPair :: BuildPair,
contributions :: [InventoryContribution]
}
deriving stock (Eq, Show, Generic)
instance ToJSON EvidenceInventory
instance FromJSON EvidenceInventory
data HighWaterMark = HighWaterMark
{ stream :: Text,
revision :: Integer
}
deriving stock (Eq, Ord, Show, Generic)
instance ToJSON HighWaterMark
instance FromJSON HighWaterMark
data DeterminismInputs = DeterminismInputs
{ clock :: Text,
randomness :: Text,
externalResponsesHash :: Text,
failureScheduleHash :: Text
}
deriving stock (Eq, Show, Generic)
instance ToJSON DeterminismInputs
instance FromJSON DeterminismInputs
-- | An observation is deliberately semantic. Artifact hashes may be included
-- as values, but they do not replace the durable state, continuation, or
-- identity coordinates which establish equivalence.
data Observation = Observation
{ durableState :: Map Text Value,
continuations :: [Value],
durableIdentities :: Map Text Text,
freshAllocations :: [Text]
}
deriving stock (Eq, Show, Generic)
instance ToJSON Observation
instance FromJSON Observation
data EvidenceVerdict
= Passed
| Failed Text
| Unverified Text
deriving stock (Eq, Show)
instance ToJSON EvidenceVerdict where
toJSON = \case
Passed -> Aeson.object ["status" Aeson..= ("passed" :: Text)]
Failed reason -> Aeson.object ["status" Aeson..= ("failed" :: Text), "reason" Aeson..= reason]
Unverified reason -> Aeson.object ["status" Aeson..= ("unverified" :: Text), "reason" Aeson..= reason]
instance FromJSON EvidenceVerdict where
parseJSON = Aeson.withObject "EvidenceVerdict" $ \object -> do
status <- object Aeson..: "status"
case status :: Text of
"passed" -> pure Passed
"failed" -> Failed <$> object Aeson..: "reason"
"unverified" -> Unverified <$> object Aeson..: "reason"
other -> fail ("unsupported evidence verdict: " <> Text.unpack other)
data CaseResult = CaseResult
{ requiredCase :: RequiredCase,
verdict :: EvidenceVerdict,
observation :: Maybe Observation
}
deriving stock (Eq, Show, Generic)
instance ToJSON CaseResult
instance FromJSON CaseResult
data CaptureReport = CaptureReport
{ reportVersion :: Text,
role :: CaptureRole,
buildPair :: BuildPair,
inventoryId :: Text,
corpusHash :: Text,
observationContractVersion :: Text,
highWaterMarks :: [HighWaterMark],
selectedSurfaces :: [PersistedSurface],
determinismInputs :: DeterminismInputs,
results :: [CaseResult]
}
deriving stock (Eq, Show, Generic)
instance ToJSON CaptureReport
instance FromJSON CaptureReport
data CompatibilityFailure
= UnsupportedInventoryVersion Text
| UnsupportedReportVersion CaptureRole Text
| WrongCaptureRole CaptureRole CaptureRole
| BuildPairMismatch Text
| InventoryIdentityMismatch CaptureRole
| CorpusIdentityMismatch
| ObservationContractMismatch
| DeterminismInputMismatch
| MissingInventorySource InventorySource
| DuplicateInventorySource InventorySource
| InvalidInventoryContribution InventorySource Text
| ConflictingRequiredCase Text
| MissingRequiredCase CaptureRole Text
| DuplicateCaseResult CaptureRole Text
| CaseDefinitionMismatch CaptureRole Text
| RequiredCaseFailed CaptureRole Text Text
| RequiredCaseUnverified CaptureRole Text Text
| EmptyPassedObservation CaptureRole Text
| ObservationMismatch Text
| MissingSelectedSurface CaptureRole PersistedSurface
| EmptyHighWaterMarks CaptureRole
| DuplicateHighWaterMark CaptureRole Text
| HighWaterMarkMismatch
deriving stock (Eq, Show)
-- | Failures for a codec whose decoder intentionally admits non-canonical wire
-- forms. The law covers normalization, the whole domain round trip, and replay
-- of the raw historical chain versus its canonical replacement.
data NormalizationFailure
= NonCanonicalDecodeFailed Text
| CanonicalEncodingMismatch
| CanonicalDecodeFailed Text
| DomainRoundTripFailed
| NonCanonicalReplayFailed Text
| CanonicalReplayFailed Text
| NormalizedReplayDiverged
deriving stock (Eq, Show)
-- | Check the shared normalization law for one non-canonical wire value.
--
-- The supplied replay function receives raw wire values, so a generated harness
-- can exercise its real parser and transducer rather than proving equality only
-- after both inputs have already been normalized.
checkNormalizationLaw ::
(Eq wire, Eq domain, Eq observation) =>
(wire -> Either Text domain) ->
(domain -> wire) ->
([wire] -> Either Text observation) ->
[wire] ->
wire ->
wire ->
[wire] ->
[NormalizationFailure]
checkNormalizationLaw decode encode replay prefix nonCanonical canonical suffix =
case decode nonCanonical of
Left problem -> [NonCanonicalDecodeFailed problem]
Right decoded ->
[CanonicalEncodingMismatch | encode decoded /= canonical]
<> case decode canonical of
Left problem -> [CanonicalDecodeFailed problem]
Right canonicalDomain ->
[DomainRoundTripFailed | canonicalDomain /= decoded || decode (encode decoded) /= Right decoded]
<> replayFailures
where
replayFailures = case (replay (prefix <> [nonCanonical] <> suffix), replay (prefix <> [canonical] <> suffix)) of
(Left problem, _) -> [NonCanonicalReplayFailed problem]
(_, Left problem) -> [CanonicalReplayFailed problem]
(Right historical, Right normalized) -> [NormalizedReplayDiverged | historical /= normalized]
-- | Union the independently supplied inventory contributions. Identical cases
-- may be required by more than one source. Conflicting definitions are reported
-- by 'validateCompatibility'.
requiredCases :: EvidenceInventory -> Map Text RequiredCase
requiredCases inventory =
Map.fromList
[ (required.caseId, required)
| contribution <- inventory.contributions,
required <- contribution.cases
]
validateCompatibility :: EvidenceInventory -> CaptureReport -> CaptureReport -> [CompatibilityFailure]
validateCompatibility inventory baselineReport candidateReport =
concat
[ inventoryFailures inventory,
reportMetadataFailures inventory BaselineCapture baselineReport,
reportMetadataFailures inventory CandidateCapture candidateReport,
pairedMetadataFailures baselineReport candidateReport,
reportCaseFailures inventory baselineReport,
reportCaseFailures inventory candidateReport,
observationFailures inventory baselineReport candidateReport
]
-- | Compare one baseline/candidate semantic observation outside the report
-- envelope. Runtime regression suites use this while constructing reports so
-- the same mismatch vocabulary covers aggregate, workflow, and process traces.
compareObservation :: Text -> Observation -> Observation -> [CompatibilityFailure]
compareObservation caseId baselineObservation candidateObservation =
[ObservationMismatch caseId | baselineObservation /= candidateObservation]
releaseReady :: EvidenceInventory -> CaptureReport -> CaptureReport -> Bool
releaseReady inventory baselineReport candidateReport =
null (validateCompatibility inventory baselineReport candidateReport)
inventoryFailures :: EvidenceInventory -> [CompatibilityFailure]
inventoryFailures inventory =
versionFailure <> sourceFailures <> contributionFailures <> conflictFailures
where
versionFailure =
[UnsupportedInventoryVersion inventory.inventoryVersion | inventory.inventoryVersion /= inventoryVersionV1]
groupedSources = group (sort (map (.source) inventory.contributions))
presentSources = Set.fromList (map (.source) inventory.contributions)
sourceFailures =
[MissingInventorySource source | source <- inventorySourcesV1, source `Set.notMember` presentSources]
<> [DuplicateInventorySource source | source : remaining <- groupedSources, not (null remaining)]
contributionFailures = concatMap validateContribution inventory.contributions
byId =
Map.fromListWith
(++)
[ (required.caseId, [required])
| contribution <- inventory.contributions,
required <- contribution.cases
]
conflictFailures =
[ ConflictingRequiredCase caseId
| (caseId, definitions) <- Map.toList byId,
Set.size (Set.fromList definitions) > 1
]
validateContribution :: InventoryContribution -> [CompatibilityFailure]
validateContribution contribution = case contribution.applicability of
Applicable
| null contribution.cases -> [InvalidInventoryContribution contribution.source "applicable source has no required cases"]
| otherwise -> []
NotApplicable reason
| Text.null reason -> [InvalidInventoryContribution contribution.source "not-applicable source has no reason"]
| not (null contribution.cases) -> [InvalidInventoryContribution contribution.source "not-applicable source supplies cases"]
| otherwise -> []
SourceUnverified reason ->
[InvalidInventoryContribution contribution.source ("source is unverified: " <> reason)]
reportMetadataFailures :: EvidenceInventory -> CaptureRole -> CaptureReport -> [CompatibilityFailure]
reportMetadataFailures inventory expectedRole report =
concat
[ [UnsupportedReportVersion expectedRole report.reportVersion | report.reportVersion /= reportVersionV1],
[WrongCaptureRole expectedRole report.role | report.role /= expectedRole],
[BuildPairMismatch (captureRoleToken expectedRole) | report.buildPair /= inventory.buildPair],
[InventoryIdentityMismatch expectedRole | report.inventoryId /= inventory.inventoryId],
[EmptyHighWaterMarks expectedRole | null report.highWaterMarks],
[DuplicateHighWaterMark expectedRole stream | stream <- duplicates (map (.stream) report.highWaterMarks)]
]
pairedMetadataFailures :: CaptureReport -> CaptureReport -> [CompatibilityFailure]
pairedMetadataFailures baselineReport candidateReport =
concat
[ [CorpusIdentityMismatch | baselineReport.corpusHash /= candidateReport.corpusHash],
[ObservationContractMismatch | baselineReport.observationContractVersion /= candidateReport.observationContractVersion],
[DeterminismInputMismatch | baselineReport.determinismInputs /= candidateReport.determinismInputs],
[HighWaterMarkMismatch | Set.fromList baselineReport.highWaterMarks /= Set.fromList candidateReport.highWaterMarks]
]
reportCaseFailures :: EvidenceInventory -> CaptureReport -> [CompatibilityFailure]
reportCaseFailures inventory report =
duplicateFailures <> concatMap checkRequired (Map.elems expected)
where
expected = requiredCases inventory
role = report.role
rowsById = Map.fromListWith (++) [(row.requiredCase.caseId, [row]) | row <- report.results]
duplicateFailures = [DuplicateCaseResult role caseId | caseId <- duplicates (map (.requiredCase.caseId) report.results)]
selected = Set.fromList report.selectedSurfaces
checkRequired required = case Map.lookup required.caseId rowsById of
Nothing -> [MissingRequiredCase role required.caseId]
Just [] -> [MissingRequiredCase role required.caseId]
Just (row : _) ->
[CaseDefinitionMismatch role required.caseId | row.requiredCase /= required]
<> [MissingSelectedSurface role required.surface | required.surface `Set.notMember` selected]
<> verdictFailures role row
verdictFailures :: CaptureRole -> CaseResult -> [CompatibilityFailure]
verdictFailures role row = case row.verdict of
Failed reason -> [RequiredCaseFailed role row.requiredCase.caseId reason]
Unverified reason -> [RequiredCaseUnverified role row.requiredCase.caseId reason]
Passed ->
[ EmptyPassedObservation role row.requiredCase.caseId
| maybe True observationIsEmpty row.observation
]
observationFailures :: EvidenceInventory -> CaptureReport -> CaptureReport -> [CompatibilityFailure]
observationFailures inventory baselineReport candidateReport =
[ ObservationMismatch caseId
| caseId <- Map.keys (requiredCases inventory),
passedObservation caseId baselineReport /= passedObservation caseId candidateReport
]
passedObservation :: Text -> CaptureReport -> Maybe Observation
passedObservation caseId report = do
row <- Map.lookup caseId (Map.fromList [(result.requiredCase.caseId, result) | result <- report.results])
case row.verdict of
Passed -> row.observation
Failed _ -> Nothing
Unverified _ -> Nothing
observationIsEmpty :: Observation -> Bool
observationIsEmpty observation =
Map.null observation.durableState
&& null observation.continuations
&& Map.null observation.durableIdentities
duplicates :: (Ord a) => [a] -> [a]
duplicates values = [value | value : remaining <- group (sort values), not (null remaining)]
parseToken :: String -> [(Text, a)] -> Text -> Parser a
parseToken label tokens token =
case lookup token tokens of
Just value -> pure value
Nothing -> fail ("unsupported " <> label <> ": " <> Text.unpack token)
renderCompatibilityFailure :: CompatibilityFailure -> Text
renderCompatibilityFailure = \case
UnsupportedInventoryVersion version -> "unsupported inventory version: " <> version
UnsupportedReportVersion role version -> captureRoleToken role <> " report has unsupported version: " <> version
WrongCaptureRole expected actual -> "expected " <> captureRoleToken expected <> " capture, got " <> captureRoleToken actual
BuildPairMismatch role -> role <> " report is not bound to the inventory build pair"
InventoryIdentityMismatch role -> captureRoleToken role <> " report names a different inventory"
CorpusIdentityMismatch -> "baseline and candidate corpus hashes differ"
ObservationContractMismatch -> "baseline and candidate observation contracts differ"
DeterminismInputMismatch -> "baseline and candidate determinism inputs differ"
MissingInventorySource source -> "inventory omits source: " <> inventorySourceToken source
DuplicateInventorySource source -> "inventory repeats source: " <> inventorySourceToken source
InvalidInventoryContribution source reason -> inventorySourceToken source <> ": " <> reason
ConflictingRequiredCase caseId -> "inventory gives conflicting definitions for case: " <> caseId
MissingRequiredCase role caseId -> captureRoleToken role <> " report omits required case: " <> caseId
DuplicateCaseResult role caseId -> captureRoleToken role <> " report repeats case: " <> caseId
CaseDefinitionMismatch role caseId -> captureRoleToken role <> " report changes required case definition: " <> caseId
RequiredCaseFailed role caseId reason -> captureRoleToken role <> " case failed (" <> caseId <> "): " <> reason
RequiredCaseUnverified role caseId reason -> captureRoleToken role <> " case is unverified (" <> caseId <> "): " <> reason
EmptyPassedObservation role caseId -> captureRoleToken role <> " case passed with an empty observation: " <> caseId
ObservationMismatch caseId -> "baseline and candidate observations differ for case: " <> caseId
MissingSelectedSurface role surface -> captureRoleToken role <> " report omits selected surface: " <> surface.owner <> "/" <> surface.identity
EmptyHighWaterMarks role -> captureRoleToken role <> " report has no stream high-water marks"
DuplicateHighWaterMark role stream -> captureRoleToken role <> " report repeats stream high-water mark: " <> stream
HighWaterMarkMismatch -> "baseline and candidate high-water marks differ"