Skip to content

Charter transfers are retry-safe: idempotent no-op + self-validated links - #1

Merged
needyaz merged 2 commits into
mainfrom
charter-transfer-retry-safety
Aug 20, 2026
Merged

Charter transfers are retry-safe: idempotent no-op + self-validated links#1
needyaz merged 2 commits into
mainfrom
charter-transfer-retry-safety

Conversation

@needyaz

@needyaz needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What

transferOwnershipWithCharter never checked whether the target already owns the group, and never validated its own output — so a routine retry could permanently poison the charter chain.

The retry trap

Transfer A→B succeeds locally (the caller applies the returned group), but the manifest publish fails. The retry calls the builder again with the already-updated group (ownerUid = B, tip = B) and mints either:

  • a B→B self-link, or
  • a link still signed with A's key while the tip requires B's.

validateCharter rejects both (self-link, sig not by prior owner) — permanently. Under CharterPolicy.strict, every member's decryptManifest then returns null and the group stops syncing; the only recovery is a deliberate charter-less republish, which permanently drops usurpation enforcement. The poisoning is silent at mint time — the builder happily returned a chain that could never validate.

Change

  • Idempotent no-op: newOwnerUid == group.ownerUid returns the group unchanged, making publish-retry loops safe by construction (the same shape as the builder's existing retry-friendly StateError posture).
  • Self-validation: the builder runs validateCharter on the extended chain before returning and throws a retryable StateError instead of handing back a poisoned link — covering the adjacent stale-state case too (a caller whose local tip the signing identity no longer matches, e.g. after someone else's transfer landed).

Tests

  • The retry, exactly as an app's publish-retry loop issues it: identical group back, chain unchanged, still valid.
  • A stale-state transfer (original owner signing after a real A→mid transfer): refused with StateError, no chain handed back.

74 tests, flutter analyze clean. No crypto/wire-format changes — the link format, signatures, and validator are untouched; both guards only constrain what the builder will emit.

…inks

transferOwnershipWithCharter had a publish-retry trap: transfer A→B
succeeds locally, the manifest publish fails, and the retry calls the
builder again with the already-updated group — minting either a B→B
self-link or a link not signed by the previous owner. validateCharter
rejects both, permanently: under CharterPolicy.strict every member's
decryptManifest then returns null and the group stops syncing, with
the only recovery a deliberate charter-less republish (permanently
dropping enforcement). The poisoning was silent at mint time.

Two guards:
- newOwnerUid == group.ownerUid returns the group unchanged, so
  publish-retry loops are safe by construction;
- the builder validates its own extended chain before returning and
  throws a retryable StateError instead of handing back a link the
  validator would reject (e.g. stale local state whose tip the signing
  identity no longer matches).

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 retry trap is real — the poisoned chain is silent at mint and permanent at publish, and under strict it's a whole-group outage — and both guards are the right fix in the right place: they constrain only what the builder emits, so the wire format, signatures, and validateCharter are untouched and the server-side verifier needs no lockstep change. The identical() assertion in the retry test is the exact right pin (a no-op, not an equivalent-value rebuild).

Two asks before merge, both small:

  1. Housekeeping: no CHANGELOG entry, no version bump, and the README's "Expect 72 tests" goes stale (this branch is 74). Repo convention is an entry per release — suggest folding both this and Manifests gain a monotonic publish counter — stale reads can't roll back the roster #2 into one 0.5.0 entry; whichever merges second reconciles the count (76 with both).
  2. Coordination with Manifests gain a monotonic publish counter — stale reads can't roll back the roster #2: both PRs edit group_service_test.dart and both will want the CHANGELOG — genuinely independent code, but the second merge should re-run and re-count.

One optional thought, non-blocking: plain transferOwnership (the charter-less variant) has no idempotence guard. Its failure mode is much milder (no chain to poison), but a same-shape newOwnerUid == group.ownerUid no-op would make the two variants uniformly retry-safe and costs one line.

- CHANGELOG 0.5.0 entry, version bump, README test count (75 on this
  branch; the second transfer-safety/counter merge reconciles to 77).
- Plain transferOwnership gains the same already-owner no-op as the
  charter variant — both transfer paths uniformly retry-safe, with an
  identical()-pinned test.
@needyaz

needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

All three taken:

  • Housekeeping: CHANGELOG 0.5.0 entry (this PR's bullet), version bumped, README count updated to this branch's 75. Per your suggestion the entry is written to be UNIONED with Manifests gain a monotonic publish counter — stale reads can't roll back the roster #2's bullet under the same 0.5.0 heading — whichever merges second resolves the trivial CHANGELOG conflict and reconciles the README count (77 with both).
  • Coordination: acknowledged — the test-file edits are in different groups, so the second merge should be a clean auto-merge plus the count/CHANGELOG reconcile; I'll re-run the suite on whichever lands second.
  • Optional guard: took it — plain transferOwnership now has the same already-owner no-op (identical()-pinned test), so both variants are uniformly retry-safe.

75 tests, analyze clean on the branch.

@needyaz

needyaz commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Re-verified at 71ad63b: 75/75 + analyze clean locally, CI green. All three points taken — unioned 0.5.0 entry, README count, and the uniform no-op on plain transferOwnership with the identical()-pinned test. LGTM, ready to merge.

@needyaz
needyaz merged commit 972a9f4 into main Aug 20, 2026
1 check passed
@needyaz
needyaz deleted the charter-transfer-retry-safety branch August 20, 2026 15:17
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