Embedding Cotal
Guide (informative) · For: implementers building a service on top of Cotal · Prereqs: Architecture, Identity and auth, Delivery daemon
The cotal binary in this repo is one composition root: an operator CLI. A separate service
(for example a hosted, multi-tenant Cotal) does not fork this repo. It writes its own
composition root that depends on the published @cotal-ai/* packages and imports the surfaces it
wants. bin/cotal.ts uses the same composition pattern. This page is the contract for that: what is a real library
export you can build against, how to boot the server-side daemons from those exports, and where the
current export surface stops short of a fully hosted composition.
This is the “guarded substrate” boundary in practice. Nothing here reveals or assumes a specific host; it documents the public seams any embedder composes.
What you embed
Section titled “What you embed”The supported reference shape here is one broker operator serving one space (one tenant: a
dedicated data account, under an operator that also holds the system account and a quarantined
auth-callout account) plus three standalone processes. The trust layer itself composes many spaces
under one broker operator today (createBrokerAuth + createSpaceAccountAuth + N-space
serverConfig); what does not exist yet is the per-space lifecycle on a shared broker (see
Known gaps). The three processes:
| daemon | package | what it is |
|---|---|---|
| auth-service | @cotal-ai/auth |
the NATS auth callout, the IdP token exchange, and JWKS. Plane 1 to Plane 2. |
| delivery | @cotal-ai/delivery |
the Plane-3 durable backstop: fan-out writer plus trusted reader, per space. |
| supervise | @cotal-ai/manager |
the per-machine agent lifecycle (spawn/despawn/attach), per space. |
mint, deliver, and auth-service expose their behavior as direct library primitives, and the
supported one-space bootstrap below re-composes from exported low-level primitives. supervise and
the full up orchestration are not public runners: up also does broker bring-up, restore,
process and registry management, and lifecycle work, and supervise’s orchestration is private (see
Supervisor signing authority).
Owner-bound platform supervisors
Section titled “Owner-bound platform supervisors”A host can start one user’s supervisor without retaining that user’s login. Pass
platformSupervisor: { authorizePlatformAdmin, observeAssignment } to startAuthService, then call
handle.platformSupervisorAuthority(owner) inside the trusted authority process. The first callback
must authenticate the platform’s administrative caller through the host’s own authority. The second
reads that owner’s recorded PlatformSupervisorAssignment fresh on every call. An absent,
ended, expired, foreign-account or wrong-revision assignment refuses issuance.
To end a supervisor, record its assignment ended and call
handle.endPlatformSupervisorAssignment(owner). It rechecks platform-admin authority and refuses
unless your record already reads ended. It retires the manager gate and the issued supervisor and
executor generations. After that, registration, activation and renewal refuse material returned
earlier, even before it expires, with platform supervisor assignment for owner <owner> has ended
or the retired-gate refusal. Stop the worker as well: an open broker connection lives until its
credential’s capped expiry.
The assignment has kind: "platform-supervisor", scope: ["supervise"], one derived owner,
one manager instance, one lifecycle UID, a revision and a finite expiresAt in Unix seconds. It is
not an interactive or managed-agent ledger row and grants no admin. The returned door is bound to
that owner and accepts PlatformSupervisorAuthorityRequest with the same owner, assignment revision
and an existing RemoteManagerAuthorityRequest. It serves only prepare, activate, renew and
renewStandingBundle. Unknown fields, generic profiles, mint, human exchange and another owner
are refused before issuance. Do not give the worker the auth handle, loopback capability or signer.
The host carries only the bound door over its authenticated worker channel.
Use remoteManagerClient and registerRemoteManagerAuthority from @cotal-ai/manager for the
existing registration ceremony. The result keeps the stock manager material shape and five nkeys.
All returned credentials expire no later than the assignment. The supervisor and executor ceilings
are recorded in the native issued store. The recorded permission set must be byte-identical to the
authority plane’s signed native permission set at issuance and every renewal, and must never widen.
These JWTs are signed by the authority plane, not returned by the user-token callout. The door
adds no human bearer, callout view, owner ledger grant or enrollment authority. Run admission,
host-backed enrollment and shutdown maintenance need their own host composition.
See the issuance design.
The export surface
Section titled “The export surface”Everything below is a real export of a published package, reachable from the package root (each
package publishes only . via dist/index.{js,d.ts} and ships files: ["dist"]). Type-only names
are marked; import them with import type.
Daemon runners and lifecycle
| symbol | package | purpose |
|---|---|---|
runAuthService(args, store?) |
@cotal-ai/auth |
boot the auth-service daemon; store injects the secret material. |
runDelivery(args, store?) |
@cotal-ai/delivery |
boot the delivery daemon; store injects the scoped delivery cred. |
startAuthService(inputs) |
@cotal-ai/auth |
start one account-scoped auth-service context and return an AuthServiceHandle with the loopback url, the per-start cap, readiness, drain, and idempotent close. close rejects when the context did not release its plane claim: the claim row was no longer its own, or the release write failed. The row then stays held, and the next start reclaims it through the liveness oracle. With the optional publicFace input it also serves the public exchange face and carries publicUrl. With the optional platformControl input the handle also has platformControlAuthority, the in-process platform control door, platformControlReadiness, its read-only readiness read, observeManagerGate, the manager gate read a host composing the delegated user intent decisions passes them, and activateManagedLifecycle, the activation that host runs at a delegated launch’s pinned lifecycle UID. With platformControl.host it also has registerHostIncarnation, which registers the host process’s own endpoint instance and returns the incarnation a delegated execution pins, observeHostGate, the read of that endpoint’s issuance gate, and awaitHostFence, which resolves once a later registration or barrier fences an incarnation. runAuthService remains the CLI entry. |
PlatformControlAuthorityRequest, PlatformControlInnerRequest, PlatformControlAuthorityResult, PlatformControlAssignment (types) |
@cotal-ai/core |
the closed envelope, its inner request union, its result and the backend’s assignment row for platformControlAuthority. platformControlOwner in @cotal-ai/auth derives the p_ owner the door issues under. |
startDeliveryService(inputs) |
@cotal-ai/delivery |
start one account-scoped delivery instance and return a HostedServiceHandle with readiness, drain, and idempotent close. The process runner remains the CLI entry. |
deliveryCredsKey(space, composition), membershipRwCredsKey(space, composition) |
@cotal-ai/workspace |
build the secret-store keys the delivery cred and the membership feed’s rw cred are read/re-signed under. Keys are per-space: space.<hex>/<kind>. A hosted composition passes { injected: true }. |
retireManagerInstanceIdentity(root, space, expected) |
@cotal-ai/workspace |
remove a persisted manager identity only if its complete instance id and serve identity still match expected. Returns removed or absent; refuses malformed, nonregular, and changed records. absent is not proof of ownership or successful teardown. The caller must separately prove stop and retirement ownership before using it. |
DELIVERY_CREDS_KIND, MEMBERSHIP_RW_CREDS_KIND |
@cotal-ai/workspace |
the operator-facing KIND names (delivery.creds, membership-rw.creds) those keys are built from, and what renewal results report. A kind is not a key: putting a cred under the bare kind writes the pre-0.4 flat location, which nothing reads. |
Manager, ManagerOptions (type) |
@cotal-ai/manager |
construct and run a supervisor in-process; ManagerOptions.secretStore injects the one store it reads/writes every secret through. ManagerOptions.remoteAuthority is the hosted manager-service authority bundle, including host-owned release, retained-validation, goal-index, and serve-time admin-authorization callbacks. |
ManagerOptions.pooled |
@cotal-ai/manager |
require signerless remote authority and an explicit non-custodial runtime before local execution starts. A pooled composition must supply the assigned account key and all-duty renewal callback; the CLI’s default remains unchanged. A signed-in human’s manager gets that material from managerServiceAuthority. A platform-run control manager gets it from AuthServiceHandle.platformControlAuthority with the shipped remoteManagerClient builders, as the platform control authority design describes. |
createRuntime, Runtime (type) |
@cotal-ai/manager |
resolve the spawn backend (pty built in). |
liveKvEntries(kv, filterOrOptions?, options?), LiveKvEntriesOptions (type) |
@cotal-ai/core |
read live KV entries in one finite scan. Pass { signal } as the second argument or after a key filter to cancel. An interrupted scan throws IncompleteKvScan; cancellation throws the signal reason, including during an empty-bucket bind. The scan deletes only its owned consumers, including each one nats.js rebuilt from it, after the broker has answered every create those rebuilds sent. A not-found delete counts as gone, a refused one leaves the consumers to broker inactivity expiry (which also covers a crash), and any other delete failure is thrown, as is a not-found delete of a consumer whose create got no reply from the broker before a timeout or a closed connection. A cleanup failure is thrown only when the scan would otherwise return; the scan’s own error, its cancellation reason and IncompleteKvScan take precedence. |
The remote manager authority parser accepts renewStandingBundle and renewRunDriver only with
an assigned account nkey, the current manager process epoch, and a host-authenticated registration
proof. A run renewal also names its active holder, takeover, epoch, fencing token, and the two
existing nkeys. The host must fresh-check those coordinates against its registration gate and run
journal before issuing server-selected profiles. A host without that renewal authorization refuses
the request. Until the host issuer wires the operations and validates them on real connections,
the presence of these types is not an operational pooled renewal guarantee.
With renewStandingBundle configured, the manager renews all five standing credentials together.
It checks every returned credential for the held nkey and assigned account, test-connects each one,
and adopts them only if the serve epoch has not moved. A refused or failed candidate leaves the
current credentials in place and records the refusal as cleanup debt until a later renewal succeeds.
On shutdown, after the standing context is drained, an expired maintenance executor is renewed
through the existing scoped host operation so deregistration can finish without restarting duties.
Provisioning and minting (all @cotal-ai/core)
| symbol | purpose |
|---|---|
createBrokerAuth(label) |
mint BROKER trust: the operator and system account one nats-server trusts. One per broker, shared by every space on it. |
createSpaceAccountAuth(broker, space) |
mint one space’s own data account, signed by that broker’s operator: the add-a-tenant primitive. |
createSpaceAuth(space) |
the one-space convenience: broker trust + one account in a single composed bundle. |
setupSpaceStreams({ servers, space, creds }) |
create the space’s JetStream streams. |
ensureDefaultDeliveryClass({ servers, space, creds?, deliveryClass }) |
write the space’s default delivery class at creation so it is wire-discoverable (SPEC section 4). |
serverConfig(broker, spaces, { storeDir, maxFileStore?, extraAccounts?, port?, host? }) |
render the broker config: one operator, N space accounts. storeDir is required, maxFileStore caps JetStream file storage in bytes (omitted, nats-server’s dynamic default applies), and extraAccounts preloads the auth-callout account. |
mintCreds(auth, identity, profile, opts?) |
mint a scoped cred for any Profile. |
mintMembershipObserverCreds, mintConnectionEvictorCreds |
mint the membership/eviction scoped creds. |
provisionAgent, provisionAgentDurables |
create a principal’s bind-only durables. |
newIdentity, stripSpaceAuth |
a fresh nkey identity; a stripped signer bundle (data signing seed only). |
Profile, CredentialKind, MintOpts, SpaceAuth (types), CREDENTIAL_LIFETIMES |
the profile matrix and cred lifetime policy. |
Auth building blocks (all @cotal-ai/auth)
| symbol | purpose |
|---|---|
createCalloutAuth, startAuthCallout |
the NATS auth-callout responder. |
createUserTokenIssuer, pinnedJwksResolver |
mint and verify the Cotal user bearer. |
createIdpBridge |
exchange a verified IdP JWT for a Cotal bearer (see the callout contract). |
deriveOwnerToken, validateUserToken |
owner derivation; strict bearer validation. |
cotalAuthProvider |
the self-registering auth-provider extension. |
ensureCalloutAuth/loadCalloutAuth, ensureIssuer/loadIssuer, ensureOwnerSecret/loadOwnerSecret |
read/write the auth secret kinds through a SecretStore. |
PLANE_CLAIM_REFUSED, planeClaimRefusal, PlaneClaimRefusal (type) |
every plane-claim refusal carries a PLANE_CLAIM_REFUSED detail, and planeClaimRefusal(err) reads its reason: corrupt, live-peer, unknown, concurrent, fenced, released or lost. Only unknown, an inconclusive liveness observation, is coded unavailable. A host can retry contention and stop on a corrupt row without matching message text. |
Seams and the wire (all @cotal-ai/core unless noted)
| symbol | purpose |
|---|---|
SecretStore (type) |
the durable hosted-secret seam (get/put/delete); get() returns raw seeds/keys into process memory, so it is a blob seam, not HSM/KMS signing. |
FsSecretStore, workspaceSecretStore(root) |
the filesystem default. These live in @cotal-ai/workspace, not core. |
AuthProvider (type), Connector (type), Runtime (type), Command (type) |
the extension contracts; implementations self-register on import. |
registry |
the shared registry a composition root pulls surfaces into. |
CotalEndpoint, subjects, message types |
the wire client and shapes. |
ParsedArgs (type) |
the shape the daemon runners take (see below). |
For a Linux Unix-socket adapter, peerCredentials(socket) from @cotal-ai/seat returns
kernel-observed peer pid, uid and gid. Compare these against the host’s authorization
policy; request-supplied identity and process liveness do not replace that policy or a
lifecycle fence. The helper starts no custodian and refuses unsupported platforms or a
missing native helper.
The runners take a CLI-shaped ParsedArgs, not a typed options object, so a host fabricates one:
const args: ParsedArgs = { values: { space, server, port: "0" }, positionals: [], raw: [] };For an embedded delivery instance, use startDeliveryService instead. Its HostedContextInputs
include the account public key and lifecycle UID, space, broker URL, injected store, stable
storeIdentity, and an explicit stateDir. The store must declare that same injected identity.
The initial delivery credential must belong to the assigned account. The function returns only
after the delivery responder is bound. close() withdraws serving and releases only the lease
owned by that instance. It closes both membership connections even when a disconnected drain
fails, so they cannot reconnect after closure. Credential-expiry health state clears after
successful broker-verified adoption through the existing reloadCreds rail. A failed start
refuses locally without exiting the host process or stopping another account’s delivery service.
If a health fault occurs during an asynchronous store read, startup rejects when the read returns
and closes any resources created by that late completion.
startAuthService takes the same HostedContextInputs. The store must declare the assigned
injected identity, and its data account must be the assigned account. The IdP pin and ledger live
under the explicit stateDir. The auth plane’s instance identity holds its private serve seed, so it
lives in the store under authInstanceKey(space). A record an earlier release kept under stateDir
moves into the store on the first start.
The context never resolves a workspace root from the working directory and has no local manager, so
only remote manager gates can be selected. It returns after
the authority plane, the callout subscription and the loopback listener are bound. A start that
fails closes the connections it opened and releases its plane claim, so a retry on the same space
can claim it. A fenced plane or
a lost broker connection makes that context unavailable and closes it without exiting the process.
The host writes no discovery file for it. The handle carries what auth-service.json holds for a
CLI start: the loopback url, publicUrl when a public face runs, and the per-start cap. The cap
alone authorizes the loopback host actions, lifecycle retirement and managed-agent enrollment
verification, so keep it in the authority process. publicFace takes the CLI’s public face inputs
(port, url, trustedProxy, advertisedServer, agentProvisioningUrl) under the same rules,
and a face without a port refuses to start.
Two optional inputs serve a platform composition. platformControl: { observeAssignment } adds
platformControlAuthority to the handle. It is a typed in-process method, served on no listener,
that issues the manager-service request family for the one control manager the backend assigned to
this account, under a derived p_ owner. It reads the assignment fresh on every call and refuses
an IdP token, another account, a stale revision, another instance or lifecycle, and an instance
another owner registered. It refuses prepare and activate while the assignment’s named
predecessor is still registered or frozen. The same input adds platformControlReadiness(instanceId),
the route for a host that needs to know whether its assigned control manager is serving. It returns
that instance’s attributed status reply and refuses any instance the current assignment does not
name, or one whose gate another owner holds. It reads over the context’s own connection, whose
grant is the assigned instance’s describe and status and its own reply rail. That connection
renews in process like the context’s other connections and never leaves it, so the host lends no
human or operator credential to a worker, mints no control instrument per read, and does not read
liveness off the manager process. Without the input both members are undefined.
standingRenewableTtlSeconds is forwarded unchanged to the authority plane, which bounds it to 5
to 86400 seconds. It is a trusted-host input for the renewal rehearsal, and no request or CLI flag
sets it. SPEC §13.1 and §13.6 define the view.
The auth plane can renew a registered manager’s five standing credentials from the current service registration. Run-driver renewal still refuses without an authoritative activated-run reader, so these handles do not yet make a complete pooled auth and delivery host. A fresh auth plane can initialize without a delivery-admin responder. Reclaiming a held claim from a dead predecessor needs the delivery instance first: its admin rail must complete the broker connection-liveness sweep before the auth plane takes the claim. An absent or inconclusive oracle refuses the reclaim.
Remote manager client composition
Section titled “Remote manager client composition”@cotal-ai/manager exports the remoteManagerClient namespace, containing the stock remote
request builders and response validators, and registerRemoteManagerAuthority for registration
with a host-issued prepare credential. It returns the process epoch and registration revision its
own registration committed. When a later start of the same instance registers before this start
authorizes its serve grant, this start is refused with expired.
RemoteManagerIdentityState describes the five private
manager identities stored under an explicit account-local root. Use these public exports when
composing ManagerOptions.remoteAuthority; do not copy CLI validators or import private modules.
managerClusterArtifacts() returns the canonical document, manifest and their digests used by
registration. Pass its [document, manifest] pair as contractArtifacts to both
remoteManagerRegistrationProof(owner, state, contractArtifacts) from @cotal-ai/core and
remoteManagerClient.remoteManagerAuthorityRequest(state, actor, "activate", { registrationProof, contractArtifacts }).
The request builder takes each operation’s coordinates as named fields of its last argument.
The host still validates the artifact closure and current registration before activation.
The namespace includes closed standing/run renewal, admission, maintenance, enrollment and
retirement helpers. publicIdentities(state) returns the identities record every request
carries: the five public ids, in the order the host echoes them. remoteRunHosting builds the four runHosting callbacks from the registration
and the transport you supply for the host’s run admission, run attempt and authority requests, so
your manager sends the run requests the stock manager sends. The namespace provides no signer or
new grant. The host still owns authenticated issuance, current registration and activated-run
observations, and any guarded foreign-holder repair.
An embedding must preserve those checks and supply a supported runtime; the client exports alone
do not provide a pooled runtime, an authority service or a complete hosted context.
Long-lived endpoints take a bearer function
Section titled “Long-lived endpoints take a bearer function”EndpointOptions.bearer accepts either a string or a function, and the difference is not stylistic.
A string is minted once, so when it expires (which it will: callout bearers live minutes) the
endpoint has nothing to renew with. It will not present the dead token to the broker, since that is
a guaranteed denial that still costs a full auth-callout round trip. It refuses to reconnect, emits
warning saying which case it is in, and retries on a widening backoff until the process
re-authenticates and rebuilds it. Retry notices use warning rather than error because Node
rethrows an unhandled error event and would kill a host the endpoint is still trying to recover.
Pass a function for anything that outlives one bearer. That is a renewal source: it is called
ahead of each expiry and again whenever a reconnect finds the cached bearer dead, and it requires
explicit card.owner and card.actor. The first-party surfaces already do this
(UserViewAuth.source, the connector’s agentBearerCommand). A string bearer is for a short
one-shot connection.
Long-lived hosts must also subscribe to the endpoint’s warning event. It carries conditions the
endpoint is surviving, including failed credential renewal, reconnect retries, and a durable leave
it keeps retrying after the broker refuses a durable channel’s live subscription. A host may choose
to ignore warnings for a one-shot endpoint whose awaited operation owns the verdict, but that choice
should be explicit. An unhandled warning is nonfatal and silent.
The error event carries a fault the endpoint cannot return from a call, such as a refused
subscription or a refused publish that no request was waiting on. Attach a listener before start(),
since Node throws on an unhandled error. A denial the broker returns to a request, such as an
observer’s read of the DM stream, reaches only that call, which decides what it means, and is not
emitted again as an error.
Booting the daemons
Section titled “Booting the daemons”auth-service
Section titled “auth-service”runAuthService(args, store?) reads its provisioned long-lived secret kinds (service keys, callout
account, issuer keys, owner secret) through the injected SecretStore; a host provisions those into
the store first. It is a signer and identity authority, not a scoped daemon: at runtime it holds
the data-account and callout-account signing seeds, the issuer’s private JWKs, and the
owner-derivation secret in process memory (SecretStore.get exports raw values). The IdP pin and the
actor ledger are not store-injected: runAuthService resolves them under
userAuthStateDir(findCotalRoot(), space), a path relative to the process working directory, so a
host provisions those into that exact directory (neither store nor COTAL_HOME selects it). It
also writes an ephemeral auth-service.json discovery file there that carries the live exchange
capability. That file appears only after every plane is bound, so waiting on it is the readiness
signal: a host that also passes the daemon’s pid to the provider’s ready() gets a process-bound
wait. The wait extends past the base timeout while that pid is alive, up to a fixed bound, and it
ends at once when the pid exits.
import { runAuthService } from "@cotal-ai/auth";// store implements SecretStore over your secret backend; get() returns raw seeds into memory.// Provision the auth secret kinds into the store, AND the IdP pin + actor ledger under// userAuthStateDir(findCotalRoot(), space), before this call.await runAuthService( { values: { space, server: brokerUrl, port: "8081" }, positionals: [], raw: [] }, store,);delivery
Section titled “delivery”runDelivery(args, store?) runs from a pre-minted scoped delivery cred and never loads the
signer. Provide the cred either through the injected store (under
deliveryCredsKey(space, { injected: true })) or with a
--creds file; the two are mutually exclusive. The daemon re-fetches the cred from the store at 75%
of its JWT lifetime and fails loud rather than riding to expiry, so something must re-sign a fresh
cred into that same store. When that read finds the previous generation still there, the daemon
reports the missed remint and retries in 60 seconds. The current cred stays live until its expiry,
and the store is read once per retry rather than once per second.
import { runDelivery } from "@cotal-ai/delivery";await runDelivery({ values: { space, server: brokerUrl }, positionals: [], raw: [] }, store);That renewal is a signer operation, not the delivery daemon’s:
remintDaemonCreds(root, space, store?, { preflight? }) (@cotal-ai/workspace) reads the SpaceAuth
signer through the same resolved store (getSpaceAuth(store ?? workspaceSecretStore(root), space),
keys auth/broker.json + auth/account.<key>.json; the pre-split auth/auth.json monolith is
migration input and the container signer mount only) and re-signs the daemon creds (delivery.creds and the membership feed’s
membership-rw.creds) back into that store. The injected store is both the signer source and the
credential destination, never a split. space is required and validated against the store’s signer, so a
store swapped to a different space cannot re-sign over the wrong broker’s creds. preflight is a
caller-supplied proof that the broker accepts the credential. The reference Manager passes a
probeConnect over its servers. It gates every candidate before overwriting the last-good,
whether the signer is a full bundle or a stripped projection: a bundle’s JWT chain proves only that
it is self-consistent and
named the space, NOT that its account is the broker’s current account for that space (two
createSpaceAuth(space) calls yield same-named, different-account chains), so a same-label alternate
signer would otherwise mint a broker-dead cred and clobber the good one. The offline local repair (doctor auth --fix) has no preflight. It permits the overwrite only
under authority continuity: the candidate must be signed by the same account signing key (iss) as the current
(already broker-accepted) cred. A same-label alternate account breaks continuity and is refused, full or
stripped; a legitimate local re-sign is continuous and proceeds without a network. The reference
Manager runs it on a schedule against its own
secretStore (see below), so passing the manager and the delivery daemon the same store closes the
renewal loop end-to-end on an injected backend: the manager reads the signer from the store, re-signs
into it, and the daemon adopts each generation on a preflight-proven 75% timer. The stock
cross-host composition cannot satisfy that by writing one filesystem and fingerprinting another:
Manager.start() and every later remint challenge the daemon’s reloadStoreIdentity and a
divergent pair is refused naming both stores. The identity is the store the daemon actually
reloads: an injected coordinate, the workstation root only when --creds is
<root>/.cotal/<spaceSegment(space)>/delivery.creds (matching the canonical arm), the
file’s own directory for any other --creds path, or the workstation root. A filesystem
store is also named by a random id it records in store.id inside its own directory, so two
hosts that use the same root path are two stores. No key and no --creds file may be that
file under any name, and a store.id that is a symbolic link or holds anything but a lowercase UUID is refused. Uninjected
--creds that names one real workstation while process cwd resolves another is refused
at start, naming both, because membership-rw still uses findCotalRoot. A --creds
path that is not under any .cotal tree is not that case and is not refused here. It never
walks ancestors with findCotalRoot. No bound daemon is not a named
store, so start proceeds; a later daemon on a foreign store is refused on the next remint.
The first-party filesystem adapter declares its workspace-root identity on the store itself. Other
injected adapters declare their stable coordinate on SecretStore.identity, or name it in
COTAL_SECRET_STORE on both processes. It never throws: it
returns per-file results (skipped: "no-auth" when the store holds no signer records),
so the caller must check them or the cred still rides to expiry. A composition whose signer lives in
KMS/Vault simply injects that store; no bespoke renewal is needed. A --creds file path must be
replaced atomically before the 75% read. The signer can now be injected behind the store seam, which
resolves custody. The remaining hosted gap is signer isolation. The seed is still decrypted
in-process at the manager’s uid, so it needs an OS sandbox or remote signer.
Supervisor signing authority
Section titled “Supervisor signing authority”@cotal-ai/manager exports the Manager class; there is no runSupervise(opts) runner. The
private CLI runManager also does broker-reachability checks, space/default resolution,
roster/launch parsing and materialization, installed-extension resolution, signal handling, staged
pre-spawn, and the forever wait. A host composes that lifecycle itself around Manager:
import { Manager } from "@cotal-ai/manager";const mgr = new Manager({ space, servers: brokerUrl, workspaceRoot });await mgr.start(); // then wire your own SIGINT/SIGTERM -> mgr.stop()stop() runs once. A later call joins the stop in progress and settles with it, and a call that
asks for a different withAgents than the running stop is refused.
Unlike delivery, the manager is not a pre-minted-scoped-cred daemon (auth-service is also a
signer: it holds fewer artifacts than the full trust bundle, but its data-account signing seed still
grants complete data-account mint authority on compromise, so this is not least-privilege). On start()
the manager reads its space’s full trust chain through its secretStore (getSpaceAuth(this.secrets, this.space), composed from auth/broker.json + auth/account.<key>.json; a container may instead
mount a stripped signer bundle at the legacy auth/auth.json key) and self-mints its supervisor cred and renewals from the
data-account signing seed. In static mode it also mints every per-agent cred from that seed; in user
mode agents instead receive callout-minted bearers, but the manager still holds the signing seed for
its own creds and renewal. So a hosted supervisor is a trusted per-tenant account-signer process,
not a least-privilege connect client. It additionally requires a ~/.cotal/meshes/space.<key>.json
registry record and the workspace user-auth marker to start in user mode. ManagerOptions.secretStore
injects the one SecretStore the manager uses for the signer itself (the split trust
records), daemon-credential renewal (remintDaemonCreds), and per-agent secrets,
defaulting to the workspace filesystem store; pass the delivery daemon the same store for end-to-end
hosted renewal. The store declares the same identity on both processes, or both set
COTAL_SECRET_STORE to the same coordinate. The manager
remints no daemon credential when the daemon names a different store, including a daemon that binds
after start; it keeps running and serving its own agents, so one space can carry a manager on more
than one workspace root. That manager also stays off the space’s renewal lease, so a manager or a
cotal doctor auth --fix on the daemon’s store can still take it. Pointing several managers at one coordinate is safe: the store identity
alone cannot pick an owner (it carries no holder and no tiebreak, so every manager sharing the store
matches), so the manager that also holds the space’s renewal lease is the one that remints and the
rest skip it. Without that lease two owners would remint on independent timers with no ordering
between them, and one write would land between the other’s re-sign and its fingerprint-only
reloadCreds. cotal doctor auth --fix takes the same lease before it re-signs, so a live manager
and a local repair never race each other either. The signer IS now injectable: a hosted composition injects a KMS/Vault store and no
signing seed lands on the hosted disk. What remains is signer isolation. The seed is decrypted
in-process at the manager’s uid. That issue needs an OS sandbox or remote signer; it is no longer a
custody problem. The other knobs are workspaceRoot and the process-global COTAL_HOME.
A store, runtime or extension the manager calls may reject with any value, including null. The
manager logs or refuses with that value’s message, or with the value itself as text when it has
none, and keeps serving. A value it cannot read reports as an unreadable rejection.
Scope note: the static-auth operator paths (
cotal spawn/join/status/web, viamesh-target→connect/preflight) still read the signer from the local split records (syncloadSpaceAuth). That is the single-machine composition, where the signer is on local disk by the static-auth model; multi-tenant hosting runs user mode, which never mints from on-disk trust. The store-injectable signer path is the hosted-server set: the manager,remintDaemonCreds, and delivery.
The typed remote-manager authority contract includes a one-shot terminal phase. A host implements
remoteAuthority.prepareAgentRetirement to revoke the managed grant and finish its resumable
release while preserving the UID, then remoteAuthority.mintRetirementRequester returns the
host-signed JWT for a fresh participant-owned nkey. The credential is pinned to the authenticated
owner, server-derived manager serve principal, current instance epoch, and exact target lifecycle.
The manager then uses the existing auth retireLifecycle rail with the operation id derived by
managedRetirementOpId(target.lifecycleUid). This derivation is the reference remote-Manager
composition’s closed contract, not a rule for every retirement entry point; interactive retirement
keeps its existing operation identity and remains compatible. The retireLifecycle rail independently
recomputes the managed id from its broker-pinned target before any gate, head, intent, or barrier
access, so mint-time validation is not the terminal boundary. A failure keeps
the alias held. This does not expose the auth barrier or give the participant signer authority.
If the participant disappears after prepare, the host finishes the retirement itself on the auth
service’s loopback face: POST /managed-lifecycle/retire (exported as MANAGED_RETIRE_PATH from
@cotal-ai/auth) with the Bearer <cap> from auth-service.json and only
{ owner, actor, lifecycleUid }. It has the interactive door’s guards (POST only, no Origin, JSON,
capability, closed body) and is never served on the public face. The managed grant must already be
revoked at that uid, or it answers 409. It runs the same managedRetirementOpId(uid) operation as
the rail, and the rail and the door share one in-process flight, so a late participant request and
the host call converge on one barrier.
| lifecycle head | answer |
|---|---|
absent, or retired at another uid |
200 { retired: false, lifecycleUid, notStarted: true } |
active/retiring at another uid |
409 |
retired at this uid |
200 { retired: true, lifecycleUid, alreadyRetired: true } |
active/retiring at this uid |
the barrier runs, then 200 { retired: true, lifecycleUid } |
Deprovisioning durables stays with deprovisionAgent and a deprovisioner credential. Pass memberChannels to both to also purge the retired lifecycle’s durable membership rows on those concrete channels. The manager fills that list from the launch’s concrete read channels and the delivery daemon’s read-only lifecycleMemberships admin verb. When that verb cannot answer, rows on other channels stay retained, the teardown logs the inventory as incomplete, and retirement remains held pending retry rather than releasing the alias. Each teardown examines two exact consumers, one ACL key and the named member keys. KV deletion uses a native revision condition; a lost condition with a live replacement refuses rather than claiming absence. Consumer INFO checks before and after DELETE distinguish verified prior absence from disappearance. acknowledged counts native DELETE success replies. disappeared counts observed live-to-absent consumers and live KV rows whose conditional purge lost to a competing deletion. These KV rows were present at the first read, so they never count as prior absent or as this caller’s deleted. The ACL and membership subtotals preserve that distinction. Neither establishes which concurrent caller uniquely removed a consumer, so consumers.deleted and the total deleted are null when a consumer disappears without a winner token. A repeat after verified absence reports zero, not an invented deletion. refused counts slots whose cleanup or state remains uncertain. A partial failure raises DeprovisionError carrying these bounded observations. The Manager’s static sweep preserves unknown uniqueness rather than adding acknowledged requests as physical removals; its slot totals remain separate.
A host that resumes retained managed actors also implements
remoteAuthority.validateRetainedAgent. The participant sends back the actor token and sentinel it
already holds, plus the nextRegistrationProof returned by the activation response. That proof is
host-issued after registration and binds the manager owner, actor, lifecycle, identity nkeys, current
registration revision, and serving epoch. The host checks it against the current open manager gate,
validates the retained secrets against its current managed row, and returns only the non-secret
authority shape. The manager binds every result coordinate and the returned authority back to its
inventory before use. Do not copy the provider’s issuer.json or callout.json into the participant
store. Both contain private signing or exchange authority.
The same composition supplies remoteAuthority.agentBearerExchangeUrl, the pinned public auth-service
base used by retained children. Remote adoption launches agent-bearer --exchange-url <base>; it must
not select the local --dir arm, which depends on a host-only auth-service process record.
A host that lets a remote participant spawn FRESH managed agents implements
remoteAuthority.enrollManagedAgent. The participant generates the standing actor token, writes it
at mode 0600, and passes only its SHA-256 digest with the requested actor, label, role,
capabilities, and channel lists, so the plaintext secret never leaves the participant machine. There
is deliberately no lifecycleUid input: the host selects the UID, because only the host sees the
retirement tombstones that make a UID permanently unusable, and a participant-chosen UID could aim a
fresh grant at a dead incarnation. The host authors the ledger grant, pre-creates the lifecycle-keyed
durables, clamps the requested lists to what the spawning owner already holds, and returns the owner,
actor, chosen lifecycleUid, the space sentinel credentials, the effective lists, and
agentBearerExchangeUrl. The manager binds every returned coordinate, re-keys the secret family onto
the returned UID, and launches agent-bearer --exchange-url <base>. When the hook is absent a
signerless manager refuses the user-mode spawn rather than authoring a local grant the host knows
nothing about.
Both managed-agent operations ride the one verified POST /manager-service-authority transport as
kind: "manager-managed-agent-enrollment" and kind: "manager-managed-agent-prepare-retirement".
Stock cotal auth-service answers both itself, because it owns the actor ledger and the space’s
provisioning authority. An enrollment writes the managed grant at a fresh UID with the supervising
actor as its parent, provisions that UID’s durables, and returns the daemon’s public exchange URL as
agentBearerExchangeUrl; a daemon started without --exchange-public-port refuses enrollment. A
retry with the same token digest answers the same UID while the supervising actor’s current grant
covers it, and a fresh enrollment’s refusal otherwise. While the agent’s grant stands, an enrollment
with another digest is refused with conflict until that lifecycle’s retirement is prepared. A
prepare-retirement releases the target UID’s broker footprint and then revokes its grant, so the
manager’s terminal rail finds the grant gone. A platform that keeps these writers in its own storage
intercepts both kinds instead. It terminates its own public route, authenticates the human there,
and asks the auth service for the decision at
POST /manager-service-authority/verify-enrollment (exported as VERIFY_ENROLLMENT_PATH from
@cotal-ai/auth) with the Bearer <cap> from auth-service.json and only { owner, request }. That
door has the managed retirement door’s guards, derives the caller’s scope from the local ledger rather
than the body, checks the manager gate and registration proof in-process, and answers
{ authorized: true, owner, actor, instanceId, serveEpoch }. authorizeRemoteManagedAgentEnrollment
and authorizeRemoteManagedAgentPrepareRetirement are exported too, for a host that composes the
decision without the HTTP hop. Both require ledger scope supervise; spawn and admin do not
imply it.
A host that runs managed agents on its own hosted runtime adds two more kinds on the same transport.
kind: "manager-managed-agent-runtime-create" asks the host to create the runtime for one agent it
already enrolled, and kind: "manager-managed-agent-runtime-status" reads that runtime’s state. Both
carry the manager envelope plus target: { owner, actor, lifecycleUid }, the coordinate the
enrollment returned. Both schemas are closed. An unknown top-level or target field, including
providerRef, handle, or name, is refused as bad-request, because the host alone issues and
holds provider references. There is no stop, adopt, or probe kind: stop goes through
prepare-retirement. Stock dispatch refuses both kinds with unimplemented, and the verify-enrollment
door decides them. authorizeRemoteManagedAgentRuntimeCreate and
authorizeRemoteManagedAgentRuntimeStatus apply the enrollment door’s checks: host space, a target
owner equal to the authenticated owner, the manager actor’s own ledger row with supervise, the open
gate, the current serve epoch, and the registration proof. Each returns only
{ owner, instanceId, actor, target }. The door touches no provider and writes nothing. The host
matches the decision to its own intent record and performs the create afterwards. The host answers
with state (reserved, creating, bound, create-unknown, closing, or closed), readiness
(ready, bound-not-ready, or none), and an optional retirementPhase. A manager builds requests
with remoteManagerClient.remoteManagedAgentRuntimeRequest and binds the answer with
remoteManagedAgentRuntimeState.
An enrollment result may also carry runtimeIntent: { state: "reserved" } when the host reserved a
hosted runtime for the agent. It is display-only. Older hosts omit it, the manager binds both shapes
to the same material, and nothing reads it as authority.
No stock door lets a platform control holder launch or retire an agent for a signed-in user. The
managed-agent kinds above act only under the authenticated owner and refuse a caller that is not
that user, so a platform could only run a user’s agent by holding the user’s login or by enrolling
the agent under its own owner. Both are refused. The
delegated user launch intent
design and SPEC §13.16 define the smallest addition. The user admits one launch or one retirement
on the host’s authenticated route. The holder consumes that intent once, from its current
registration, epoch and lifecycle. The host then enrolls the agent under the user’s u_ owner with
the user’s own actor as its ledger parent, so the envelope walk, membership and channel lists match
what the user’s own manager would produce. Retirement keeps the prepare, provider closure and
terminal barrier order, and the host finishes it when the holder is gone. A launch the host had to
undo keeps its agent name held until the host process that ran it confirms it has stopped, and
while the name is held the host also refuses it to the user’s own manager. @cotal-ai/auth ships the
two decisions, authorizeDelegatedUserIntentAdmission and authorizeDelegatedUserIntentExecution,
for a host that owns an intent store and those writers to compose on its own routes. Both read the
holder’s gate through the handle’s observeManagerGate, present with platformControl, which reads
over the context’s own connection, so the host opens no second data-account connection. It is an
observation for the decision: the consuming CAS and the writers still apply their own checks. The
consuming CAS pins the incarnation of the host process that runs the flight as the executor, never
the auth plane’s, because the two can restart independently. platformControl.host names that
process’s reverse-DNS endpoint, the closure digest of its §13.7 cluster and the contract artifacts
registration reads, and the auth plane self-authorizes that one name. Registration reads the closure
manifest { v: 1, root, members } at clusterDigest, then the cluster document at the manifest’s
root, and verifies each against its digest. artifacts therefore carries both, and clusterDigest
is the digest of the manifest. members stays empty: SPEC §13.7 lists every reachable artifact
there, but this implementation registers single-document clusters only and refuses a manifest that
lists members. A manifest with any field beyond v, root and members is refused too, as the
contract store refuses it. singleDocumentClosure(document) returns that manifest and its closure
digest. The instance id is a lifecycle token, [a-z0-9]{26,32}. The minimal construction below has
one command over the void schema. A host copies it, replaces document with its real cluster, and
passes host as platformControl: { observeAssignment, host }.
import { mintLifecycleUid, singleDocumentClosure, VOID_SCHEMA_DIGEST } from "@cotal-ai/core";
const document = { urn: "com.example.host", revision: 1, attributes: [], events: [], commands: [{ name: "ping", class: "ephemeral", targeted: false, capability: "host.ping", inputDigest: VOID_SCHEMA_DIGEST, outputDigest: VOID_SCHEMA_DIGEST, }],};const { manifest, closureDigest } = singleDocumentClosure(document);const host = { endpoint: "com.example.host", clusterDigest: closureDigest, artifacts: [document, manifest] };const instanceId = mintLifecycleUid(); // first start only; later starts reuse the persisted idregisterHostIncarnation(instanceId) publishes the artifacts, registers that instance through the
ceremony the plane runs for itself, and returns { instanceId, processEpoch } with the epoch that
registration committed. The host calls it at every start with its persisted instance id, before it
admits or recovers any flight, so a restart fences its predecessor. The first registration of an
instance commits epoch 0, which is open and serving like any later epoch, and each later start of
that instance commits the previous epoch plus one. A consumer compares epochs for equality and never
reads 0 as absent or not ready. A start
whose confirming read of the gate finds that a later start of the same instance registered is
refused with conflict. The returned epoch is a committed coordinate and stays current only until
the next start registers, which can happen before the call returns. observeHostGate(instanceId)
is a point-in-time read of an executor’s gate, and answers null for an absent gate; a sweeper
decides on it. awaitHostFence(instanceId, processEpoch) resolves with the gate once it is no
longer open at that epoch, and with null once it is absent. It takes any non-negative safe integer
epoch, 0 included, and refuses any other value with bad-request. The host arms it with its returned
incarnation before it admits or recovers any flight, and stops serving when it resolves. It polls
the gate, because no runtime credential may watch the auth bucket. The
host’s launch writer first activates the agent’s lifecycle at the pinned UID through the handle’s
activateManagedLifecycle, before any ledger row or durable, and its compensation runs the same
call before the terminal barrier, so a launch whose agent never exchanged its bearer still reaches
the terminal barrier at that UID. The launched agent exchanges its bearer on the context’s public
face, so the host starts it with publicFace, and its retirement writer ends at
POST /managed-lifecycle/retire with the handle’s cap. Stock dispatch refuses both kinds as
unimplemented. The holder’s composition passes
remoteAuthority.executeDelegatedUserIntent, which posts the execution request and binds the answer
with parseRemoteDelegatedUserIntentExecutionResult. It then starts the agent with
startAgent({ ..., delegatedIntent: { intentId, owner, parent } }) and retires it with
retireDelegatedAgent(name, intentId), which stops the agent only after the host confirms
retired: true for its exact target. A delegated agent’s stop, exit or failed launch keeps its name
held until that retirement confirms.
Remote user-mode managers must also supply remoteAuthority.authorizeAdmin. The manager builds each
request only from the caller tuple parsed from the broker-authenticated endpoint subject, then relays
that tuple over the current registered manager lifecycle. HTTPS does not separately authenticate the
relayed caller. The host authenticates the manager operator, binds the request to the current open
manager gate, registration proof, serving epoch, and identity nkeys, then reads the caller’s unified
authoritative row fresh. It returns only the manager owner and authorized: boolean, with every request
coordinate echoed. Missing, revoked, narrowed, foreign-owner, and stale-lifecycle callers all return
false; malformed coordinates or corrupt and unavailable authority state fail the operation. The
participant never reads or mirrors the host ledger, and the remote branch has no local fallback. The
same callback gates all manager.admin handlers, any-mode cross-owner control, and ps or inspect
cross-owner visibility. Launch keeps its owner-equality policy.
The remote authority’s instance executor remains the scoped maintenance credential for clean service
deregistration and exact instance registration operations. It carries no records-stream consumer
lifecycle authority. The manager’s boot goalidx sweep uses the authenticated host operation, which
returns parsed goalidx.manager.<owner>.> entries for that owner only. The host keeps the sealed
consumer connection and its create/delete rights. The five-minute executor already renews through
remoteAuthority.renewExecutor. The signerless supervisor, serve, goal-writer, session-ledger and
per-run driver credentials do not yet have a complete remote renewal and adoption path. The
hosted runtime contract records the bounded additions and
their ownership; it is not a shipped pooled service.
Signer isolation needs an OS sandbox. The default pty runtime
runs agent children under the same OS uid and the same workspaceRoot, so mode-0600 on
the trust records does not stop a hostile same-uid agent from reading their absolute paths. The reference
deploy tree does not solve this: it mounts the signer into the agent’s own container, so
its phase-1 boundary isolates agents from each other, not the signer from the agent. A hosted
composition must run the manager/minter that holds the signer in a different uid, container, or mount
namespace from the agent children, which mount no signer at all; that split is future
hosted-composition work, so until it (or a remote/injected minter) exists, do not run untrusted
agents under this manager.
Delegated seats outside the manager’s filesystem
Section titled “Delegated seats outside the manager’s filesystem”The portable lifecycle bootstrap
design and SPEC §13.17 define how a managed agent that enrollManagedAgent already enrolled starts
in a child that cannot see the manager’s filesystem. The ordinary spawn path writes the token and
sentinel under the manager’s workspace root and hands the runtime a launch whose bearer command and
material file are paths on that filesystem, so such a child needs this path instead.
The delegation boundary is one optional runtime method. A runtime that implements
Runtime.spawnDelegated(launch, handoff) receives two values and no paths: a DelegatedSeatLaunch
(connector name, persona text, model and the other launch choices) and a ManagedLifecycleHandoff
(space, owner, actor, the host-chosen lifecycleUid, broker and IdP pins, the pinned exchange base,
the sentinel, the channel lists, and the raw actor token). The manager enrolls once, as it does
today, and builds the handoff from what it holds. It never sends the token to the host, never copies
a file from its workspace or secret store, and never builds a local launch for that seat. No signer,
issuer or callout record, loopback capability, provisioner or manager credential, control token, or
manager path crosses. A spawn choice that only the manager’s host can honour is refused before
enrollment: --resume, a manifest agent’s continuity: exact, --cwd, and any shared MCP server,
whether from --share-tools or the config default (--share-tools none passes).
The runtime creates one provider resource under managedRuntimeKey(target), writes the handoff into
it as one 0600 file and the persona beside it, and runs the stock bootstrap there:
cotal spawn --config <persona-file> --space <space> --name <actor> --expect-owner <owner> --expect-lifecycle-uid <uid>
with COTAL_MANAGED_HANDOFF_FILE naming the file. delegatedSeatCommand builds that argv. The
cotal entry reads the file into memory, deletes it and drops the variable before it parses flags,
prints help or loads extensions, so every outcome, a refusal of its own flags included, leaves no
file. It refuses before any broker connection or exchange request when the
space, owner, actor, or lifecycle UID differ from the expected values, and then runs the
enrollment-redeem consumer: it registers the mesh in its own home, writes the token to its own 0600
file, and exchanges it through agent-bearer --exchange-url unchanged. It never enrolls, redeems, or
mints a token or UID.
Readiness is still mesh presence. A create whose answer is lost leaves the handle running, so the
launch settles uncertain and stays held; the manager never retries it. A provider read that finds no
resource under the key is not an exit, because the create may still land. Every close by
managedRuntimeKey is fenced: it completes only once the create was answered or the provider refuses
any later create under the key. A provider that names its own resources may run the create as a
durable operation keyed by managedRuntimeKey and close through the identifier its authenticated
create response returned, kept where the host can read it without the manager. Only that response
binds an identifier to the key; one derived from the key or found by name or listing is never closed
or adopted, and while the response is unknown the launch stays held. Every stop, the reap of a child
whose parent exited and Manager.stop({ withAgents: true }) included, runs prepareAgentRetirement
for the UID-exact target, then stop() on the handle spawnDelegated returned, then the terminal
barrier. preparePreservation refuses a cut that holds a delegated seat, and a Manager.stop() after
a refused cut retires the seat through the same steps. After the manager is gone
the host runs the same steps, makes the same fenced close by managedRuntimeKey, and finishes at
MANAGED_RETIRE_PATH. Supply spawnDelegated only from a runtime whose host can make that fenced
close without the manager. The enrollment redeem
(COTAL_ENROLLMENT_FILE) stays for lifecycles whose token the host generated itself; a host cannot
mint one for a manager-enrolled lifecycle because it holds only the digest.
Provisioning a space (one-space reference shape)
Section titled “Provisioning a space (one-space reference shape)”import { createSpaceAuth, setupSpaceStreams, ensureDefaultDeliveryClass, mintCreds, newIdentity } from "@cotal-ai/core";const auth = await createSpaceAuth(space); // trust bundle (in-memory seeds)const provisionerCreds = await mintCreds(auth, newIdentity(), "provisioner");await setupSpaceStreams({ servers: brokerUrl, space, creds: provisionerCreds });// SPEC section 4: write the default delivery class at space creation so it is wire-discoverable,// never inferred from the resolution fallback. A daemon-backed space is "durable".await ensureDefaultDeliveryClass({ servers: brokerUrl, space, creds: provisionerCreds, deliveryClass: "durable" });const deliveryCreds = await mintCreds(auth, newIdentity(), "delivery");// put deliveryCreds into your SecretStore under deliveryCredsKey(space, { injected: true })// (@cotal-ai/workspace) before booting delivery — the key is per-space, not the bare kind.Rendering the broker config for a user-auth space is serverConfig(broker, spaces, { storeDir, maxFileStore?, extraAccounts }), where extraAccounts must include the callout account from
createCalloutAuth so the auth-service has a broker account to answer on. That account never shares
the data account. maxFileStore is an optional positive integer byte cap; any other value throws.
Broker trust and space accounts are separate authorities: createBrokerAuth mints the one
operator + system account a broker trusts, createSpaceAccountAuth(broker, space) signs each
tenant’s data account under it, and serverConfig(broker, spaces, opts) renders them all into one
config. A host composition can therefore provision several spaces on one broker today. cotal up
renders that config from every tenant the root’s auth directory holds, so booting one space keeps
the broker trusting its siblings, and it refuses to render at all while any account record is
unreadable. The rest of the CLI lifecycle is still broker-wide: down, clean and backup refuse
on a multi-space root rather than scoping to one tenant, and the per-space lifecycle is the
remaining multi-space operator layer. See
Known gaps.
Hazardous provisioning primitives
Section titled “Hazardous provisioning primitives”mintCreds, the full Profile/CredentialKind matrix, createSpaceAuth, and stripSpaceAuth are
low-level operator primitives. Handle them as account-authority material:
- A holder of a
SpaceAuth(or astripSpaceAuthbundle, which keeps the data signing seed) is a fully-trusted tenant-account authority: it can mintadmin,provisioner, and destructive profiles, not merelysupervisor, and mint a DM-reading identity.createSpaceAuth’s full result holds operator, system, and account seeds in memory. - Choose
profileandMintOptsfrom server-side constants, never from tenant input.MintOptscan widen the bounded TTL defaults; cap it at your boundary.CREDENTIAL_LIFETIMESis a policy record, not an authorization boundary. - Never log signer material or export it into env. Do not co-locate signer access with an untrusted
connector/runtime process at the same OS uid (file permissions do not contain a same-uid reader;
see the manager’s isolation note). Segregate per tenant; rotate on compromise
(
rotateDataAccountSigningKey).
Hosted composition gaps
Section titled “Hosted composition gaps”The primitives above are present as exports, but three capabilities are not cleanly composable from the public contract today. Each is tied to work in flight; a host either waits for the seam or scopes the capability out. None is a wire concern.
- Delivery immediate live eviction and a fully-hosted membership feed. The renewable
membership-rw.credsis now aSecretStorekind.startMembershipreads it through the injected store, and the manager re-signs it there. The graph-feed writer therefore renews on a hosted backend (its data connection adopts each generation on a preflight-proven 75% timer). What still reads from a fixed on-disk path are the staticmembership-observer.credsandconnection-evictor.creds($SYS creds, minted at theupthat provisions the account and renewed byup --rotate-sys) andmembership.json({accountId}, non-secret config); those, plus the private provisioning wrapper, keep immediate live eviction and a fully-hosted feed a partial gap. Missing files degrade membership to traffic-only and make live eviction refuse (loudly). The supported delivery contract here is the Plane-3 durable backstop. - Supervisor signer isolation.
ManagerOptions.secretStorenow injects the oneSecretStorethe manager reads/writes every secret through, including the composedSpaceAuthsigner (the split trust records), its daemon-cred renewal, and its per-agent kinds. What remains is process isolation: the manager still decrypts the signer in-process at its uid, so untrusted agent children must run under a different uid/container/mount namespace or behind a future remote signer. - Per-space lifecycle on a shared broker. The trust layer is multi-space
(
createBrokerAuth+createSpaceAccountAuth+ N-spaceserverConfig, persisted asbroker.json+account.<key>.json) andcotal uprenders the whole tenant list, but there is no per-space provisioning verb and no per-space teardown/backup/restore: the CLI’s broker-wide lifecycle verbs refuse on a multi-space root, naming the tenants. This is the remaining multi-space operator layer. - A non-Better-Auth production IdP. The exchange core (
createIdpBridge) is EdDSA-generic, but the stock provider and login client are Better-Auth-endpoint-shaped,cotalAuthProviderself-registers on import (colliding with a host-owned provider underresolveAuthProvider), and the login flow speaks Better Auth’s device-code endpoints. A different IdP is a host-built auth composition on the low-level primitives, not a configuration change (see the IdP callout contract).
Hosted durability
Section titled “Hosted durability”Space-durable coordination state (chat/DM/task history, live presence, membership runtime, the durable ACL registry, leases) lives in JetStream, written by the delivery daemon and the endpoints. It is broker-resident and needs no host-side durable path.
What is not in JetStream, and is hosting-critical, is trust and authorization state a host must place and keep:
| state | class | where today | hosted injection |
|---|---|---|---|
full SpaceAuth trust chain (auth/broker.json + auth/account.<key>.json, composed; a stripped signer bundle may instead be mounted at the legacy auth/auth.json key) |
signing authority | SecretStore |
SecretStore (manager + renewal) |
| auth kinds: callout account/creds/xkey, issuer private keys, owner-derivation secret, data-signer projection | signing/identity authority | four SecretStore kinds |
SecretStore (auth-service) |
| auth plane instance identity (instance id + serve nkey seed) | restart identity | root .cotal/space.<hex>/auth-instance.json |
SecretStore (startAuthService) |
delivery.creds |
standing scoped cred | SecretStore or --creds |
SecretStore (delivery) |
| actor ledger, IdP pin | authorization + trust config | ambient userAuthStateDir(findCotalRoot(), space) |
none (root-relative; not store/COTAL_HOME) |
membership-rw.creds |
standing scoped cred | SecretStore |
SecretStore (delivery + manager renewal) |
membership-observer / connection-evictor creds + membership.json |
scoped $SYS creds / config | workspace filesystem | none (see gap 1) |
| manager agent creds, actor tokens, sentinel creds | lifecycle authority | SecretStore |
SecretStore (manager secretStore) |
~/.cotal/meshes/space.<key>.json record (holds IdP trust pins/root pointers) |
non-secret, integrity-critical | machine home | process-global COTAL_HOME only |
| auth-health, renewal records | non-secret diagnostics | workspace filesystem | workspaceRoot |
The SpaceAuth trust chain and the auth-service store kinds are separate identities/projections,
never parts of one document. auth-service.json (the live exchange capability) is ephemeral runtime
state, not durable, but is sensitive while the daemon runs. @cotal-ai/workspace is machine-local
operator tooling by design; personas, PID files, and the current-mesh pointer are truly local and
must not sit on a hosted durable path. Everything classed above as an authority is what a hosted
composition must provision and persist: signer-bearing server secrets now have SecretStore seams;
the remaining non-injectable rows are the explicit ambient workspaceRoot/cwd paths above.
See also
Section titled “See also”- Substrate stability: what v0.3 and the 0.x packages guarantee, and the projected v0.4 break.
- Identity and auth: the profile matrix, the signer, and the IdP callout contract.
- Delivery daemon: the Plane-3 durable backstop.
- Deploy: the reference container against an external broker.
Hosted run revocation
Section titled “Hosted run revocation”The authenticated manager-service authority door accepts manager-run-revoke with the registered
manager envelope and revoke: { runId, reason }. The issuing host reads the admission’s recorded
owner and requires the verified requester to be that owner or to hold admin in its fresh actor
ledger row. It verifies the current manager registration proof, space, account and epoch. It accepts
no owner or attribution from the body.
AuthAuthorityPlane.revokeManagerRun implements the operation. AuthProvider.requestRemoteRunRevoke
transports it, and remoteRunHosting supplies RunHostingContext.revokeRun for a signerless
manager. The callback returns the stored RunRevocation, including on repeats. A host composing
its own remote authority must supply that callback to revoke. No signer reaches the participant.
CLI and MCP revoke verbs are a follow-up described in the run-start design.