fix(state-node): merge concurrent versions field by field so a write cannot erase a revoke - #75
Merged
somasekimoto merged 3 commits intoSep 17, 2026
Conversation
…ntegrity verdicts Accounts. monas-account holds exactly one signing key, and a delegated token's audience is that key — so a share addressed to any other identity could open its envelope but never read or write the state node. The dialog stopped pretending otherwise: no "register as signing account" checkbox, no keypair-only identities, no "Use" switch. One Create account per device; remove and recreate to start over. Legacy keypair-only identities still show (as "keypair only", removable) so nothing already in localStorage silently disappears. The Share dialog is paste-a-public-key only: with one identity there was nothing to pick, and "prove access" decrypted as the recipient into the owner's CEK slot, which a later revoke then broke. Sync status. Every synced row now carries one of up to date / newer on network / synced (unchecked) / can't reach network, and the preview repeats it as a one-line status with "Check now" and, when behind, "Pull & edit". The comparison is a verified read of the Content Network head: the plain id its plaintext re-addresses to against the id this device holds (the owner's local version, or a recipient's last write / envelope version). It runs on open, on demand, and in a 30 s background sweep (sequential — the state node rate-limits). Writes this device makes record themselves as the head so the badge is right without a round trip. Integrity. "Verify integrity" reported a bare red "invalid" with the SDK's reason in a right-aligned kv row nobody read. It now classifies the reason: "not the head — newer version on the network" (amber, not a corruption), "no local ciphertext" (this gateway's store was reset), and only otherwise "invalid". The reason string is shown under the badge in every non-valid case. Tests: G-34 aria snapshot follows the simplified Share dialog; J-2 shares to two pasted keys instead of a local identity; J-4 asserts Alice's row goes behind → current across Bob's write and her pull. UI suite 20/20, J-1..J-4 pass against node1/node2. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ff reached
A revoke advances `min_valid_issued_at` on the committing node and pushes the
new policy to the other members best-effort; a push failure was a `warn!` and
the call returned success. But each member authorizes a write against its
own copy of the policy (`update_content_inner` commits on the local view), so
a member the push did not reach keeps accepting writes under the voided
tokens until its next periodic sync — and the UI, on top, asserted "the
revoked recipient cannot write in between". Seen live: a revoked recipient's
PUT landed on node2 ~10 s after node1 had revoked (see
docs/revoke-write-bypass-investigation.md).
This change does not close that window — a writer must never be able to hold
up a revocation, and closing it needs either quorum on both sides or a
policy-aware merge (options A/B in the investigation doc; B is tracked
separately). It makes the outcome truthful:
- state-node: `invalidate_tokens` retries each push once and returns
`InvalidateTokensOutcome { new_min_valid_issued_at, notified_members,
unreached_members, relayed }`. The HTTP response always serializes the
three propagation fields, so a client can tell "reached everyone" from
"this node predates the report" (fields absent). Relayed requests report
`relayed: true` with unknown (empty) lists — the relay protocol carries
only success.
- sdk: `RevokeShareOutput.token_invalidation_reach`; `None` for a legacy
node's response rather than an empty list that would read as "all
reached".
- example-ui: the revoke pipeline gains a "Cutoff propagation" step
(optional — the revoke itself succeeded) that names unreached members and
the ~30 s sync window, distinguishes relayed/legacy "unknown", and an
error toast when propagation is incomplete. The "cannot write in between"
wording is gone.
Tests: state-node (unreached member reported with retry count, self never
pushed; all-notified case), sdk (legacy vs. reporting response parsing).
J-4 unchanged and passing.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…cannot erase a revoke A version's payload holds the content body and the access policy together, and crsl-lib merged concurrent versions by copying the newest one whole. That treats the policy as part of the body, and it breaks in both directions whenever a revoke and a write run concurrently — a member the revoke has not reached yet, a partition, plain sync lag: - write newer than the revoke: the write's node carries the policy it inherited, i.e. the pre-revoke `min_valid_issued_at`. The merge copies that node whole and the revoke is gone from the head. The revoked recipient is not "able to write once in a window" — after that one write the cutoff is back to 0 and they can keep writing. - revoke newer than the write: the revoke's node carries the body it inherited. The merge copies *that* whole and a legitimate write that was never in conflict with anything is dropped. The fix keeps the payload as it is — one DAG, one version CID binding body and policy — and changes only how a merge node is computed. crsl-lib now takes an application merge policy (`Repo::with_merge_policy`, with each head's parent payloads in `ResolveInput`); `MonasMergePolicy` merges field by field: the body is LWW among the heads that actually changed it against their parent, `min_valid_issued_at` is the max across heads, owner and content id are carried. Every head read (`get_latest`, `get_access_policy`, the policy copy in `update_content`, the body copy in `update_access_policy`) first converges concurrent heads via `Repo::merge_heads`, so a reader sees what the policy decides rather than one branch's tip, and a new version starts from the converged value. `AccessPolicy::raise_min_valid_issued_at` is the monotone step the merge uses; it ignores a lower value instead of erroring, since a merge has no notion of "current". `ContentAccessControl::merge` (Sled side) already expressed this intent but sits outside the authorization path; it is left untouched. This decides what a merge node contains, not whether a head should have been accepted. A write a stale member let through under a since-voided token still wins the body if it is the newest write; ruling it out needs the write to carry its token so the merge can check it against the merged cutoff — tracked separately. The merge policy is per process and does not travel with the data, so all members of a Content Network must run the same one; there is no mixed-version deployment to protect yet, so no policy_type versioning is added. Tests: MonasMergePolicy unit (write-after-revoke keeps cutoff, revoke-after-write keeps write, two writes LWW, two revokes max, orphan head counts as a write, order-independent); two-replica integration through CrslCrdtRepository for both orderings, syncing both ways and asserting each replica's policy and body after convergence. crsl-lib: 0fbabbf (feat/injectable-merge-policy). Co-Authored-By: Claude Code <noreply@anthropic.com>
somasekimoto
marked this pull request as ready for review
September 17, 2026 10:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #47. Depends on Monas-project/crsl-lib#45;
Cargo.tomlpins its commit0fbabbfand must be bumped to the merge commit once that lands.The bug
Investigating the revoke bypass seen in #47's demo (
docs/revoke-write-bypass-investigation.md) turned up something worse than a propagation window. A version's payload holds body and access policy together, and crsl-lib merged concurrent versions by copying the newest one whole. So when a revoke and a write run concurrently — a member the revoke has not reached, a partition, sync lag:min_valid_issued_at. The revoke is gone from the head. The recipient can keep writing, not just once.The fix
Same DAG, same payload, same version CID binding body and policy. Only the merge node's computation changes:
MonasMergePolicymerges field by field — body is LWW among heads that actually changed it against their parent,min_valid_issued_atis the max across heads, owner/content id carried. Every head read first converges concurrent heads (Repo::merge_heads) so readers and new versions start from the merged value.Not in scope: a write a stale member accepted under a since-voided token still wins the body if it is the newest write; ruling it out needs the write to carry its token for the merge to check (issue to follow). No
policy_typeversioning — there is no mixed-version deployment to protect yet; all members must run the same policy.Files
monas-state-node/src/infrastructure/merge_policy.rs(new): the policy + 6 unit testscrdt_repository.rs: install the policy;converged_head()before every head read; 2 two-replica integration tests (both orderings, sync both ways)access_policy.rs:raise_min_valid_issued_atdocs/design.md§11 + §7 revoke: field-wise merge, propagation semanticsdocs/revoke-write-bypass-investigation.md: decision trail (C → this)Cargo.toml×2 /Cargo.lock: crsl-lib revVerification
cargo test --workspacegreen (state-node 387), clippy clean. Not yet deployed to demo nodes;.demo-permissions.mjsPhase D should be re-run after deploy.