Manager embedding authority
Interactive manager primary
A trusted composition can pass { interactiveManagerSelection: "local-primary" } as the third
argument of runAuthService(args, store, options). AuthServiceRuntimeOptions is exported by
@cotal-ai/auth. The third argument still accepts the native daemon lifecycle hooks. No CLI
flag or exchange field sets the policy, and unsupported runtime values refuse before startup effects. openAuthAuthorityPlane accepts the same option beside its fresh
localManager reader; OpenAuthAuthorityPlaneOptions is exported too.
The opt-in chooses the genuine local Manager up front for an unpinned human manager-caller
exchange. It reads the persisted local identity and requires the existing open gate, serve
principal, registration revision and status epoch checks. An absent, unreadable or noncandidate
local Manager refuses without scanning or substituting remote Managers. Explicit selectors win,
and managed-secret exchanges and launching or issued instance bindings keep their existing rules.
The option adds no scopes, registry reads or inventory rows.
This routes all unpinned interactive Manager control, not only ps: slot reads, stop, attach,
input, spawn, models, hosted run verbs and generic Manager describe/invoke share the view. It
chooses local even when the person has a live remote Manager. A host must decide whether that
routing is appropriate; a local Manager without execution cannot forward a spawn or run to the
person's remote Manager. The person selects that Manager explicitly for those operations.
Absent the option, selection is unchanged. startAuthService still has no local Manager and
keeps its remote-only composition. Supplying this programmatic option does not enable it in stock
up, restart or recovery paths; a host must compose those paths separately after adopting the
published API. ps still lists only the selected Manager's managed seats, not HTTP-hosted sessions.
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.
start() refuses with the exported ManagerInstanceLeaseHeld when this instance's lease is held
by another live process on this root, or by a holder on another root. Its held field is the lease
row (runtime, pid, root, holder, since) and sameRoot says whether that root is this one. A restart
whose predecessor on this root is dead, including this process's own earlier start, waits for the
old key to expire instead. A lease that could not be read or written is a plain Error.
Signer custody
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.
Daemon renewal ownership
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.
Rejection handling
A store, runtime, extension or host callback 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.
Managed lifecycle retirement
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 } |
Durable cleanup results
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 and channel families. The manager fills that list from the launch's concrete read channels and the delivery daemon's read-only lifecycleMemberships admin verb. Before it reads that verb, it calls fenceLifecycleAcl under a deprovisioner credential for the same lifecycle with no member channels. That narrows the lifecycle's read-ACL row to no reads, and a durable join the inventory does not see then removes its own row. 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 exact.
A separate host activates one exact managed lifecycle through the same full account auth service:
POST /managed-lifecycle/activate (exported as MANAGED_ACTIVATE_PATH) with the Bearer <cap> from
that account's auth-service.json and the closed body
{ accountPublicKey, owner, actor, lifecycleUid }. This route is present on an ordinary separately
hosted full auth service. It does not require platformControl; that option adds the equivalent
in-process handle for a composition sharing the process. A callout-only replica has no HTTP listener
or authority plane and cannot serve it.
The repeated account key prevents a host routing error from asking one account authority to act on
another account's target. It must equal the service's assigned data account before lifecycle work.
The route is POST-only, refuses Origin, requires JSON and the per-start capability, rejects unknown
fields, and is absent from the public exchange face. Parser and authority-selection refusals state
outcome: "not-executed". After dispatch, the native activation code, details and outcome are
returned. A non-native failure reports outcome: "unknown", never activation success.
The operation calls the authority plane's existing fence before activateLifecycleAtUid. Success
returns { accountPublicKey, owner, actor, lifecycleUid, activated: true } only after the head is
active at that UID and its issuance gate is open. The native lifecycle contract remains unchanged:
| lifecycle state | answer |
|---|---|
absent, or retired at another UID | activate this UID |
active at this UID with an open gate | idempotent success |
active at another UID | already-exists refusal |
retiring | lifecycle-blocked failed-precondition |
| this UID frozen by retirement, or terminally retired | lifecycle-blocked refusal |
| authority fenced or unavailable | unavailable; no success result |
Retained managed agents
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.
Fresh managed agents
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.
Managed-agent authority transport
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. The footprint includes every durable membership row of
that UID, found as the manager's own teardown finds them: the grant's concrete channels, plus the
delivery daemon's lifecycleMemberships inventory read after the ACL fence, which adds family rows
and rows on channels a wildcard grants. When that inventory cannot be read, the prepare removes what
the grant names, fails, and keeps the grant for a retry. 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.
Host runtime contracts
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.
Delegated user intent decisions
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.
Host registration artifacts
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 idHost incarnation fencing
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.
Delegated intent execution
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 and removes its local secret family
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, and a secret file the retirement
fails to remove keeps it held too.
Remote administration authorization
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.
Maintenance limits
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. remoteManagerClient.remoteStandingBundleRenewal builds standing-bundle renewal requests, with adoption bound to the current serve epoch. remoteRunHosting supplies renewRun through the renewRunDriver authority operation. The host validates and adopts the driver and mediator pair before reconnecting, using the current holder and fencing coordinates. The
hosted runtime contract records the bounded additions and
their ownership; it is not a shipped pooled service.
Agent isolation
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.