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.
On by default
Section titled “On by default”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).
Shared identity
Section titled “Shared identity”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.
Provisioner
Section titled “Provisioner”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.
Profiles
Section titled “Profiles”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).
Issued authority
Section titled “Issued authority”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.
Declared capabilities
Section titled “Declared capabilities”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.
Per-user authentication
Section titled “Per-user authentication”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.
Enrollment redeem
Section titled “Enrollment redeem”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:
spacebrokerAccess { kind, ... }owneractorlifecycleUidactorTokensentinelCredsauthServiceUrlidp { 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.
Remote manager authority
Section titled “Remote manager authority”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).
The IdP callout contract
Section titled “The IdP callout contract”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:
- 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. - 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 referencecreateIdpBridgefixes 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. - Actor authorization and mint. The operator’s ledger hook authorizes the
(owner, actor)pair and is the only source of the bearer’sscope/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});Joining
Section titled “Joining”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.
Honest limitations (v0)
Section titled “Honest limitations (v0)”- 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
$SYScreds renew through rotation.membership-observerandconnection-evictorare 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 downthencotal 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-runcotal backupafter). Past that horizon the mesh keeps delivering, but the membership feed and live eviction stop;cotal doctor authand 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 authis the one diagnosis and repair surface). But a static agent cred has no TTL yet:cotal_despawncuts 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 revokedenies 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).