Skip to content

feat: nested spaces — parent link, legalOwner, child-register + keyless governance (SYN-50, SYN-53)#725

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

feat: nested spaces — parent link, legalOwner, child-register + keyless governance (SYN-50, SYN-53)#725
cheggaaa wants to merge 11 commits into
files-v2from
cheggaaa/nested-spaces

Conversation

@cheggaaa

@cheggaaa cheggaaa commented Jul 6, 2026

Copy link
Copy Markdown
Member

Nested spaces — any-sync protocol + ACL machinery

Draft PR accumulating the any-sync side of nested (parent/child) spaces. Design: any-sync-sdk docs/16-nested-spaces.md (anyproto/any-sync-sdk#30). Targets files-v2 (v0.13.x line).

Landed (SYN-50 — phase 1 core)

  • SpaceHeader.parentSpaceId = 10 — first-class signed header field; empty = top-level space, full backward compat (header verification reads raw stored bytes, unknown fields preserved by vtproto).
  • AclRoot.parentSpaceId = 12 + AclRoot.legalOwner = 13 — the child ACL mirrors the parent link and pins the parent-owner pubkey at genesis. Both-or-neither, enforced at record build, root apply and create-payload validation (validateParentLink cross-checks header ↔ root).
  • AclChildRegister / AclChildRegisterRevoke (oneof 17/18) — registration of a child in the parent ACL: childSpaceId + childAclRootId binding + orgPermission (permission the org grants itself; Owner forbidden). Admin+ authorship; duplicate registration rejected; revoke + re-register supported.
  • AclLegalOwnerUpdate (oneof 19) — advances the child's stored legalOwner after parent ownership transfers, validated by signature induction: each embedded raw parent record (consensusproto.RawRecord bytes with exactly one AclOwnershipChange) must be signed by the currently-expected owner and names the next; the update's author must be the final owner. Consumed-CID replay guard — a cycled ownership (A→B→A) cannot be replayed by an ex-owner. O(transfers) proof size, verifiable fully offline (external-seat members never need the parent ACL).
  • AclState: legalOwner/parent tracking + child-registration map, LegalOwner() / ParentSpaceId() / IsChildSpace() / ChildRegistration(s)() accessors, Copy() support.
  • spacepayloads: SpaceCreatePayload.ParentSpaceId/LegalOwner threaded into the V1 header + ACL root; V0 create rejects children.

Note vs the design doc: the root does not carry parentAclRecordId (doc's AclParentRef) — the child spaceId depends on the ACL root (V1 headers embed it), so the registration record can only exist after the root; the binding is one-directional (register → child root CID) and the coordinator gets the record id via SpaceSignRequest instead. Doc will be updated.

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

  • AclAccountRemoveNoRotate (oneof 20) — removal with no read-key rotation, authorable only by the child's current legalOwner (keyless-auth track: the author holds no permissions in the child ACL). Removed members drop to None immediately; the child owner is not removable (the legalOwner's lever there is space deletion). Never a nil-rotation AclAccountRemove — old clients skip the unknown variant safely.
  • Pending-rotation stateAclState.PendingKeylessRemovals() / HasPendingKeylessRemovals(); any AclReadKeyChange clears it (the standard rotation naturally excludes the already-None member — the rotation record is unchanged).
  • Editor-completed rotation opt-inAclSpaceOptions.editorsCanCompleteKeylessRotation: a Writer may author the standalone rotation, gated on an actual pending keyless removal (both build and validate paths). Default remains admins/owner.
  • SpaceSignRequest.parentAclRecordId = 6 — the pointer the coordinator needs for nested validation (consumed by the coordinator PR / SYN-51).

Linear: SYN-50, SYN-53 (parent SYN-49).

🤖 Generated with Claude Code

cheggaaa added 3 commits July 6, 2026 17:45
…inery (SYN-50)

- SpaceHeader.parentSpaceId (field 10, signed, empty = top-level)
- AclRoot.parentSpaceId + legalOwner (pinned pubkey of the parent's owner);
  both-or-neither, enforced at build, apply and payload validation
- new AclContentValue variants: AclChildRegister / AclChildRegisterRevoke
  (Admin+ in the parent; re-register after revoke allowed) and
  AclLegalOwnerUpdate — advances the stored legalOwner by signature
  induction over embedded raw parent AclOwnershipChange records, with a
  consumed-cid replay guard (an ownership cycle cannot be replayed)
- AclState: legalOwner/parentSpaceId tracking, child registrations,
  accessors, Copy support
- spacepayloads: SpaceCreatePayload.ParentSpaceId/LegalOwner threaded into
  the V1 header + acl root; V0 rejects children; create-payload validation
  cross-checks the header/root parent link
…tate (SYN-53)

- AclAccountRemoveNoRotate (oneof 20): removal with NO read-key rotation,
  authorable only by the child space's current legalOwner (keyless-auth
  track — the author holds no permissions in the child acl). Removed
  members drop to None immediately; child owner is not removable (the
  legalOwner's lever there is space deletion)
- pending-rotation state: AclState tracks identities removed keylessly
  until any AclReadKeyChange applies (PendingKeylessRemovals /
  HasPendingKeylessRemovals); rotation clears it
- editor-completed rotation opt-in: AclSpaceOptions.editorsCanComplete-
  KeylessRotation lets a Writer author the standalone rotation, gated on
  an actual pending keyless removal (build + validate paths)
- SpaceSignRequest.parentAclRecordId (field 6) for the coordinator's
  nested-space validation (SYN-51)
Parent-settings toggle (per docs/16 resolved Q2): AclSpaceOptions gains
childrenCreationDisallowed — zero value keeps children allowed so existing
spaces are unaffected. Enforced in ValidateChildRegister; the coordinator
reads the same option at child SpaceSign (SYN-51).
cheggaaa added 8 commits July 6, 2026 18:17
…partments rpc (SYN-57)

- AccountLimitsSetRequest.externalSeatsLimit: caps the distinct non-org
  identities admitted across an org's children (paid external seats)
- coordinator rpc ExternalCompartments: discovery for external-seat
  holders, who are not parent members and cannot read its registrations
- coordinatorclient.ExternalCompartments
…iew)

Critical: AclLegalOwnerUpdate proofs were only author-signature-checked,
so an ownership change of ANY space signed by the current legalOwner's
key could be replayed to seize a child's governance. Now:
- AclOwnershipChange carries aclRootId (the acl it belongs to; stamped by
  BuildOwnershipChange = the acl root id)
- AclRoot pins parentAclRootId (all-or-none with parentSpaceId/legalOwner)
- ValidateLegalOwnerUpdate requires every proof's aclRootId == the child's
  pinned parentAclRootId — a cross-space or unbound proof is rejected
Regression tests: cross-space proof, unbound proof.

Also from review:
- validateParentLink requires the V1 header when a parent link is present
- applyChildRegisterRevoke no longer synthesizes a phantom registration
  for an unknown child on the non-validating path
- ErrNotChildSpace message fixed
The client-side induction is offline-verifiable and replay-guarded, but proofs
are only author-signature-checked, not proven accepted into the parent acl — so
a stored legal owner could mint a fresh off-chain ownership change. The
authoritative guard is the coordinator's current-owner gate; document this
instead of implying the offline check is sufficient on its own.
…te.Id (review R1-1/R2-1)

- validateParentLink now REQUIRES the V1 header when the acl root carries a
  parent link. Without it a non-V1 child could be signed by the coordinator
  against one acl root and pushed to nodes with another (the V1-only
  bytes.Equal(aclPayload, header.AclPayload) binding is skipped for V0),
  letting an attacker pin themselves as legalOwner in the canonical acl.
  This was claimed in an earlier commit but the edit had been reverted.
- AclState.Id() exposes the acl root id so the coordinator can pin-check a
  child's parentAclRootId against the real parent acl (coordinator R2-1).
Regression test: validateParentLink rejects a V0 child header.
The consumed-proof set keyed on the cid of the whole RawRecord envelope,
but acceptor fields sit outside the author signature: a consumed
ownership-change proof re-serialized with a mutated acceptor field kept a
valid signature while minting a fresh cid, bypassing the replay guard and
letting an ex-owner reclaim a child's legalOwner. Key on the cid of the
signed payload instead, and cover the mutated-envelope replay in tests.
- ValidateOwnershipChange now rejects a record whose aclRootId names a
  different acl: the field is what later authorizes the record as a
  legalOwner proof in child spaces, so a forged binding must not be
  accepted even into its own acl (empty stays allowed for old records).
- applyLegalOwnerUpdate decodes proofs without re-verifying signatures:
  the validator already checked them when validation is on, and a
  non-validating node must not fail signature-dependent work on a
  coordinator-accepted record.
- applyChildRegister no longer synthesizes a phantom entry for an empty
  child id or overwrites a live registration on the non-validating path,
  mirroring its revoke sibling.
The field recorded any permission the registrar chose, but nothing adds
the org to the child acl or encrypts the read key to it — a non-None
registration claimed access the org does not hold and a keyless org
cannot self-add. Fail closed until the access grant is implemented.
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