Skip to content

fix(state-node): merge concurrent versions field by field so a write cannot erase a revoke - #75

Merged
somasekimoto merged 3 commits into
feat/example-ui-monas-drivefrom
feat/policy-aware-merge
Sep 17, 2026
Merged

somasekimoto merged 3 commits into
feat/example-ui-monas-drivefrom
feat/policy-aware-merge

Conversation

@somasekimoto

Copy link
Copy Markdown
Contributor

Stacked on #47. Depends on Monas-project/crsl-lib#45; Cargo.toml pins its commit 0fbabbf and 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:

  • write newer than the revoke → the merge copies the write's node, whose policy is the pre-revoke min_valid_issued_at. The revoke is gone from the head. The recipient can keep writing, not just once.
  • revoke newer than the write → the merge copies the revoke's node, whose body is what it inherited. A legitimate write that competed with nothing is dropped.

The fix

Same DAG, same payload, same version CID binding body and policy. Only the merge node's computation changes: MonasMergePolicy merges field by field — body is LWW among heads that actually changed it against their parent, min_valid_issued_at is 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_type versioning — 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 tests
  • crdt_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_at
  • docs/design.md §11 + §7 revoke: field-wise merge, propagation semantics
  • docs/revoke-write-bypass-investigation.md: decision trail (C → this)
  • Cargo.toml ×2 / Cargo.lock: crsl-lib rev

Verification

cargo test --workspace green (state-node 387), clippy clean. Not yet deployed to demo nodes; .demo-permissions.mjs Phase D should be re-run after deploy.

somasekimoto and others added 3 commits September 13, 2026 22:22
…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
somasekimoto marked this pull request as ready for review September 17, 2026 10:35
@somasekimoto
somasekimoto merged commit 5e0fb9b into feat/example-ui-monas-drive Sep 17, 2026
5 checks passed
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