Skip to content

Commit 3e2f734

Browse files
copeusclaude
andcommitted
task-1(v3.0): the record can finally say which diff it covers
Spec §C says the triage record binds session_id, base_commit and declared_paths, and the Stop-hook rule task-8 landed reads exactly those to decide whether a record covers the current diff. The schema is additionalProperties:false and carried none of them, so no record could express them and no record could ever cover anything. The gate is off by default, so nothing was broken — Feature C would simply have shipped inert, a correct mechanism with nothing to feed it. WHY each field is shaped the way it is: * `session_id` is optional and nullable, never required. This engine never sees a session; only /v:triage does. Requiring it would invalidate every record the scorer writes outside a triage command. Absent or null means the record binds no session and therefore covers nothing, which is the fail-closed direction the gate's exact-match test already produces. The EMPTY STRING is rejected: it looks like a binding and can never match, and that is the one shape that misleads a reader. * `declared_paths` items are constrained to what the consumer can actually read. The hook serializes candidates as `tier<US>run_id<US>path` and DROPS any entry containing U+001F, LF or CR. Dropping narrows the declared set, which is safe for the gate but silent for the producer — so the schema rejects every C0 control character here, where the producer still finds out. Absolute paths and `..` segments are rejected for the same reason: git never reports either, so such an entry could only ever match nothing while looking like coverage. * `base_commit` is expressible and deliberately inert. task-8 reads it and derives no freshness rule, because HEAD legitimately advances mid-session — /v:triage's own commit of the record moves it — so a mismatch is not evidence of staleness. The description says so, and says not to add such a rule without evidence that a mismatch correlates with a bad decision. * `tier` is added and PINNED to `decision` by three conditionals. The hook prefers `.tier` over `.decision` when present, so an unconstrained `tier` would be a second, higher-priority source of truth for a safety-relevant classification: a record could say FULL_PIPELINE and wear `tier: DIRECT` and be exempted as the auto-route class. Pinned, the hook's preferred branch is provably identical to its fallback instead of a way around it. This is the same hazard as override #4 one layer up, and it gets the same answer — make the disagreement unrepresentable rather than trusted. * `build_record` gains an optional `binding` kwarg, and it is a footgun removal rather than a convenience. `digest` covers the whole record, so a producer that attached these fields after building would ship a record whose self-integrity digest silently no longer verifies — silently, because `digest` is optional and checked only when present. The kwarg folds the binding in before the digest is taken. It does NOT accept `tier`: that is derived from the decision, so the producer cannot introduce the disagreement the schema forbids. Without a binding the record is byte-for-byte and digest-for-digest what it was. Verified against the consumer, not just asserted: the hook's own `_TRIAGE_JQ` was extracted verbatim and run over records built to this schema. All three decisions map to DIRECT/SCOPED/FULL, a record carrying explicit `tier` produces output identical to its decision-only twin, and a foreign-session record emits nothing. Selftests: preeval 131 -> 174 cases. Covers tier agreement and disagreement for all three decisions, session_id null/absent/empty, base_commit shapes, the three declared-path forms the gate understands, twelve rejected path shapes including the separator and line breaks it would otherwise drop, and the digest staying correct through build_record while breaking when bolted on afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3b14a7e commit 3e2f734

2 files changed

Lines changed: 203 additions & 3 deletions

File tree

schemas/pre-eval-record.schema.json

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@
4444
"properties": { "decision": { "const": "FULL_PIPELINE" } },
4545
"required": ["decision"]
4646
}
47+
},
48+
{
49+
"if": {
50+
"properties": { "decision": { "const": "FASTPATH_ELIGIBLE" } },
51+
"required": ["decision", "tier"]
52+
},
53+
"then": {
54+
"description": "Tier agreement: `tier` is a readability alias for `decision`, never an independent claim. The triage gate prefers `tier`, so a disagreement would let a record be exempted as a tier its own decision refuses.",
55+
"properties": { "tier": { "const": "DIRECT" } }
56+
}
57+
},
58+
{
59+
"if": {
60+
"properties": { "decision": { "const": "SCOPED_PIPELINE" } },
61+
"required": ["decision", "tier"]
62+
},
63+
"then": {
64+
"description": "Tier agreement - see the FASTPATH_ELIGIBLE case above.",
65+
"properties": { "tier": { "const": "SCOPED" } }
66+
}
67+
},
68+
{
69+
"if": {
70+
"properties": { "decision": { "const": "FULL_PIPELINE" } },
71+
"required": ["decision", "tier"]
72+
},
73+
"then": {
74+
"description": "Tier agreement - see the FASTPATH_ELIGIBLE case above. This is the case that matters most: a FULL record wearing `tier: DIRECT` would be exempted by the triage gate as if it were the auto-route class.",
75+
"properties": { "tier": { "const": "FULL" } }
76+
}
4777
}
4878
],
4979
"properties": {
@@ -99,6 +129,29 @@
99129
"enum": ["FASTPATH_ELIGIBLE", "SCOPED_PIPELINE", "FULL_PIPELINE"],
100130
"description": "The triage outcome, one of THREE proportionate tiers (v3.0 spec §A1). FASTPATH_ELIGIBLE = tier DIRECT (implement in place, run the floor, commit on the branch — no manifest, no run dir); SCOPED_PIPELINE = tier SCOPED (manifest, run dir, scope gate, floor, one combined SPEC+QUALITY review; recon and the three pre-flights skipped); FULL_PIPELINE = tier FULL (the whole pipeline). The value is produced by the 3x3 difficulty×impact matrix INSIDE compound-v-preeval.py's `score`, and consumers MUST branch on all three values explicitly — a `== FASTPATH_ELIGIBLE ? A : B` reader is a two-value reader of a three-value enum. Iron-Invariant #4 AS AMENDED (spec §A4): the score OFFERS by default and auto-routes only inside the DIRECT auto-route class, whose membership is decided by mechanically checkable predicates and never by model judgement; every other tier still requires a human offer and acceptance. Any override / unknown axis / missing-or-malformed taxonomy / token-cap overrun ⇒ FULL_PIPELINE (fail-closed), and a non-null `override_fired` ALWAYS pairs with FULL_PIPELINE regardless of the recorded bands."
101131
},
132+
"tier": {
133+
"type": "string",
134+
"enum": ["DIRECT", "SCOPED", "FULL"],
135+
"description": "OPTIONAL, and REDUNDANT BY CONSTRUCTION: the manifest-vocabulary token for `decision` (FASTPATH_ELIGIBLE=DIRECT, SCOPED_PIPELINE=SCOPED, FULL_PIPELINE=FULL - compound-v-preeval.py's DECISION_TO_TIER). The triage gate in `hooks/epic-goal-stop.sh` PREFERS this field over `decision` when it is present, so an unconstrained `tier` would be a second, higher-priority source of truth for a safety-relevant classification: a record could say `decision: FULL_PIPELINE` and `tier: DIRECT` and be exempted as DIRECT. The three tier-agreement conditionals in the top-level `allOf` therefore PIN it to `decision`, which makes the hook's preferred branch provably identical to its fallback rather than a way around it. Carry it for human readability; never as an independent claim. `decision` remains the authority.",
136+
"$comment": "Pinned to `decision` by the tier-agreement conditionals in the top-level allOf."
137+
},
138+
"session_id": {
139+
"type": ["string", "null"],
140+
"minLength": 1,
141+
"description": "The harness session this record was written in - the value on the `Stop` hook's stdin payload, compared EXACTLY by the triage gate in `hooks/epic-goal-stop.sh`. Written by `/v:triage`, the only component that knows it: the scoring engine never sees a session, so a record produced by `compound-v-preeval.py` alone omits this field. ABSENT OR NULL MEANS THE RECORD BINDS NO SESSION AND THEREFORE COVERS NOTHING - the gate's exact-match test cannot succeed against an empty value, which is the fail-closed direction. Never required: requiring it would invalidate every record the engine writes outside a triage command.",
142+
"$comment": "minLength applies only to the string form; null passes. The empty string is rejected because it asserts a session binding that can never match."
143+
},
144+
"base_commit": {
145+
"type": ["string", "null"],
146+
"pattern": "^[0-9a-f]{7,40}$",
147+
"description": "The commit HEAD pointed at when this record was written. RECORDED, NOT DECISIVE: `hooks/epic-goal-stop.sh` reads it and deliberately derives NO freshness rule from it, because HEAD legitimately advances mid-session - any commit moves it, including /v:triage's own commit of this very record - so a mismatch is not evidence that the record is stale. The field exists so a human or a later audit can say which tree the classification was made against. Do not add a staleness check keyed on it without evidence that a mismatch actually correlates with a bad decision. (`pattern` applies only when the value is a string, so null passes.)"
148+
},
149+
"declared_paths": {
150+
"type": "array",
151+
"uniqueItems": true,
152+
"items": { "$ref": "#/$defs/declared_path" },
153+
"description": "The repo-relative paths this record's classification actually covers - the field that turns the triage gate from an existence check into a COVERAGE check. `hooks/epic-goal-stop.sh` exempts a changed path only when the record is for the same session AND the path lies inside this set; without it, one triage of 'change the README' would exempt an unrelated later edit to that hook itself. COVERAGE IS THE THREE-WAY MATCH THAT HOOK IMPLEMENTS, and it is deliberately narrow: an exact string match, a DIRECTORY PREFIX only when the entry ends in `/`, or a glob only when the entry contains `*`. A bare `scripts` does NOT cover `scripts/app.py` - write `scripts/` or `scripts/**`. Widening a declared set by accident is the one direction this must not fail in, which is also why the item constraints reject what the hook would otherwise silently drop. Written by `/v:triage`; usually the localization's `resolved_paths`, but kept a separate field because a record may legitimately declare a broader set than triage resolved."
154+
},
102155
"min_sample_status": {
103156
"type": "string",
104157
"enum": ["insufficient", "calibrated"],
@@ -111,10 +164,17 @@
111164
"digest": {
112165
"type": "string",
113166
"pattern": "^sha256:[0-9a-f]{64}$",
114-
"description": "Self-integrity digest of THIS record: 'sha256:'+sha256(canonical_json(record without `digest`)). Optional; when present, downstream verifies it with compound-v-taxonomy.record_digest(record, exclude_field='digest')."
167+
"description": "Self-integrity digest of THIS record: 'sha256:'+sha256(canonical_json(record without `digest`)). Optional; when present, downstream verifies it with compound-v-taxonomy.record_digest(record, exclude_field='digest'). IT COVERS EVERY OTHER FIELD, the Feature-C binding (`session_id`, `base_commit`, `declared_paths`, `tier`) included. A producer that attaches the binding AFTER building the record ships one whose digest no longer verifies, and does so silently because this field is optional and only checked when present. Pass the binding to compound-v-preeval.py `build_record(..., binding={...})`, which folds it in before the digest is taken."
115168
}
116169
},
117170
"$defs": {
171+
"declared_path": {
172+
"type": "string",
173+
"minLength": 1,
174+
"pattern": "^[^/\u0000-\u001f][^\u0000-\u001f]*$",
175+
"not": { "pattern": "(^|/)\\.\\.(/|$)" },
176+
"description": "One entry in `declared_paths`: a repo-relative path, a directory prefix (trailing `/`), or a glob (containing `*`). CONSTRAINED TO MATCH WHAT THE CONSUMER CAN ACTUALLY READ. `hooks/epic-goal-stop.sh` serializes candidates as `tierU+001Frun_idU+001Fpath` lines and DROPS any entry containing U+001F, LF or CR, because a re-escaped path would no longer equal the path git reports. Dropping narrows the declared set, which is the safe direction for the hook but silent for the producer - so this pattern rejects every C0 control character here, where the producer still learns. A leading `/` is rejected because paths are repo-relative and git never reports an absolute one, so such an entry could only ever match nothing while looking like coverage. `..` segments are rejected because an entry that escapes the repo cannot describe a changed path, and normalizing it here would create a second path semantics to keep in sync with the gate's."
177+
},
118178
"band": {
119179
"type": "string",
120180
"enum": ["low", "medium", "high", "unknown"],

scripts/compound-v-preeval.py

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,9 +701,29 @@ def write_taxonomy_snapshot(repo, pre_eval_id, taxonomy_bytes):
701701

702702

703703
def build_record(pre_eval_id, request, verdict, localization, taxonomy_version,
704-
taxonomy_ref, taxonomy_digest, ts=None):
704+
taxonomy_ref, taxonomy_digest, ts=None, binding=None):
705705
"""Assemble the write-once pre-eval RECORD (conforms to pre-eval-record.schema.json).
706-
`status: PRE_EVAL_DONE` is a RECORD field, not a state.json phase (AC-7/CR2-8)."""
706+
`status: PRE_EVAL_DONE` is a RECORD field, not a state.json phase (AC-7/CR2-8).
707+
708+
binding: the OPTIONAL Feature-C coverage binding (spec §C) — a dict with any of
709+
`session_id`, `base_commit`, `declared_paths`. It is what lets the triage gate in
710+
`hooks/epic-goal-stop.sh` decide whether this record COVERS the current diff, and
711+
only `/v:triage` can supply it: this engine never sees a session. Absent (the
712+
default, and every call this module makes) the record is byte-for-byte what it was
713+
before v3.0 — no binding keys, and an identical `digest`.
714+
715+
THE KWARG EXISTS TO REMOVE A FOOTGUN, not merely as a convenience. `digest` is
716+
computed over the whole record, so a producer that called `build_record` and THEN
717+
attached the binding fields would ship a record whose self-integrity digest no
718+
longer verifies — silently, because `digest` is optional and only checked when
719+
present. Passing the binding through here keeps the digest correct by construction.
720+
721+
`tier` is NOT accepted from the caller. It is DERIVED from the decision via
722+
DECISION_TO_TIER whenever a binding is supplied, because the triage gate prefers
723+
`tier` over `decision`: accepting it as an argument would let a producer hand the
724+
gate a tier the record's own decision refuses. The schema pins the two together as
725+
well, so the disagreement is unrepresentable on both sides of the boundary.
726+
"""
707727
tax = _tax()
708728
rec = {
709729
"pre_eval_id": pre_eval_id,
@@ -723,6 +743,18 @@ def build_record(pre_eval_id, request, verdict, localization, taxonomy_version,
723743
"min_sample_status": verdict["min_sample_status"],
724744
"confidence": _evidence_confidence(verdict, localization),
725745
}
746+
747+
# Feature C coverage binding — added BEFORE the digest, never after (see the docstring).
748+
# Each key is emitted only when the caller supplied it, so an unbound record keeps its
749+
# pre-3.0 bytes exactly. `tier` rides along derived, never supplied.
750+
if binding:
751+
for _k in ("session_id", "base_commit", "declared_paths"):
752+
if binding.get(_k) is not None:
753+
rec[_k] = binding[_k]
754+
_tier = DECISION_TO_TIER.get(verdict["decision"])
755+
if _tier is not None:
756+
rec["tier"] = _tier
757+
726758
rec["digest"] = tax.record_digest(rec, exclude_field="digest")
727759
return rec
728760

@@ -1559,6 +1591,114 @@ def fake_localize_factory(result):
15591591
_schema_check(expect, _null_tax_full, must_validate=True,
15601592
label="schema ACCEPTS a null-taxonomy FULL_PIPELINE record")
15611593

1594+
# ===== (c5) The three fields Feature C's triage gate reads (spec §C) ========= #
1595+
# `hooks/epic-goal-stop.sh` decides whether a record COVERS the current diff by
1596+
# reading session_id, declared_paths and (for display) base_commit. The schema is
1597+
# additionalProperties:false, so until these exist NO record can carry them and NO
1598+
# record can ever cover a diff — the gate would ship inert. The engine does not
1599+
# produce them (it never sees a session); /v:triage does. What is tested here is
1600+
# that they are EXPRESSIBLE and CONSTRAINED, not that this module emits them.
1601+
_bound = dict(ress["record"],
1602+
session_id="sess-abc123",
1603+
base_commit="a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
1604+
declared_paths=["scripts/app.py", "scripts/", "docs/**"],
1605+
tier="SCOPED")
1606+
_schema_check(expect, _bound, must_validate=True,
1607+
label="schema ACCEPTS session_id + base_commit + declared_paths + tier")
1608+
1609+
# `tier` is what the gate PREFERS over `decision`, so a disagreement would exempt a
1610+
# record as a tier its own decision refuses. Pinned for all three decisions.
1611+
for _dec, _good, _bad in ((DECISION_FASTPATH, "DIRECT", "FULL"),
1612+
(DECISION_SCOPED, "SCOPED", "DIRECT"),
1613+
(DECISION_FULL, "FULL", "DIRECT")):
1614+
_rec = dict(_bound, decision=_dec, tier=_good)
1615+
_schema_check(expect, _rec, must_validate=True,
1616+
label="schema ACCEPTS tier %s beside %s" % (_good, _dec))
1617+
_schema_check(expect, dict(_rec, tier=_bad), must_validate=False,
1618+
label="schema REJECTS tier %s beside %s" % (_bad, _dec))
1619+
# ...and `tier` stays optional: the gate falls back to mapping `decision`.
1620+
_no_tier = dict(_bound)
1621+
_no_tier.pop("tier")
1622+
_schema_check(expect, _no_tier, must_validate=True,
1623+
label="schema ACCEPTS a record with no tier (gate maps decision)")
1624+
1625+
# session_id: null and absent both mean "binds no session", which the gate can only
1626+
# read as covering nothing. The EMPTY STRING is rejected — it looks like a binding
1627+
# and can never match, which is the one shape that misleads a reader.
1628+
_schema_check(expect, dict(_bound, session_id=None), must_validate=True,
1629+
label="schema ACCEPTS session_id null (binds no session)")
1630+
_schema_check(expect, dict(_bound, session_id=""), must_validate=False,
1631+
label="schema REJECTS an empty session_id")
1632+
1633+
# base_commit is recorded, not decisive — but it still has to be a commit.
1634+
_schema_check(expect, dict(_bound, base_commit=None), must_validate=True,
1635+
label="schema ACCEPTS base_commit null")
1636+
_schema_check(expect, dict(_bound, base_commit="a1b2c3d"), must_validate=True,
1637+
label="schema ACCEPTS a short base_commit sha")
1638+
for _badsha in ("HEAD", "A1B2C3D", "a1b2c3", "z" * 40, "a1b2c3d4 "):
1639+
_schema_check(expect, dict(_bound, base_commit=_badsha), must_validate=False,
1640+
label="schema REJECTS base_commit %r" % (_badsha,))
1641+
1642+
# declared_paths: exactly the three forms the gate's `_path_covered` understands.
1643+
_schema_check(expect, dict(_bound, declared_paths=[]), must_validate=True,
1644+
label="schema ACCEPTS an empty declared_paths (covers nothing)")
1645+
for _good_path in ("a.py", "scripts/app.py", "scripts/", "docs/**", "src/*.css"):
1646+
_schema_check(expect, dict(_bound, declared_paths=[_good_path]),
1647+
must_validate=True,
1648+
label="schema ACCEPTS declared path %r" % (_good_path,))
1649+
# The gate DROPS an entry carrying its own separator or a line break, which silently
1650+
# narrows the set. Rejecting here is where the producer still finds out.
1651+
for _bad_path in (chr(31) + "x", "a" + chr(31) + "b", "a\nb", "a\rb", chr(0) + "a",
1652+
"", "/abs/path", "../escape", "a/../b", "..",
1653+
"/", "\ttab"):
1654+
_schema_check(expect, dict(_bound, declared_paths=[_bad_path]),
1655+
must_validate=False,
1656+
label="schema REJECTS declared path %r" % (_bad_path,))
1657+
_schema_check(expect, dict(_bound, declared_paths=["a.py", "a.py"]),
1658+
must_validate=False,
1659+
label="schema REJECTS duplicate declared paths")
1660+
_schema_check(expect, dict(_bound, declared_paths="scripts/"), must_validate=False,
1661+
label="schema REJECTS a bare-string declared_paths")
1662+
1663+
# ===== (c6) build_record's optional binding — the digest stays correct ======== #
1664+
# The producer is /v:triage, not this engine, but the PRIMITIVE lives here because
1665+
# `digest` covers the whole record: a producer that attached the binding AFTER
1666+
# calling build_record would ship a record whose self-integrity digest silently no
1667+
# longer verifies. Passing it through keeps that impossible.
1668+
_tax_mod = _tax()
1669+
_bv = score(_loc(["src/ui/Widget.tsx"], flags=[], fan_out=1), taxonomy)
1670+
_unbound = build_record("2026-07-12T101600Z-b-a1b2", "bind me", _bv,
1671+
_loc(["src/ui/Widget.tsx"], flags=[], fan_out=1),
1672+
1, "tax.yaml", "sha256:" + "0" * 64,
1673+
ts="2026-07-12T10:16:00Z")
1674+
expect("build_record without a binding is unchanged (no binding keys)",
1675+
not any(k in _unbound for k in
1676+
("session_id", "base_commit", "declared_paths", "tier")))
1677+
_boundrec = build_record("2026-07-12T101600Z-b-a1b2", "bind me", _bv,
1678+
_loc(["src/ui/Widget.tsx"], flags=[], fan_out=1),
1679+
1, "tax.yaml", "sha256:" + "0" * 64,
1680+
ts="2026-07-12T10:16:00Z",
1681+
binding={"session_id": "sess-abc123",
1682+
"base_commit": "a1b2c3d4e5f6",
1683+
"declared_paths": ["src/ui/Widget.tsx"]})
1684+
expect("build_record carries the binding through",
1685+
_boundrec["session_id"] == "sess-abc123"
1686+
and _boundrec["base_commit"] == "a1b2c3d4e5f6"
1687+
and _boundrec["declared_paths"] == ["src/ui/Widget.tsx"])
1688+
expect("build_record DERIVES tier from the decision (never taken from the caller)",
1689+
_boundrec["tier"] == DECISION_TO_TIER[_bv["decision"]])
1690+
expect("the bound record's digest covers the binding and still verifies",
1691+
_boundrec["digest"] == _tax_mod.record_digest(_boundrec,
1692+
exclude_field="digest")
1693+
and _boundrec["digest"] != _unbound["digest"])
1694+
_schema_check(expect, _boundrec, must_validate=True,
1695+
label="a build_record-produced bound record validates")
1696+
# The footgun this prevents, demonstrated: bolt the fields on afterwards and the
1697+
# digest no longer verifies.
1698+
_bolted = dict(_unbound, session_id="sess-abc123")
1699+
expect("bolting a binding on AFTER build_record breaks the digest (why the kwarg)",
1700+
_bolted["digest"] != _tax_mod.record_digest(_bolted, exclude_field="digest"))
1701+
15621702
# (d) needs_t3 end-to-end: NO record + NO predicted are written; artifacts durable.
15631703
fk_need = fake_localize_factory(_loc(["tools/gen.py"], flags=[], fan_out=1))
15641704
resn = run_preeval("do the mysterious thing", repo=repo, _localize=fk_need,

0 commit comments

Comments
 (0)