Skip to content

feat: verdict@1 consumption — --compare, replay-based chain ancestry, anchor@1, signer trust, and a head-bearing subject (slice 4b) - #110

Merged
SollanSystems merged 12 commits into
mainfrom
feat/verdict-consumption
Jul 30, 2026
Merged

SollanSystems merged 12 commits into
mainfrom
feat/verdict-consumption

Conversation

@SollanSystems

Copy link
Copy Markdown
Owner

Makes the attested verdict consumable. Slice 4b of the tamper-evident-provenance
program, and the change that makes gh attestation verify executable against a
verdict@1 attestation for the first time.

What lands

  • loop verdict --compare <file|-> <workspace> — agreement over four facets
    (run_id, chain.head, the whole terminal object, 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 json envelope is refused by name with the documented jq
    path. signature_checked is the literal false on every path, pinned at AST level, and
    verdict rejects --verify-signature / --signature / --signer-workflow /
    --signer-digest so the absence is a contract, not an omission.
  • loop doctor --expect-chain-ancestor SHA256 / --anchor PATH — the answerable
    cross-run question. --expect-chain-head is exact current-head equality and fails by
    construction
    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_hash column, because a tamperer who can rewrite the store can also insert a row
    bearing the anchored digest.
  • loop/anchor.py + schemas/anchor.schema.json — the anchor@1 carry channel. An
    attestation can corroborate a carried head; it can never discover one.
  • loop/attestation.py — a pure signer-trust policy over already-verified certificate
    claims that refuses when a claim it needs is absent.
  • scripts/action_anchor_resolve.py — the single gh call site, fail-closed on
    anything 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 paragraph
    rewritten, 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:

Environment Result
canonical (pyyaml+jsonschema+pytest) 1567 passed / 18 skipped (1353 + 214)
structural-fallback (pyyaml+pytest) 1468 passed / 117 skipped
CI-equivalent (+ hypothesis) 1577 passed / 17 skipped (the live checkout reads 1579/15 via the documented checked-when-present ±2 delta)

self_eval.py 13/13 · validate_frontmatter.py 9/9 · py_compile clean · reference/
still 8 files.

Honest limits — please read these as part of the diff

  1. attest.yml cannot be validated before merge. It fires on push to main only, so a
    real 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: attest still defaults to false, so no consumer is
    affected, 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.
  2. This repo cannot dogfood cross-run ancestry. attest.yml seeds an ephemeral
    $RUNNER_TEMP workspace on every run, so its chain head is new by construction and
    there is no persistent store to anchor. Coverage is therefore (a) synthetic, via a fake
    gh on PATH, and (b) a real within-run grown-store ancestry exercise in
    ci.yml. Do not read the CI green as a cross-run proof. A persistent-store CI job
    is worth doing and is out of scope here.
  3. ADR 0002 decision 6 is documented but NOT in force. The live repository ruleset
    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 described
    as an operative control until the ruleset requires it. That gap is deliberate and
    recorded rather than papered over.
  4. Anchor trust is exactly ordinary write access — no better. An actor who can edit the
    anchor file re-points it at a head they had attested: the same class of limit as "the
    worker can edit the verifier".
  5. Detection of an unattested rewrite is at best one run late, unchanged.
  6. Public/private asymmetry. Public repos sign against the Sigstore Public Good instance
    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.
  7. --compare is a same-run check. An attested head that is merely an ancestor is a
    different run's verdict, so it is a disagreement by design. The action therefore runs
    ancestry unconditionally and --compare only when the anchored head is the current head
    (see the review finding below).
  8. Issues cli: identity-flag polish + the --mode misuse-guard gap (scaffolds a literal '--mode' directory) #96 / tests: pin the symlink-loop-workspace basis; consider a record-in-bundle-namespace tripwire #97 / kernel: write_verify_evidence hardening — self-validate the record at write time, typed error for malformed code_identity #98 stay open and out of scope. Each was re-reproduced against
    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 #98
    does sit on the evidence-write path this slice reads, but 4b degrades safely without
    it: _strict_evidence_failure already treats a malformed record as a failure, so a bad
    record is excluded from the projection rather than trusted. Ship them separately.

Two execution rulings worth a reviewer's attention

  • The plan's literal purity greps could not hold, and were already non-zero at
    c493804.
    grep "environ\|getenv" loop/ matches prose ("environment"), including
    pre-existing loop/verdict.py:4; and grep "subprocess" loop/ matches
    loop/runner.py, which genuinely shells out — that is the slice-3b subprocess-isolated
    verifier, and loop run cannot execute a verify command without it. The binding gates
    are therefore the AST/code-shaped tests, not prose greps. Measured honestly:
    os.environ/getenv call sites in loop/: 0 · network imports: 0 · subprocess
    import sites: exactly 1 (loop/runner.py, named in the test so a new one anywhere else
    fails).
    Same scoping reasoning the plan itself applies to action.yml's three
    deliberate advisory set +e / always() uses.
  • The structural-fallback leg had no CI cover at all (every job installed jsonschema),
    so every mode-parity claim in this slice was untested by CI. Measured: loosening a
    fullmatch to a match in loop/anchor.py kills 4 tests in the pyyaml-only leg but
    only 2 with jsonschema installed, because the schema layer masks the rest. A new
    gates-fallback job closes that class. Deliberately a separate job rather than a
    matrix leg on gates: a matrix would rename gates' check contexts, and this repo's
    ruleset 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.yml ran loop verdict --compare against the anchored predicate
unconditionally, but --compare treats an ancestor head as a disagreement — so on any
store that had legitimately grown, i.e. exactly the cross-run case the anchor input
exists for, the gate would fail every time. Fixed in 09ba65b: ancestry runs first and
unconditionally, --compare is guarded on head equality, and a skipped compare is
announced in the step summary. Pinned by a new test, verified to fail against the pre-fix
tree.

Post-merge, still owed

No version bump: pyproject.toml, .claude-plugin/plugin.json, README and the CHANGELOG's
released headings are untouched.

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.
Copilot AI review requested due to automatic review settings July 30, 2026 02:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@SollanSystems
SollanSystems merged commit 74e3743 into main Jul 30, 2026
14 checks passed
@SollanSystems
SollanSystems deleted the feat/verdict-consumption branch July 30, 2026 02:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread loop/attestation.py
Comment on lines +73 to +76
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 "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread loop/anchor.py
Comment on lines +78 to +81
for field, (minimum, maximum) in _OPTIONAL_STRINGS.items():
value = data.get(field)
if value is None:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread loop/verdict.py
Comment on lines +196 to +199
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

SollanSystems added a commit that referenced this pull request Jul 30, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants