Skip to content

fix(change): a definition approval may not withdraw the delta binding an earlier approval recorded - #727

Merged
0xLeif merged 4 commits into
mainfrom
0xleif/fix-719-landing
Aug 27, 2026
Merged

0xLeif merged 4 commits into
mainfrom
0xleif/fix-719-landing

Conversation

@0xLeif

@0xLeif 0xLeif commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #719. The last known way to defeat a gate shipped in this release.

The bypass

#711 bound delta bodies to the approval that signed them. change approve --portable-5-0-1 undid that: append_portable_definition_approval_v501 appended two definition-gate records carrying approved_delta_digests: None, and effective_definition_approval selects with rposition — the last one. So a portable approve after a normal one downgraded the effective approval to "no claim".

Correcting the issue's mechanism

The literal sequence I filed already refuses on unfixed main, but for a different reason. The portable projection is workflow-v1 only, and a v1 definition digest hashes every delta payload through definition_artifact_snapshot, so ensure_definition_approval_valid catches the swap one line before the binding is consulted:

portable definition approval pair is malformed or stale

On v1 the downgrade costs recorded evidence and a correct diagnostic, not a materialization — and that diagnostic is actively harmful, since it points the reader at re-running --portable-5-0-1, which re-approves the swapped wording and again records no claim about it.

The consequence that generalizes is v2's, where the scope digest hashes intent and boundary only and this binding is all there is. The second discriminator therefore builds the downgraded shape directly rather than replaying the filed sequence.

The fix — monotonicity on both sides

Write side: the portable pair carries the binding forward instead of recording None. That matches what the module already does — append_approval records it for every definition gate, and the normalizing approval inside accept_change already carries it forward. The None was an omission predating #711, not a position. Refusing the portable approve instead would have removed a capability with no workaround, since approve → approve --portable-5-0-1 is the normal route to a 5.0.1-verifiable approval.

Read side: absence still returns Ok(()) — but absence is now a property of a ledger, not of one event. It is trusted only when no definition-gate approval in that ledger records a digest; a withdrawn claim is refused and names the remedy. Closing and finalization gates are excluded, since they record None by design.

The distinction #711 was built on is preserved: absence means this ledger predates the binding, never this approval declines to say what it signed.

Other digests unaffected, pinned rather than assumed: approved_delta_digests is an input to none of definition_digest, definition_projection_bytes_v501, or definition_approval_pair_id. The discriminator asserts the pair's digests still equal portable_definition_digest_pair_v501 and that ensure_definition_approval_valid still resolves. ApprovalLedger tolerates unknown fields by design, so a 5.0.1 reader still parses it.

Tests, each run with the fix disabled in place

Test Against unfixed main
a_portable_definition_approval_carries_the_delta_binding_it_inherits FAIL — left: None, right: Some({"auth": "66d9882e…"})
a_later_definition_approval_may_not_withdraw_a_recorded_delta_binding FAIL with no message at all — returns Ok(canonical_applied: true) and the spec contains BACKDOOR
a_portable_definition_approval_records_delta_wording_with_no_prior_approval FAIL — legitimate portable use recorded no wording
a_ledger_that_never_recorded_delta_wording_still_materializes_a_swapped_body PASS — control

Honest label on the control, and it is the important one: it builds the ledger monotonicity is easiest to break — a pre-#711 change approved several times, no digest anywhere — swaps the body, and requires materialization to succeed. That is what fails if someone "fixes" this by making absence fail closed, which would fail all 193 archived changes on evidence nobody could have written (#672 / #684 / #689's first design).

Blast radius measured, not argued: all 197 approvals.json files under .specsync/ scanned for the newly refused shape — none matches.

clippy -D warnings bare clean · fmt clean · 2395 unit + 407 integration · change check exit 0 · change audit --strict exit 0 · pre-push gate 62 specs, 100%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DgYAvsQM6P9fKxDotnDuzP

0xLeif and others added 4 commits August 27, 2026 13:02
… an earlier one recorded (#719)

#711 bound semantic delta bodies to the definition approval that signed them, and
`ensure_approved_delta_bodies_unchanged` returns early when an approval records no
binding — because every approval written before the field existed carries none, and
absent evidence must read as unknown rather than as tampering.

`change approve --portable-5-0-1` defeated that. It appended two `definition`-gate
approvals with `approved_delta_digests: None`, and `effective_definition_approval`
reads the LAST definition event, so a change that had just recorded a digest ended up
with an effective approval recording none. A compatibility path meaning "written
before the binding existed" was made to mean "this approver declines to say".

The rule is monotonicity: once a ledger has recorded a delta digest for a change, no
later definition approval may record none. Both halves are here.

- The portable pair now records the wording it approves, on both members. Carrying it
  forward rather than refusing the approve is what the rest of the module already
  does — `append_approval` records this for every definition gate, and the normalizing
  approval in `accept_change` carries it forward with that reasoning in a comment.
  Refusing would have removed the only route an adopter has to a 5.0.1-verifiable
  approval on a change the current binary already approved. The bodies are read after
  `validate_delta_files`, so the claim is the wording this actor is approving now.
- The projection is untouched, and pinned rather than assumed:
  `approved_delta_digests` is an input to none of `definition_digest`, the 5.0.1
  projection bytes, or `definition_approval_pair_id`, and `ApprovalLedger` tolerates
  unknown fields by design, so a 5.0.1 reader still parses the record it came for.
- The read side keeps trusting absence and qualifies it. Absence is a property of a
  LEDGER, not of an event, so it is trusted only when no definition approval in that
  ledger records a digest. A withdrawn claim is refused, naming
  `specsync change approve <id>` as the remedy.

Blast radius measured, not argued: all 197 `approvals.json` files under `.specsync/`
were scanned for the refused shape and none matches — archived ledgers carry no digest
on any definition approval, so they take the untouched path exactly as before.

Three discriminators and one honestly labelled control, each run with the fix disabled
in place. The control — a pre-binding ledger holding several silent definition
approvals, body swapped, materialization required to succeed — passes on the unfixed
binary too, which is the point: it is what fails if absence is ever made to fail closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgYAvsQM6P9fKxDotnDuzP
…the-delta-binding-an-earlier-approval-recorded
…elta-binding-an-earlier-approval-recorded verification
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgYAvsQM6P9fKxDotnDuzP
@0xLeif
0xLeif requested a review from a team as a code owner August 27, 2026 20:08
@0xLeif
0xLeif requested review from 0xGaspar, Kyntrin and tofu-ux and removed request for a team August 27, 2026 20:08
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@0xLeif
0xLeif merged commit d6f266a into main Aug 27, 2026
21 checks passed
@0xLeif
0xLeif deleted the 0xleif/fix-719-landing branch August 27, 2026 20:15
0xLeif added a commit that referenced this pull request Aug 27, 2026
…y what the release lane has actually run (#731)

* docs(6.0): backfill the changelog for the release's back half, and say what the release lane has actually run

The [Unreleased] section covered work through #627 and stopped. Every PR from
#630 through #727 — 46 merges, ~52 issues — had no entry, including every
defect an adopter reported against a release candidate.

46 entries, derived from each commit's diff and issue thread rather than its
subject line. That produced seven recorded divergences, kept as HTML comments
beside the entries they explain. Two matter most: #668's title claims it fixed
tag reading, but its whole diff is a fetch-tags: true that is a no-op at
fetch-depth: 0, and #669 fixed it 39 minutes later; #715's 'one canonical
frontmatter reader' unified four strippers while two non-canonical readers
survive on main.

ci-confidence.md's tag-authority section reasoned about what the release lane
enforces without ever saying which of it had run. It now records that resolve
and validate executed for real, qualify executed for the first time on rc.8 and
is failing on Windows, and promote has never executed — with what was proven
about promote ahead of time, and what remains unproven, stated separately.

* chore(lifecycle): record review and finalization for the changelog backfill

* chore(lifecycle): record review and finalization for the changelog backfill
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.

a portable v5.0.1 approval downgrades the delta binding to "no claim" and lets a swapped delta through

1 participant