fix(change): a definition approval may not withdraw the delta binding an earlier approval recorded - #727
Merged
Conversation
… 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
requested review from
0xGaspar,
Kyntrin and
tofu-ux
and removed request for
a team
August 27, 2026 20:08
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-1undid that:append_portable_definition_approval_v501appended twodefinition-gate records carryingapproved_delta_digests: None, andeffective_definition_approvalselects withrposition— 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 throughdefinition_artifact_snapshot, soensure_definition_approval_validcatches the swap one line before the binding is consulted: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_approvalrecords it for everydefinitiongate, and the normalizing approval insideaccept_changealready carries it forward. TheNonewas an omission predating #711, not a position. Refusing the portable approve instead would have removed a capability with no workaround, sinceapprove→approve --portable-5-0-1is 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 nodefinition-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 recordNoneby 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_digestsis an input to none ofdefinition_digest,definition_projection_bytes_v501, ordefinition_approval_pair_id. The discriminator asserts the pair's digests still equalportable_definition_digest_pair_v501and thatensure_definition_approval_validstill resolves.ApprovalLedgertolerates unknown fields by design, so a 5.0.1 reader still parses it.Tests, each run with the fix disabled in place
maina_portable_definition_approval_carries_the_delta_binding_it_inheritsleft: None,right: Some({"auth": "66d9882e…"})a_later_definition_approval_may_not_withdraw_a_recorded_delta_bindingOk(canonical_applied: true)and the spec containsBACKDOORa_portable_definition_approval_records_delta_wording_with_no_prior_approvala_ledger_that_never_recorded_delta_wording_still_materializes_a_swapped_bodyHonest 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.jsonfiles under.specsync/scanned for the newly refused shape — none matches.clippy -D warningsbare clean ·fmtclean · 2395 unit + 407 integration ·change checkexit 0 ·change audit --strictexit 0 · pre-push gate 62 specs, 100%.🤖 Generated with Claude Code
https://claude.ai/code/session_01DgYAvsQM6P9fKxDotnDuzP