serverpod-offline-sync/docs/shared-spaces.md
2026-10-08 20:32:24 -03:00

35 KiB
Raw Blame History

Shared spaces

Status: implemented. This records the shared-space 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 space is the unit of replication and ownership. Each user has a personal space and may also belong to any number of shared spaces. The load-bearing requirement is:

A single sync call syncs every space the authenticated user has access to. One syncOnce cycles through all accessible spaces exactly once; a continuous session keeps cycling forever. There is no separate stream or separate call per space.

The row-ownership work keys storage, locking, HLC chains, visibility, and ownership by space, not user, so sharing is an indirection in front of offline_sync_spaces, not a re-architecture. The implemented pieces are a server-side membership relation and a sync protocol that iterates spaces.

Merges serialize one space at a time. A merge runs in exactly one space's transaction and lock, so spaces always serialize on the merge step regardless of framing. A cycle's outbound changes are instead collected in a single pass over the active spaces and carried in one combined batch, each change tagged with its uuidSpaceId; the receiver regroups by space and merges each group in its own transaction. The wire stays a near-verbatim repetition of today's frames, now space-tagged.

The server is the authority on membership; the client follows. The set of spaces a session syncs is computed server-side from offline_sync_space_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 spaces 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 spaces is a separate revocation policy, not part of the sync loop.

Row ownership is unchanged. Every row still has exactly one owning spaceId, (table, uuidRowId) stays globally identified, and every enforcement rule in row-ownership.md survives untouched. Sharing adds a membership layer in front of spaces; it does not touch row ownership.

Goals and constraints

  1. One call, every space. The public client API (syncOnce, syncContinuously) does not change shape: callers pass a session, never a space. A device with only a personal space behaves exactly as today.
  2. Smallest protocol delta. The exchanged frames are today's, plus a per-cycle space-set exchange and a space tag on each frame. collectNextBatch demultiplexes a cycle's combined batch, collectPendingChanges collects every active space in one pass, and mergeInboundBatch applies each inbound space group while atomically persisting received progress for every author.
  3. Chunked streaming. Outbound changes are collected in a single pass over the active spaces and streamed in syncBatchSize chunks, so the wire payload stays bounded regardless of space count. The collection step itself materializes a cycle's pending rows across the active spaces (one spaceId IN (…) query per change kind), so peak memory tracks a cycle's pending rows rather than being held strictly one space at a time. See outbound and rebuild behavior for snapshot guarantees and capture costs.
  4. Membership cannot be forged. The server computes its own space set from offline_sync_space_members (plus the implicit personal space) and never widens it from anything the client sends. A space the user is not a member of is never synced, even if the client names it.
  5. One space per transaction. Reads are membership-wide; writes stay pinned to exactly one space. This is deliberate honesty about CRDT semantics: two spaces' chains replicate independently and a remote replica can never observe a cross-space write atomically.
  6. Roles are closed CRDT access roles. The package stores and projects non-null OfflineSyncSpaceRole values in space grants and shared memberships: readWrite allows CRDT writes and readOnly blocks them. The implicit personal space is announced as a readWrite grant, so single-tenant servers and multi-account devices remain the same code path with different space counts.

Membership

The offline_sync_space_members table

class: OfflineSyncSpaceMember
table: offline_sync_space_members
database: all
fields:
  space: OfflineSyncSpace?, relation(onDelete=Cascade)
  userUuid: UuidValue
  role: OfflineSyncSpaceRole
indexes:
  offline_sync_space_member_unique_idx:
    fields: userUuid, spaceId
    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 through session.offlineSync.spaces (OfflineSyncSpaces). Invitations, acceptance, and authorization policy remain app domain.
  • Personal-space membership is implicit. By convention a user's personal space has the user's own UUID (already true: the space UUID keys the chain), so no offline_sync_space_members row is stored for it. Shared spaces are offline_sync_spaces 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-space writes.

Members vs. nodes

offline_sync_space_members and the nodes list on a space are orthogonal layers and both stay:

  • A member is an authorization fact: a user identity allowed to access a space. It answers who may sync, read, and write.
  • A node (crdt_nodes) is a stable CRDT replica identity. A space's nodes relation points at offline_sync_space_nodes, which records that replica's participation and checkpoint state in that space. Together they answer whose changes, up to which HLC and are what per-(space, 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 space it syncs but is not a member row. A freshly invited member has zero space-node rows until their first device syncs. Nodes and space-node rows are created implicitly by space initialization and inbound merge; membership rows are managed through session.offlineSync.spaces from application endpoints. Removing the nodes list would break checkpointing and causal filtering — it is the chain topology, not the access list.

The client membership table

offline_sync_space_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 offline_sync_space_members rows plus the implicit personal space are its membership view for membership gating: a membership-wide read filters over projected membership, and a local offline_sync_spaces row by itself does not grant access. The members table also carries roles, which the client consults before local writes.
  • The authoritative OfflineSyncSpaceSet carries OfflineSyncSpaceGrants — (uuidSpaceId, role) — not bare UUIDs. Every grant has an explicit role; the personal space 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 space is skipped), delete rows whose space 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 space from a cycle's SpaceSet: the client stops cycling it and discards in-memory checkpoint state for it. The space 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:

enum OfflineSyncSpaceRole {
  readOnly,
  readWrite,
}

Only readWrite allows writes. readOnly denies writes. The implicit personal space 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 space group, it resolves the authenticated user's role from offline_sync_space_members; if the role is not readWrite, the server records OfflineSyncIntegrityViolation(type: unauthorizedWrite) and fails the sync session before opening the merge transaction for that space. 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, spaceId: sharedSpace) resolves the projected role and throws OfflineSyncSpaceRoleException before any local write when the role cannot write. A follower also omits non-writable shared spaces from outbound pending-change collection while still receiving inbound changes for those spaces. This avoids repeatedly streaming local read-only changes; the server enforcement remains the security boundary for stale, misconfigured, or hostile clients.

Space enumeration (server is the authority)

At the start of each sync cycle the server resolves the authoritative set:

memberGrants(userUuid) =
    { personal space = userUuid, role = readWrite }
  ∪ { (s.uuidSpaceId, m.role)
      : offline_sync_space_members m
        join offline_sync_spaces s on s.id = m.spaceId
        where m.userUuid = <auth user> }

This grant set bounds everything the cycle may sync and carries the role used by followers for outbound filtering. The client never expands it; it only learns of additions through what the server announces.

Sync: one call syncs every accessible space

One sync, two cadences: bounded once, idle-silent continuous

Merge is inherently one space at a time: a merge runs in exactly one space's transaction and lock, so the spaces 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 SpaceSet. 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 space announcement (only when this peer's grants changed), a SinceHlc for each newly active space, and merge chunks whose changes carry their space, 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 space count.

once stays in the data loop until every active space 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 spaces in the first cycle and absorbs membership changes as they are announced (a newly granted space 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 space; continuous: only on change, inside one combined batch). Frame changes (regenerated models + a migration on both client and server schemas):

  • OfflineSyncConnect { syncTablesHash, localNodeId } — sent once by each peer. The schema hash is validated once, before any space work. localNodeId identifies this peer's CRDT node for the whole session. The space list is dynamic and exchanged per cycle.
  • OfflineSyncSpaceSet { spaces: List<OfflineSyncSpaceGrant> } (each grant a uuidSpaceId + role) — the server's content is authoritative (resolved from offline_sync_space_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.
  • OfflineSyncSinceHlc { uuidSpaceId, nodeCheckpoints } — per space. Carries that space's per-node checkpoints. Exchanged when a space first becomes active (and again if a dropped space is later re-adopted); checkpoints then live in memory for the rest of the session.
  • OfflineSyncMergeChunk { changes } — each change carries uuidSpaceId, letting a combined batch carry every space's changes and regroup them on receive.
  • OfflineSyncEndOfBatch, OfflineSyncClose, OfflineSyncIdleTimeout — 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 { spaceSet?, 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 SpaceSet(myGrants); read peer SpaceSet → adopt → activeSpaces
  follower: materialize activeSpaces, project membership
data loop (both cadences, one combined batch per cycle):
  loop:
    resolve activeSpaces      (authoritative: my set; follower: adopted peerGrants)
    discard state for spaces no longer active
    # send phase — emit only what changed; track whether any frame was sent
    if myGrants != announcedGrants: send SpaceSet(myGrants)
    for space in activeSpaces not yet sinceHlcSent: send SinceHlc(space)
    if peer checkpoints exist: send MergeChunk(changes)*  # follower skips non-writable spaces
    if anything was sent (or once): send EndOfBatch
    # receive phase
    batch = collectNextBatch(allowCloseBeforeBatch: !once)   # {spaceSet?, sinceHlcs, changes} | idle | closed
    if closed: return
    adopt batch.spaceSet (follower: materialize + project); store sinceHlcs;
      group changes by uuidSpaceId; mergeInboundBatch each group
    if once: send Close; read Close; drain; return
    delay continuousSyncInterval

The shared establishment exchanges the space set so a follower never acts before it knows the agreed set. Both cadences handshake initial and newly added spaces through the loop's own SinceHlc exchange.

collectPendingChanges, mergeInboundBatch, and the ownership-violation internals (_streamInserts/Updates/Deletes, _fetchDomainRow, the durable-violation path) are space-parameterized and merge each inbound space group in its own transactionForUser/lock, including its per-author checkpoints; any space'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 space's data until both peers have exchanged its SinceHlc, a newly active space 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 spaces 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 space-aware: Function(UuidValue spaceUuid, Hlc hlc). This touches three test call sites and the example app's demo_controller.

Space-set reconciliation and lockstep

Both peers must cycle the same ordered space 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 space 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: orderedSpaces = sort(memberGrants(userUuid).uuidSpaceId), computed from offline_sync_space_members plus the implicit personal grant. It never reads the client's SpaceSet for authority (constraint 4).
  • Client: takes the server's SpaceSet as the set to cycle — getOrCreate (OfflineSyncSpaceManager) any space it lacks, ignore any local space the server omitted for this cycle — then orderedSpaces = sort(peer spaceIds).

This asymmetry is expressed by a OfflineSyncPeerMode 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 offline_sync_space_members table (via OfflineSyncSpaceMembership); 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 OfflineSyncSpaceMembership.roleOf(...).canWrite against the authoritative table.

In once, the client adopts straight from the cycle's SpaceSet before it iterates, so a newly granted space syncs in that same round. In continuous, adoption happens when the announcement arrives and the space's data follows over the next cycles once its SinceHlc round-trips. Sorting by space UUID makes the order deterministic on both sides with no extra negotiation. Frames carry uuidSpaceId so a receiver groups each chunk into the right space and fails loud on an unauthorized tag rather than merging a batch into the wrong space.

Re-resolving memberGrants every cycle is an indexed offline_sync_space_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 (space, node) in offline_sync_space_nodes, while crdt_nodes stores the stable replica identity. The same local replica node can participate in multiple spaces, with separate lastReceivedHlc values per space. SinceHlc is exchanged once per space on first visit; later cycles reuse the in-memory nodeCheckpoints[space], advanced as chunks are sent and as inbound batches merge — the multi-space analogue of today's single-space continuous loop, which already keeps checkpoints in memory across rounds.

The continuous interval delay moves to after each full cycle, not after each space, to keep per-space latency low while still preventing a busy loop when idle.

Access changes during a session

Because the authoritative space 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 SpaceSet that includes the new space; 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 SpaceSet that omits the space; 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 space'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 space 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 space:

db.transactionForUser(userId, fn);                  // acts in the personal space
db.transactionForUser(userId, fn, spaceId: listId); // acts in a shared space
  • Without spaceId, the space resolves to the user's personal space — userId itself by convention — which is exactly today's behavior.
  • With spaceId, the package resolves the member role with OfflineSyncSpaceMembership.roleOf — no injected validator. On the server that table is authoritative; on a persistent client it is the server-projected membership cache. A missing role throws OfflineSyncSpaceMembershipException; anything other than readWrite throws OfflineSyncSpaceRoleException before the transaction starts.
  • A transaction acts in exactly one space (constraint 5). The write path — stamp-or-assert, the space-scoped WHERE, spaceId immutability — is unchanged beyond which space is resolved.

Read path: membership-wide

A user-space-scoped read in a sharing world means "rows in any space I am a member of". The single-space resolver becomes a set:

  • mergeWhereWithTombstone isolates space-scoped reads with spaceId IN (<member space ids>) through OfflineSyncSpaceIdsResolver. For writes, the resolver supplies the single acting space. For reads, both server sessions and persistent clients resolve the set from OfflineSyncSpaceMembership.memberSpaces (authoritative on the server, projected on the client); unscoped admin reads still pass no space filter.
  • The visibility probe is the row-keyed form for the multi-space case, so sharing simplifies the predicate matrix rather than growing it: reads differ only in their membership filter (one space, a user's spaces, or none for admin).
  • This is the shape of a Postgres row-level-security policy (spaceId IN (SELECT spaceId FROM offline_sync_space_members WHERE userUuid = …)); the package's application-level filter and RLS become two enforcements of one predicate.

Writes stay space-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 space; merge-insert ownership checks compare spaces; unique conflict groups form within a space; ordinary global unique indexes stay rejected; stamp-or-assert applies with the transaction's resolved space; the durable offline_sync_integrity_violations contract now also records unauthorized role writes. The merge, FK, and unique engines already key on the space 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 offline_sync_space_members (database: all, unsynced) and its migration. Added the shared OfflineSyncSpaceMembership helpers (memberGrants, memberSpaces, roleOf, isMember, and the follower projection path). No behavior change yet: a personal-space-only set reproduces today's single-space sync exactly.
  • Phase 2 — sequential multi-space sync (the requirement). Implemented protocol model changes (Connect with session localNodeId, new per-cycle OfflineSyncSpaceSet, per-space SinceHlc, MergeChunk.uuidSpaceId) plus generate/migrate. Outer cycle loop in OfflineSyncEngine.sync with per-cycle SpaceSet exchange with role-carrying OfflineSyncSpaceGrants, per-space handshake-on-first-visit, in-memory per-space checkpoints, and the once/continuous cadence. Reconciliation via a OfflineSyncPeerMode enum: the server endpoint passes authoritative (cycles OfflineSyncSpaceMembership.memberGrants); the client driver passes follower (adopts the server's set). Space-aware onMergeSuccess. A personal-space-only device behaves identically to today.
  • Phase 3 — transaction API. Implemented transactionForUser(userId, fn, {spaceId}) with the membership assertion; one space per transaction.
  • Phase 4 — membership-wide reads. Implemented the space-scoped read filter as spaceId IN (…) over the user's member spaces.
  • Phase 5 — lifecycle and docs. Documented client adoption of newly announced shares; reconcile with the space-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 space-scoped-change batch terminated by a single EndOfBatch, with SpaceSet/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 space count (Phase 2's loop re-announced the set and an EndOfBatch per space every tick). once runs one data cycle then closes; mid-session continuous spaces are deferred until their SinceHlc round-trips, and inbound frames are honored only for authorized spaces.
  • Phase 7 — roles within a space. Implemented the closed OfflineSyncSpaceRole enum (readOnly, readWrite) on server and client. readWrite is the only shared-space write grant. Added authoritative inbound enforcement that records unauthorizedWrite and fails before merge, client-side OfflineSyncSpaceRoleException early rejection, and follower outbound skipping for non-writable shared spaces while reads remain membership-wide.
  • Phase 8 — space management service. Implemented the server-side session.offlineSync.spaces service (create, createFor, grant, grantAll, revoke, members) with transaction/savepoint threading. Single grants reuse the bulk-grant path; unknown-space grants throw OfflineSyncSpaceNotFoundException. Invitations, acceptance, and authorization policy stay in app endpoints.

Test plan

Because no code branches on space count or deployment role, mechanisms are tested once, not per combination.

  • Regression. Every existing single-space suite passes unchanged on the personal-space-only path.
  • Protocol. Connect/SinceHlc round-trip a multi-space list; merge chunks route to the correct space by uuidSpaceId; per-space checkpoints advance independently across cycles.
  • One call, every space. A single syncOnce converges N spaces in one pass; a continuous session keeps cycling. Outbound changes for the active spaces are collected in one pass and streamed in syncBatchSize chunks; the receive groups a cycle's inbound chunks by space 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 space set nor sends an EndOfBatch while idle.
  • Membership. The server syncs personal + shared spaces; a client-announced non-member space is refused and never streamed; a newly granted space 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 spaces while still receiving inbound changes, and a stale or hostile client write is rejected and recorded by the server as unauthorizedWrite. Personal spaces remain writable through their implicit readWrite grant.
  • Access changes mid-session. During a continuous session, a grant added to offline_sync_space_members appears and syncs on the next cycle; a revoke stops cycling that space without dropping the session.
  • Isolation under sharing. Two users sharing space X both converge on X while their personal spaces stay isolated; an ownership collision in one space records a durable violation and fails the session without corrupting another space's merge.
  • Smoke. One withServerpod round-trip with one personal plus one shared space, proving the end-to-end wiring (mirrors sync_flow_test.dart).

Resolved during review

  • Combined sync batch: merges serialize regardless, so combining spaces is not a throughput optimization. Both cadences use the same combined batch shape: each change carries its uuidSpaceId, and receive groups changes by space before merging each space group. Continuous mode omits idle frames so idle latency is constant in space count.
  • Membership authority: the server, from offline_sync_space_members. The reconciliation is directional — server dictates, client conforms — and is neither union (insecure) nor intersection (cannot adopt). It is a OfflineSyncPeerMode enum (authoritative / follower): membership still resolves through OfflineSyncSpaceMembership, so the only residual variation is cycle direction — a closed enum, not a callback. The enum is not the security boundary; server-side OfflineSyncSpaceMembership.roleOf(...).canWrite gates every applied write regardless of the client's declared mode.
  • Space set is dynamic, not pinned at connect: the authoritative peer re-resolves its set every cycle and re-announces a SpaceSet whenever it changed, so grants and revokes take effect mid-session without reconnecting. In syncOnce the client adopts from the initial SpaceSet, exchanges SinceHlc, then runs the sendable data cycle; in syncContinuously a newly announced space 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 OfflineSyncSpaceSet carries OfflineSyncSpaceGrants (uuidSpaceId + 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-space membership gating and roles for UI and offline role-gated writes; local offline_sync_spaces 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 space UUIDs, so both peers stay in lockstep without negotiation. Each merge change carries its space UUID, so receive can regroup a combined batch deterministically.
  • One space per transaction: kept. Cross-space writes need separate transactions; remote replicas cannot observe a cross-space 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 removed: continuous sync has one combined data loop per cycle. When neither peer has grants, handshakes, or changes to send, it emits no SpaceSet, SinceHlc, MergeChunk, or EndOfBatch; collectNextBatch resolves the idle cycle from the timeout once per session cycle, not once per space.
  • Initial-sync ordering is not a current concern: a device joining many spaces cycles them one at a time on first sync. Prioritizing recently active spaces in the iteration order is a compatible refinement if cold-start latency over large space 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 space from the next cycle's SpaceSet and both sides stop syncing it promptly. The device may still store the space'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.)