35 KiB
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
synccall syncs every space the authenticated user has access to. OnesyncOncecycles 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_membersand 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 inrow-ownership.mdsurvives untouched. Sharing adds a membership layer in front of spaces; it does not touch row ownership.
Goals and constraints
- 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. - Smallest protocol delta. The exchanged frames are today's, plus a
per-cycle space-set exchange and a space tag on each frame.
collectNextBatchdemultiplexes a cycle's combined batch,collectPendingChangescollects every active space in one pass, andmergeInboundBatchapplies each inbound space group while atomically persisting received progress for every author. - Chunked streaming. Outbound changes are collected in a single pass over
the active spaces and streamed in
syncBatchSizechunks, so the wire payload stays bounded regardless of space count. The collection step itself materializes a cycle's pending rows across the active spaces (onespaceId 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. - 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. - 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.
- Roles are closed CRDT access roles. The package stores and projects
non-null
OfflineSyncSpaceRolevalues in space grants and shared memberships:readWriteallows CRDT writes andreadOnlyblocks them. The implicit personal space is announced as areadWritegrant, 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: allbut not insyncTables. 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
userUuidso the per-cycleWHERE 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_membersrow is stored for it. Shared spaces areoffline_sync_spacesrows whose UUID is no user's id, with explicit membership rows. roleis a required CRDT access enum. Shared memberships always store an explicit role:readWriteallows writes, whilereadOnlydenies 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'snodesrelation points atoffline_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_membersrows plus the implicit personal space are its membership view for membership gating: a membership-wide read filters over projected membership, and a localoffline_sync_spacesrow by itself does not grant access. The members table also carries roles, which the client consults before local writes. - The authoritative
OfflineSyncSpaceSetcarriesOfflineSyncSpaceGrants —(uuidSpaceId, role)— not bare UUIDs. Every grant has an explicit role; the personal space is announced asreadWritebut remains implicit in storage. When the follower receives an announcement,projectFollowerMembershipreconciles 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:
- Establishment (shared, lockstep). After
Connect, both peers exchange theSpaceSet. 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. - Data loop (shared, idle-silent). Each cycle is one combined batch: the
space announcement (only when this peer's grants changed), a
SinceHlcfor each newly active space, and merge chunks whose changes carry their space, all closed by a singleEndOfBatch. 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.localNodeIdidentifies this peer's CRDT node for the whole session. The space list is dynamic and exchanged per cycle.OfflineSyncSpaceSet { spaces: List<OfflineSyncSpaceGrant> }(each grant auuidSpaceId+role) — the server's content is authoritative (resolved fromoffline_sync_space_members, roles included). Followers send an empty set because the authoritative peer never widens access from follower-reported state. Inonceit is exchanged exactly once. Incontinuousa 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 carriesuuidSpaceId, letting a combined batch carry every space's changes and regroup them on receive.OfflineSyncEndOfBatch,OfflineSyncClose,OfflineSyncIdleTimeout— unchanged. A singleEndOfBatchterminates a cycle's combined batch (and is itself omitted when nothing was sent, so an idle peer resolves to an empty batch instead).Closeends 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 fromoffline_sync_space_membersplus the implicit personal grant. It never reads the client'sSpaceSetfor authority (constraint 4). - Client: takes the server's
SpaceSetas the set to cycle —getOrCreate(OfflineSyncSpaceManager) any space it lacks, ignore any local space the server omitted for this cycle — thenorderedSpaces = 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
SpaceSetthat 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
SpaceSetthat omits the space; both sides stop cycling it and discard its in-memory checkpoint state, and the follower'sprojectFollowerMembershipdeletes 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 —userIditself by convention — which is exactly today's behavior. - With
spaceId, the package resolves the member role withOfflineSyncSpaceMembership.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 throwsOfflineSyncSpaceMembershipException; anything other thanreadWritethrowsOfflineSyncSpaceRoleExceptionbefore the transaction starts. - A transaction acts in exactly one space (constraint 5). The write path —
stamp-or-assert, the space-scoped
WHERE,spaceIdimmutability — 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:
mergeWhereWithTombstoneisolates space-scoped reads withspaceId IN (<member space ids>)throughOfflineSyncSpaceIdsResolver. For writes, the resolver supplies the single acting space. For reads, both server sessions and persistent clients resolve the set fromOfflineSyncSpaceMembership.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 sharedOfflineSyncSpaceMembershiphelpers (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 (
Connectwith sessionlocalNodeId, new per-cycleOfflineSyncSpaceSet, per-spaceSinceHlc,MergeChunk.uuidSpaceId) plus generate/migrate. Outer cycle loop inOfflineSyncEngine.syncwith per-cycleSpaceSetexchange with role-carryingOfflineSyncSpaceGrants, per-space handshake-on-first-visit, in-memory per-space checkpoints, and the once/continuous cadence. Reconciliation via aOfflineSyncPeerModeenum: the server endpoint passesauthoritative(cyclesOfflineSyncSpaceMembership.memberGrants); the client driver passesfollower(adopts the server's set). Space-awareonMergeSuccess. 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
syncmethod (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 singleEndOfBatch, withSpaceSet/SinceHlc/MergeChunksent only on change and read viacollectNextBatch. 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 anEndOfBatchper space every tick).onceruns one data cycle then closes; mid-session continuous spaces are deferred until theirSinceHlcround-trips, and inbound frames are honored only for authorized spaces. - Phase 7 — roles within a space. Implemented the closed
OfflineSyncSpaceRoleenum (readOnly,readWrite) on server and client.readWriteis the only shared-space write grant. Added authoritative inbound enforcement that recordsunauthorizedWriteand fails before merge, client-sideOfflineSyncSpaceRoleExceptionearly 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.spacesservice (create,createFor,grant,grantAll,revoke,members) with transaction/savepoint threading. Single grants reuse the bulk-grant path; unknown-space grants throwOfflineSyncSpaceNotFoundException. 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/SinceHlcround-trip a multi-space list; merge chunks route to the correct space byuuidSpaceId; per-space checkpoints advance independently across cycles. - One call, every space. A single
syncOnceconverges N spaces in one pass; a continuous session keeps cycling. Outbound changes for the active spaces are collected in one pass and streamed insyncBatchSizechunks; 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
EndOfBatchwhile 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
syncOncecall. - Roles.
readOnlycan still read shared rows, local writes fail before mutation,readWritesyncs 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 asunauthorizedWrite. Personal spaces remain writable through their implicitreadWritegrant. - Access changes mid-session. During a continuous session, a grant added to
offline_sync_space_membersappears 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
withServerpodround-trip with one personal plus one shared space, proving the end-to-end wiring (mirrorssync_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 aOfflineSyncPeerModeenum (authoritative/follower): membership still resolves throughOfflineSyncSpaceMembership, so the only residual variation is cycle direction — a closed enum, not a callback. The enum is not the security boundary; server-sideOfflineSyncSpaceMembership.roleOf(...).canWritegates 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
SpaceSetwhenever it changed, so grants and revokes take effect mid-session without reconnecting. InsyncOncethe client adopts from the initialSpaceSet, exchangesSinceHlc, then runs the sendable data cycle; insyncContinuouslya newly announced space is adopted then established over the next cycles as itsSinceHlcround-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: allputs the table on the client, populated as a read-only projection. TheOfflineSyncSpaceSetcarriesOfflineSyncSpaceGrants (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; localoffline_sync_spacesrows 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: allbut 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, orEndOfBatch;collectNextBatchresolves 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.
- Membership revocation. When a user loses membership, the server omits the
space from the next cycle's
SpaceSetand 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 fromrow-ownership.mdopen question 3.)