Skip to content

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.

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).

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.

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.

@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.

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,
);

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.

@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, via mesh-target → connect/preflight) still read the signer from the local split records (sync loadSpaceAuth). 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 id

registerHostIncarnation(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.

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 a stripSpaceAuth bundle, which keeps the data signing seed) is a fully-trusted tenant-account authority: it can mint admin, provisioner, and destructive profiles, not merely supervisor, and mint a DM-reading identity. createSpaceAuth’s full result holds operator, system, and account seeds in memory.
  • Choose profile and MintOpts from server-side constants, never from tenant input. MintOpts can widen the bounded TTL defaults; cap it at your boundary. CREDENTIAL_LIFETIMES is 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).

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.

  1. Delivery immediate live eviction and a fully-hosted membership feed. The renewable membership-rw.creds is now a SecretStore kind. startMembership reads 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 static membership-observer.creds and connection-evictor.creds ($SYS creds, minted at the up that provisions the account and renewed by up --rotate-sys) and membership.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.
  2. Supervisor signer isolation. ManagerOptions.secretStore now injects the one SecretStore the manager reads/writes every secret through, including the composed SpaceAuth signer (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.
  3. Per-space lifecycle on a shared broker. The trust layer is multi-space (createBrokerAuth + createSpaceAccountAuth + N-space serverConfig, persisted as broker.json + account.<key>.json) and cotal up renders 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.
  4. 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, cotalAuthProvider self-registers on import (colliding with a host-owned provider under resolveAuthProvider), 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).

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.

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.