Charter transfers are retry-safe: idempotent no-op + self-validated links - #1
Conversation
…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.
|
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 Two asks before merge, both small:
One optional thought, non-blocking: plain |
- 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.
|
All three taken:
75 tests, analyze clean on the branch. |
|
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. |
What
transferOwnershipWithCharternever 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:validateCharterrejects both (self-link,sig not by prior owner) — permanently. UnderCharterPolicy.strict, every member'sdecryptManifestthen 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
newOwnerUid == group.ownerUidreturns the group unchanged, making publish-retry loops safe by construction (the same shape as the builder's existing retry-friendlyStateErrorposture).validateCharteron the extended chain before returning and throws a retryableStateErrorinstead 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
StateError, no chain handed back.74 tests,
flutter analyzeclean. No crypto/wire-format changes — the link format, signatures, and validator are untouched; both guards only constrain what the builder will emit.