feat: nested spaces — parent link, legalOwner, child-register + keyless governance (SYN-50, SYN-53)#725
Draft
cheggaaa wants to merge 11 commits into
Draft
feat: nested spaces — parent link, legalOwner, child-register + keyless governance (SYN-50, SYN-53)#725cheggaaa wants to merge 11 commits into
cheggaaa wants to merge 11 commits into
Conversation
…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).
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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). Targetsfiles-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 (validateParentLinkcross-checks header ↔ root).AclChildRegister/AclChildRegisterRevoke(oneof 17/18) — registration of a child in the parent ACL:childSpaceId+childAclRootIdbinding +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.RawRecordbytes with exactly oneAclOwnershipChange) 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/LegalOwnerthreaded into the V1 header + ACL root; V0 create rejects children.Note vs the design doc: the root does not carry
parentAclRecordId(doc'sAclParentRef) — 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 viaSpaceSignRequestinstead. 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 toNoneimmediately; the child owner is not removable (the legalOwner's lever there is space deletion). Never a nil-rotationAclAccountRemove— old clients skip the unknown variant safely.AclState.PendingKeylessRemovals()/HasPendingKeylessRemovals(); anyAclReadKeyChangeclears it (the standard rotation naturally excludes the already-Nonemember — the rotation record is unchanged).AclSpaceOptions.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