feat: verdict@1 consumption — --compare, replay-based chain ancestry, anchor@1, signer trust, and a head-bearing subject (slice 4b) - #110
Conversation
Slice 4b task 1. anchor@1 is a tracked carry channel for a previously attested chain head: GitHub exposes no endpoint that lists attestations without a subject digest, so an attestation can corroborate a head but never discover one. Nothing in the anchor is trusted - anchor trust is exactly ordinary write access to the file, the same class of limit as ADR 0002's "the worker can edit the verifier". subject_bytes is the ONE definition of the attested subject byte form - exactly 64 lowercase hex, no trailing newline - so the signer side and the consumer side cannot disagree about 64 bytes. It exists because gh attestation verify accepts only a file path or an OCI URI and hashes that file's content, so an attestation whose subject digest IS the chain head can never be presented an artifact. Slice 4a's subject is therefore unverifiable by construction; ADR 0002 decision 2 is amended in task 11. read_anchor validates structurally in every environment and additionally through jsonschema when it is importable. Mode parity is the point: a schema-only check would let a jsonschema-less environment accept a document the schema rejects, and iter_errors never raises, so there is no ValueError to catch around it. Mutation probe, fullmatch to match, reverted: 2 of 4 mutants survive the canonical leg because the jsonschema layer masks the structural regression; all 4 die in the structural-fallback leg. The structural checks are therefore pinned only by that leg - and no CI job runs it, because every job installs jsonschema. Recorded as a follow-up rather than fixed here. Gates, measured in this worktree at c493804: scripts/test_anchor.py 27 passed with pyyaml+jsonschema+pytest and 26 passed / 1 skipped with pyyaml+pytest. Full suite 1380 passed / 18 skipped and 1282 passed / 116 skipped, against 1353/18 and 1256/115 baselines measured here before any edit - zero regressions. self_eval 13/13, validate_frontmatter 9 skills 0 errors.
…anchored head is current
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09ba65ba8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| missing = [claim for claim in REQUIRED_CERTIFICATE_CLAIMS if claim not in certificate] | ||
| if missing: | ||
| raise AttestationPolicyError( | ||
| f"certificate is missing required claim(s): {', '.join(missing)} — a pinned " |
There was a problem hiding this comment.
Read signer claims from certificate extensions
For every genuine gh attestation verify --format json result, subjectAlternativeName is on the certificate itself, but sourceRepositoryURI, runnerEnvironment, and githubWorkflowTrigger are emitted under signature.certificate.extensions; the documented gh attestation verify JSON output reflects that nesting. Checking all of these names directly in certificate therefore raises AttestationPolicyError, which action_anchor_resolve.py converts to unavailable, so the new anchor action can never corroborate a real GitHub attestation.
Useful? React with 👍 / 👎.
| for field, (minimum, maximum) in _OPTIONAL_STRINGS.items(): | ||
| value = data.get(field) | ||
| if value is None: | ||
| continue |
There was a problem hiding this comment.
Reject explicit nulls in optional anchor fields
When jsonschema is unavailable, data.get(field) makes a missing optional field indistinguishable from an explicitly supplied JSON null, so anchors such as {"run_id": null} are accepted even though anchor.schema.json requires these fields to be strings when present. The same issue affects attestation_id, recorded_at, and the analogous sequence check, causing the supported structural-fallback mode to accept documents rejected by schema validation.
Useful? React with 👍 / 👎.
| return frozenset( | ||
| (entry.get("digest"), entry.get("code_digest"), entry.get("policy_digest")) | ||
| if isinstance(entry, dict) else ("<non-object>", json.dumps(entry, default=str), None) | ||
| for entry in entries |
There was a problem hiding this comment.
Refuse unhashable evidence values without traceback
If an otherwise recognizable predicate contains a malformed JSON evidence value such as {"digest": [], "code_digest": null, "policy_digest": null}, constructing this frozenset raises TypeError because the tuple contains an unhashable list. The exception escapes the CLI handler and produces a traceback with exit 1—the documented code for an ordinary disagreement—instead of the typed exit-2 refusal expected for unusable comparison input.
Useful? React with 👍 / 👎.
Collects slice 4a (#106) and slice 4b (#110, #111, #112): loop verdict, opt-in keyless attestation, --compare, replay-based chain ancestry, anchor@1, signer trust, and the head-bearing subject. Neither slice shipped on its own; both sat in Unreleased and go out under one number. Beyond the version surfaces, this cut carries corrections a mechanical bump would not have made. The changelog said the predicate is handed to actions/attest as a subject-path. It is handed as predicate-path, alongside a SEPARATE subject file, and §23 says outright that the predicate bytes are deliberately not the subject — doctor validation_mode and tool.version live inside the predicate, so the same run projects different bytes in different environments. A reader following that sentence would hash the wrong file and fail every verification. "the three attestations minted before this release": the count was right, the scope word was wrong. attest.yml shipped inside this same window, so nothing predates the release; the three carrying the retired subject form are the pushes through c493804. Re-scoped to match what attest.yml and reference §24 already said. ADR 0002 decision 6 is withdrawn, in a new dated amendment. It described code-owner review as in force once the ruleset required it. The ruleset cannot require it: one collaborator, no self-approval, bypass_actors empty, current_user_can_bypass never, and no classic protection underneath. Flipping it would invert the intent rather than merely fail — maintainer-authored pull requests become unmergeable while bot-authored ones become gated, and agent work here lands under the maintainer's account. CODEOWNERS is demoted in place to what it honestly is, a record of the gate-defining surface, across the ADR, the changelog, reference §23 and §24, and the file's own header. test_docs_version.py's changelog check now ADDS 0.12.0 and keeps 0.11.0, and restores 0.10.0 — the previous cut replaced its predecessor's assertion instead of adding to it, leaving that heading with no cover. Baselines, live checkout: 1577 passed / 16 skipped with pyyaml+jsonschema+pytest, 1478 / 115 with pyyaml+pytest. Both legs +7 against pre-cut, which is the CLI guard's new tests plus the retired version-bump pin offset by the new code-owner pin. CI additionally installs hypothesis, which moves the number.
Makes the attested verdict consumable. Slice 4b of the tamper-evident-provenance
program, and the change that makes
gh attestation verifyexecutable against averdict@1attestation for the first time.What lands
loop verdict --compare <file|-> <workspace>— agreement over four facets(
run_id,chain.head, the wholeterminalobject, the verified-evidence digest set).Exit 0 agree / 1 disagree / 2 refuse. Accepts a bare predicate only: an in-toto
Statement or a
gh --format jsonenvelope is refused by name with the documented jqpath.
signature_checkedis the literalfalseon every path, pinned at AST level, andverdictrejects--verify-signature/--signature/--signer-workflow/--signer-digestso the absence is a contract, not an omission.loop doctor --expect-chain-ancestor SHA256/--anchor PATH— the answerablecross-run question.
--expect-chain-headis exact current-head equality and fails byconstruction once a store grows; ancestry asks "was this digest ever my head?" and
answers by replay, recomputing every hash — never by trusting the stored
event_hashcolumn, because a tamperer who can rewrite the store can also insert a rowbearing the anchored digest.
loop/anchor.py+schemas/anchor.schema.json— theanchor@1carry channel. Anattestation can corroborate a carried head; it can never discover one.
loop/attestation.py— a pure signer-trust policy over already-verified certificateclaims that refuses when a claim it needs is absent.
scripts/action_anchor_resolve.py— the singleghcall site, fail-closed onanything it cannot confidently classify.
action.yml— D1: the subject is now a head-bearing file (subject-path),exactly 64 lowercase hex bytes, no trailing newline, written by the one definition
loop verdict --emit-subject.reference/repo-os-contract.md§24 (appended; §23's subject-seam paragraphrewritten, since D1 makes its central claim false) and an ADR 0002 amendment.
Baselines — state the dependency set with every number
Measured from inside a fresh tmpfs worktree:
pyyaml+jsonschema+pytest)pyyaml+pytest)+ hypothesis)self_eval.py13/13 ·validate_frontmatter.py9/9 ·py_compileclean ·reference/still 8 files.
Honest limits — please read these as part of the diff
attest.ymlcannot be validated before merge. It fires on push to main only, so areal attestation mints only after landing: the first post-merge run IS the
experiment. Its falsifiable check is
subject[0].digest.sha256 != predicate.chain.head— an inequality that was false in all three attestations this repo minted before this
slice, because the subject digest WAS the head. Under D1 the subject digest is the hash
of a file containing the head, so it must now differ. If the run fails, the fix is a
follow-up PR, not a revert:
atteststill defaults tofalse, so no consumer isaffected, and the three previous attestations remain valid records of what they were.
The most likely failure is a mismatched certificate leaf claim name — the design is
fail-closed precisely so that surfaces as a legible red build and a one-line diff.
attest.ymlseeds an ephemeral$RUNNER_TEMPworkspace on every run, so its chain head is new by construction andthere is no persistent store to anchor. Coverage is therefore (a) synthetic, via a fake
ghonPATH, and (b) a real within-run grown-store ancestry exercise inci.yml. Do not read the CI green as a cross-run proof. A persistent-store CI jobis worth doing and is out of scope here.
requires 0 approvals, so this slice lands autonomously. The anchor path
(
loop-anchor.json) joined decision 6's path list, and CODEOWNERS must not be describedas an operative control until the ruleset requires it. That gap is deliberate and
recorded rather than papered over.
anchor file re-points it at a head they had attested: the same class of limit as "the
worker can edit the verifier".
and its public transparency log; private repos use GitHub's own instance, which has
no transparency log. The independent-audit property holds for public repos only.
--compareis a same-run check. An attested head that is merely an ancestor is adifferent run's verdict, so it is a disagreement by design. The action therefore runs
ancestry unconditionally and
--compareonly when the anchored head is the current head(see the review finding below).
c493804; none touches verdict,--compare, anchor resolution or signer trust. kernel: write_verify_evidence hardening — self-validate the record at write time, typed error for malformed code_identity #98does sit on the evidence-write path this slice reads, but 4b degrades safely without
it:
_strict_evidence_failurealready treats a malformed record as a failure, so a badrecord is excluded from the projection rather than trusted. Ship them separately.
Two execution rulings worth a reviewer's attention
c493804.grep "environ\|getenv" loop/matches prose ("environment"), includingpre-existing
loop/verdict.py:4; andgrep "subprocess" loop/matchesloop/runner.py, which genuinely shells out — that is the slice-3b subprocess-isolatedverifier, and
loop runcannot execute a verify command without it. The binding gatesare therefore the AST/code-shaped tests, not prose greps. Measured honestly:
os.environ/getenvcall sites inloop/: 0 · network imports: 0 ·subprocessimport sites: exactly 1 (
loop/runner.py, named in the test so a new one anywhere elsefails). Same scoping reasoning the plan itself applies to
action.yml's threedeliberate advisory
set +e/always()uses.so every mode-parity claim in this slice was untested by CI. Measured: loosening a
fullmatchto amatchinloop/anchor.pykills 4 tests in the pyyaml-only leg butonly 2 with jsonschema installed, because the schema layer masks the rest. A new
gates-fallbackjob closes that class. Deliberately a separate job rather than amatrix leg on
gates: a matrix would renamegates' check contexts, and this repo'sruleset pins its required contexts by name.
Whole-branch review — one confirmed defect, found and fixed
Run before opening this PR (the auto-merge ruleset would otherwise outrun a post-PR
review). It found a genuine cross-task contradiction that no per-task review could see:
action.ymlranloop verdict --compareagainst the anchored predicateunconditionally, but
--comparetreats an ancestor head as a disagreement — so on anystore that had legitimately grown, i.e. exactly the cross-run case the
anchorinputexists for, the gate would fail every time. Fixed in
09ba65b: ancestry runs first andunconditionally,
--compareis guarded on head equality, and a skipped compare isannounced in the step summary. Pinned by a new test, verified to fail against the pre-fix
tree.
Post-merge, still owed
attest.ymlrun and record the observed outcome and the observedcertificate claim names; ship the one-line correction to
REQUIRED_CERTIFICATE_CLAIMS/_TRIGGER_CLAIM_ALIASESsame-day if the prediction was wrong; capture the denial-shapesigner_denied.txtfixture that cannot be captured until a verifiable attestationexists. The slice is not done until that is checked.
precedent), so this diff stays reviewable.
No version bump:
pyproject.toml,.claude-plugin/plugin.json, README and the CHANGELOG'sreleased headings are untouched.