diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index 6954a26..fdf4f40 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -193,8 +193,10 @@ func sanitizePaths(detail, repoRoot string) string { // by five tests where four pass and one fails does NOT hold, and letting // passing probes outvote a failing one would launder a defect into a // verified row. -// - verified: no refutation, and some probe ran and passed; effective tier is -// the highest a passing probe conferred. +// - verified: no refutation, and some probe ran, passed, and conferred a +// tier above T0 (Evidence.Verified enforces the tier floor — a T0 pass +// raises nothing); effective tier is the highest a passing probe +// conferred. // - unverified: nothing ran that could raise the claim. Remainder. func weigh(evs []schema.Evidence) (schema.Status, schema.Tier) { best := schema.T0Unverified diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go index 0af1d67..0772992 100644 --- a/internal/receipt/receipt_test.go +++ b/internal/receipt/receipt_test.go @@ -96,6 +96,29 @@ func TestINV007_RefutationDominatesVerification(t *testing.T) { } } +// TestT0PassConfersNoVerification: a probe result that ran and passed but +// confers only T0 raises nothing — T0 IS the unverified tier, so accepting +// such a pass would mint a verified row whose effective tier means "nothing +// checked this". The claim stays in the remainder, and the pass does not +// dilute an honest refutation either. +func TestT0PassConfersNoVerification(t *testing.T) { + claims := []schema.Claim{{ID: "Z1", Shape: schema.ShapeInvariant, + ProbeIDs: []string{"noop:x"}}} + evidence := [][]schema.Evidence{{ + {ClaimID: "Z1", ProbeID: "noop:x", Tier: schema.T0Unverified, Ran: true, Passed: true}, + }} + r := Assemble(gitdiff.Change{}, claims, evidence, schema.Coverage{}) + if r.Results[0].Status != schema.StatusUnverified { + t.Fatalf("status = %q, want unverified (a T0 pass confers nothing)", r.Results[0].Status) + } + if len(r.Remainder) != 1 || r.Remainder[0].Claim.ID != "Z1" { + t.Fatalf("claim with only a T0 pass must land in the remainder") + } + if r.Summary.Verified != 0 { + t.Fatalf("summary.Verified = %d, want 0", r.Summary.Verified) + } +} + // TestRemainderSectionAlwaysRenders: the text receipt states the remainder even // when it is empty, so its absence is a declared result, not an omission. func TestRemainderSectionAlwaysRenders(t *testing.T) { diff --git a/schema/schema.go b/schema/schema.go index 70ee9a1..08698e5 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -192,10 +192,13 @@ type Evidence struct { Binding string `json:"binding,omitempty"` } -// Verified reports whether this evidence raises its claim: the probe ran and -// passed. A probe that ran and FAILED refutes the claim; a probe that did not -// run says nothing. -func (e Evidence) Verified() bool { return e.Ran && e.Passed } +// Verified reports whether this evidence raises its claim: the probe ran, +// passed, AND confers a tier above T0. A pass at T0 confers nothing — T0 IS +// the unverified tier, so counting such a pass as verification would mint +// StatusVerified with an effective tier that means "nothing checked this". +// A probe that ran and FAILED refutes the claim; a probe that did not run +// says nothing. +func (e Evidence) Verified() bool { return e.Ran && e.Passed && e.Tier > T0Unverified } // Refuted reports whether this evidence refutes its claim: the probe ran and // the claim did not hold. @@ -207,8 +210,9 @@ type Status string const ( // StatusVerified: at least one probe ran and passed. EffectiveTier > T0. StatusVerified Status = "verified" - // StatusRefuted: at least one probe ran and failed, and none passed at a - // higher tier. The merge gate should block on refuted claims. + // StatusRefuted: at least one probe ran and failed. Refutation dominates + // unconditionally — no volume or tier of passing probes outweighs a + // failure. The merge gate should block on refuted claims. StatusRefuted Status = "refuted" // StatusUnverified: nothing ran that could raise the claim. Remainder. StatusUnverified Status = "unverified"