Skip to content

Manifests gain a monotonic publish counter — stale reads can't roll back the roster - #2

Merged
needyaz merged 3 commits into
mainfrom
manifest-publish-counter
Aug 20, 2026
Merged

Manifests gain a monotonic publish counter — stale reads can't roll back the roster#2
needyaz merged 3 commits into
mainfrom
manifest-publish-counter

Conversation

@needyaz

@needyaz needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What

Roster/key changes bump nothing wire-visible — charter height only 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. decryptManifest had 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 the expectedGroupId pin, 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 minCharterHeight recipe)

Persist the highest accepted counter per group (the same shape as a charter-height store), pass it as minPublishCounter on decrypt, and route publishes through bumpedForPublish. Nothing changes for consumers until they opt in — an unbumped fleet reads and writes the exact bytes it does today.

Tests

  • The rollback scenario end-to-end: publish → remove + rotate + publish → the re-served v1 manifest is refused at the v2 high-water mark; v2 itself stays readable at its own mark.
  • Golden: a counter-less manifest carries no publishCounter key; bump → serialize → parse → bump round-trips.

74 tests, flutter analyze clean. Independent of #1 (different functions; either merges first without conflict).

…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.
@needyaz

needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

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), _knownKeys consumption so it can't be shadowed via extra, the floor enforced under every charter policy as caller-provided arbitration alongside the expectedGroupId pin, and equality-passes so concurrent same-counter publishers stay mutually readable. I also specifically checked the owner-restart wedge — toJson spreads toManifestJson(), so an adopted bump persists locally and the counter can't silently reset below the fleet's high-water marks. And the end-to-end test exercises the real vector (removal + key rotation rolled back by a re-served manifest).

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 decryptManifest rejection, but SPEC.md and the README still document the old world:

  • SPEC.md "Data model" / the manifest wire shape: add publishCounter (optional, >0 only).
  • SPEC.md's decryptManifest check table: add the publishCounter >= minPublishCounter row with its rationale.
  • SPEC.md "Threat model" and README "Known gaps" both state "Manifests carry no freshness field" as an open gap with no wire fix. That's no longer accurate — it should now say the manifest carries an opt-in monotonic publish counter, closed for consumers that adopt bumpedForPublish + minPublishCounter, and note the residual (an unadopted fleet keeps the old exposure; marks only start protecting once the owner bumps).

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).
@needyaz

needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Doc parity done — SPEC.md now describes the wire it defines:

  • the Group shape lists publishCounter (wire-visible only when > 0, pointing at the freshness section);
  • the decryptManifest check table gains the minPublishCounter row with its rationale and the note that it's enforced under every charter policy (caller-provided arbitration like the groupId pin, not charter trust);
  • the threat-model invariant is rewritten from 'manifests carry no freshness field' to the opt-in posture, with both residuals stated plainly: an unadopted fleet keeps the old exposure until the publisher bumps AND readers pass a floor, and the counter arbitrates rollback, not divergence — equal counters from independent bumps remain last-write-wins (your non-blocking note, now a spec sentence).

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.

@needyaz

needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

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
@needyaz
needyaz merged commit acd3fa6 into main Aug 20, 2026
1 check passed
@needyaz
needyaz deleted the manifest-publish-counter branch August 20, 2026 15:39
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