Team Workspaces and control Nodes
Status: the first-owner bootstrap is a Source candidate: all five
implementation-slice steps are source-implemented behind release-disabled
methods (releaseQualified: false), and qualification is pending. Every later
delivery step is Planned, including the mapping from the profile bearer to
the first owner. The slice adds no listener, credential, key,
dependency, remote identity provider or runtime extension. An earlier
prototype (a Node scope and module-local protocol schemas) was deleted in
commit 9cdbe82; this slice does not reuse it. This
is the identity foundation of the
organization hub direction
(decision) and the administrator
bootstrap that MN0 depends on.
A team Workspace is the shared scope of an organization on its Control Node:
the people (Users) who are members, the Devices and clients they use, and the
grants that let their agents connect and delegate through that Node. One Control
Node is the only durable writer (decision).
The team Workspace is a new aggregate (kind: team-workspace). It does not
reuse the execution Workspace (workspace_*) or reinterpret the
organizational container_* Workspace; see the
naming contract.
Delivery order
Each step needs the one before it (roadmap):
- First owner: an empty local Control Node creates its first User, local Device, CLI client, team Workspace and owner membership (this document).
- Grants and machine enrollment (MN0): client and machine principals, explicit grants and revocation, as specified in Node identity and tenancy.
- Members: invitations, roles, per-user Project grants and per-client context.
- Other Devices and Nodes: cross-domain client pairing and clients that talk to more than one Control Node.
Rules for clients of several Control Nodes:
- Keep a separate connection, credential, Node epoch and cache per Control Node.
- Never federate authority between Nodes or retarget an open command when the user switches Node.
- Preserve legacy execution
workspace_*identities through the R1b naming migration.
Person identity (SSO/OIDC), authenticated remote access, isolation between people on a shared Node and cross-person delegation policy are also required before an organization deployment; the roadmap direction lists them.
First-owner bootstrap
The smallest prerequisite: one empty local Control Node creates its first owner after an explicit local confirmation. Invitations, other members or Devices, grants, remote pairing, multiple Nodes, active-context selection and execution Workspace renaming are out of scope for this slice.
Authority
The local Node API bearer is transport authentication only. Holding it never lets a caller choose or create the first owner. Authority comes from the OS account that owns the profile (decision):
- The current OS account owns the exact profile runtime directory and its
private credential and descriptor. Reads are bounded, no-follow and
metadata-stable, followed by an authenticated
node.statusidentity probe. - The Node binds the resulting local principal server-side. Requests cannot submit an OS identity, User ID, Device ID, role, Node ID, profile path, credential or authority claim.
- This is an OS-account trust boundary. The terminal prompt is an explicit operator ceremony, not proof of physical user presence. Keychain, biometric, managed-device and remote identity claims are out of scope.
One-shot confirmation channel
A bearer-only HTTP request cannot complete bootstrap. The local launcher owns a bootstrap-only inherited channel:
maia node bootstrap --profile <name>starts an ordinary empty local Node with a private child-process pipe inherited as file descriptor 3. The launcher hands exclusive ownership of the readable end to the confirmation reader, which uses Effect's Node readable adapter (not raw file reads or a named FIFO). No path, descriptor number or pipe bytes enter a protocol request or result.- Preview uses the ordinary loopback request transport. The CLI prints the reserved identities, names, Node epoch, digest and expiry.
- The CLI opens its controlling terminal directly, refuses redirected or
missing input, and requires
YES <preview-id>. It then writes one confirmation record (preview ID, input digest, command ID, Node epoch) to the pipe. - Apply uses the ordinary loopback transport. The CLI sends it once the
confirmation write returns, which can precede the Node reading the record.
The Node therefore waits up to five seconds for the reader to settle. It
commits only when the unexpired in-memory preview, the apply request and the
unread confirmation record match exactly. On a Node with an open channel, a
bearer-only apply waits the same five seconds, then fails with
bootstrap_confirmation_requiredand opens no transaction. Only one bootstrap call runs at a time: a concurrent preview or apply is refused at once with the retryablesaturatederror (admission), so calls that wait for a confirmation never queue and never hold more than one request slot.
Channel limits: one strict JSON record of at most 4 KiB of UTF-8, ended by EOF, read within five minutes, no further frames. EOF without a record, extra bytes, invalid UTF-8, an invalid channel, launcher exit, timeout, Node restart or any ownership change closes the channel and disposes the pending preview. So does the two-minute preview expiry: a timer bound to the preview fires without any further request. The scoped reader destroys its readable, awaits closure and clears its frame buffers (this does not zeroize decoded strings or Node's internal buffers). Successful apply disables bootstrap for that profile permanently.
The confirmation record is an internal channel frame, not a third operation or a general IPC protocol.
State and invariants
The Node owns one bootstrap aggregate with revision 0 (unowned) or 1
(owned). There is no way back to unowned and no import, reset,
replace-owner, force or all target.
Apply is one SQLite transaction that creates exactly:
- one
ControlNodeIdentitywith the Node-reserved ID and current epoch; - one active
User; - one active local
Deviceowned by that User, authentication kindlocal-os-profile-owner, no generated key material; - one active CLI
ClientRegistrationfor that Device; - one active team
Workspace(kind: team-workspace); - one active
WorkspaceUserMembershipwith roleowner; - one active
WorkspaceDeviceMembershipthat cannot outlive or elevate the User membership; - one active
ClientActiveContextfor that User, client and Workspace; - one ordered bootstrap event and one durable receipt.
Every new aggregate and policy revision starts at 1. All rows commit together
or not at all. No legacy execution Workspace, workspace_memberships or
active_workspace_context row is read, rewritten or treated as team authority.
After commit, the profile bearer stays the process-scoped local transport credential. Apply issues no credential or key.
Resolving that bearer to the fixed bootstrap User, Device and client is
Planned; this slice does not build it. The rows exist, but no request
resolves to them yet, and the later managed-credential slice must provide the
mapping before any request acts as the owner and before remote or
additional-device access exists. Until then the bearer's actor is the legacy
actor_local_user (D-138).
Operations
Two release-disabled internal operations. They enter the public registry only when protocol, SDK, Node handler, CLI, receipt, event, authorization and contract parity land together in one vertical slice.
team-workspace.bootstrap.preview parameters:
{
"schemaVersion": 1,
"commandId": "command_01",
"expectedBootstrapRevision": "0",
"ownerDisplayName": "Ada",
"workspaceName": "Ada's Workspace"
}
commandIduses the existing 1–128 character protocol identifier grammar.- Names are NFC, contain no NUL or control character, have no leading or trailing whitespace, and encode to 1–160 UTF-8 bytes. Unnormalized input is rejected, not rewritten.
- No other IDs, role, authority field, credential, proof, path, profile or client timestamp is accepted.
The Node resolves the profile, OS-owner evidence, Node epoch and bootstrap revision, reserves all new IDs, and returns:
{
"schemaVersion": 1,
"method": "team-workspace.bootstrap.preview",
"releaseQualified": false,
"authoritySource": "local-os-profile-owner",
"commandId": "command_01",
"expectedBootstrapRevision": "0",
"nodeEpoch": "epoch_01",
"nodeId": "node_01",
"userId": "user_01",
"deviceId": "device_01",
"clientId": "client_01",
"teamWorkspaceId": "team_workspace_01",
"role": "owner",
"inputDigest": "sha256:<64 lowercase hex characters>",
"previewId": "preview_01",
"issuedAt": "<RFC 3339 timestamp>",
"expiresAt": "<RFC 3339 timestamp>"
}
The digest is SHA-256 over canonical UTF-8 JSON (sorted keys, no insignificant whitespace) of the apply method, schema version, command ID, expected revision, both normalized names, Node ID, Node epoch and every reserved ID. Preview writes nothing durable. A profile holds at most one pending preview; it lives two minutes and never survives restart. Preview issues no proof token; the binding values are not authority. The matching inherited confirmation stays Node-private evidence of the approved ceremony.
team-workspace.bootstrap.apply repeats the preview intent and adds only
the binding values:
{
"schemaVersion": 1,
"commandId": "command_01",
"expectedBootstrapRevision": "0",
"ownerDisplayName": "Ada",
"workspaceName": "Ada's Workspace",
"previewId": "preview_01",
"inputDigest": "sha256:<64 lowercase hex characters>"
}
- Before storage the Node rechecks the OS owner and profile metadata, Node
identity and epoch, revision
0, preview expiry, digest, every reserved ID and the matching unread FD 3 confirmation. An apply that waits behind another write is judged again when it runs: it verifies the OS owner once it holds the Node's write permit, and inside the SQLite transaction, afterBEGIN IMMEDIATEreturns, it re-reads ownership and the revision and judges expiry against a clock read at that moment. The same reading stamps every row and the receipt, so a wait for the database lock cannot carry a stale decision or time into the commit. - The result is bounded to the created IDs, the fixed
ownerrole, bootstrap revision1, delivery (newor exactduplicate) and the existing receipt shape. It returns no proof, credential, key, path, OS identifier or raw authority evidence. - Receipt: method
team-workspace.bootstrap.apply, target the new Node with typecontrol-node-bootstrap, actor the new User, aggregate revision1. - Same command ID and request returns the prior result without another write.
The receipt stores a hash of the whole apply request, preview ID included, and
a retry returns
duplicateonly when its hash matches exactly. Same command ID with another digest fails withidempotency_conflict. Same command ID and digest with another name or preview ID fails withbootstrap_binding_mismatch, after success and after a restart. - No third read operation: the apply result is the initial projection. After an
unknown transport outcome the CLI calls
receipt.get(commandId)on the same profile; its stored result is that same bounded projection (created IDs,ownerrole, revision1, delivery). The command stops its Node when it exits, so the recovery output names the profile: start that profile's Node withmaia-node --desktop --profile <name>, then read the receipt. If no receipt exists and the Node is still unowned with the same live preview and unread confirmation, apply may be retried with the same values. Any inconclusive retry (saturated, unavailable, deadline, transport or an unrecognised code) leaves the outcome unknown, because the first apply may still be running or may have committed; only a success, a replay or a typed bootstrap refusal replaces it. An owned Node without the matching receipt isbootstrap_integrity_failed, never permission to bootstrap again.
Errors
A closed error set; every failure mutates nothing:
| Code | Condition |
|---|---|
bootstrap_already_completed | any owner or bootstrap revision already exists |
bootstrap_authority_unavailable | unsafe or changed OS ownership or profile metadata |
bootstrap_confirmation_required | no exact unread inherited confirmation |
bootstrap_channel_invalid | inherited channel is absent, invalid, oversized, malformed or has extra data |
bootstrap_preview_expired | preview or confirmation deadline elapsed |
bootstrap_binding_mismatch | digest, command, preview, names, IDs, Node identity or epoch differ |
revision_conflict | expected revision is not the current revision 0 |
idempotency_conflict | command ID already binds another normalized digest |
bootstrap_integrity_failed | partial or inconsistent ownership evidence is observed |
saturated (admission) | another bootstrap call is running; retry with the same values |
bootstrap_already_completed wins over bearer possession or a new
confirmation: there is no implicit takeover. A call that overlaps a running
bootstrap call is refused with saturated; the CLI retries that refusal with
the same values, and a matching retry after the commit returns duplicate, so
the same intent commits once. A conflicting apply fails. Mismatched
attempts do not consume a matching confirmation, but the single preview and its
deadline bound them. Success, expiry, channel failure, ownership change or
restart disposes all pending material.
Acceptance
Required negative evidence, in priority order:
- P0, no takeover: an existing owner, revision
1, partial ownership rows or legacy Workspace data never allows a second bootstrap or a repair path. - P0, bearer is not enough: a valid bearer plus exact preview values but no matching confirmation runs no read or write transaction.
- P0, exact binding: a wrong command, digest, preview ID, epoch, reserved ID, normalized name, expiry or profile metadata mutates nothing.
- P0, atomicity and recovery: a failure injected at every row, event or
receipt write leaves revision
0; a commit with a lost response is recovered only through the same-profile receipt. - P1, concurrency: same intent gives one commit and, on retry, one duplicate; different intent gives one commit and one deterministic conflict; an overlapping bootstrap call is refused at once and never queues, and an altered name or preview ID is never acknowledged as the committed request.
- P1, disposal: EOF, timeout, extra frame, launcher exit, restart and success each close the channel and dispose the pending confirmation.
- P1, strict schema: caller-supplied IDs, role, authority, credential, proof, path, UID, profile, timestamp or unknown fields are rejected.
Schema tests cover three groups: valid preview and apply shapes; rejection of caller authority and malformed bindings; and result, receipt and owner relationships. Authority, transaction, concurrency and disposal evidence belongs to Node and CLI boundary tests.
Implementation slice
One change, in repository order:
- the two Effect schemas and their three contract test groups;
- both release-disabled methods in the registry with risk, confirmation, receipt, error and CLI-only exposure metadata;
- SDK bindings that never expose the inherited confirmation or channel internals;
- one scoped Node bootstrap service, two handlers, the FD 3 adapter and one atomic SQLite transaction;
maia node bootstraponly: controlling-terminal confirmation, exact JSON output, receipt recovery and built local tests.
No invitations, member administration, grants, remote transport, Keychain, Desktop or mobile projection, dynamic registry loading or other operation in this slice. No bootstrap or team Workspace capability may be claimed until all five steps and their parity checks land.
Imported from Maia OS docs at 3dc3e07