diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bcaf4dc..1b73165e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ repository still gets a decision, never by following the link; no release carrie ### Testing - :white_check_mark: test(cmd): assert the REL-03 error wrap as one contiguous substring +- :test: test(compare): kill the surviving EffectChallenge intervention mutant (TEST-02) ## [0.3.0] - 2026-08-18 ### Chores diff --git a/examples/comparison/promotion-gates/candidate.yaml b/examples/comparison/promotion-gates/candidate.yaml index 3ab1a7a7..e83b1328 100644 --- a/examples/comparison/promotion-gates/candidate.yaml +++ b/examples/comparison/promotion-gates/candidate.yaml @@ -45,3 +45,20 @@ spec: onFailure: effect: comment code: value-bumped + # AUD2-S04 (REQ-AUD2-S04-03): a challenge-effect intervention the baseline + # profile does not carry. It matches only the challenge-intervention-added + # case's /retentionMs pointer, so the other corpus cases are untouched. + - name: retention-ack + phase: enforce + match: + valueChanges: + pointers: ["/retentionMs"] + kinds: [modify] + prove: + obligation: retention-ack + when: + cel: "new >= old" + message: "retention must not shrink without an acknowledged challenge" + onFailure: + effect: challenge + code: retention.shrunk diff --git a/examples/comparison/promotion-gates/cases/challenge-intervention-added/bundle.json b/examples/comparison/promotion-gates/cases/challenge-intervention-added/bundle.json new file mode 100644 index 00000000..d645a1c7 --- /dev/null +++ b/examples/comparison/promotion-gates/cases/challenge-intervention-added/bundle.json @@ -0,0 +1,36 @@ +{ + "apiVersion": "assent.dev/v1alpha1", + "kind": "ReplayBundle", + "pins": { + "toolVersion": "0.0.0-corpus", + "toolDigest": "sha256:aaaa", + "policySha": "sha256:bbbb", + "sourceSha": "cccc", + "targetSha": "dddd", + "mergeResultDigest": "sha256:eeee", + "factsResolvedAt": {} + }, + "evaluationInput": { + "apiVersion": "assent.dev/v1alpha1", + "kind": "EvaluationInput", + "changeSet": { + "changes": [ + { + "subject": "topic-registry:orders.events.v1", + "file": "topics/prod/orders.events.v1.yaml", + "path": "/retentionMs", + "kind": "modify", + "old": 604800000, + "new": 86400000 + } + ] + }, + "facts": {}, + "mr": { + "author": "alice", + "sourceBranch": "topic/retention", + "targetBranch": "main" + }, + "require": ["ownership", "non-destructive"] + } +} diff --git a/examples/comparison/promotion-gates/records/challenge-intervention-added.json b/examples/comparison/promotion-gates/records/challenge-intervention-added.json new file mode 100644 index 00000000..ddcaca3c --- /dev/null +++ b/examples/comparison/promotion-gates/records/challenge-intervention-added.json @@ -0,0 +1 @@ +{"apiVersion":"assent.dev/v1alpha1","kind":"ComparisonRecord","baselineProfile":"prod-strict@6","candidateProfile":"prod-strict@7","caseId":"challenge-intervention-added","deltas":[{"kind":"stricter-intervention-added","rule":"retention-ack","subject":"topic-registry:orders.events.v1","obligation":"retention-ack","baseline":{"present":false,"decision":"APPROVE"},"candidate":{"present":true,"decision":"REVIEW","effect":"challenge"}}]} \ No newline at end of file diff --git a/examples/comparison/promotion-gates/suite.yaml b/examples/comparison/promotion-gates/suite.yaml index 171f9d5c..b11ebb5c 100644 --- a/examples/comparison/promotion-gates/suite.yaml +++ b/examples/comparison/promotion-gates/suite.yaml @@ -19,6 +19,9 @@ spec: - caseId: score-threshold-change-accepted replayBundleDigest: 7a1c59d5c5b3ed12d5fcef2e1651f8e4734e5825688ecdf6e6b25c5920b8d5fa description: Score-threshold delta allowlisted under explicitly-accepted-deltas gate. + - caseId: challenge-intervention-added + replayBundleDigest: 6e68e17f1d48e682d010de4fac77b7413c8619c6ab6058c4899c8f68b32db815 + description: Candidate adds a challenge-effect intervention the baseline APPROVE side lacks (AUD2-S04 / TEST-02). promotionGates: - gateId: zero-missed-destructive failOnKinds: [destructive-or-authorization-intervention-missed] @@ -58,3 +61,9 @@ spec: rule: soft-bump subject: topic-registry:orders.events.v1 rationale: reviewed score arithmetic tightening + - caseId: challenge-intervention-added + kind: stricter-intervention-added + rule: retention-ack + subject: topic-registry:orders.events.v1 + obligation: retention-ack + rationale: reviewed retention-shrink challenge added by the candidate profile diff --git a/internal/compare/classify_intervention_test.go b/internal/compare/classify_intervention_test.go new file mode 100644 index 00000000..12665960 --- /dev/null +++ b/internal/compare/classify_intervention_test.go @@ -0,0 +1,112 @@ +package compare + +import ( + "testing" + + "github.com/PlatformRelay/assent/internal/core/aggregate" +) + +// classify_intervention_test.go defends the intervention-effect predicates against +// the mutant the 2026-08-18 audit demonstrated survives every wired gate (TEST-02): +// deleting `|| e == aggregate.EffectChallenge` from isStricterInterventionEffect +// left `go test ./internal/compare/...` AND `task dogfood-comparison` green. +// +// The cases below are written so the mutant is reached, not short-circuited. classify +// resolves by priority (D-117): missed intervention > uncovered > newly-auto-mergeable +// > score-threshold > stricter-added. A challenge delta lands in the LAST slot, so the +// fixture must keep the four earlier detectors silent or the assertion is decorative: +// - baseline has no findings -> detectMissedIntervention cannot fire +// - no `obligation.uncovered` codes -> isObligationUncovered cannot fire +// - candidate is REVIEW, not APPROVE -> the newly-auto-mergeable branch is skipped +// - intervention keys differ -> isScoreThresholdChange cannot fire +// +// The baseline side must also stay free of challenge findings: +// interventionFindingsByIdentity consults the SAME predicate, so a mutated build +// would drop a baseline challenge out of baseByID and report a spurious `true`, +// masking the kill. + +// REQ-AUD2-S04-01 / REQ-AUD2-S04-02: baseline APPROVE with no intervention, candidate +// adds a finding whose effect is `challenge` -> stricter-intervention-added. This is the +// named defence of the EffectChallenge term in isStricterInterventionEffect: deleting +// that term makes classify fail closed (ErrUnclassifiable) and this test red. +func TestClassifyStricterInterventionAddedChallengeEffect(t *testing.T) { + baseline := aggregate.Result{ + Decision: aggregate.DecisionApprove, + Findings: nil, + } + candidate := aggregate.Result{ + Decision: aggregate.DecisionReview, + Findings: []aggregate.Finding{{ + Rule: "retention-ack", + Obligation: "retention-ack", + Effect: aggregate.EffectChallenge, + Subject: "topic-registry:orders.events.v1", + Code: "retention.shrunk", + }}, + } + + got, err := classify(baseline, candidate) + if err != nil { + t.Fatalf("classify: %v", err) + } + if got != KindStricterInterventionAdded { + t.Fatalf("kind = %q, want %q", got, KindStricterInterventionAdded) + } +} + +// REQ-AUD2-S04-01: the predicate itself, asserted directly so the mutant dies even if +// a future classify refactor reroutes the challenge delta to another taxonomy slot. +// isMissedInterventionEffect is asserted alongside it because the two predicates differ +// by exactly one term, and that difference is deliberate (a challenge is NOT a missed +// destructive/authorization intervention) — pinning both stops a "fix" that unifies them. +func TestInterventionEffectPredicates(t *testing.T) { + stricter := map[aggregate.Effect]bool{ + aggregate.EffectBlock: true, + aggregate.EffectRequireReview: true, + aggregate.EffectChallenge: true, + aggregate.EffectComment: false, + } + for effect, want := range stricter { + if got := isStricterInterventionEffect(effect); got != want { + t.Errorf("isStricterInterventionEffect(%q) = %v, want %v", effect, got, want) + } + } + + missed := map[aggregate.Effect]bool{ + aggregate.EffectBlock: true, + aggregate.EffectRequireReview: true, + aggregate.EffectChallenge: false, + aggregate.EffectComment: false, + } + for effect, want := range missed { + if got := isMissedInterventionEffect(effect); got != want { + t.Errorf("isMissedInterventionEffect(%q) = %v, want %v", effect, got, want) + } + } +} + +// REQ-AUD2-S04-01: a challenge intervention the baseline ALREADY carries on the same +// delta identity is not "added". This pins the interventionFindingsByIdentity half of +// the predicate's use — the half a lone stricter-added assertion leaves untested, and +// the half through which a mutated build could otherwise answer correctly by accident. +func TestStricterInterventionAddedIgnoresPreexistingChallenge(t *testing.T) { + finding := aggregate.Finding{ + Rule: "retention-ack", + Obligation: "retention-ack", + Effect: aggregate.EffectChallenge, + Subject: "topic-registry:orders.events.v1", + Code: "retention.shrunk", + } + baseline := aggregate.Result{ + Decision: aggregate.DecisionApprove, + Findings: []aggregate.Finding{finding}, + } + candidate := aggregate.Result{ + Decision: aggregate.DecisionReview, + Findings: []aggregate.Finding{finding}, + } + + if detectStricterInterventionAdded(baseline, candidate) { + t.Fatal("detectStricterInterventionAdded = true for a challenge the baseline already carried, want false") + } +}