Skip to content

Close the PQ bind's consume-then-fail window - #117

Merged
germ-mark merged 4 commits into
mainfrom
llm/pq-bind-consume-window
Jul 27, 2026
Merged

Close the PQ bind's consume-then-fail window#117
germ-mark merged 4 commits into
mainfrom
llm/pq-bind-consume-window

Conversation

@germ-mark

Copy link
Copy Markdown
Contributor

Stacked on #115 — review that first; this branch targets its head.

A bind's PQ half carries the -02 AppDataUpdate attestation, and apq::rules rejects any Update co-riding it. So one by-ref proposal cached in our send-PQ makes every later bind commit fail — after the round's one-shot input is spent (A.4's ephemeral opened, A.3's KP consumed by the join, A.5's Commit' applied).

That was reachable by the counterparty with no forgery: mls-rs caches a by-ref proposal inside process_incoming_message, before the A.4 leg door inspects the message kind, so a proposal routed through a door that answers a benign retriable error still lands in the cache and stays there. The next bind then tore itself apart — ephemeral gone, parked EK no longer re-mintable, every retry answering the apparently-retriable SessionNotReady forever, is_fully_established() still true. And because the bind's persist captures partial mutations by design, the tear reached the archive, so restoring reproduced it.

Guard before consuming. The three bind entry points now check for that residue in their guard phase, where refusing is free, and the two admitting doors drop what they refuse.

Latch what no guard can catch. Past the point of no return each trigger's tail runs inside a region that renames any escape to the new fatal BindTriggerFailed. That latch rides the archive, unlike BindApplyFailed — the apply latch may heal on restore only because inbound processing persists on success alone, which is exactly what these closures do not do. Queryable via pq_side_band_wedged(); classical messaging is unaffected throughout.

Two smaller corrections ride along: the A.3/A.5 responder derives its bind secret before consuming the round, and the post-commit header-key capture is best-effort with a re-derivation backstop in should_listen_on (it is the repeatable exporter — latching a round that succeeded would over-fire).

Binding contract 29 → 30. Archive stays v3, gaining one tail field.

Tests

test_a_peer_proposal_smuggled_into_the_send_pq_leaves_no_cached_residue and test_bind_refuses_a_poisoned_send_pq_before_the_ephemeral_is_spent both fail without the fix and pass with it — verified by stashing pq_ops.rs and re-running. Plus the wedge's fatal-and-survives-restore case, A.3's answer-honestly-not-DuplicateSideBand case, and a negative control over clean A.3/A.4/A.5 rounds. test_failed_bind_apply_breaks_receive_not_send_and_heals_on_restore is byte-for-byte unchanged — it guards the heal-on-restore property the new region must not regress.

374 Rust tests pass; cargo fmt and both clippy gates clean. Swift compiles (library and tests); the Swift test runtime could not be exercised locally — the test bundle fails to dlopen the framework by rpath under this Xcode 27 beta, and SIP strips DYLD_* — so CI's swift job is the real check there. taplo is not installed locally, but no .toml changed.

🤖 Generated with Claude Code

germ-mark and others added 4 commits July 27, 2026 11:02
A bind's PQ commit carries the -02 AppDataUpdate attestation, and apq::rules
rejects any Update co-riding it — so one cached proposal in our send-PQ makes
commit_pq_and_owe_bind fail with the round's one-shot input already spent, and
mutate_and_persist writes that tear to the blob. mls-rs caches a by-ref proposal
inside process_incoming_message, before the A.4 leg door inspects the message
kind, so the peer can park one without forging anything.

Guard the three bind entry points before they consume, and drop what a refusal
admitted at the two doors that admit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A.3's and A.5's S is re-exported from our own send-PQ, and that export is
fallible. Taking the round state first left a failed export with the slot
already empty: the peer re-staples the same bind on every frame, every retry
answered the retriable SessionNotReady, and nothing latched it — the failure
never reaches apply_bind, so pq_receive_broken() reported a healthy session
that could not receive. Classify first, export second, consume third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A.3's join, A.4's decapsulation and A.5's applied Commit' each land before the
bind's own commit, and mutate_and_persist writes that partial state even when
the commit then fails. The session was left answering SessionNotReady forever —
or, at A.3, DuplicateSideBand, reporting a round that never closed as done while
is_fully_established() returned true and the peer waited for a staple nobody
would build.

Wrap each trigger's tail in past_no_return, which renames any escape to the
fatal BindTriggerFailed and latches pq_wedged. The latch rides the archive,
unlike bind_apply_broken: that one may heal on restore only because the receive
path persists on success only, which is exactly what these closures do not do.
The header-key capture stays outside the region — it is the repeatable exporter,
re-derived by should_listen_on, so latching a round that succeeded would
over-fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regenerated binding (the new case appends at ordinal 31, so every prior
ordinal is stable), the SessionError code and its .reestablish disposition,
the bridge mapping, isSideBandWedged, and binding contract 29 -> 30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0bbcb6e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@germ-network/two-mls-pq Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Base automatically changed from llm/a4-pq-ratchet-transmission-759385 to main July 27, 2026 18:31
@germ-mark
germ-mark merged commit 4ee4749 into main Jul 27, 2026
10 checks passed
@germ-mark
germ-mark deleted the llm/pq-bind-consume-window branch July 27, 2026 18:36
@github-actions github-actions Bot mentioned this pull request Jul 27, 2026
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.

1 participant