Skip to content

Identity

Concept (informative) · For: operators and implementers · Normative: SPEC §2, §9, §10, Appendix B

Who can do what on a mesh, and how it is enforced. The design goal: the mesh is a real boundary against untrusted peers in a shared space; an agent can only speak as itself and only where its declared permissions allow, enforced by the broker, not by agent goodwill. What that boundary does and does not protect is the security model; the exact ACLs are SPEC Appendix B.

cotal up provisions a JWT-authed space; cotal up --open runs an unauthenticated dev mesh instead. Both bind loopback by default. --host 0.0.0.0 widens the bind independently, so “network-reachable” never silently means “unauthenticated”. Open mode is for quick local experiments and sits outside every security claim (SPEC §9).

An agent’s wire identity is a principal: an owner.actor pair, where the owner is the account (a human, or an organization) the agent acts on behalf of, and the actor is the agent’s own handle under that owner (SPEC §2). The same pair is the card id, the sender tokens in every subject it publishes, the presence key, and its durable-consumer names. On an open dev mesh the owner is the literal local; on a per-user-auth mesh it is a derived token (u_ plus 26 characters, so no PII rides the wire). The connection still authenticates with an nkey, generated locally (the signer only ever sees the public half), but the nkey is the transport credential, not the identity: it scopes only the per-connection reply inbox.

The sender is encoded in the subject. Every publish carries the sender’s owner and actor in positions the broker’s permissions pin to that connection, so an agent cannot emit as anyone else: not as another owner, and not as a sibling actor under its own owner. Receivers verify the payload’s from.id against the subject sender and reject mismatches; sender authenticity is broker-enforced end to end (SPEC §3, §5).

Account = space, user = agent. A space is one NATS account, a server-enforced isolation boundary. An operator signs the account; an account signing key mints per-agent user JWTs.

The provisioner is whoever holds the account signing key. It mints profile-scoped credentials and pre-creates the durables agents may only bind (their DM inbox, their role’s task queue). The manager hosts it today, but nothing is manager-special about it; privilege attaches to the signer, and a space can run without a manager. cotal mint <name> --profile <agent|observer|admin> is the out-of-band path; spawn calls the same library (CLI). The out-of-band profiles carry no default TTL: pass --expires-in <seconds> (or --expires-at) for a bounded credential, which a standing-renewal consumer requires; pass --identity <creds> to re-mint for the nkey a file already carries, keeping the principal and its durables. Minting static creds is a static-auth surface: a per-user-auth space refuses it, because agents there join under a logged-in user, never via a handed-out file (see Per-user auth below).

Agent-profile minting resolves one mesh root for the persona ACL, account signer and default credential storage. If the current folder holds trust for a different space or account, mint refuses and names both roots. It never signs one root’s persona policy with another root’s authority.

Every credential is a profile: an explicit allow-list built from the same subject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The normative shapes are SPEC Appendix B; in brief:

Profile Is
agent The ordinary peer: publishes as itself to its declared channels, reads within its read ACL + its own DM/task inboxes. Its read-only presence and channel-registry watches create and inspect client-managed ordered consumers but cannot delete any consumer on those streams; the broker removes a finished watch’s consumer five minutes after its last interest.
observer Read-only chat + presence; DMs invisible. What cotal console runs. Holds no consumer delete on any stream it reads, so it cannot remove another principal’s watch or the delivery daemon’s fan-out consumer; the broker removes its own finished consumers.
admin Elevated read-only god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (cotal web).
operator-side Narrow single-purpose creds for the machinery (supervising, provisioning, teardown, delivery); the reference implementation splits these so no one connection can read every DM and delete every stream (security model).
run-driver One workflow run and takeover attempt: its journal subject, replay durable and run-owned record writes. Store reads and effects go through the host.
run-mediator The trusted hosting process’s separate connection for workflow effects and leader reads. It exposes journal-checked, run-bound operations and never hands its credential to the driver.
run-operator One served run read, or one half of an answer, minted per call: a read holds the records walk, one run’s replay and the admission read; the answering half is minted for one checkpoint token and holds that pause’s answer record and settle alone.
issuer One issuance window: the party holding the space signer mints it for a few minutes to stage and release a credential’s evidence, retire the issuances a lifecycle terminal leaves behind, or resolve the evidence a request rides.
run-admitter One run’s admission record or revocation marker, minted per run for a minute: two exact keys in the admission store and nothing else.

An agent’s channel scope is three verbs: subscribe (reads at boot), allowSubscribe (read ACL), allowPublish (post ACL, default-deny), declared in its agent file or manifest, minted into its cred. One card with the recipes: Channels & permissions.

DM confidentiality holds against peers by construction: deliveries ride per-identity inbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an agent cannot create a consumer filtered to someone else’s inbox (SPEC §9 items 1–5).

A credential says who is calling. It does not, by itself, say what the caller was granted, and a host that acts on a caller’s behalf (a workflow run, workflows) needs that from the issuer, not from a ledger that may have changed since. So a static agent credential is an issuance (SPEC §13.15): before the material is handed out, the issuer records the credential’s final permission ceiling, as evidence keyed by a fresh generation, in cotal_issued_<space>. That store is append-only at the broker and the evidence is read as the first message on its key, so nothing written later on the same key, by anyone, changes what a resolver sees. The credential’s endpoint rows then ride a versioned rail, cotal.<space>.ep.v1.…, with that generation pinned beside the caller triple, so the broker binds every request to the ceiling the issuer accepted. The legacy ep. rail and the ep.v1. rail are disjoint subject spaces; a credential holds rows on one of them, and every endpoint serves both.

A connected client learns its generation by reading one row in cotal_accepted_<space> under a token the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts what the file says. A renewal keeps the generation only while the ceiling is byte-identical to the evidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection. A static agent’s evidence names its credential ledger family as the source it depends on, and the lifecycle terminal that retires that family retires its issuances with it.

The manager, cotal spawn, and the CLI’s control-caller instruments all mint through an issuer session. The deployer instrument does not: it is the one instrument with no default expiry, and an issuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own lifecycle uid. Only workflow run-start requires the binding today: a request for it on the legacy rail is refused with permission-denied and the detail ai.cotal.ep.unbound-caller-authority naming the caller. Every other command serves both rails.

Control-plane power is a declared capability, not a default. An agent file carrying capabilities: [spawn] gets the privileged control subject minted into its cred: spawn, plus stop/despawn of its own children, plus persona definition. On a static or open mesh its own children include what it launches with cotal spawn --detach from its own shell, since that command runs as the seat. Without it, an agent can only self-despawn and pull or yield the run turns addressed to it. capabilities: [run] mints the manager’s workflow-run commands (start, resume, answer, status, list) together with the spawn set, since a program the agent starts may spawn; the manager drives the run under a per-run run-driver credential of its own, never the caller’s. The tool surface mirrors the grant: cotal_spawn / cotal_persona / cotal_personas are injected only for spawn, and cotal_run only for run (agent files). Destructive operator ops (history purge, cross-agent stop) live on a third tier no agent credential reaches. Persona redefinition separates content from policy; the write path takes only model/persona, so a peer cannot grant itself a capability by redefining a file.

cotal up --user-auth --idp <auth base URL> (or manifest broker.auth: "user") puts a human identity plane above the per-agent one: people sign in to an external IdP once, and every connect is authorized live against the operator’s actor ledger. No creds files to hand out, and revoking a grant actually bites.

The flow. Each person runs cotal login --idp <url> once per machine. The IdP URL, the JWKS URL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a loopback IP literal such as 127.0.0.1, never on localhost, whose resolution would choose the keys the mesh trusts. After that, any command works: cached IdP session → fresh IdP proof per connect (so IdP-side revocation bites here too) → the configured exchange turns it into a short-lived Cotal bearer → the broker’s auth callout checks the bearer and the ledger at connect time and mints a scoped credential on the spot. Every bearer also names a root credential row in the space’s credential ledger, proved live at each connect, so revoking that one credential bites at the very next connect. The operator grants access with cotal actor grant <actor> --sub <their id> --full for the full envelope (all channels; scope spawn,role:default, so it may spawn and may delegate the default role), or names --scope, --allow-subscribe and --allow-publish for a narrow row. A grant with any of the three left off and no --full is refused. No ledger row, no access; there is no allow-by-default.

Space catalogs. A successful authenticated GET <idp>/token may advertise one catalog with:

Link: <https://idp.example/spaces>; rel="https://cotal.ai/relations/space-catalog"

The target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may use HTTP for local development. A missing, foreign-origin, or insecure link records that this account has no catalog. The client never guesses a path.

The catalog request carries the opaque cached session as its bearer and returns a complete snapshot:

{
"v": 1,
"account": { "idpUrl": "https://idp.example/api/auth", "issuer": "https://idp.example", "sub": "user-id" },
"spaces": [
{ "id": "space-id", "slug": "shared_project", "name": "Shared project", "kind": "hosted", "role": "owner", "registration": {} }
]
}

The client checks every registration with the same checkUserBundle validator used by cotal meshes add. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the catalog’s ETag; a transport error, non-success response, or invalid candidate leaves the prior snapshot intact and reports the failure. Only a 401 response for the saved session recommends signing in again. Transport errors and server failures report that account’s refresh error without discarding the session. The registry is reconciled under the provider’s catalog lock, and a snapshot whose reconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or not modified.

The user-auth registration document may include one closed policy object:

{ "policy": { "events": "required" } }

No other key under policy and no other value for policy.events is accepted. The registry preserves this field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is refreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a join, or a manager start, after that command’s own local refusals; the returned space, broker, transport, IdP, issuer, audience, and exchange pins must all match before only the policy is added. A failed expired refresh refuses that operation. Read-only commands such as status and meshes never refresh. The five-second warm window makes no request.

Every registration is also bound to the proved account. Its IdP URL must match the account and its issuer must match the exact JWT iss pin. Its exchange, provisioning, and manager-authority endpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole candidate snapshot.

The slug is the space identity resolved by --space, use, registry roots, and collisions. The name is a display label only.

Discovered registry entries are owned by the normalized IdP origin plus the proved sub. That key is stored as an opaque digest, so accounts on one machine never union their spaces and the registry does not persist the subject. A manual or locally started record with the same name is never overwritten. Logout removes only the entries owned by the account whose session was revoked. Local teardown, cleanup, and liveness pruning do not remove discovered entries.

An account that previously advertised no catalog is checked again by explicit cotal sync. The ordinary lazy path checks again after its five-second capability window, so an IdP can enable the Link for an existing login without making the person sign in again.

One auth service per space hosts both halves: the NATS auth callout and the token exchange. Its default HTTP listener remains loopback-only and requires the per-start capability stored in the owner-only auth-service.json file. An operator may add a second listener with cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example. That listener still binds 127.0.0.1; put a reverse proxy in front of it and terminate TLS there. In-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal and fork proxy-based deployments.

The loopback face also serves three host-only doors, all capability-gated and never on the public face. Two retire a lifecycle: /interactive-lifecycle/retire (used by cotal actor grant/revoke) and /managed-lifecycle/retire, which finishes a managed agent’s terminal retirement after its remote manager is gone. The third decides one: POST /manager-service-authority/verify-enrollment answers whether a remote manager may have a managed agent enrolled or released under its authenticated owner. The body is { owner, request } and nothing else. The caller’s capability scope is read from this machine’s ledger, never taken from the body, so a host that forwarded a participant-supplied scope could not grant itself supervise. The door reads the manager gate and checks the registration proof inside the service process, so no signing material reaches the caller, and it returns { authorized: true, owner, actor, instanceId, serveEpoch } or maps its refusal to 400, 401, 403, 409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock dispatchManagerAuthorityRequest refuses both request kinds with unimplemented rather than answering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted runtime create and status kinds. For those it reads the manager actor’s ledger row itself and returns { authorized: true, owner, instanceId, actor, target }. embedding.md documents the managed doors’ contracts.

The public listener has a closed surface: GET /health, GET /jwks, POST /exchange, POST /manager-service-authority, and GET /.well-known/cotal-mesh; every other path is 404. It does not require the loopback capability. That capability proves same-uid access to a 0600 local file and has no remote meaning; on the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against the pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must match a fresh managed-ledger row. The public face mints two elevated views, both still gated on ledger scope admin: channel-writer (cotal channels set/default) and channel-purger (the dashboard’s per-click channel delete). It also mints the narrowing manager-caller view for humans or managed agents. That view adds no capability and binds the bearer to one live registered manager instance. God-view (admin), space-history purger, deployer, and manager-service stay loopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: cotal web still mints the read-only admin view at startup, so a remote dashboard that needs that god-view still fails even when a later delete would mint channel-purger.

The well-known response contains the IdP pins and the actual deny-all sentinel credential remote agents need before the bearer-driven auth callout. The pins ride a userAuth arm that names the auth provider, and that name is the same one the local arm registers under. A document naming a different provider than the one serving it would register an entry nothing can resolve, so both read one constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but consumers must still take the bundle only from the intended HTTPS origin and must verify TLS. --exchange-trusted-proxy opts into peer attribution by the last X-Forwarded-For hop; use it only when the listener is reachable solely through a proxy you control. Without it, forwarded headers are ignored and the socket address is the peer key. Public failure buckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at most 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling more than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid credential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape matters to the deployment.

A remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached IdP login. The enrollment is a secret-bearing URL. The client performs one request:

GET <enrollment URL>

It sends no Authorization header and no request body. The URL must be HTTPS, except for plain HTTP to a loopback IP literal. The client redeems only an enrollment URL that is already in canonical form and contains none of \ @ ? #. That is checked on the raw string before parsing, so every rewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case folding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal rather than a redeem of a URL the owner never minted. Redirects are refused. The client never retries because a successful claim deletes the server-side token row. The token expires five minutes after mint.

Success is 200 with this JSON object:

space
brokerAccess { kind, ... }
owner
actor
lifecycleUid
actorToken
sentinelCreds
authServiceUrl
idp { url, issuer, audience }
subscribe[]
allowSubscribe[]
allowPublish[]

The grant arrays are informational; the broker row remains authoritative. A stock-dialable deployment also includes server, tlsRequired, userAuth, and optional policy, forming the same user-bundle superset that cotal meshes add --user-auth-file accepts. That lets a bare seat register the mesh from the enrollment response before launch. For brokerAccess.kind: "direct", the stock server must equal brokerAccess.url byte for byte or the client refuses the bundle before registration. A tunnel kind carries no dial address, so its brokerAccess is not compared to the operator-asserted stock server face.

Unknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They all return 404 {"error":"unknown, expired, or already-used enrollment"}. The client reports only enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one. It does not guess which case occurred.

After redeem, the seat stores only the normal remote user-mesh and agent material. The actor token is exchanged at authServiceUrl through the existing agent-bearer --exchange-url path. The enrollment URL is not logged, persisted, or forwarded into any child process, including the bearer preflight and harness.

The service starts with the broker, is torn down by cotal down, and holds the data-account signing key for the callout (a running manager is the other standing holder, for the creds it mints); the operator seed never enters it. It also owns the space’s two authority stores (lifecycle records and the credential ledger), provisions them at boot, and refuses connects it cannot credential-check against them; there is no fallback path. If it dies while the broker lives, re-running cotal up heals it, and a boot whose auth service never became ready exits non-zero, so automation never reads a dead identity plane as success. Changing any public-listener flag requires cotal down followed by cotal up with the new values; a refresh adopts an already-running auth service rather than silently replacing its listener policy. “One per space” is enforced, not assumed (SPEC §13.13): at boot the service takes a broker-backed ownership claim, so a second same-space auth process refuses with instructions instead of silently splitting the plane, and a crashed one’s claim is reclaimed only once the broker confirms its connections are gone. That verdict is trusted only on a standalone broker (a clustered one refuses the reclaim, since a partitioned member could still hold them). If the claim’s connections die mid-run, the service downs itself loudly instead of serving from a half-dead plane.

Ctrl-C on a foreground up, and a broker that exits under it, stop the service with the same stop cotal down auth uses. It holds the reservation down takes, so a concurrent cotal down auth is refused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after SIGTERM.

Your agents are yours. cotal spawn on a user mesh grants a managed actor under the spawning operator’s owner and launches the agent with a bearer command instead of a creds file. The agent exchanges its spawn-time secret for short bearers (five minutes or less) and refreshes ahead of each expiry. Rows are runtime grants: every start rotates the secret, every stop or despawn revokes the row, so a non-running agent holds no standing authority. A spawn whose auth preflight fails is rolled back: the manager, or cotal spawn itself for a foreground agent, revokes the row, shreds the secret files and deletes the broker footprint. Every step runs even when an earlier one fails, and the refusal names each step that failed. A foreground agent’s exit runs the same teardown. Manifest deploys (up -f) stamp the logged-in owner into the launch, so those agents are yours too.

Despawn tears the lifecycle down, then frees the name. When you despawn an agent, the manager drives the full teardown of that lifecycle: it shreds the local credential files, revokes the agent’s standing mint authority (its ledger row, so a copied token can no longer mint a fresh credential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks the auth service to retire the lifecycle (settle in-flight work, evict the departed credentials, record it retired). The name is held reserved pending retirement until all of that completes, the broker-footprint cleanup, the standing-authority revoke, and the lifecycle retirement, not the retirement alone, so a same-name respawn in the gap is refused with a plain reason and a retry hint rather than quietly handing the alias to a new agent while the old lifecycle’s teardown is still running. Only once the broker footprint is gone, the standing authority is revoked, and the retirement is confirmed does the name free, and cotal spawn <same-name> gives you a fresh agent cleanly. This is what makes reusing an agent’s name safe: the old lifecycle is fully torn down before the new one takes the alias. A local file that cannot be removed does not stop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and the name stays held. If the auth service is unreachable or the standing-authority revoke fails, the despawn still stops the agent and holds the name. A same-name cotal spawn re-drives the whole teardown and finishes it. Retrying the despawn has no effect because the agent is already stopped. The operator copy tells you to recover the stack (cotal supervise) rather than reusing the name over an unretired predecessor.

A crash mid-retirement resumes at the next boot. The retirement’s last two steps (recording the issuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash between them leaves the gate retired while the head is still retiring: an alias that can neither mint nor be replaced. The auth service’s boot crash-resume decides what it owes across both objects (the gate and the alias head), so the next boot finishes that tail from the durable operation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal landed, or a successor already took the alias) is left skipped. A retry of the despawn converges on the same recovery.

Delegation only narrows (the envelope rule). A user’s grant is their envelope: everything under their owner (their CLI, every agent they spawn, every agent those spawn) stays within its channel lists and its capability scope. Handing a role to a spawned agent needs the matching role:<r> capability in the spawner’s scope. The whole delegation chain is checked, not just the last link, and re-checked at every bearer exchange, so narrowing a user’s grant reaches their agents within minutes, and revoking the user revokes everything under them, grandchildren included. A spawn beyond the envelope is refused with the exact widening re-grant to ask the operator for.

Control ops ride your own login, gated by ledger scope. spawn covers launching, ps, and stop/attach of the agents under your own owner: the owner is the administrative boundary of its own subtree, so you (and your agents) manage what you own without any extra grant. admin is the explicit opt-in for touching other owners’ agents; it is never part of a default grant and never accepted from a manifest.

Elevated operator surfaces ride the same login through a short-lived view: the exchange stamps a server-authored view claim into the bearer, and the callout mints that connection as the matching non-agent profile instead of agent. cotal web and cotal console ask for the read-only admin view, clean history for the purger, channels set/default for the channel-writer (all gated on ledger scope admin); up -f deploys over the deployer view, gated on spawn, because deploying your own team is spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist only on a signed-in human exchange. The manager-caller view is the one managed-exchange exception because it narrows the agent’s existing manager command set to one server-selected instance and adds no capability. All views are authorized against the fresh ledger row at every connect and expire with the bearer, so narrowing or revoking a grant bites within minutes here too. On the public exchange face only channel-writer, channel-purger, manager-caller, session-caller, and transfer-writer are served; admin, purger, deployer, and manager-service remain loopback-only.

The session-caller view is how cotal attach opens a seat’s session on a user-auth mesh. It needs no ledger scope, because the session grant is the authority. The exchange takes the grant with the login proof and leader-reads the redeemed session.<id> row. It issues the bearer only when the row is active and unexpired, its signature equals the presented grant’s, its holder is this owner and actor at this lifecycle, its endpoint and serving epoch match, and the serving manager’s gate is open at that epoch. The callout repeats the same check at connect and mints the session’s caller rails with the grant’s expiry instead of the bearer’s.

The transfer-writer view is how cotal spawn --resume <id> --detach --on <instance> uploads a session this host holds on a user-auth mesh. It needs scope admin, as the transcript-receive call it serves does, and names one object: the target instance and the transcript’s SHA-256. The callout mints writes to that object of that instance’s transfer bucket and nothing else. The bearer, and with it the broker connection, lives at most five minutes, the lifetime of the static transfer-writer credential, and never past the login proof it was exchanged for.

A registered user remains an ordinary agent bearer by default. Running a detached manager on a remote user-auth mesh needs the closed server-authored manager-service view, which is distinct from every general-purpose profile. The operator grants it only by adding supervise to that user’s actor-ledger scope. supervise is deliberately distinct from spawn and admin: spawn controls your agents, admin permits the separate cross-owner operations, and neither grants persistent manager registration authority.

Only a signed-in human may request this view from the loopback/operator exchange. The public exchange and every managed-agent secret exchange refuse it. At exchange and each connection, the auth service re-reads the actor row; revoking or removing supervise therefore denies the next view exchange and connection. A grant must carry the whole requested row just like every other actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not only supervise.

The service is one opaque manager instance for the user’s derived owner and a fixed server-selected manager actor. Its authority is limited to that instance’s manager registration, contracts, status, endpoint rails, gate and credential family; it cannot read or write another owner or instance. It never exposes a signer, static provisioner credential, owner secret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the public-nkey JWT material through the typed lifecycle-bound protocol: prepare → activate → renew, plus host-owned evict-family-principal and reconcile-registration maintenance operations and a one-shot retire phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation coordinate; the host writes its credential ledger row and finalizes the gate before it releases usable material. The retire phase fresh-checks the current manager instance, server-derived serve principal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester credential pinned to that target. The manager invokes the registered auth endpoint’s retire-lifecycle command through the generic client, resolving the service and calling it with an exact target and the operation id derived from the target lifecycle UID. The endpoint recomputes that id from the broker-pinned target before any durable access. A caller cannot substitute another valid operation identity for the same target, and retries plus auth-service boot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.

Registration maintenance stays on the host. One eviction request carries up to 256 holders, and the host accepts it only when its single sealed scan of the caller instance’s epcred.manager.<instanceId>.* family finds every one of them. Reconciliation may target a foreign manager slot holder in the same space, but it runs only after the delivery daemon proves the frozen gate’s holder gone under a complete sweep. The participant receives neither an evictor credential nor authority over another instance’s records or gate. A clean stop refreshes an unhealthy executor before deregistration. A restart verify-evicts its old family, and a manager blocked by a foreign governance slot whose holder’s gate is still frozen at the slot’s stamp asks the host to reconcile that holder and retries the registration once.

A remote manager can provision only descendants of the same derived owner, and the host validates that relation and the current manager grant for every provision. It cannot broaden the user’s envelope or provision a sibling owner’s agent. Renewals are bounded. If login, the supervise grant, or the host manager authority service is unavailable, the manager reports a degraded state and refuses new agents, restarts, or replacement credentials rather than substituting local/static authority. Existing live agents remain running only while their own valid authority permits it; recovery requires the host service and a fresh successful renewal.

The manager-authority protocol is closed, so a host whose auth service predates a field the manager sends refuses the whole request. The manager reports that refusal as version skew after the host’s reason: it names its own Cotal version and the refused field, and says it needs a host at that version or later. It never drops the field to fit the older host. Upgrade the host first; Upgrading promises no rolling upgrade between versions.

A manager on remote authority mints from that authority alone; it consults the local root’s records only to refuse a conflict, and only the supervised space’s own trust records count as one. A workspace that hosts an unrelated static space beside the participant sign-in is a normal configuration.

User authentication has one path. On a user-auth space, commands never fall back to static minting or credless connects: a missing login or a down auth service is one sentence naming the exact recovery, and static agent/observer/admin minting is refused outright. The refusal is deny-new: a static cred signed before the space flipped stays broker-valid until the signing key is rotated (security model).

Any OIDC identity provider that issues EdDSA/Ed25519 JWTs plugs in here directly; a provider that issues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or re-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The reference implementation ships Better Auth as a dev and test fixture only (it is a devDependency of @cotal-ai/auth; the only code that imports it is the dev-idp.ts harness and the smoke tests, never the runtime src). The one runtime coupling to an IdP is the idp.ts bridge plus the auth-provider extension. The bridge core (createIdpBridge) is IdP-generic for EdDSA tokens (issuer, audience, JWKS as configuration). The stock end-to-end flow around it, though, is Better-Auth-shaped: cotalAuthProvider pins <base>/jwks and issuer/audience to the IdP origin, and the login client speaks Better Auth’s device-code endpoints (/device/code, /device/token, /token) with an opaque revocable session. So a Better-Auth-shaped EdDSA IdP uses the stock flow directly; any other production IdP is a hosted-composability gap, not a configuration change. A host integrates it by building its own login and provider wiring on the low-level primitives (createIdpBridge, createUserTokenIssuer), not by reusing the stock provider. Note that importing @cotal-ai/auth self-registers cotalAuthProvider, and resolveAuthProvider() throws when two providers are registered, so a host on the registry-resolution path must not also register its own. Whatever the path, never loosen the issuer/audience/JWKS pins to force-fit an IdP.

The bridge (createIdpBridge) exchanges a verified IdP token for a Cotal bearer in three steps:

  1. Bearer validation. Verify the IdP’s JWT offline against its pinned JWKS, with the token algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded key material (jku/jwk/x5u/x5c) is rejected, so the token can never influence key resolution. Issuer and audience are checked, and the minted Cotal bearer is capped to the upstream proof’s remaining lifetime.
  2. Owner derivation. The opaque per-space owner derives deterministically from the JSON-array encoding of [idp issuer, sub], namespaced by issuer so no issuer/sub pair can straddle a delimiter, and re-login re-lands the same person in the same lanes. The owner-token format (u_ followed by 26 base32-lower characters) is normative (SPEC section 2). At the contract level the derivation from an identity is a pluggable edge, but the reference createIdpBridge fixes it (deriveOwnerForIdpSubject) and takes no derivation callback, so what a host configures is the IdP, not the derivation. The encoding is frozen: changing it, or changing the IdP issuer string, re-keys every owner in the space, which is a migration on the order of rotating the space secret.
  3. Actor authorization and mint. The operator’s ledger hook authorizes the (owner, actor) pair and is the only source of the bearer’s scope/parent; the issuer then mints the Cotal bearer, re-asserting every claim shape.

A host wires this with the IdP’s own coordinates and nothing from @cotal-ai/auth changes:

import { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from "@cotal-ai/auth";
const bridge = createIdpBridge({
idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP
space,
spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime
issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer
authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant
});

A single join link carries server, auth, and space (SPEC §10):

cotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)

Humans: cotal join --link …. Agents: COTAL_LINK=… in the environment. The connector expands it and auto-joins. Token/user-pass links are the open-mode path; the default authed path threads a minted creds file, and the endpoint adopts the credential’s identity as its card id. A seat the manager spawned reaches that file through its launch material rather than through COTAL_CREDS in an environment every descendant process inherits (see Configuration); a session you drive by hand still sets COTAL_CREDS itself.

  • The signing key is hot on the mint/manager box of a static-auth mesh; the “real boundary” holds given operator-controlled cred distribution. On a per-user-auth mesh the data-account signing key is held by the auth service (the callout stage) and by any running manager, which loads the trust bundle and self-mints its supervisor cred and renewals from it; a copied signing seed still stays valid for its identity until the signing key is rotated. Rotation remains the revocation lever for trust material.
  • The two $SYS creds renew through rotation. membership-observer and connection-evictor are signed by the system-account seed, which is never persisted, so no running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new system account (cotal down then cotal up --rotate-sys), which leaves the data account, every agent cred and the store untouched but does invalidate earlier full backups (they bind to the operator JWT and system account they were taken under, so re-run cotal backup after). Past that horizon the mesh keeps delivering, but the membership feed and live eviction stop; cotal doctor auth and the manager warn from the 75% point onward.
  • Static agent creds are long-lived; the machinery’s are not. One-shot command creds expire in minutes and the standing daemon creds in 24h with the manager renewing them (cotal doctor auth is the one diagnosis and repair surface). But a static agent cred has no TTL yet: cotal_despawn cuts a session, not a credential, and a compromised agent that copied its creds can reconnect until the signing key is rotated. Per-user-auth spaces close this: bearers live minutes, cotal actor revoke denies the next exchange and the next connect and evicts the principal’s live connections immediately.
  • Not non-repudiation. Authenticity is broker-enforced, not portable proof; it does not survive an untrusted relay. Signed envelopes are reserved (SPEC §11).
  • Chat metadata leaks in-space. Content reads are ACL-bounded; stream metadata (channel names, per-subject counts) is not yet (security model).

Denials are loud, never silent. A publish outside an ACL surfaces as a logged denial (“denied, not absent”) on the endpoint’s error path; an over-tight ACL never looks like a missing peer (run a mesh).