feat: Implement the scope management interface (#96)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-07 11:48:19 -03:00 • committed by GitHub
commit d89118a43a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 755 additions and 227 deletions

View file

@ -1,29 +1,30 @@
# Scope management API
Status: proposal for a separate implementation issue. Builds on the shared-scope
Status: implemented. Builds on the shared-scope
membership model in `shared-scopes.md`; this document specifies the developer-
facing API for *managing* membership, which that document deliberately left as
"app domain".
facing API for *managing* membership while leaving invitations, acceptance, and
authorization policy in app domain.
## Summary
The shared-scope machinery is complete for **resolving and enforcing**
membership (`CrdtScopeMembership.memberScopes` / `memberGrants` / `roleOf` /
`isMember`, the sync-loop enforcement, membership-wide reads). It has no API for
**managing** membership — creating a shared scope, adding a member with a role,
changing a role, revoking. `shared-scopes.md` says so outright: *"Rows are
managed by application code: invitations, acceptance, and role assignment are app
domain, outside this package."*
`isMember`, the sync-loop enforcement, membership-wide reads), but those helpers
are package internals. The developer-facing API for **managing** membership is
the server-side `session.crdt.scopes` service: creating a shared scope, adding a
member with a role, changing a role, revoking, and listing explicit members.
`shared-scopes.md` records the same boundary: rows are managed through
`session.crdt.scopes`, while invitations, acceptance, and authorization policy
remain app domain.
Today a developer must replicate what `scope_membership_test.dart` does — two
non-atomic raw `insertRow` calls against the **server** package's generated
`CrdtScope` / `CrdtScopeMember`, leaking every internal invariant
(`scopeId` vs `uuidScopeId`, the non-null `role` contract, the unique index,
personal-scope-UUID == user-UUID). This proposal replaces that with a small,
honest server-side service:
The service avoids making developers replicate what `scope_membership_test.dart`
used to do — two non-atomic raw `insertRow` calls against the **server**
package's generated `CrdtScope` / `CrdtScopeMember`, leaking every internal
invariant (`scopeId` vs `uuidScopeId`, the non-null `role` contract, the unique
index, personal-scope-UUID == user-UUID):
```dart
final scopeId = await session.crdt.scopes.create(owner: creatorUuid);
final scopeId = await session.crdt.scopes.createFor(creatorUuid);
await session.crdt.scopes.grant(scope: scopeId, user: bob, role: CrdtScopeRole.readWrite);
await session.crdt.scopes.revoke(scope: scopeId, user: bob);
```
@ -97,7 +98,7 @@ own only what it enforces.*
Everything uneasy traced back to `admin`: the forgeable `by`, the bypass, and a
"last-admin" orphan guard all evaporate once it leaves. `CrdtScopeRole` stays
`{ readOnly, readWrite }` — exactly what is already implemented — so this
proposal adds **no** enum churn.
implementation adds **no** enum churn.
Apps model "admin" with what they already have: a Serverpod `Scope` on the auth
user for global staff, or their own domain data for per-scope owners. If the
@ -109,26 +110,34 @@ who is an admin, display name, joined-at). The app keeps them in step — adding
domain member also calls `scopes.grant` — the same split Serverpod has between
`AuthUser` and an app's user profile.
## Proposed API
## API
Server-side, hung off the existing `session.crdt` (`CrdtSync`) accessor as
`session.crdt.scopes`. A pure data layer: atomic, transaction-threaded, no
internal auth.
Server-side, hung off the `session.crdt` accessor (the session-bound
`CrdtSession` facade) as `session.crdt.scopes`. A pure data layer: atomic,
transaction-threaded, no internal auth.
```dart
/// Creates a shared scope and returns its UUID.
///
/// [owner], if given, is granted [CrdtScopeRole.readWrite]. [grants] adds any
/// further members. Both are applied in one transaction with the scope insert.
/// With no owner and no grants the scope is created dormant — it exists but no
/// one can sync it until membership is granted. Throws [ArgumentError] if
/// [owner] also appears in [grants].
/// [grants], if given, adds each user as a member with its role, applied in
/// one transaction with the scope insert. With no grants the scope is created
/// dormant — it exists but no one can sync it until membership is granted.
Future<UuidValue> create({
UuidValue? owner,
Map<UuidValue, CrdtScopeRole>? grants,
Transaction? transaction,
});
/// Creates a shared scope granting [user] the given [role], in one
/// transaction.
///
/// Pure sugar for [create] with a single-entry grants map — the member has no
/// special bond to the scope and can be re-granted or revoked like any other.
Future<UuidValue> createFor(
UuidValue user, {
CrdtScopeRole role = CrdtScopeRole.readWrite,
Transaction? transaction,
});
/// Grants [user] the given [role] in [scope], upserting an existing membership.
Future<void> grant({
required UuidValue scope,
@ -151,14 +160,6 @@ Future<void> revoke({
Transaction? transaction,
});
/// The role [user] holds in [scope], or null if not a member. The read an app
/// wraps with its own admin policy before calling [grant] / [revoke].
Future<CrdtScopeRole?> roleOf({
required UuidValue user,
required UuidValue scope,
Transaction? transaction,
});
/// The members of [scope] and their roles.
Future<Map<UuidValue, CrdtScopeRole>> members(
UuidValue scope, {
@ -168,14 +169,20 @@ Future<Map<UuidValue, CrdtScopeRole>> members(
Notes:
- **`owner` is nullable** — a scope with no owner (and no grants) is valid but
dormant. `create` skips the membership insert when `owner` is null.
- **`owner` and `grants` compose.** `owner` is ergonomic sugar for
`{owner: readWrite}`; `grants` adds the rest. If `owner` also appears in
`grants`, throw `ArgumentError` rather than silently pick a winner — a creator
listed twice with a possibly-contradictory role is a caller bug.
- **There is no "owner".** An earlier sketch had an `owner` parameter on
`create`, but the name implied a durable bond the data model does not have —
the scope row stores no owner, and the creator ends up an ordinary
`readWrite` member. It was replaced by `createFor`, which is honest sugar:
create a scope and grant one member, atomically, with no special standing.
Removing `owner` also dissolved the owner-appears-in-`grants` `ArgumentError`
special case. A durable creator/owner concept is app domain, like `admin`.
- **`grants` may be empty or absent** — a scope with no members is valid but
dormant. `create()` skips the membership insert entirely.
- **`create` and `grantAll` share one internal helper** so the create path and
the later bulk-grant path have a single implementation.
- **Unknown-scope grants throw `CrdtScopeNotFoundException`** rather than a
generic argument error, so callers can distinguish a stale or invalid scope
UUID from other failures.
- **`create` returns a server-generated UUID** — the model already defaults
`uuidScopeId` to `random_v7`. An optional explicit `uuidScopeId` param
(app-chosen or idempotent create) is a compatible later addition; left out
@ -193,14 +200,14 @@ announcement and the follower projection already implemented in
## Test plan
- **Create.** `create(owner: u)` yields a scope with `u` at `readWrite` and a
member-resolvable scope; `create()` (no owner) yields a dormant scope no user
can sync; `create(owner: u, grants: {u: ...})` throws `ArgumentError`.
- **Create.** `createFor(u)` yields a scope with `u` at `readWrite` and a
member-resolvable scope; `createFor(u, role: ...)` stores the explicit role;
`create()` (no grants) yields a dormant scope no user can sync;
`create(grants: {...})` stores every entry with its role.
- **Grant/revoke.** `grant` inserts and upserts (role change); `revoke` removes
and is a no-op for a non-member; `grantAll` applies a map atomically.
- **Reads.** `roleOf` returns the stored role or null; `members` returns the
full map. Personal scope resolves to `readWrite` via the existing implicit
path.
- **Reads.** `members` returns the explicit member-role map. Internal role
resolution still covers stored, missing, and implicit personal membership.
- **Propagation.** After a server-side `grant`, a continuous follower session
adopts the new scope on the next cycle (covered by the existing access-change
suite); after `revoke`, it stops cycling it.

View file

@ -101,8 +101,8 @@ indexes:
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.
- Rows are managed through `session.crdt.scopes` (`CrdtScopes`). Invitations,
acceptance, and authorization policy remain app domain.
- **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
@ -128,9 +128,10 @@ 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.
created implicitly by sync (`getOrCreate`, `recordSyncCheckpoint`); membership
rows are managed through `session.crdt.scopes` 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
@ -143,7 +144,7 @@ is **populated as a read-only projection** of the server's authoritative grants
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,
- The authoritative `CrdtSyncScopeSet` carries `CrdtScopeGrant`s — `(uuidScopeId,
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
@ -201,13 +202,17 @@ security boundary for stale, misconfigured, or hostile clients.
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> }
memberGrants(userUuid) =
{ personal scope = userUuid, role = readWrite }
∪ { (s.uuidScopeId, m.role)
: crdt_scope_members m
join crdt_scopes s on s.id = m.scopeId
where m.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.
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 scope
@ -251,7 +256,7 @@ cadences; only *when* they are sent differs (`once`: a fixed sequence per scope;
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
`uuidScopeId` + `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
@ -347,9 +352,9 @@ not a symmetric set operation:
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).
- **Server**: `orderedScopes = sort(memberGrants(userUuid).uuidScopeId)`,
computed from `crdt_scope_members` plus the implicit personal grant. 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)`.
@ -363,8 +368,8 @@ 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.
peer's set; writes are still gated server-side by
`CrdtScopeMembership.roleOf(...).canWrite` 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`,
@ -374,7 +379,7 @@ 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`
Re-resolving `memberGrants` 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.
@ -435,12 +440,11 @@ 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.
- With `scopeId`, the package resolves the member role with
`CrdtScopeMembership.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 `CrdtScopeMembershipException`; 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.
@ -485,17 +489,19 @@ 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.
`CrdtScopeMembership` helpers (`memberGrants`, `memberScopes`, `roleOf`,
`isMember`, and the follower projection path). 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
`ScopeSet` exchange with role-carrying `CrdtScopeGrant`s, 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`
`CrdtScopeMembership.memberGrants`); 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
@ -524,6 +530,11 @@ to run with `--concurrency=1`.
`unauthorizedWrite` and fails before merge, client-side
`CrdtScopeRoleException` early rejection, and follower outbound skipping for
non-writable shared scopes while reads remain membership-wide.
- **Phase 8 — scope management service.** Implemented the server-side
`session.crdt.scopes` service (`create`, `createFor`, `grant`, `grantAll`,
`revoke`, `members`) with transaction/savepoint threading. Single grants reuse
the bulk-grant path; unknown-scope grants throw `CrdtScopeNotFoundException`.
Invitations, acceptance, and authorization policy stay in app endpoints.
## Test plan
@ -550,7 +561,7 @@ tested once, not per combination.
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
`unauthorizedWrite`. Personal scopes remain writable through their implicit
`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
@ -576,8 +587,8 @@ tested once, not per combination.
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.
`CrdtScopeMembership.roleOf(...).canWrite` 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.
@ -591,7 +602,7 @@ tested once, not per combination.
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
`CrdtScopeGrant`s (`uuidScopeId` + `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
@ -606,11 +617,11 @@ tested once, not per combination.
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.
- **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 `ScopeSet`, `SinceHlc`, `MergeChunk`, or `EndOfBatch`; `collectNextBatch`
resolves the idle cycle from the timeout once per session cycle, not once per
scope.
- **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