Skip to content

feat: nested spaces — SpaceSign gate, legalOwner limits, keyless governance + external seats (SYN-51, SYN-54, SYN-57) - #134

Draft
cheggaaa wants to merge 10 commits into
files-v2from
cheggaaa/nested-spaces
Draft

feat: nested spaces — SpaceSign gate, legalOwner limits, keyless governance + external seats (SYN-51, SYN-54, SYN-57)#134
cheggaaa wants to merge 10 commits into
files-v2from
cheggaaa/nested-spaces

Conversation

@cheggaaa

@cheggaaa cheggaaa commented Jul 6, 2026

Copy link
Copy Markdown
Member

Nested spaces — coordinator validation, limits & keyless governance

Draft PR accumulating the coordinator side of nested (parent/child) spaces (SYN-51, SYN-54, SYN-57). Design: any-sync-sdk docs/16-nested-spaces.md (anyproto/any-sync-sdk#30). Protocol side: anyproto/any-sync#725. Targets files-v2; any-sync pinned to the cheggaaa/nested-spaces branch.

Landed (SYN-51 — SpaceSign trust gate + legalOwner limits)

  • rpcHandler.SpaceSign threads the new SpaceSignRequest.parentAclRecordId.
  • verifyNestedSpace — nothing else enforces parent authority, so at child sign the coordinator verifies: parent exists, is active, is not tech/1-1 and not itself a child (v1 = single-level nesting); the child ACL root mirrors the header's parentSpaceId and pins the parent's current owner (acl.ReadState — every ACL write flows through this coordinator, so its cache is current for records it accepted); the AclChildRegister record matches on record id + child space id + child ACL root CID and is not revoked; its author held Admin+ at that record's position; parent options don't forbid children (childrenCreationDisallowed).
  • legalOwner limit accounting: spacestatus.NewChildStatus stores billedIdentity on the space doc and enforces the legalOwner's sharedSpacesLimit at sign — a net-new check (top-level spaces only hit the global cap at sign today). The creator's identity stays on the doc; billing is a separate axis.
  • Top-level signs reject a stray parentAclRecordId.

Landed (SYN-54 — phase 2, keyless governance)

  • legalOwner SpaceDelete: the parent's current owner (fresh acl.OwnerPubKey) may delete a child it cannot read; the doc-identity predicate is skipped via SpaceDeletion.AuthorizedByLegalOwner since the coordinator already authorized. Top-level path unchanged.
  • AclAddRecord fail-closed gate: keyless-governance records (AclAccountRemoveNoRotate / AclLegalOwnerUpdate) must be authored by the parent's current owner — the fresh-derivation half of the hybrid legalOwner model. An ex-owner passes stale client validation (stored key) but is rejected here at submit. Content sniffing only; opaque payloads fall through to the full validation inside acl.AddRecord.
  • Space docs carry parentSpaceId (set at child sign) so delete/ACL-add resolve the parent without parsing headers.

Linear: SYN-51, SYN-54 (parent SYN-49).

🤖 Generated with Claude Code

Landed (SYN-57 — phase 5, external seats)

  • accountlimit.Limits.ExternalSeatsLimit, settable via AccountLimitsSet.
  • AclAddRecord gate: admissions into a child (direct adds, request accepts, invite joins) naming identities not in the parent ACL are external seats, charged against one per-org pool of distinct external identities across all the org's children (Q9 resolved: per-org total), counted live from the ACL states.
  • ExternalCompartments RPC — discovery for externals (who cannot read the parent's registrations), backed by a best-effort externalSeats mongo registry maintained in the AclAddRecord path; deleted spaces filtered lazily.

…g (SYN-51)

- SpaceSignRequest.parentAclRecordId threaded through rpcHandler.SpaceSign
- verifyNestedSpace — the trust gate for child spaces at sign time:
  parent exists, is active, is not tech/1-1 and not itself a child
  (v1 = single-level nesting); the child acl root mirrors the header's
  parentSpaceId and pins the parent's CURRENT owner (fresh read through
  the acl service — all acl writes flow through this coordinator, so its
  cache is current for records it accepted); the AclChildRegister record
  matches (record id, child space id, child acl root cid, not revoked)
  and its author held Admin+ at that record; parent options don't forbid
  children
- limit accounting: child spaces are billed to the legalOwner —
  spacestatus.NewChildStatus stores billedIdentity on the space doc and
  enforces the legalOwner's sharedSpacesLimit at sign (net-new check;
  the creator's own identity/limits stay on the doc as before)
- top-level signs reject a stray parentAclRecordId
@socket-security

socket-security Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedgithub.com/​anyproto/​any-sync@​v0.13.0-alpha.5 ⏵ v0.13.0-alpha.5.0.20260707151954-0499748aa6a174 +110010010070

View full report

cheggaaa added 2 commits July 6, 2026 18:15
…e (SYN-54)

- SpaceDelete: the parent space's CURRENT owner may delete a child it
  cannot read — resolved fresh via acl.OwnerPubKey; the doc-identity
  predicate is skipped via SpaceDeletion.AuthorizedByLegalOwner (the
  coordinator already authorized). Top-level spaces keep the owner-only
  path unchanged
- AclAddRecord: keyless-governance records (AclAccountRemoveNoRotate /
  AclLegalOwnerUpdate) must be authored by the parent's current owner —
  the fresh-derivation half of the hybrid legalOwner model: an ex-owner
  passes stale client validation but is rejected here at submit
  (fail-closed transfer window). Content sniffing only; anything opaque
  falls through to the full validation inside acl.AddRecord
- space docs carry parentSpaceId (set at child sign) so delete/acl-add
  can resolve the parent without parsing headers
…registry (SYN-57)

- accountlimit.Limits.ExternalSeatsLimit, settable by the payment node
  via AccountLimitsSet
- AclAddRecord gate: admissions into a child space (direct adds, request
  accepts, invite joins) that name identities NOT in the parent acl are
  external seats, charged against ONE per-org pool of distinct external
  identities across all of the org's children (Q9 grooming decision),
  counted live from the acl states — no persistent counter to drift
- externalSeats mongo registry (best-effort, maintained on admit/remove
  in the AclAddRecord path) backing the new ExternalCompartments rpc —
  the discovery surface for externals, who cannot read the parent's
  registrations; rows for deleted spaces are filtered lazily
@cheggaaa cheggaaa changed the title feat: nested spaces — SpaceSign gate, legalOwner limits + keyless governance (SYN-51, SYN-54) feat: nested spaces — SpaceSign gate, legalOwner limits, keyless governance + external seats (SYN-51, SYN-54, SYN-57) Jul 6, 2026
cheggaaa added 7 commits July 6, 2026 19:56
…s, child type (review)

- verifyNestedSpace: the pinned legalOwner must equal the parent's CURRENT
  owner only at child creation (isNew); on re-sign the parent may have
  transferred ownership since — previously every receipt renewal failed
  forever. Billing/authorization now targets the parent's current owner.
- verifyExternalSeats: a child ACL that can't be counted now FAILS CLOSED
  (rejects the admission) instead of silently undercounting the pool and
  letting an over-limit external through.
- child SpaceType tech/1-1 rejected at sign — such a child would be
  permanently undeletable (SpaceDelete refuses those types).
- pin any-sync with the legalOwner-proof binding fix.
Regression test: re-sign after a real parent a->b ownership transfer.
…ild type (review R2-1)

- verifyNestedSpace now checks childAclRoot.ParentAclRootId == the parent's
  actual acl root id (st.Id()). Without it a registering admin could pin a
  binding scope they control, partially resurrecting the cross-space
  legalOwner-proof replay the any-sync fix targets.
- require the V1 header for children at sign time (defense-in-depth; nodes
  also enforce it now).
- child spaceType is allowlisted to Regular (was a denylist that let a
  Personal-classified header through).
- ChildrenCreationDisallowed is enforced at CREATION only — enforcing it on
  re-sign would starve existing children of receipt renewals.
Regression test: mismatched parentAclRootId rejected.
billedIdentity was pinned at child creation: after an org transfer the old
and new owners held disjoint pools, so ping-ponging ownership across N
accomplices multiplied the per-owner children allowance N times. ChangeOwner
now migrates all children of the space to the new owner's pool in the same
transaction, and a child re-sign refreshes a stale assignment to the billed
identity the coordinator resolved from the parent's current owner.
…pass

- The distinct-external pool was counted live across sibling children with
  nothing ordering the count against a concurrent admission's commit
  (different children = different consensus logs), so two admits could both
  see the pre-commit count and overshoot the paid limit. The check->commit
  window is now serialized per org via a striped lock.
- Permission changes never routed through the gate: admit an external at
  None (uncounted by the steady-state scan), elevate to Writer, and the pool
  limit was bypassed. Non-None permission changes now count as admissions;
  demotions to None stay free.
Surfaced by de-skipping the SDK's nested-spaces e2e:

- MakeSpaceShareable rejected non-owners before the already-shareable
  no-op, so an org admin ensuring the parent was shareable ahead of an
  AclChildRegister write got ErrForbidden even though the org was already
  shareable. The idempotent no-op now comes first; the actual flip stays
  owner-gated.
- defaultLimits gains externalSeatsLimit: external seats default to a
  per-identity payment-node grant (0 without one), which is right for
  production but left self-hosted networks unable to admit any org-external
  member into a child. The config knob keeps 0 as the default.
The striped mutex around the seat check→commit window only covers one
process: with several coordinator instances, two admissions into sibling
children can land on different replicas, each pass the live count, and
both commit past the limit (sibling children are different consensus logs,
so nothing else orders them).

Adds db.Lease — a mongo-backed per-key lease (TTL takeover, jittered
polling, release survives a canceled request context) — and holds the
org's lease across the window in addition to the local stripe. The live
counting stays (it is drift-free ground truth); a reservation ledger was
considered and rejected because every seat-freeing path (removal,
demotion, child deletion, registration revoke) would need bookkeeping
that drifts fail-closed when missed. Residual: a holder stalled past the
30s TTL loses exclusivity; the TTL sits well above the worst-case
check→commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant