Manifests gain a monotonic publish counter — stale reads can't roll back the roster - #2
Conversation
…ack the roster Roster and key changes bumped nothing wire-visible (charter height moves only on ownership transfers), so a re-served OLD manifest — a server cache, a racy second device — was accepted whole: the removed member silently reappeared and the group key rolled back to the retired one, with consumers then encrypting group-wide payloads under it. There was nothing on the wire to arbitrate with. - Group.publishCounter: wire-visible, serialized only when > 0, so counter-less groups (and every consumer that never bumps) stay byte-identical — the roles-field compat pattern, golden-pinned. - GroupService.bumpedForPublish: bump once per publish EVENT, before the per-member fan-out, and adopt the result locally. - decryptManifest(minPublishCounter:): a manifest below the caller's persisted high-water mark is refused under EVERY charter policy — this is caller-provided arbitration like expectedGroupId, not charter trust. Equality passes (concurrent same-counter publishers stay readable; the vector is rollback, not writer arbitration). Consumers adopt with the minCharterHeight pattern: persist the highest accepted counter per group, pass it on decrypt, bump on publish. 74 tests, analyze clean.
|
Reviewed and verified locally: 74/74 green on the branch, CI green. The mechanism is right and carefully built: counter inside the encrypted+authenticated manifest (a server can't forge or strip it without breaking the box), omitted-at-zero so unbumped fleets stay byte-identical (golden-pinned), One request before merge, and it's the substantive one: the protocol docs must describe this. This PR adds a wire-visible manifest field and a new
Without the doc update, the spec actively contradicts the wire format it claims to define — and these repos' whole public posture is that SPEC.md is the contract. Also the same housekeeping as #1: CHANGELOG entry + version bump + README test count (74 here; 76 once both land — second merge reconciles). One non-blocking design note worth a sentence somewhere: the counter arbitrates rollback, not divergence — two devices bumping independently from the same base produce equal counters with different contents, and last-write-wins remains the (pre-existing) semantics there. Stating that explicitly in SPEC will save a future reader from over-trusting it. |
- SPEC.md now matches the wire it defines: publishCounter in the Group shape (wire-visible only when > 0), a decryptManifest check-table row for the minPublishCounter floor (with its rationale and its every-policy enforcement), and the threat-model gap rewritten from 'manifests carry no freshness field' to the opt-in posture — with the residuals stated plainly: an unadopted fleet keeps the old exposure, and the counter arbitrates rollback, not divergence (equal-counter concurrent publishes remain last-write-wins). - README's Known gaps bullet updated the same way; CHANGELOG 0.5.0 entry; version bump; README test count (74 on this branch; the second merge with the transfer-safety PR reconciles to 77).
|
Doc parity done — SPEC.md now describes the wire it defines:
README's Known gaps bullet says the same; CHANGELOG 0.5.0 entry, version bump, and the README count set to this branch's 74 — written to union with #1's 0.5.0 bullet, second merge reconciles to 77. 74 tests + analyze clean on the branch. |
|
Re-verified at d86d8d2: 74/74 + analyze clean locally, CI green. The SPEC/README updates are exactly right — the check-table row, the wire-shape note, and the threat-model rewrite with both residuals stated plainly (including rollback-vs-divergence, which is now a spec sentence a future reader can't miss). CHANGELOG/version/count all in order. LGTM, ready to merge — noting for the record the second of the two 0.5.0 PRs to land needs the CHANGELOG union + README count reconciled to 77 (the entries are pre-written to union, so it's mechanical). |
Second of the two 0.5.0 PRs to land carries the reconcile: both release bullets under one heading, README total 77 with the per-file breakdown (18/28/31) verified by running each file. Claude-Session: https://claude.ai/code/session_01HEhHsk2nymiphLPctedfzS
What
Roster/key changes bump nothing wire-visible — charter
heightonly advances on ownership transfers — so a stale manifest read (a server cache, a racy second device) is accepted whole. The failure that motivates this: an owner removes a member and the group key rotates; a re-served pre-removal manifest then silently restores the removed member and rolls the key back to the retired one, and the consumer goes on encrypting group-wide payloads under it.decryptManifesthad nothing to arbitrate with.Change
Group.publishCounter— a monotonic, wire-visible counter, serialized only when > 0. Counter-less groups, and every consumer that never bumps, stay byte-identical on the wire (the roles-field compat pattern; golden-pinned in the model tests).GroupService.bumpedForPublish(group)— bump once per publish event, before the per-member manifest fan-out (every member's copy carries the same counter), and adopt the returned group locally so the next publish bumps from there.decryptManifest(minPublishCounter: …)— a manifest below the caller's persisted high-water mark is refused under every charter policy: this is caller-provided arbitration, like theexpectedGroupIdpin, not charter trust. Equality passes — the vector being closed is rollback, not concurrent-writer arbitration (two same-counter publishers stay mutually readable, last-write-wins as today).Consumer adoption (the
minCharterHeightrecipe)Persist the highest accepted counter per group (the same shape as a charter-height store), pass it as
minPublishCounteron decrypt, and route publishes throughbumpedForPublish. Nothing changes for consumers until they opt in — an unbumped fleet reads and writes the exact bytes it does today.Tests
publishCounterkey; bump → serialize → parse → bump round-trips.74 tests,
flutter analyzeclean. Independent of #1 (different functions; either merges first without conflict).