638 lines
35 KiB
Markdown
638 lines
35 KiB
Markdown
|
|
# Shared scopes
|
|||
|
|
|
|||
|
|
Status: implemented. This records the shared-scope behavior built on top of
|
|||
|
|
the row-ownership work in `row-ownership.md`. Revocation cleanup is still a
|
|||
|
|
deferred follow-up; where the ownership summary and this document differ, this
|
|||
|
|
one wins.
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
A user is an authentication identity; a scope is the unit of replication and
|
|||
|
|
ownership. Each user has a personal scope and may also belong to any number of
|
|||
|
|
shared scopes. The load-bearing
|
|||
|
|
requirement is:
|
|||
|
|
|
|||
|
|
> **A single `sync` call syncs every scope the authenticated user has access
|
|||
|
|
> to.** One `syncOnce` cycles through all accessible scopes exactly once; a
|
|||
|
|
> continuous session keeps cycling forever. There is no separate stream or
|
|||
|
|
> separate call per scope.
|
|||
|
|
|
|||
|
|
The row-ownership work keys storage, locking, HLC chains, visibility, and
|
|||
|
|
ownership by **scope**, not user, so sharing is an indirection in front of
|
|||
|
|
`crdt_scopes`, not a re-architecture. The implemented pieces are a server-side
|
|||
|
|
membership relation and a sync protocol that iterates scopes.
|
|||
|
|
|
|||
|
|
> **Merges serialize one scope at a time.** A merge runs in exactly one scope's
|
|||
|
|
> transaction and lock, so scopes always serialize on the merge step regardless
|
|||
|
|
> of framing. A cycle's outbound changes are instead collected in a single pass
|
|||
|
|
> over the active scopes and carried in one combined batch, each change tagged
|
|||
|
|
> with its `uuidScopeId`; the receiver regroups by scope and merges each group
|
|||
|
|
> in its own transaction. The wire stays a near-verbatim repetition of today's
|
|||
|
|
> frames, now scope-tagged.
|
|||
|
|
|
|||
|
|
> **The server is the authority on membership; the client follows.** The set of
|
|||
|
|
> scopes a session syncs is computed server-side from `crdt_scope_members` and
|
|||
|
|
> re-resolved at the start of every sync cycle, so access changes take effect
|
|||
|
|
> mid-session. The server dictates the set; the client adopts scopes it
|
|||
|
|
> announces and stops cycling the ones it omits. The reconciliation is
|
|||
|
|
> directional — not a set operation toggled by a flag — and membership itself is
|
|||
|
|
> never synced. Local data cleanup for omitted scopes is a separate revocation
|
|||
|
|
> policy, not part of the sync loop.
|
|||
|
|
|
|||
|
|
> **Row ownership is unchanged.** Every row still has exactly one owning
|
|||
|
|
> `scopeId`, `(table, uuidRowId)` stays globally identified, and every
|
|||
|
|
> enforcement rule in `row-ownership.md` survives untouched. Sharing adds a
|
|||
|
|
> membership layer in front of scopes; it does not touch row ownership.
|
|||
|
|
|
|||
|
|
## Goals and constraints
|
|||
|
|
|
|||
|
|
1. **One call, every scope.** The public client API (`syncOnce`,
|
|||
|
|
`syncContinuously`) does not change shape: callers pass a session, never a
|
|||
|
|
scope. A device with only a personal scope behaves exactly as today.
|
|||
|
|
2. **Smallest protocol delta.** The exchanged frames are today's, plus a
|
|||
|
|
per-cycle scope-set exchange and a scope tag on each frame. `collectNextBatch`
|
|||
|
|
demultiplexes a cycle's combined batch, `collectPendingChanges` collects every
|
|||
|
|
active scope in one pass, and the per-scope merge plus `recordSyncCheckpoint`
|
|||
|
|
run once per inbound scope group.
|
|||
|
|
3. **Chunked streaming.** Outbound changes are collected in a single pass over
|
|||
|
|
the active scopes and streamed in `syncBatchSize` chunks, so the wire payload
|
|||
|
|
stays bounded regardless of scope count. The collection step itself
|
|||
|
|
materializes a cycle's pending rows across the active scopes (one
|
|||
|
|
`scopeId IN (…)` query per change kind), so peak memory tracks a cycle's
|
|||
|
|
pending rows rather than being held strictly one scope at a time. See
|
|||
|
|
`outbound-collection-consistency.md`.
|
|||
|
|
4. **Membership cannot be forged.** The server computes its own scope set from
|
|||
|
|
`crdt_scope_members` (plus the implicit personal scope) and never widens it
|
|||
|
|
from anything the client sends. A scope the user is not a member of is never
|
|||
|
|
synced, even if the client names it.
|
|||
|
|
5. **One scope per transaction.** Reads are membership-wide; writes stay pinned
|
|||
|
|
to exactly one scope. This is deliberate honesty about CRDT semantics: two
|
|||
|
|
scopes' chains replicate independently and a remote replica can never
|
|||
|
|
observe a cross-scope write atomically.
|
|||
|
|
6. **Roles are closed CRDT access roles.** The package stores and projects
|
|||
|
|
non-null `CrdtScopeRole` values in scope grants and shared memberships:
|
|||
|
|
`readWrite` allows CRDT writes and `readOnly` blocks them. The implicit
|
|||
|
|
personal scope is announced as a `readWrite` grant, so single-tenant servers
|
|||
|
|
and multi-account devices remain the same code path with different scope
|
|||
|
|
counts.
|
|||
|
|
|
|||
|
|
## Membership
|
|||
|
|
|
|||
|
|
### The `crdt_scope_members` table
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
class: CrdtScopeMember
|
|||
|
|
table: crdt_scope_members
|
|||
|
|
database: all
|
|||
|
|
fields:
|
|||
|
|
scope: CrdtScope?, relation(onDelete=Cascade)
|
|||
|
|
userUuid: UuidValue
|
|||
|
|
role: CrdtScopeRole
|
|||
|
|
indexes:
|
|||
|
|
crdt_scope_member_unique_idx:
|
|||
|
|
fields: userUuid, scopeId
|
|||
|
|
unique: true
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- The table is **`database: all`** but **not** in `syncTables`. It exists on
|
|||
|
|
every node so membership resolves with the same code on both ends, but it is
|
|||
|
|
never CRDT-replicated by this package. The server is the authoritative writer;
|
|||
|
|
a client copy (once populated) is a read-only cache for UI and offline role
|
|||
|
|
checks — see *The client membership table*.
|
|||
|
|
- The index **leads with `userUuid`** so the per-cycle `WHERE userUuid = ?`
|
|||
|
|
resolution is index-covered.
|
|||
|
|
- Rows are managed by application code: invitations, acceptance, and role
|
|||
|
|
assignment are app domain, outside this package.
|
|||
|
|
- **Personal-scope membership is implicit.** By convention a user's personal
|
|||
|
|
scope has the user's own UUID (already true: the scope UUID keys the chain),
|
|||
|
|
so no `crdt_scope_members` row is stored for it. Shared scopes are
|
|||
|
|
`crdt_scopes` rows whose UUID is no user's id, with explicit membership rows.
|
|||
|
|
- `role` is a required CRDT access enum. Shared memberships always store an
|
|||
|
|
explicit role: `readWrite` allows writes, while `readOnly` denies
|
|||
|
|
shared-scope writes.
|
|||
|
|
|
|||
|
|
### Members vs. nodes
|
|||
|
|
|
|||
|
|
`crdt_scope_members` and the `nodes` list on a scope are orthogonal layers and
|
|||
|
|
both stay:
|
|||
|
|
|
|||
|
|
- A **member** is an authorization fact: a user identity allowed to access a
|
|||
|
|
scope. It answers *who may sync, read, and write*.
|
|||
|
|
- A **node** (`crdt_nodes`) is a stable CRDT replica identity. A scope's
|
|||
|
|
`nodes` relation points at `crdt_scope_nodes`, which records that replica's
|
|||
|
|
participation and checkpoint state in that scope. Together they answer
|
|||
|
|
*whose changes, up to which HLC* and are what per-`(scope, node)` checkpoints
|
|||
|
|
key on.
|
|||
|
|
|
|||
|
|
They do not map one-to-one and neither replaces the other. One member may have
|
|||
|
|
many nodes (one per replica/database install). The **server is a node** in every
|
|||
|
|
scope it syncs but is not a member row. A freshly invited member has **zero
|
|||
|
|
scope-node rows** until their first device syncs. Nodes and scope-node rows are
|
|||
|
|
created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); members are
|
|||
|
|
created by application code. Removing the `nodes` list would break checkpointing
|
|||
|
|
and causal filtering — it is the chain topology, not the access list.
|
|||
|
|
|
|||
|
|
### The client membership table
|
|||
|
|
|
|||
|
|
`crdt_scope_members` is `database: all`, so the table exists on the client and
|
|||
|
|
is **populated as a read-only projection** of the server's authoritative grants
|
|||
|
|
— without being bidirectionally CRDT-synced:
|
|||
|
|
|
|||
|
|
- The client's projected `crdt_scope_members` rows plus the implicit personal
|
|||
|
|
scope are its membership view for *membership gating*: a membership-wide read
|
|||
|
|
filters over projected membership, and a local `crdt_scopes` row by itself
|
|||
|
|
does not grant access. The members table also carries **roles**, which the
|
|||
|
|
client consults before local writes.
|
|||
|
|
- The authoritative `CrdtSyncScopeSet` carries `CrdtScopeGrant`s — `(scopeUuid,
|
|||
|
|
role)` — not bare UUIDs. Every grant has an explicit role; the personal scope
|
|||
|
|
is announced as `readWrite` but remains implicit in storage. When the follower
|
|||
|
|
*receives* an announcement, `projectFollowerMembership` reconciles the local
|
|||
|
|
table from it: upsert a row per shared grant (the personal scope is skipped),
|
|||
|
|
delete rows whose scope is no longer granted. Since the authoritative peer
|
|||
|
|
announces only when its grants change, projection runs only on change and an
|
|||
|
|
idle session writes nothing.
|
|||
|
|
- This is a **server-authored, client-read-only projection**, never a synced
|
|||
|
|
members table: the client never writes membership the server reads, so it
|
|||
|
|
cannot self-grant. The server stays the security boundary and re-verifies on
|
|||
|
|
sync.
|
|||
|
|
- A revoke takes effect for sync as soon as the server omits the scope from a
|
|||
|
|
cycle's `ScopeSet`: the client stops cycling it and discards in-memory
|
|||
|
|
checkpoint state for it. The scope row and domain rows may remain stored
|
|||
|
|
locally until the deferred revocation cleanup policy decides whether to purge
|
|||
|
|
or keep them, but membership-wide reads stop returning those rows.
|
|||
|
|
- Writes are bounded by projected membership and by the projected role. They are
|
|||
|
|
re-verified by the server on sync, which remains the security boundary.
|
|||
|
|
Client-side checks are early-failure UX, not the enforcement point.
|
|||
|
|
|
|||
|
|
### Role write access
|
|||
|
|
|
|||
|
|
The package exposes a closed CRDT access role enum rather than free-form role
|
|||
|
|
strings:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
enum CrdtScopeRole {
|
|||
|
|
readOnly,
|
|||
|
|
readWrite,
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Only `readWrite` allows writes. `readOnly` denies writes. The implicit personal
|
|||
|
|
scope has no stored membership row, but its authoritative grant and role lookup
|
|||
|
|
resolve to `readWrite`.
|
|||
|
|
|
|||
|
|
Roles gate **writes only**. Reads stay membership-wide and role-independent.
|
|||
|
|
The authoritative server enforces role writes before applying inbound changes:
|
|||
|
|
for each accepted inbound scope group, it resolves the authenticated user's
|
|||
|
|
role from `crdt_scope_members`; if the role is not `readWrite`, the server
|
|||
|
|
records `CrdtSyncIntegrityViolation(type: unauthorizedWrite)` and fails the
|
|||
|
|
sync session before opening the merge transaction for that scope. The rejected
|
|||
|
|
change is not applied and checkpoints do not advance past it.
|
|||
|
|
|
|||
|
|
The client uses the same enum as early UX: `transactionForUser(userId, fn,
|
|||
|
|
scopeId: sharedScope)` resolves the projected role and throws
|
|||
|
|
`CrdtScopeRoleException` before any local write when the role cannot write. A
|
|||
|
|
follower also omits non-writable shared scopes from outbound pending-change
|
|||
|
|
collection while still receiving inbound changes for those scopes. This avoids
|
|||
|
|
repeatedly streaming local read-only changes; the server enforcement remains the
|
|||
|
|
security boundary for stale, misconfigured, or hostile clients.
|
|||
|
|
|
|||
|
|
### Scope enumeration (server is the authority)
|
|||
|
|
|
|||
|
|
At the start of each sync cycle the server resolves the authoritative set:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
memberScopes(userUuid) =
|
|||
|
|
{ personal scope = userUuid }
|
|||
|
|
∪ { m.scopeUuid : crdt_scope_members where userUuid = <auth user> }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This set bounds everything the cycle may sync. The client never expands it; it
|
|||
|
|
only learns of additions through what the server announces.
|
|||
|
|
|
|||
|
|
## Sync: one call syncs every accessible scope
|
|||
|
|
|
|||
|
|
### One `sync`, two cadences: bounded `once`, idle-silent `continuous`
|
|||
|
|
|
|||
|
|
Merge is inherently one scope at a time: a merge runs in exactly one scope's
|
|||
|
|
transaction and lock, so the scopes always serialize regardless of framing. A
|
|||
|
|
single `sync` method serves both `syncOnce` and `syncContinuously`, branching on
|
|||
|
|
`once` only where they genuinely differ — read discipline and termination — so
|
|||
|
|
there is no duplicated send/merge logic:
|
|||
|
|
|
|||
|
|
1. **Establishment (shared, lockstep).** After `Connect`, both peers exchange the
|
|||
|
|
`ScopeSet`. Doing this *before* the loop is what removes the announce/adopt
|
|||
|
|
offset that would otherwise make a follower act before it knows the agreed
|
|||
|
|
set.
|
|||
|
|
2. **Data loop (shared, idle-silent).** Each cycle is **one combined batch**: the
|
|||
|
|
scope announcement (only when this peer's grants changed), a `SinceHlc` for
|
|||
|
|
each newly active scope, and merge chunks whose changes carry their scope,
|
|||
|
|
all closed by a single `EndOfBatch`. Continuous mode omits the terminator when
|
|||
|
|
nothing was sent. The receive phase demultiplexes the peer's batch by type
|
|||
|
|
until that terminator or — when the peer was idle — an idle timeout. An idle
|
|||
|
|
continuous cycle therefore sends nothing and idles out **once**, regardless of
|
|||
|
|
scope count.
|
|||
|
|
|
|||
|
|
`once` stays in the data loop until every active scope has exchanged `SinceHlc`
|
|||
|
|
and had one sendable data cycle, then performs the symmetric `Close` handshake.
|
|||
|
|
`continuous` loops instead, with no per-cycle chatter; it handshakes its initial
|
|||
|
|
scopes in the first cycle and absorbs membership changes as they are announced
|
|||
|
|
(a newly granted scope is announced, adopted, and established over the following
|
|||
|
|
cycles — its data deferred until its `SinceHlc` round-trips).
|
|||
|
|
|
|||
|
|
### Protocol frames
|
|||
|
|
|
|||
|
|
`Connect` opens the session once. After that the frames are the same in both
|
|||
|
|
cadences; only *when* they are sent differs (`once`: a fixed sequence per scope;
|
|||
|
|
`continuous`: only on change, inside one combined batch). Frame changes
|
|||
|
|
(regenerated models + a migration on both client and server schemas):
|
|||
|
|
|
|||
|
|
- `CrdtSyncConnect { syncTablesHash, localNodeId }` — sent once by each peer. The
|
|||
|
|
schema hash is validated once, before any scope work. `localNodeId` identifies
|
|||
|
|
this peer's CRDT node for the whole session. The scope list is dynamic and
|
|||
|
|
exchanged per cycle.
|
|||
|
|
- `CrdtSyncScopeSet { scopes: List<CrdtScopeGrant> }` (each grant a
|
|||
|
|
`scopeUuid` + `role`) — the server's content is authoritative (resolved from
|
|||
|
|
`crdt_scope_members`, roles included). Followers send an empty set because the
|
|||
|
|
authoritative peer never widens access from follower-reported state. In `once`
|
|||
|
|
it is exchanged exactly once. In `continuous` a peer re-announces it
|
|||
|
|
**only when its own grant set changed** since the last announcement — this is
|
|||
|
|
the frame that carries access changes mid-session, and gating it on change is
|
|||
|
|
what keeps an idle session silent while still letting a grant or revoke take
|
|||
|
|
effect.
|
|||
|
|
- `CrdtSyncSinceHlc { uuidScopeId, nodeCheckpoints }` — per scope. Carries that
|
|||
|
|
scope's per-node checkpoints. Exchanged when a scope first becomes active
|
|||
|
|
(and again if a dropped scope is later re-adopted);
|
|||
|
|
checkpoints then live in memory for the rest of the session.
|
|||
|
|
- `CrdtSyncMergeChunk { changes }` — each change carries `uuidScopeId`, letting
|
|||
|
|
a combined batch carry every scope's changes and regroup them on receive.
|
|||
|
|
- `CrdtSyncEndOfBatch`, `CrdtSyncClose`, `CrdtSyncIdleTimeout` — unchanged. A
|
|||
|
|
single `EndOfBatch` terminates a cycle's combined batch (and is itself omitted
|
|||
|
|
when nothing was sent, so an idle peer resolves to an empty batch instead).
|
|||
|
|
`Close` ends the streaming session. Because it is an intentional peer control
|
|||
|
|
frame, it may arrive even after frames for a partial cycle; the receiver
|
|||
|
|
discards that partial cycle and records no checkpoint progress for it.
|
|||
|
|
|
|||
|
|
The data cycle's receive uses `collectNextBatch`, which demultiplexes one
|
|||
|
|
cycle's frames by type into `{ scopeSet?, sinceHlcs, changes }`,
|
|||
|
|
returning on the single `EndOfBatch`, on `Close` with a closed result, or —
|
|||
|
|
when the peer was idle — on the idle timeout. A raw transport close without a
|
|||
|
|
`Close` frame is stricter: it is graceful only between continuous-session
|
|||
|
|
batches and is treated as truncation once a partial batch has arrived.
|
|||
|
|
|
|||
|
|
### The session state machine
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
handshake (both cadences):
|
|||
|
|
send Connect(syncTablesHash, localNodeId); read peer Connect; validate
|
|||
|
|
syncTablesHash; store peer localNodeId
|
|||
|
|
|
|||
|
|
establishment (both cadences, lockstep):
|
|||
|
|
send ScopeSet(myGrants); read peer ScopeSet → adopt → activeScopes
|
|||
|
|
follower: materialize activeScopes, project membership
|
|||
|
|
data loop (both cadences, one combined batch per cycle):
|
|||
|
|
loop:
|
|||
|
|
resolve activeScopes (authoritative: my set; follower: adopted peerGrants)
|
|||
|
|
discard state for scopes no longer active
|
|||
|
|
# send phase — emit only what changed; track whether any frame was sent
|
|||
|
|
if myGrants != announcedGrants: send ScopeSet(myGrants)
|
|||
|
|
for scope in activeScopes not yet sinceHlcSent: send SinceHlc(scope)
|
|||
|
|
if peer checkpoints exist: send MergeChunk(changes)* # follower skips non-writable scopes
|
|||
|
|
if anything was sent (or once): send EndOfBatch
|
|||
|
|
# receive phase
|
|||
|
|
batch = collectNextBatch(allowCloseBeforeBatch: !once) # {scopeSet?, sinceHlcs, changes} | idle | closed
|
|||
|
|
if closed: return
|
|||
|
|
adopt batch.scopeSet (follower: materialize + project); store sinceHlcs;
|
|||
|
|
group changes by uuidScopeId; mergeInboundBatch each group
|
|||
|
|
if once: send Close; read Close; drain; return
|
|||
|
|
delay continuousSyncInterval
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The shared establishment exchanges the scope set so a follower never acts before
|
|||
|
|
it knows the agreed set. Both cadences handshake initial and newly added scopes
|
|||
|
|
through the loop's own `SinceHlc` exchange.
|
|||
|
|
|
|||
|
|
`collectPendingChanges`, `mergeInboundBatch`, `recordSyncCheckpoint`, and the
|
|||
|
|
ownership-violation internals (`_streamInserts`/`Updates`/`Deletes`,
|
|||
|
|
`_fetchDomainRow`, the durable-violation path) are scope-parameterized and merge
|
|||
|
|
each inbound scope group in its own `transactionForUser`/lock; any scope's merge
|
|||
|
|
failure (for example an ownership collision or unauthorized role write) records
|
|||
|
|
the durable violation and fails the session, preserving today's fail-fast
|
|||
|
|
semantics.
|
|||
|
|
|
|||
|
|
Because `continuous` defers a scope's data until both peers have exchanged its
|
|||
|
|
`SinceHlc`, a newly active scope is established over the **following** cycles
|
|||
|
|
rather than in lockstep — the deliberate trade for keeping the steady state
|
|||
|
|
silent. A peer only honors inbound `SinceHlc`/`MergeChunk` frames for scopes it
|
|||
|
|
authorizes (authoritative: its own membership; follower: the announced set), so
|
|||
|
|
the combined batch tolerates a peer's informational extras without widening
|
|||
|
|
access.
|
|||
|
|
|
|||
|
|
`onMergeSuccess` becomes scope-aware: `Function(UuidValue scopeUuid, Hlc hlc)`.
|
|||
|
|
This touches three test call sites and the example app's `demo_controller`.
|
|||
|
|
|
|||
|
|
### Scope-set reconciliation and lockstep
|
|||
|
|
|
|||
|
|
Both peers must cycle the **same ordered scope set** or send/receive desyncs.
|
|||
|
|
Membership is asymmetric — the client trusts the server's membership claims, the
|
|||
|
|
server trusts none of the client's — so the reconciliation is **directional**,
|
|||
|
|
not a symmetric set operation:
|
|||
|
|
|
|||
|
|
- **Union is wrong** (insecure): a client could name a scope the user is not a
|
|||
|
|
member of and force the server to sync it.
|
|||
|
|
- **Intersection is wrong** (cannot adopt): a brand-new share is not in the
|
|||
|
|
client's set yet, so it would never enter the intersection and never sync —
|
|||
|
|
the client cannot hold what it has not synced, and cannot sync what it does
|
|||
|
|
not hold.
|
|||
|
|
|
|||
|
|
The correct operation is neither: **the server dictates its set; the client
|
|||
|
|
conforms.** Each cycle:
|
|||
|
|
|
|||
|
|
- **Server**: `orderedScopes = sort(memberScopes(userUuid))`, computed from
|
|||
|
|
`crdt_scope_members`. It never reads the client's `ScopeSet` for authority
|
|||
|
|
(constraint 4).
|
|||
|
|
- **Client**: takes the server's `ScopeSet` as the set to cycle —
|
|||
|
|
`getOrCreate` (`CrdtScopeManager`) any scope it lacks, ignore any local scope
|
|||
|
|
the server omitted for this cycle — then `orderedScopes = sort(peer scopeIds)`.
|
|||
|
|
|
|||
|
|
This asymmetry is expressed by a **`CrdtSyncPeerMode` enum**
|
|||
|
|
(`authoritative` / `follower`) passed at the call site: the server endpoint
|
|||
|
|
passes `authoritative`, the client driver passes `follower`. Membership is
|
|||
|
|
resolved from the same shared `crdt_scope_members` table (via
|
|||
|
|
`CrdtScopeMembership`); the mode varies only the cycle *direction* —
|
|||
|
|
authoritative dictates its own set, follower adopts the peer's.
|
|||
|
|
Once the data is unified, a closed enum is the right shape: there is no
|
|||
|
|
per-role enumeration callback left to inject. Security does not rest on the
|
|||
|
|
enum — a malicious client passing `authoritative` only fails to adopt the
|
|||
|
|
peer's set; writes are still gated server-side by `CrdtScopeMembership.isMember`
|
|||
|
|
against the authoritative table.
|
|||
|
|
|
|||
|
|
In `once`, the client adopts straight from the cycle's `ScopeSet` *before* it
|
|||
|
|
iterates, so a newly granted scope syncs in that same round. In `continuous`,
|
|||
|
|
adoption happens when the announcement arrives and the scope's data follows over
|
|||
|
|
the next cycles once its `SinceHlc` round-trips. Sorting by scope UUID makes the
|
|||
|
|
order deterministic on both sides with no extra negotiation. Frames carry
|
|||
|
|
`uuidScopeId` so a receiver groups each chunk into the right scope and fails loud
|
|||
|
|
on an unauthorized tag rather than merging a batch into the wrong scope.
|
|||
|
|
|
|||
|
|
Re-resolving `memberScopes` every cycle is an indexed `crdt_scope_members`
|
|||
|
|
lookup on the server only; it may be throttled or invalidated by an
|
|||
|
|
application hook if per-cycle querying becomes hot, at the cost of slower
|
|||
|
|
propagation of access changes.
|
|||
|
|
|
|||
|
|
### Checkpoints, once vs. continuous
|
|||
|
|
|
|||
|
|
Checkpoint rows stay per `(scope, node)` in `crdt_scope_nodes`, while
|
|||
|
|
`crdt_nodes` stores the stable replica identity. The same local replica node can
|
|||
|
|
participate in multiple scopes, with separate `lastReceivedHlc` values per
|
|||
|
|
scope. `SinceHlc` is exchanged once per scope on first visit; later cycles reuse
|
|||
|
|
the in-memory
|
|||
|
|
`nodeCheckpoints[scope]`, advanced as chunks are sent and as inbound batches
|
|||
|
|
merge — the multi-scope analogue of today's single-scope continuous loop, which
|
|||
|
|
already keeps checkpoints in memory across rounds.
|
|||
|
|
|
|||
|
|
The continuous interval delay moves to **after each full cycle**, not after each
|
|||
|
|
scope, to keep per-scope latency low while still preventing a busy loop when
|
|||
|
|
idle.
|
|||
|
|
|
|||
|
|
### Access changes during a session
|
|||
|
|
|
|||
|
|
Because the authoritative scope set is re-resolved every cycle and re-announced
|
|||
|
|
whenever it changes (not pinned at `Connect`), a continuous session absorbs
|
|||
|
|
membership changes without reconnecting:
|
|||
|
|
|
|||
|
|
- **Grant.** The server re-announces a `ScopeSet` that includes the new scope;
|
|||
|
|
the client adopts and materializes it, then handshakes and syncs it over the
|
|||
|
|
next cycles. (The server detects the grant on its next cycle, so propagation
|
|||
|
|
is bounded by one idle poll plus the handshake round-trip.)
|
|||
|
|
- **Revoke.** The server re-announces a `ScopeSet` that omits the scope; both
|
|||
|
|
sides stop cycling it and discard its in-memory checkpoint state, and the
|
|||
|
|
follower's `projectFollowerMembership` deletes its members-table row. The
|
|||
|
|
device can still store the revoked scope's rows locally, but membership-wide
|
|||
|
|
reads no longer return them. Purging stored rows (and the fate of any unsynced
|
|||
|
|
local changes) is the deferred revocation question, not a sync-loop concern.
|
|||
|
|
- **Demote.** The server re-announces the same scope with a different role; the
|
|||
|
|
follower projection updates the cached role. The next local write consults
|
|||
|
|
the new role and fails early if it is read-only. Pending local changes already
|
|||
|
|
authored before demotion are not silently applied by the server: if streamed,
|
|||
|
|
they are rejected and recorded as `unauthorizedWrite`. Whether the client
|
|||
|
|
purges, surfaces, or keeps skipped pending changes is part of the deferred
|
|||
|
|
revocation-cleanup design.
|
|||
|
|
|
|||
|
|
The authoritative peer originates every set change, so both sides converge on
|
|||
|
|
the same active set from the same announcement and stay in step across it. A
|
|||
|
|
one-shot `syncOnce` captures the set once; the next `syncOnce` call picks up any
|
|||
|
|
change.
|
|||
|
|
|
|||
|
|
## Transaction API
|
|||
|
|
|
|||
|
|
`transactionForUser` keeps its name and user-first semantics and gains an
|
|||
|
|
optional scope:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
db.transactionForUser(userId, fn); // acts in the personal scope
|
|||
|
|
db.transactionForUser(userId, fn, scopeId: listId); // acts in a shared scope
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Without `scopeId`, the scope resolves to the user's personal scope —
|
|||
|
|
`userId` itself by convention — which is exactly today's behavior.
|
|||
|
|
- With `scopeId`, the package asserts membership before acting by calling
|
|||
|
|
`CrdtScopeMembership.isMember` directly against the shared table — no injected
|
|||
|
|
validator. On the server that table is authoritative; on a persistent client
|
|||
|
|
it is the server-projected membership cache. After membership passes,
|
|||
|
|
shared-scope writes also resolve the member role; anything other than
|
|||
|
|
`readWrite` throws `CrdtScopeRoleException` before the transaction starts.
|
|||
|
|
- A transaction acts in **exactly one** scope (constraint 5). The write path —
|
|||
|
|
stamp-or-assert, the scoped `WHERE`, `scopeId` immutability — is unchanged
|
|||
|
|
beyond which scope is resolved.
|
|||
|
|
|
|||
|
|
## Read path: membership-wide
|
|||
|
|
|
|||
|
|
A user-scoped read in a sharing world means "rows in any scope I am a member
|
|||
|
|
of". The single-scope resolver becomes a set:
|
|||
|
|
|
|||
|
|
- `mergeWhereWithTombstone` isolates scoped reads with `scopeId IN (<member
|
|||
|
|
scope ids>)` through `CrdtScopeIdsResolver`. For writes, the resolver supplies
|
|||
|
|
the single acting scope. For reads, both server sessions and persistent
|
|||
|
|
clients resolve the set from `CrdtScopeMembership.memberScopes`
|
|||
|
|
(authoritative on the server, projected on the client); unscoped admin reads
|
|||
|
|
still pass no scope filter.
|
|||
|
|
- The visibility probe is the row-keyed form for the multi-scope case, so
|
|||
|
|
sharing simplifies the predicate matrix rather than growing it: reads differ
|
|||
|
|
only in their membership filter (one scope, a user's scopes, or none for
|
|||
|
|
admin).
|
|||
|
|
- This is the shape of a Postgres row-level-security policy
|
|||
|
|
(`scopeId IN (SELECT scopeId FROM crdt_scope_members WHERE userUuid = …)`); the
|
|||
|
|
package's application-level filter and RLS become two enforcements of one
|
|||
|
|
predicate.
|
|||
|
|
|
|||
|
|
Writes stay scope-pinned (above); only reads widen.
|
|||
|
|
|
|||
|
|
## What sharing does not change
|
|||
|
|
|
|||
|
|
Every enforcement rule in `row-ownership.md` survives unchanged:
|
|||
|
|
`(table, uuidRowId)` stays globally identified; rows have exactly one owning
|
|||
|
|
scope; merge-insert ownership checks compare scopes; unique conflict groups form
|
|||
|
|
within a scope; ordinary global unique indexes stay rejected; stamp-or-assert
|
|||
|
|
applies with the transaction's resolved scope; the durable
|
|||
|
|
`crdt_sync_integrity_violations` contract now also records unauthorized role
|
|||
|
|
writes. The merge, FK, and unique engines already key on the scope the merge
|
|||
|
|
runs in and need no ownership changes for sharing.
|
|||
|
|
|
|||
|
|
## Implementation status
|
|||
|
|
|
|||
|
|
The implementation landed in independently green phases. Tests should continue
|
|||
|
|
to run with `--concurrency=1`.
|
|||
|
|
|
|||
|
|
- **Phase 1 — membership foundation.** Implemented `crdt_scope_members`
|
|||
|
|
(`database: all`, unsynced) and its migration. Added the shared
|
|||
|
|
`CrdtScopeMembership` helpers (`memberScopes` / `isMember`). No behavior change
|
|||
|
|
yet: a personal-scope-only set reproduces today's single-scope sync exactly.
|
|||
|
|
- **Phase 2 — sequential multi-scope sync (the requirement).** Implemented
|
|||
|
|
protocol model
|
|||
|
|
changes (`Connect` with session `localNodeId`, new per-cycle
|
|||
|
|
`CrdtSyncScopeSet`, per-scope `SinceHlc`, `MergeChunk.uuidScopeId`) plus
|
|||
|
|
generate/migrate. Outer cycle loop in `CrdtSync.sync` with per-cycle
|
|||
|
|
`ScopeSet` exchange, per-scope handshake-on-first-visit, in-memory per-scope
|
|||
|
|
checkpoints, and the once/continuous cadence. Reconciliation via a
|
|||
|
|
`CrdtSyncPeerMode` enum: the server endpoint passes `authoritative` (cycles
|
|||
|
|
`CrdtScopeMembership.memberScopes`); the client driver passes `follower`
|
|||
|
|
(adopts the server's set). Scope-aware `onMergeSuccess`. A personal-scope-only
|
|||
|
|
device behaves identically to today.
|
|||
|
|
- **Phase 3 — transaction API.** Implemented
|
|||
|
|
`transactionForUser(userId, fn, {scopeId})` with the membership assertion;
|
|||
|
|
one scope per transaction.
|
|||
|
|
- **Phase 4 — membership-wide reads.** Implemented the scoped read filter as
|
|||
|
|
`scopeId IN (…)` over the user's member scopes.
|
|||
|
|
- **Phase 5 — lifecycle and docs.** Documented client adoption of newly
|
|||
|
|
announced shares; reconcile with the scope-purge semantics in
|
|||
|
|
`sync-non-sync-relations.md`.
|
|||
|
|
Revocation cleanup is deferred (see *Follow-ups*).
|
|||
|
|
- **Phase 6 — idle-silent sync, one method.** Reworked the single `sync` method
|
|||
|
|
(no separate once/continuous bodies) into a shared lockstep establishment plus
|
|||
|
|
a shared combined data loop: each cycle coalesces into one scoped-change batch
|
|||
|
|
terminated by a single `EndOfBatch`, with `ScopeSet`/`SinceHlc`/`MergeChunk`
|
|||
|
|
sent only on change and read via `collectNextBatch`. An idle continuous
|
|||
|
|
session now sends zero frames and idles out once per cycle regardless of scope
|
|||
|
|
count (Phase 2's loop re-announced the set and an `EndOfBatch` per scope every
|
|||
|
|
tick). `once` runs one data cycle then closes; mid-session continuous scopes are
|
|||
|
|
deferred until their `SinceHlc` round-trips, and inbound frames are honored
|
|||
|
|
only for authorized scopes.
|
|||
|
|
- **Phase 7 — roles within a scope.** Implemented the closed
|
|||
|
|
`CrdtScopeRole` enum (`readOnly`, `readWrite`) on server and client.
|
|||
|
|
`readWrite` is the only shared-scope write grant. Added authoritative inbound
|
|||
|
|
enforcement that records
|
|||
|
|
`unauthorizedWrite` and fails before merge, client-side
|
|||
|
|
`CrdtScopeRoleException` early rejection, and follower outbound skipping for
|
|||
|
|
non-writable shared scopes while reads remain membership-wide.
|
|||
|
|
|
|||
|
|
## Test plan
|
|||
|
|
|
|||
|
|
Because no code branches on scope count or deployment role, mechanisms are
|
|||
|
|
tested once, not per combination.
|
|||
|
|
|
|||
|
|
- **Regression.** Every existing single-scope suite passes unchanged on the
|
|||
|
|
personal-scope-only path.
|
|||
|
|
- **Protocol.** `Connect`/`SinceHlc` round-trip a multi-scope list; merge chunks
|
|||
|
|
route to the correct scope by `uuidScopeId`; per-scope checkpoints advance
|
|||
|
|
independently across cycles.
|
|||
|
|
- **One call, every scope.** A single `syncOnce` converges N scopes in one pass;
|
|||
|
|
a continuous session keeps cycling. Outbound changes for the active scopes are
|
|||
|
|
collected in one pass and streamed in `syncBatchSize` chunks; the receive
|
|||
|
|
groups a cycle's inbound chunks by scope before merging each in its own
|
|||
|
|
transaction.
|
|||
|
|
- **Idle silence.** A continuous session with no pending changes settles and then
|
|||
|
|
emits no further frames — neither peer re-announces the scope set nor sends an
|
|||
|
|
`EndOfBatch` while idle.
|
|||
|
|
- **Membership.** The server syncs personal + shared scopes; a client-announced
|
|||
|
|
non-member scope is refused and never streamed; a newly granted scope is
|
|||
|
|
adopted and syncs within the same `syncOnce` call.
|
|||
|
|
- **Roles.** `readOnly` can still read shared rows, local writes fail before
|
|||
|
|
mutation, `readWrite` syncs normally, a follower skips outbound pending changes
|
|||
|
|
for non-writable scopes while still receiving inbound changes, and a stale or
|
|||
|
|
hostile client write is rejected and recorded by the server as
|
|||
|
|
`unauthorizedWrite`. Personal scopes remain writable through their explicit
|
|||
|
|
`readWrite` grant.
|
|||
|
|
- **Access changes mid-session.** During a continuous session, a grant added to
|
|||
|
|
`crdt_scope_members` appears and syncs on the next cycle; a revoke stops
|
|||
|
|
cycling that scope without dropping the session.
|
|||
|
|
- **Isolation under sharing.** Two users sharing scope X both converge on X
|
|||
|
|
while their personal scopes stay isolated; an ownership collision in one scope
|
|||
|
|
records a durable violation and fails the session without corrupting another
|
|||
|
|
scope's merge.
|
|||
|
|
- **Smoke.** One `withServerpod` round-trip with one personal plus one shared
|
|||
|
|
scope, proving the end-to-end wiring (mirrors `sync_flow_test.dart`).
|
|||
|
|
|
|||
|
|
## Resolved during review
|
|||
|
|
|
|||
|
|
- **Combined sync batch:** merges serialize regardless, so combining scopes is
|
|||
|
|
not a throughput optimization. Both cadences use the same combined batch shape:
|
|||
|
|
each change carries its `uuidScopeId`, and receive groups changes by scope
|
|||
|
|
before merging each scope group. Continuous mode omits idle frames so idle
|
|||
|
|
latency is constant in scope count.
|
|||
|
|
- **Membership authority:** the server, from `crdt_scope_members`. The
|
|||
|
|
reconciliation is directional — server dictates, client conforms — and is
|
|||
|
|
neither union (insecure) nor intersection (cannot adopt). It is a
|
|||
|
|
**`CrdtSyncPeerMode` enum** (`authoritative` / `follower`): membership still
|
|||
|
|
resolves through `CrdtScopeMembership`, so the only residual variation is
|
|||
|
|
cycle direction — a closed enum, not a callback. The enum is not the security
|
|||
|
|
boundary; server-side
|
|||
|
|
`CrdtScopeMembership.isMember` gates every applied write regardless of the
|
|||
|
|
client's declared mode.
|
|||
|
|
- **Scope set is dynamic, not pinned at connect:** the authoritative peer
|
|||
|
|
re-resolves its set every cycle and re-announces a `ScopeSet` whenever it
|
|||
|
|
changed, so grants and revokes take effect mid-session without reconnecting.
|
|||
|
|
In `syncOnce` the client adopts from the initial `ScopeSet`, exchanges
|
|||
|
|
`SinceHlc`, then runs the sendable data cycle; in `syncContinuously` a newly
|
|||
|
|
announced scope is adopted then established over the next cycles as its
|
|||
|
|
`SinceHlc` round-trips.
|
|||
|
|
- **Members vs. nodes:** orthogonal layers, both kept. Members are authorization
|
|||
|
|
(`who`); nodes are CRDT causality (`whose changes, up to which HLC`). One
|
|||
|
|
member maps to many nodes; the server is a node but not a member; a new member
|
|||
|
|
has no node until first sync.
|
|||
|
|
- **Client membership table:** `database: all` puts the table on the client,
|
|||
|
|
populated as a read-only projection. The `CrdtSyncScopeSet` carries
|
|||
|
|
`CrdtScopeGrant`s (`scopeUuid` + `role`), and each follower cycle reconciles
|
|||
|
|
the local table from the announcement (upsert granted, delete revoked; only
|
|||
|
|
when the set changed). The projected members table drives shared-scope
|
|||
|
|
membership gating and roles for UI and offline role-gated writes; local
|
|||
|
|
`crdt_scopes` rows alone do not grant access. It is a server-authored,
|
|||
|
|
client-read-only projection — never a
|
|||
|
|
bidirectionally-synced members table, so a client cannot self-grant.
|
|||
|
|
- **Iteration order:** sorted scope UUIDs, so both peers stay in lockstep
|
|||
|
|
without negotiation. Each merge change carries its scope UUID, so receive can
|
|||
|
|
regroup a combined batch deterministically.
|
|||
|
|
- **One scope per transaction:** kept. Cross-scope writes need separate
|
|||
|
|
transactions; remote replicas cannot observe a cross-scope write atomically
|
|||
|
|
anyway.
|
|||
|
|
- **Membership table is `database: all` but unsynced:** the schema exists on
|
|||
|
|
every node, yet it is server-authoritative app state, never CRDT-replicated.
|
|||
|
|
- **Idle chatter accepted for the initial implementation:** an idle continuous
|
|||
|
|
cycle does one empty scope-turn per accessible scope rather than one per
|
|||
|
|
session. It is proportional to scope count and harmless to start; skipping
|
|||
|
|
scopes with no local pending changes and no expected inbound is an optional
|
|||
|
|
later optimization, not a launch blocker.
|
|||
|
|
- **Initial-sync ordering is not a current concern:** a device joining many
|
|||
|
|
scopes cycles them one at a time on first sync. Prioritizing recently active
|
|||
|
|
scopes in the iteration order is a compatible refinement if cold-start latency
|
|||
|
|
over large scope counts ever matters, but it is not a present worry.
|
|||
|
|
|
|||
|
|
## Follow-ups
|
|||
|
|
|
|||
|
|
Deferred work needing its own design pass. It does not block the implemented
|
|||
|
|
role enforcement above.
|
|||
|
|
|
|||
|
|
1. **Membership revocation.** When a user loses membership, the server omits the
|
|||
|
|
scope from the next cycle's `ScopeSet` and both sides stop syncing it
|
|||
|
|
promptly. The device may still store the scope's data and unsynced local
|
|||
|
|
changes, though membership-wide reads no longer expose revoked rows.
|
|||
|
|
Purge-vs-keep policy, and whether revoked or demoted unsynced changes are
|
|||
|
|
surfaced, dropped, or kept pending locally, need their own design. (Carried
|
|||
|
|
from `row-ownership.md` open question 3.)
|
|||
|
|
2. **Outbound collection consistency.** Collection reads CRDT metadata and the
|
|||
|
|
domain data separately and lock-free, so a concurrent delete — or an FK
|
|||
|
|
reference to a row inserted after the insert snapshot — can fail a healthy
|
|||
|
|
stream sync and record a durable violation for a race that the next round
|
|||
|
|
would resolve. The single-pass multi-scope collection widened this window.
|
|||
|
|
Failing the session for a transient, self-correcting race is the weakness to
|
|||
|
|
fix; the leading direction is a read-only snapshot (MVCC, not a lock). See
|
|||
|
|
`outbound-collection-consistency.md`.
|