@cotal-ai/connector-jcode 0.90.0 → 0.90.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host.js +3 -3
- package/dist/mcp.js +3 -3
- package/package.json +3 -3
package/dist/host.js
CHANGED
|
@@ -64372,10 +64372,10 @@ function date4(params) {
|
|
|
64372
64372
|
config(en_default());
|
|
64373
64373
|
|
|
64374
64374
|
// ../connector-core/dist/docs-bundle.generated.js
|
|
64375
|
-
var DOCS_VERSION = "0.90.
|
|
64375
|
+
var DOCS_VERSION = "0.90.1";
|
|
64376
64376
|
function loadDocsBundle() {
|
|
64377
64377
|
return {
|
|
64378
|
-
"version": "0.90.
|
|
64378
|
+
"version": "0.90.1",
|
|
64379
64379
|
"generatedFrom": "docs/*.md + SPEC.md + spec/cotal-lang.md + spec/cotal.schema.json",
|
|
64380
64380
|
"pages": [
|
|
64381
64381
|
{
|
|
@@ -64418,7 +64418,7 @@ function loadDocsBundle() {
|
|
|
64418
64418
|
"title": "Identity",
|
|
64419
64419
|
"kind": "Concept (informative)",
|
|
64420
64420
|
"summary": "Who can do what on a mesh, and how it is enforced.",
|
|
64421
|
-
"body": "# Identity\n\n> **Concept** (informative) \xB7 **For:** operators and implementers \xB7 **Normative:** [SPEC \xA72](../SPEC.md#2-identity), [\xA79](../SPEC.md#9-nats--jetstream-security-and-authorization), [\xA710](../SPEC.md#10-connection-and-onboarding), [Appendix B](../SPEC.md#appendix-b-profile-acls)\n\nWho can do what on a mesh, and how it is enforced. The design goal: the mesh is a **real\nboundary against untrusted peers in a shared space**; an agent can only speak as itself\nand only where its declared permissions allow, enforced by the broker, not by agent\ngoodwill. What that boundary does and does not protect is the\n[security model](security.md); the exact ACLs are\n[SPEC Appendix B](../SPEC.md#appendix-b-profile-acls).\n\n## On by default\n\n`cotal up` provisions a JWT-authed space; `cotal up --open` runs an unauthenticated dev\nmesh instead. Both bind loopback by default. `--host 0.0.0.0` widens the bind\nindependently, so \"network-reachable\" never silently means \"unauthenticated\". Open mode\nis for quick local experiments and sits outside every security claim\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)).\n\n## Shared identity\n\nAn agent's wire identity is a **principal**: an `owner.actor` pair, where the owner is\nthe account (a human, or an organization) the agent acts on behalf of, and the actor is\nthe agent's own handle under that owner ([SPEC \xA72](../SPEC.md#2-identity)). The same pair\nis the card id, the sender tokens in every subject it publishes, the presence key, and\nits durable-consumer names. On an open dev mesh the owner is the literal `local`; on a\nper-user-auth mesh it is a derived token (`u_` plus 26 characters, so no PII rides the\nwire). The connection still authenticates with an **nkey**, generated locally (the signer\nonly ever sees the public half), but the nkey is the transport credential, not the\nidentity: it scopes only the per-connection reply inbox.\n\n**The sender is encoded in the subject.** Every publish carries the sender's owner and\nactor in positions the broker's permissions pin to that connection, so an agent *cannot*\nemit as anyone else: not as another owner, and not as a sibling actor under its own\nowner. Receivers verify the payload's `from.id` against the subject sender and reject\nmismatches; sender authenticity is broker-enforced end to end\n([SPEC \xA73](../SPEC.md#3-subject-layout), [\xA75](../SPEC.md#5-envelopes)).\n\n**Account = space, user = agent.** A space is one NATS account, a server-enforced\nisolation boundary. An operator signs the account; an account **signing key** mints\nper-agent user JWTs.\n\n## Provisioner\n\nThe **provisioner** is whoever holds the account signing key. It mints profile-scoped\ncredentials and pre-creates the durables agents may only *bind* (their DM inbox, their\nrole's task queue). The manager hosts it today, but nothing is manager-special about it;\nprivilege attaches to the signer, and a space can run without a manager.\n`cotal mint <name> --profile <agent|observer|admin>` is the out-of-band path; spawn calls\nthe same library ([CLI](cli.md)). The out-of-band profiles carry no default TTL: pass\n`--expires-in <seconds>` (or `--expires-at`) for a bounded credential, which a\nstanding-renewal consumer requires; pass `--identity <creds>` to re-mint for the nkey a\nfile already carries, keeping the principal and its durables. Minting static creds is a\n**static-auth** surface: a\nper-user-auth space refuses it, because agents there join under a logged-in user, never\nvia a handed-out file (see *Per-user auth* below).\n\nAgent-profile minting resolves one mesh root for the persona ACL, account signer and default\ncredential storage. If the current folder holds trust for a different space or account, mint\nrefuses and names both roots. It never signs one root's persona policy with another root's authority.\n\n## Profiles\n\nEvery credential is a profile: an explicit allow-list built from the same\nsubject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The\nnormative shapes are [SPEC Appendix B](../SPEC.md#appendix-b-profile-acls); in brief:\n\n| Profile | Is |\n|---|---|\n| **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. |\n| **observer** | Read-only chat + presence; DMs invisible. What `cotal console` runs. Its only consumer deletes name its own history readers (`hist_<readerId>_<n>` on CHAT, keyed on its nkey or on the connection the server assigned), so each history read removes its consumers when it ends, and it cannot remove another principal's watch or the delivery daemon's fan-out consumer. The broker removes its finished watch consumers. |\n| **admin** | Elevated *read-only* god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (`cotal web`). Deletes its own history readers on CHAT and DM, and no other consumer. |\n| 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](security.md)). |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n\n**An agent's channel scope is three verbs**: `subscribe` (reads at boot),\n`allowSubscribe` (read ACL), `allowPublish` (post ACL, default-deny), declared in its\n[agent file](agent-files.md) or [manifest](manifest.md), minted into its cred. One card\nwith the recipes: [Channels & permissions](channels-and-permissions.md).\n\n**DM confidentiality** holds against peers by construction: deliveries ride per-identity\ninbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an\nagent cannot create a consumer filtered to someone else's inbox\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization) items 1\u20135).\n\n## Issued authority\n\nA credential says who is calling. It does not, by itself, say what the caller was granted, and a\nhost that acts on a caller's behalf (a workflow run, [workflows](workflows.md)) needs that from\nthe issuer, not from a ledger that may have changed since. So a static agent credential is an\n**issuance** ([SPEC \xA713.15](../SPEC.md#1315-issued-authority)): before the material is handed\nout, the issuer records the credential's final permission ceiling, as evidence keyed by a fresh\n**generation**, in `cotal_issued_<space>`. That store is append-only at the broker and the\nevidence is read as the first message on its key, so nothing written later on the same key, by\nanyone, changes what a resolver sees. The credential's endpoint rows then ride a versioned\nrail, `cotal.<space>.ep.v1.\u2026`, with that generation pinned beside the caller triple, so the\nbroker binds every request to the ceiling the issuer accepted. The legacy `ep.` rail and the\n`ep.v1.` rail are disjoint subject spaces; a credential holds rows on one of them, and every\nendpoint serves both.\n\nA connected client learns its generation by reading one row in `cotal_accepted_<space>` under a\ntoken the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts\nwhat the file says. A renewal keeps the generation only while the ceiling is byte-identical to the\nevidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection.\nA static agent's evidence names its credential ledger family as the source it depends on, and the\nlifecycle terminal that retires that family retires its issuances with it.\n\nThe manager, `cotal spawn`, and the CLI's control-caller instruments all mint through an `issuer`\nsession. The deployer instrument does not: it is the one instrument with no default expiry, and an\nissuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own\nlifecycle uid. Only workflow `run-start` requires the binding today: a request for it on the legacy\nrail is refused with `permission-denied` and the detail `ai.cotal.ep.unbound-caller-authority`\nnaming the caller. Every other command serves both rails.\n\nA user-auth `manager-caller` view is issued at the auth callout for an interactive row, or for a\nmanaged seat whose current persona-derived scope contains `run` and whose direct parent's fresh\nsame-owner grant also contains `run`. An admin parent's containment bypass does not count. The\nsingle source is the child's own actor row and lifecycle, and evidence records the signed ceiling\nwithout adding capabilities. Same-nonce renewal requires the same triple and byte-identical ceiling.\nThe existing view policy and manager instance gate are unchanged, with no new request kind. A seat\nmissing either `run` input keeps ordinary legacy commands. Without signed `run`, the broker refuses\n`run-start`; with signed `run` but no eligible parent, the manager refuses with\n`ai.cotal.ep.unbound-caller-authority`. A failed issuance never retries on that rail.\n\n## Declared capabilities\n\nControl-plane power is a **declared capability**, not a default. An agent file carrying\n`capabilities: [spawn]` gets the privileged control subject minted into its cred: spawn,\nplus stop/despawn of its *own* children, plus persona definition. On a static or open mesh its own\nchildren include what it launches with `cotal spawn --detach` from its own shell, since that\ncommand runs as the seat. Without it, an agent can\nonly self-despawn and pull or yield the run turns addressed to it. `capabilities: [run]` mints\nthe manager's workflow-run commands (start, resume, answer, status, list) together with the spawn\nset, since a program the agent starts may spawn; the manager drives the run under a per-run\n`run-driver` credential of its own, never the caller's. The tool surface mirrors the grant:\n`cotal_spawn` / `cotal_persona` / `cotal_personas` are injected only for `spawn`, and `cotal_run`\nonly for `run` ([agent files](agent-files.md)). Destructive\noperator ops (history purge, cross-agent stop) live on a third tier no agent credential\nreaches. Persona redefinition separates content from policy; the write path takes only\n`model`/`persona`, so a peer cannot grant itself a capability by redefining a file.\n\n## Per-user authentication\n\n`cotal up --user-auth --idp <auth base URL>` (or manifest `broker.auth: \"user\"`) puts a\n**human identity plane** above the per-agent one: people sign in to an external IdP once,\nand every connect is authorized live against the operator's **actor ledger**. No creds\nfiles to hand out, and revoking a grant actually bites.\n\nThe first enable pins the space to that IdP. A later `--idp` naming a different IdP is refused,\nbecause the IdP is part of every derived owner. Two first enables that race on one state dir leave\none pin: the other caller adopts it when it names the same IdP and is refused when it does not.\n\n**The flow.** Each person runs `cotal login --idp <url>` once per machine. The IdP URL, the JWKS\nURL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a\nloopback IP literal such as `127.0.0.1`, never on `localhost`, whose resolution would choose the\nkeys the mesh trusts. After that,\nany command works: cached IdP session \u2192 fresh IdP proof per connect (so IdP-side\nrevocation bites here too) \u2192 the configured exchange turns it into a short-lived Cotal bearer \u2192\nthe broker's **auth callout** checks the bearer and the ledger at connect time and mints\na scoped credential on the spot. Every bearer also names a **root credential** row in the\nspace's credential ledger, proved live at each connect, so revoking that one credential\nbites at the very next connect. The operator grants access with\n`cotal actor grant <actor> --sub <their id> --full` for the full envelope (all\nchannels; scope `spawn,role:default`, so it may spawn and may delegate the default role), or names\n`--scope`, `--allow-subscribe` and `--allow-publish` for a narrow row. A grant with any of the\nthree left off and no `--full` is refused.\nNo ledger row, no access; there is no allow-by-default.\n\n**Space catalogs.** A successful authenticated `GET <idp>/token` may advertise one catalog with:\n\n```http\nLink: <https://idp.example/spaces>; rel=\"https://cotal.ai/relations/space-catalog\"\n```\n\nThe target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may\nuse HTTP for local development. A missing, foreign-origin, or insecure link records that this account\nhas no catalog. The client never guesses a path.\n\nThe catalog request carries the opaque cached session as its bearer and returns a complete snapshot:\n\n```json\n{\n \"v\": 1,\n \"account\": { \"idpUrl\": \"https://idp.example/api/auth\", \"issuer\": \"https://idp.example\", \"sub\": \"user-id\" },\n \"spaces\": [\n { \"id\": \"space-id\", \"slug\": \"shared_project\", \"name\": \"Shared project\", \"kind\": \"hosted\", \"role\": \"owner\", \"registration\": {} }\n ]\n}\n```\n\nThe client checks every `registration` with the same `checkUserBundle` validator used by `cotal\nmeshes add`. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the\ncatalog's `ETag`; a transport error, non-success response, or invalid candidate leaves the prior\nsnapshot intact and reports the failure. Only a 401 response for the saved session recommends signing\nin again. Transport errors and server failures report that account's refresh error without discarding\nthe session. The registry is reconciled under the provider's catalog lock, and a snapshot whose\nreconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or\nnot modified.\n\nThe user-auth registration document may include one closed policy object:\n\n```json\n{ \"policy\": { \"events\": \"required\" } }\n```\n\nNo other key under `policy` and no other value for `policy.events` is accepted. The registry preserves\nthis field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is\nrefreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a\njoin, or a manager start, after that command's own local refusals; the returned space, broker,\ntransport, IdP, issuer, audience, and exchange pins must all match before only the policy is added.\nA failed expired refresh refuses that operation. Read-only commands such as `status` and `meshes`\nnever refresh. The five-second warm window makes no request.\n\nEvery registration is also bound to the proved account. Its IdP URL must match the account and its\nissuer must match the exact JWT `iss` pin. Its exchange, provisioning, and manager-authority\nendpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole\ncandidate snapshot.\n\nThe `slug` is the space identity resolved by `--space`, `use`, registry roots, and collisions. The\n`name` is a display label only.\n\nDiscovered registry entries are owned by the normalized IdP origin plus the proved `sub`. That key is\nstored as an opaque digest, so accounts on one machine never union their spaces and the registry does\nnot persist the subject. A manual or locally started record with the same name is never overwritten.\nLogout removes only the entries owned by the account whose session was revoked. Local teardown,\ncleanup, and liveness pruning do not remove discovered entries.\n\nAn account that previously advertised no catalog is checked again by explicit `cotal sync`. The\nordinary lazy path checks again after its five-second capability window, so an IdP can enable the\nLink for an existing login without making the person sign in again.\n\n**One auth service per space** hosts both halves: the NATS auth callout and the token\nexchange. Its default HTTP listener remains loopback-only and requires the per-start capability\nstored in the owner-only `auth-service.json` file. An operator may add a second listener with\n`cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example`.\nThat listener still binds `127.0.0.1`; put a reverse proxy in front of it and terminate TLS there.\nIn-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal\nand fork proxy-based deployments.\n\nThe loopback face also serves four host-only doors, all capability-gated and never on the public\nface. Two retire a lifecycle: `/interactive-lifecycle/retire` (used by `cotal actor grant/revoke`)\nand `/managed-lifecycle/retire`, which finishes a managed agent's terminal retirement after its\nremote manager is gone. The third decides one:\n`POST /manager-service-authority/verify-enrollment` answers whether a remote manager may have a\nmanaged agent enrolled or released under its authenticated owner. The body is\n`{ owner, request }` and nothing else. The caller's capability scope is read from this machine's\nledger, never taken from the body, so a host that forwarded a participant-supplied scope could not\ngrant itself `supervise`. The door reads the manager gate and checks the registration proof inside\nthe service process, so no signing material reaches the caller, and it returns\n`{ authorized: true, owner, actor, instanceId, serveEpoch }` or maps its refusal to 400, 401, 403,\n409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock\n`dispatchManagerAuthorityRequest` refuses both request kinds with `unimplemented` rather than\nanswering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted\nruntime create and status kinds. For those it reads the manager actor's ledger row itself and returns\n`{ authorized: true, owner, instanceId, actor, target }`.\nThe fourth, `POST /tool-result-human-proof/verify`, decides a pinned password proof for the\nhost's authenticated session subject without issuing consent or credentials.\nThe opt-in `POST /tool-result-consent/issue` door signs and records a same-owner consent\nunder a host-custodied scoped anchor. It returns no result content, private key or capability.\n[embedding.md](embedding.md) documents these host doors' contracts.\n\nThe public listener has a closed surface: `GET /health`, `GET /jwks`, `POST /exchange`,\n`POST /manager-service-authority`, `POST /manager-instances`, and `GET /.well-known/cotal-mesh`;\nevery other path is 404. It\ndoes **not** require the loopback\ncapability. That capability proves same-uid access to a 0600 local file and has no remote meaning;\non the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against\nthe pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must\nmatch a fresh managed-ledger row. The public face mints two elevated views, both still gated on\nledger scope `admin`: `channel-writer` (`cotal channels set/default`) and `channel-purger` (the\ndashboard's per-click channel delete). It also mints the narrowing `manager-caller` view for humans\nor managed agents. That view binds the bearer to one live registered manager instance. It adds no\ncapability, except that a `spawn` holder's view may read that manager's `status`, `models` and\n`resolve-cwd`, which a host-local launch there needs. A manager using a delegated runtime refuses\nhost cwd resolution before filesystem lookup because its seats execute outside that host.\nGod-view (`admin`), space-history `purger`, `deployer`, and `manager-service` stay\nloopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: `cotal web` still\nmints the read-only admin view at startup, so a remote dashboard that needs that god-view still\nfails even when a later delete would mint `channel-purger`.\n\nThe well-known response contains the IdP pins and the actual deny-all sentinel credential remote\nagents need before the bearer-driven auth callout. The pins ride a `userAuth` arm that names the\nauth provider, and that name is the same one the local arm registers under. A document naming a\ndifferent provider than the one serving it would register an entry nothing can resolve, so both read\none constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but\nconsumers must still take the bundle only from the intended HTTPS origin and must verify TLS.\n`--exchange-trusted-proxy` opts into peer attribution by the **last** `X-Forwarded-For` hop; use it\nonly when the listener is reachable solely through a proxy you control.\nWithout it, forwarded headers are ignored and the socket address is the peer key. Public failure\nbuckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at\nmost 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling\nmore than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid\ncredential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape\nmatters to the deployment. A refusal at `POST /manager-service-authority` counts against the same\nbudget as one at `/exchange` on that face, so after 30 refusals in a minute either route answers a\nfurther refusal with 429.\n\n### Enrollment redeem\n\nA remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached\nIdP login. The enrollment is a secret-bearing URL. The client performs one request:\n\n```http\nGET <enrollment URL>\n```\n\nIt sends no `Authorization` header and no request body. The URL must be HTTPS, except for plain HTTP\nto a loopback IP literal. The client redeems only an enrollment URL that is already in canonical\nform and contains none of `\\ @ ? #`. That is checked on the raw string before parsing, so every\nrewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case\nfolding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal\nrather than a redeem of a URL the owner never minted. Redirects are refused. The client never\nretries because a successful claim deletes the server-side token row. The token expires five minutes\nafter mint.\n\nSuccess is `200` with this JSON object:\n\n```text\nspace\nbrokerAccess { kind, ... }\nowner\nactor\nlifecycleUid\nactorToken\nsentinelCreds\nauthServiceUrl\nidp { url, issuer, audience }\nsubscribe[]\nallowSubscribe[]\nallowPublish[]\n```\n\nThe grant arrays are informational; the broker row remains authoritative. A stock-dialable\ndeployment also includes `server`, `tlsRequired`, `userAuth`, and optional `policy`, forming the same user-bundle\nsuperset that `cotal meshes add --user-auth-file` accepts. That lets a bare seat register the mesh\nfrom the enrollment response before launch. For `brokerAccess.kind: \"direct\"`, the stock `server`\nmust equal `brokerAccess.url` byte for byte or the client refuses the bundle before registration. A\ntunnel kind carries no dial address, so its `brokerAccess` is not compared to the operator-asserted\nstock `server` face.\n\nUnknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They\nall return `404 {\"error\":\"unknown, expired, or already-used enrollment\"}`. The client reports only\n`enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one`. It does not\nguess which case occurred.\n\nAfter redeem, the seat stores only the normal remote user-mesh and agent material. The actor token\nis exchanged at `authServiceUrl` through the existing `agent-bearer --exchange-url` path. The\nenrollment URL is not logged, persisted, or forwarded into any child process, including the bearer\npreflight and harness.\n\nThe service starts with the broker, is torn down by `cotal down`, and holds the\ndata-account signing key for the callout (a running manager is the other standing holder, for\nthe creds it mints); the operator seed never enters it. It also owns the space's two authority\nstores (lifecycle records and the credential ledger), provisions them at boot, and refuses\nconnects it cannot credential-check against them; there is no fallback path. If it\ndies while the broker lives, re-running `cotal up` heals it, and a boot whose auth\nservice never became ready exits non-zero, so automation never reads a dead identity\nplane as success. Changing any public-listener flag requires `cotal down` followed by `cotal up`\nwith the new values; a refresh adopts an already-running auth service rather than silently replacing\nits listener policy. \"One per space\" is enforced, not assumed (SPEC \xA713.13): at boot the\nservice takes a broker-backed ownership claim, so a second same-space auth process refuses\nwith instructions instead of silently splitting the plane, and a crashed one's claim is\nreclaimed only once the broker confirms its connections are gone. That verdict is trusted only\non a standalone broker (a clustered one refuses the reclaim, since a partitioned member\ncould still hold them). If the claim's connections die mid-run, the service downs itself\nloudly instead of serving from a half-dead plane.\n\nCtrl-C on a foreground `up`, and a broker that exits under it, stop the service with the same stop\n`cotal down auth` uses. It holds the reservation `down` takes, so a concurrent `cotal down auth` is\nrefused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after\nSIGTERM.\n\n**Your agents are yours.** `cotal spawn` on a user mesh grants a managed actor under the\n*spawning operator's* owner and launches the agent with a bearer command instead of a\ncreds file. The agent exchanges its spawn-time secret for short bearers (five minutes or\nless) and refreshes ahead of each expiry. A locally provisioned foreground launch refuses an\nexisting actor rather than rotating another launch's secret. Its exit and failed-start cleanup\nrevoke only the grant matching the launch's token and lifecycle. Broker-footprint deletion follows\na successful owned revoke; credential files are removed only while they still hold this launch's\nmaterial. Cleanup reports failures without deleting a successor's state. A crashed launcher can\nleave a grant that needs operator recovery. Remote foreground launches remove their own local\nmaterial but leave revocation to the issuer. Managed despawn follows the retirement sequence below.\nManifest deploys (`up -f`) stamp the logged-in owner into the launch, so those agents are yours too.\n\n**Despawn tears the lifecycle down, then frees the name.** When you despawn an agent, the manager\ndrives the *full* teardown of that lifecycle: it shreds the local credential files, revokes the\nagent's standing mint authority (its ledger row, so a copied token can no longer mint a fresh\ncredential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks\nthe auth service to *retire* the lifecycle (settle in-flight work, evict the departed credentials,\nrecord it retired). The name is held *reserved pending retirement* until **all** of that completes,\nthe broker-footprint cleanup, the standing-authority revoke, **and** the lifecycle retirement, not the\nretirement alone, so a same-name respawn in the gap is refused with\na plain reason and a retry hint rather than quietly handing the alias to a new agent while\nthe old lifecycle's teardown is still running. Only once the broker footprint is gone, the standing\nauthority is revoked, and the retirement is confirmed does the name free, and `cotal spawn <same-name>`\ngives you a fresh agent cleanly. This is what makes reusing an agent's name safe: the old lifecycle is\nfully torn down before the new one takes the alias. A local file that cannot be removed does not\nstop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and\nthe name stays held. If the auth service is unreachable or the standing-authority revoke fails,\nthe despawn still stops the agent and *holds* the name. **A\nsame-name `cotal spawn` re-drives the whole teardown** and finishes it. Retrying the despawn has no\neffect because the agent is already stopped. The operator copy tells you to recover the stack\n(`cotal supervise`) rather than reusing the name over an unretired predecessor.\n\n**A crash mid-retirement resumes at the next boot.** The retirement's last two steps (recording the\nissuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash\nbetween them leaves the gate retired while the head is still `retiring`: an alias that can neither\nmint nor be replaced. The auth service's boot crash-resume decides what it owes across *both*\nobjects (the gate *and* the alias head), so the next boot finishes that tail from the durable\noperation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal\nlanded, or a successor already took the alias) is left skipped. A retry of the despawn converges on\nthe same recovery.\n\n**Delegation only narrows (the envelope rule).** A user's grant is their envelope:\neverything under their owner (their CLI, every agent they spawn, every agent those\nspawn) stays within its channel lists and its capability scope. Handing a role to a\nspawned agent needs the matching `role:<r>` capability in the spawner's scope. The whole\ndelegation chain is checked, not just the last link, and re-checked at every bearer\nexchange, so narrowing a user's grant reaches their agents within minutes, and revoking\nthe user revokes everything under them, grandchildren included. A spawn beyond the\nenvelope is refused with the exact widening re-grant to ask the operator for.\n\n**Control ops ride your own login**, gated by ledger scope. `spawn` covers launching,\n`ps`, and stop/attach of the agents under **your own owner**: the owner is the\nadministrative boundary of its own subtree, so you (and your agents) manage what you own\nwithout any extra grant. `admin` is the explicit opt-in for touching **other owners'**\nagents; it is never part of a default grant and never accepted from a manifest.\n\n**Elevated operator surfaces ride the same login** through a short-lived *view*: the\nexchange stamps a server-authored view claim into the bearer, and the callout mints that\nconnection as the matching non-agent profile instead of `agent`. `cotal web` and\n`cotal console` ask for the read-only admin view, `clean history` for the purger,\n`channels set/default` for the channel-writer (all gated on ledger scope `admin`);\n`up -f` deploys over the deployer view, gated on `spawn`, because deploying your own team\nis spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist\nonly on a signed-in human exchange. The `manager-caller` view is the one managed-exchange exception\nbecause it narrows the agent's existing manager command set to one server-selected instance and adds\nno capability. All views are authorized against the fresh ledger row at every connect and expire\nwith the bearer, so narrowing or revoking a grant bites within minutes here too. On the public\nexchange face only `channel-writer`, `channel-purger`, `manager-caller`, `session-caller`, and\n`transfer-writer` are served; `admin`, `purger`, `deployer`, and `manager-service` remain loopback-only.\n\nThe `session-caller` view is how `cotal attach` opens a seat's session on a user-auth mesh. It needs\nno ledger scope, because the session grant is the authority. The exchange takes the grant with the\nlogin proof and leader-reads the redeemed `session.<id>` row. It issues the bearer only when the row\nis active and unexpired, its signature equals the presented grant's, its holder is this owner and\nactor at this lifecycle, its endpoint and serving epoch match, and the serving manager's gate is open\nat that epoch. The callout repeats the same check at connect and mints the session's caller rails\nwith the grant's expiry instead of the bearer's.\n\nThe `transfer-writer` view is how `cotal spawn --resume <id> --detach --on <instance>` uploads a\nsession this host holds on a user-auth mesh. It needs scope `admin`, as the `transcript-receive`\ncall it serves does, and names one object: the target instance and the transcript's SHA-256. The\ncallout mints writes to that object of that instance's transfer bucket and nothing else. The bearer,\nand with it the broker connection, lives at most five minutes, the lifetime of the static\n`transfer-writer` credential, and never past the login proof it was exchanged for.\n\n### Remote manager authority\n\nA registered user remains an ordinary `agent` bearer by default. Running a detached manager\non a remote user-auth mesh needs the closed server-authored **`manager-service`** view, which\nis distinct from every general-purpose profile. The operator grants it only by adding\n`supervise` to that user's actor-ledger scope. `supervise` is deliberately distinct from\n`spawn` and `admin`: spawn controls your agents, admin permits the separate cross-owner\noperations, and neither grants persistent manager registration authority.\n\nOnly a signed-in human may request this view from the loopback/operator exchange. The public\nexchange and every managed-agent secret exchange refuse it. At exchange and each connection,\nthe auth service re-reads the actor row; revoking or removing `supervise` therefore denies the\nnext view exchange and connection. A grant must carry the whole requested row just like every\nother actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not\nonly `supervise`.\n\nThe service is one opaque manager instance for the user's derived owner and a fixed\nserver-selected manager actor. Its authority is limited to that instance's manager\nregistration, contracts, status, endpoint rails, gate and credential family; it cannot read or\nwrite another owner or instance. It never exposes a signer, static provisioner credential, owner\nsecret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the\npublic-nkey JWT material through the typed lifecycle-bound protocol: **prepare \u2192 activate \u2192\nrenew**, plus host-owned **evict-family-principal** and **reconcile-registration** maintenance\noperations and a one-shot **retire** phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation\ncoordinate; the host writes its credential ledger row and finalizes the gate before it releases\nusable material. The retire phase fresh-checks the current manager instance, server-derived serve\nprincipal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester\ncredential pinned to that target. The manager invokes the registered `auth` endpoint's\n`retire-lifecycle` command through the generic client, resolving the service and calling it with\nan exact target and the operation id derived from the target lifecycle UID. The endpoint\nrecomputes that id from the broker-pinned target before any durable access. A caller cannot\nsubstitute another valid operation identity for the same target, and retries plus auth-service\nboot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.\n\nRegistration maintenance stays on the host. One eviction request carries up to 256 holders, and the\nhost accepts it only when its single sealed scan of the caller instance's\n`epcred.manager.<instanceId>.*` family finds every one of them. Reconciliation may\ntarget a foreign manager slot holder in the same space, but it runs only after the delivery daemon\nproves the frozen gate's holder gone under a complete sweep. The participant receives neither an\nevictor credential nor authority over another instance's records or gate. A clean stop refreshes an\nunhealthy executor before deregistration. A restart verify-evicts its old family, and a manager\nblocked by a foreign governance slot whose holder's gate is still frozen at the slot's stamp asks\nthe host to reconcile that holder and retries the registration once. The goal-writer and session-ledger\ncredentials carry a random `issuance:` tag, so a restart that re-mints them within the second of an\nearlier issuance gets new ledger rows and never re-signs a credential its barrier revoked.\n\nA remote manager can provision only descendants of the same derived owner, and the host\nvalidates that relation and the current manager grant for every provision. It cannot broaden the\nuser's envelope or provision a sibling owner's agent. Renewals are bounded. If login, the\n`supervise` grant, or the host manager authority service is unavailable, the manager reports a\ndegraded state and refuses new agents, restarts, or replacement credentials rather than\nsubstituting local/static authority. Existing live agents remain running only while their own\nvalid authority permits it; recovery requires the host service and a fresh successful renewal.\n\nThe manager-authority protocol is closed, so a host whose auth service predates a field the\nmanager sends refuses the whole request. The manager reports that refusal as version skew after the\nhost's reason: it names its own Cotal version and the refused field, and says it needs a host at\nthat version or later. It never drops the field to fit the older host. Upgrade the host first;\n[Upgrading](UPGRADING.md) promises no rolling upgrade between versions.\n\nA manager on remote authority mints from that authority alone; it consults the local root's\nrecords only to refuse a conflict, and only the supervised space's own trust records count as\none. A workspace that hosts an unrelated static space beside the participant sign-in is a\nnormal configuration.\n\n**User authentication has one path.** On a user-auth space, commands never fall back to\nstatic minting or credless connects: a missing login or a down auth service is one\nsentence naming the exact recovery, and static agent/observer/admin minting is refused\noutright. The refusal is deny-new: a static cred signed before the space flipped stays\nbroker-valid until the signing key is rotated ([security model](security.md)).\n\n## The IdP callout contract\n\nAny OIDC identity provider that issues **EdDSA/Ed25519** JWTs plugs in here directly; a provider that\nissues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or\nre-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The\nreference implementation ships **Better Auth** as a\ndev and test fixture only (it is a `devDependency` of `@cotal-ai/auth`; the only code that imports\nit is the `dev-idp.ts` harness and the smoke tests, never the runtime `src`). The one runtime\ncoupling to an IdP is the `idp.ts` bridge plus the `auth-provider` extension. The bridge core\n(`createIdpBridge`) is IdP-generic for **EdDSA** tokens (issuer, audience, JWKS as configuration).\nThe stock end-to-end flow around it, though, is **Better-Auth-shaped**: `cotalAuthProvider` pins\n`<base>/jwks` and issuer/audience to the IdP origin, and the login client speaks Better Auth's\ndevice-code endpoints (`/device/code`, `/device/token`, `/token`) with an opaque revocable session.\nSo a Better-Auth-shaped EdDSA IdP uses the stock flow directly; **any other production IdP is a\nhosted-composability gap, not a configuration change**. A host integrates it by building its own\nlogin and provider wiring on the low-level primitives (`createIdpBridge`, `createUserTokenIssuer`),\nnot by reusing the stock provider. Note that importing `@cotal-ai/auth` self-registers\n`cotalAuthProvider`, and `resolveAuthProvider()` throws when two providers are registered, so a host\non the registry-resolution path must not also register its own. Whatever the path, never loosen the\nissuer/audience/JWKS pins to force-fit an IdP.\n\nThe bridge (`createIdpBridge`) exchanges a verified IdP token for a Cotal bearer in three steps:\n\n1. **Bearer validation.** Verify the IdP's JWT offline against its **pinned JWKS**, with the token\n algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded\n key material (`jku`/`jwk`/`x5u`/`x5c`) is rejected, so the token can never influence key\n resolution. Issuer and audience are checked. The minted Cotal bearer never expires after the\n upstream proof: the issuer signs the earlier of the requested lifetime and the proof's `exp`,\n read when it signs, and refuses once the proof has expired. Time spent in the bridge's hooks\n shortens the bearer instead of extending it.\n2. **Owner derivation.** The opaque per-space owner derives deterministically from the JSON-array\n encoding of `[idp issuer, sub]`, namespaced by issuer so no issuer/sub pair can straddle a\n delimiter, and re-login re-lands the same person in the same lanes. The owner-token *format*\n (`u_` followed by 26 base32-lower characters) is normative\n ([SPEC section 2](../SPEC.md#2-identity)). At the contract level the *derivation* from an\n identity is a pluggable edge, but the reference `createIdpBridge` fixes it\n (`deriveOwnerForIdpSubject`) and takes no derivation callback, so what a host configures is the\n IdP, not the derivation. **The encoding is frozen:** changing it, or changing the IdP issuer\n string, re-keys every owner in the space, which is a migration on the order of rotating the space\n secret.\n3. **Actor authorization and mint.** The operator's ledger hook authorizes the `(owner, actor)` pair\n and is the only source of the bearer's `scope`/`parent`; the issuer then mints the Cotal bearer,\n re-asserting every claim shape.\n\nA host wires this with the IdP's own coordinates and nothing from `@cotal-ai/auth` changes:\n\n```ts\nimport { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from \"@cotal-ai/auth\";\nconst bridge = createIdpBridge({\n idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP\n space,\n spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime\n issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer\n authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant\n});\n```\n\nFor private-result consent, `verifyToolResultHumanProof` applies the separate password-freshness\nrule to the same pinned signature check. A signed integer-second `auth_time` must be no later\nthan the decision and at most 300 seconds old, and signed `amr` must contain `pwd`. Neither a\nrecent JWT `iat` nor a session's creation time supplies password provenance. Missing, malformed,\nfuture or stale provenance refuses this decision without changing ordinary bearer exchange.\nThe host-only proof door and the remaining activation limits are documented under\n[private tool results](embedding.md#private-tool-results).\n\n## Joining\n\nA single **join link** carries server, auth, and space\n([SPEC \xA710](../SPEC.md#10-connection-and-onboarding)):\n\n```\ncotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)\n```\n\nHumans: `cotal join --link \u2026`. Agents: `COTAL_LINK=\u2026 ` in the environment. The connector\nexpands it and auto-joins. Token/user-pass links are the open-mode path; the default\nauthed path threads a minted creds file, and the endpoint adopts the credential's identity\nas its card id. A seat the manager spawned reaches that file through its **launch\nmaterial** rather than through `COTAL_CREDS` in an environment every descendant process\ninherits (see [Configuration](config.md#launch-material)); a session you drive by hand\nstill sets `COTAL_CREDS` itself.\n\n## Honest limitations (v0)\n\n- **The signing key is hot** on the mint/manager box of a static-auth mesh; the \"real\n boundary\" holds given operator-controlled cred distribution. On a per-user-auth mesh\n the data-account signing key is held by the auth service (the callout stage) and by any\n running manager, which loads the trust bundle and self-mints its supervisor cred and\n renewals from it; a copied signing *seed* still stays valid for its identity until the\n signing key is rotated. Rotation remains the revocation lever for trust material.\n- **The two `$SYS` creds renew through rotation.** `membership-observer` and\n `connection-evictor` are signed by the system-account seed, which is never persisted, so no\n running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new\n system account (`cotal down` then `cotal up --rotate-sys`), which leaves the data account,\n every agent cred and the store untouched but does invalidate earlier full backups (they bind to\n 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\n membership feed and live eviction stop; `cotal doctor auth` and the manager warn from the 75%\n point onward.\n- **A copied static agent cred outlives its session.** One-shot command creds expire in\n minutes and the standing daemon creds in 24h with the manager renewing them (`cotal doctor\n auth` is the one diagnosis and repair surface). A manager-spawned agent cred also expires\n after 24h, the manager renews it only while the agent is live, and despawn retires it. A\n static mesh has no auth callout, so a compromised agent that copied its creds can still\n reconnect until they expire, and a `cotal mint` cred without `--expires-in` or `--expires-at`\n stays valid until the signing key is rotated ([security](security.md)). Per-user-auth spaces\n close this: bearers live minutes, `cotal actor revoke` denies the next exchange and the next\n connect and evicts the principal's live connections immediately.\n- **Not non-repudiation.** Authenticity is broker-enforced, not portable proof; it does\n not survive an untrusted relay. Signed envelopes are reserved\n ([SPEC \xA711](../SPEC.md#11-versioning-and-extensibility)).\n- **Chat metadata leaks in-space.** Content reads are ACL-bounded; stream metadata\n (channel names, per-subject counts) is not yet ([security model](security.md)).\n\n**Denials are loud, never silent.** A publish outside an ACL surfaces as a logged denial\n(\"denied, not absent\") on the endpoint's error path; an over-tight ACL never looks like a\nmissing peer ([run a mesh](run-a-mesh.md)).\n"
|
|
64421
|
+
"body": "# Identity\n\n> **Concept** (informative) \xB7 **For:** operators and implementers \xB7 **Normative:** [SPEC \xA72](../SPEC.md#2-identity), [\xA79](../SPEC.md#9-nats--jetstream-security-and-authorization), [\xA710](../SPEC.md#10-connection-and-onboarding), [Appendix B](../SPEC.md#appendix-b-profile-acls)\n\nWho can do what on a mesh, and how it is enforced. The design goal: the mesh is a **real\nboundary against untrusted peers in a shared space**; an agent can only speak as itself\nand only where its declared permissions allow, enforced by the broker, not by agent\ngoodwill. What that boundary does and does not protect is the\n[security model](security.md); the exact ACLs are\n[SPEC Appendix B](../SPEC.md#appendix-b-profile-acls).\n\n## On by default\n\n`cotal up` provisions a JWT-authed space; `cotal up --open` runs an unauthenticated dev\nmesh instead. Both bind loopback by default. `--host 0.0.0.0` widens the bind\nindependently, so \"network-reachable\" never silently means \"unauthenticated\". Open mode\nis for quick local experiments and sits outside every security claim\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)).\n\n## Shared identity\n\nAn agent's wire identity is a **principal**: an `owner.actor` pair, where the owner is\nthe account (a human, or an organization) the agent acts on behalf of, and the actor is\nthe agent's own handle under that owner ([SPEC \xA72](../SPEC.md#2-identity)). The same pair\nis the card id, the sender tokens in every subject it publishes, the presence key, and\nits durable-consumer names. On an open dev mesh the owner is the literal `local`; on a\nper-user-auth mesh it is a derived token (`u_` plus 26 characters, so no PII rides the\nwire). The connection still authenticates with an **nkey**, generated locally (the signer\nonly ever sees the public half), but the nkey is the transport credential, not the\nidentity: it scopes only the per-connection reply inbox.\n\n**The sender is encoded in the subject.** Every publish carries the sender's owner and\nactor in positions the broker's permissions pin to that connection, so an agent *cannot*\nemit as anyone else: not as another owner, and not as a sibling actor under its own\nowner. Receivers verify the payload's `from.id` against the subject sender and reject\nmismatches; sender authenticity is broker-enforced end to end\n([SPEC \xA73](../SPEC.md#3-subject-layout), [\xA75](../SPEC.md#5-envelopes)).\n\n**Account = space, user = agent.** A space is one NATS account, a server-enforced\nisolation boundary. An operator signs the account; an account **signing key** mints\nper-agent user JWTs.\n\n## Provisioner\n\nThe **provisioner** is whoever holds the account signing key. It mints profile-scoped\ncredentials and pre-creates the durables agents may only *bind* (their DM inbox, their\nrole's task queue). The manager hosts it today, but nothing is manager-special about it;\nprivilege attaches to the signer, and a space can run without a manager.\n`cotal mint <name> --profile <agent|observer|admin>` is the out-of-band path; spawn calls\nthe same library ([CLI](cli.md)). The out-of-band profiles carry no default TTL: pass\n`--expires-in <seconds>` (or `--expires-at`) for a bounded credential, which a\nstanding-renewal consumer requires; pass `--identity <creds>` to re-mint for the nkey a\nfile already carries, keeping the principal and its durables. Minting static creds is a\n**static-auth** surface: a\nper-user-auth space refuses it, because agents there join under a logged-in user, never\nvia a handed-out file (see *Per-user auth* below).\n\nAgent-profile minting resolves one mesh root for the persona ACL, account signer and default\ncredential storage. If the current folder holds trust for a different space or account, mint\nrefuses and names both roots. It never signs one root's persona policy with another root's authority.\n\n## Profiles\n\nEvery credential is a profile: an explicit allow-list built from the same\nsubject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The\nnormative shapes are [SPEC Appendix B](../SPEC.md#appendix-b-profile-acls); in brief:\n\n| Profile | Is |\n|---|---|\n| **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. |\n| **observer** | Read-only chat + presence; DMs invisible. What `cotal console` runs. Its only consumer deletes name its own history readers (`hist_<readerId>_<n>` on CHAT, keyed on its nkey or on the connection the server assigned), so each history read removes its consumers when it ends, and it cannot remove another principal's watch or the delivery daemon's fan-out consumer. The broker removes its finished watch consumers. |\n| **admin** | Elevated *read-only* god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (`cotal web`). Deletes its own history readers on CHAT and DM, and no other consumer. |\n| 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](security.md)). |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n\n**An agent's channel scope is three verbs**: `subscribe` (reads at boot),\n`allowSubscribe` (read ACL), `allowPublish` (post ACL, default-deny), declared in its\n[agent file](agent-files.md) or [manifest](manifest.md), minted into its cred. One card\nwith the recipes: [Channels & permissions](channels-and-permissions.md).\n\n**DM confidentiality** holds against peers by construction: deliveries ride per-identity\ninbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an\nagent cannot create a consumer filtered to someone else's inbox\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization) items 1\u20135).\n\n## Issued authority\n\nA credential says who is calling. It does not, by itself, say what the caller was granted, and a\nhost that acts on a caller's behalf (a workflow run, [workflows](workflows.md)) needs that from\nthe issuer, not from a ledger that may have changed since. So a static agent credential is an\n**issuance** ([SPEC \xA713.15](../SPEC.md#1315-issued-authority)): before the material is handed\nout, the issuer records the credential's final permission ceiling, as evidence keyed by a fresh\n**generation**, in `cotal_issued_<space>`. That store is append-only at the broker and the\nevidence is read as the first message on its key, so nothing written later on the same key, by\nanyone, changes what a resolver sees. The credential's endpoint rows then ride a versioned\nrail, `cotal.<space>.ep.v1.\u2026`, with that generation pinned beside the caller triple, so the\nbroker binds every request to the ceiling the issuer accepted. The legacy `ep.` rail and the\n`ep.v1.` rail are disjoint subject spaces; a credential holds rows on one of them, and every\nendpoint serves both.\n\nA connected client learns its generation by reading one row in `cotal_accepted_<space>` under a\ntoken the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts\nwhat the file says. A renewal keeps the generation only while the ceiling is byte-identical to the\nevidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection.\nA static agent's evidence names its credential ledger family as the source it depends on, and the\nlifecycle terminal that retires that family retires its issuances with it.\n\nThe manager, `cotal spawn`, and the CLI's control-caller instruments all mint through an `issuer`\nsession. The deployer instrument does not: it is the one instrument with no default expiry, and an\nissuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own\nlifecycle uid. Only workflow `run-start` requires the binding today: a request for it on the legacy\nrail is refused with `permission-denied` and the detail `ai.cotal.ep.unbound-caller-authority`\nnaming the caller. Every other command serves both rails.\n\nA user-auth `manager-caller` view is issued at the auth callout for an interactive row, or for a\nmanaged seat whose current persona-derived scope contains `run` and whose direct parent's fresh\nsame-owner grant also contains `run`. An admin parent's containment bypass does not count. The\nsingle source is the child's own actor row and lifecycle, and evidence records the signed ceiling\nwithout adding capabilities. Same-nonce renewal requires the same triple and byte-identical ceiling.\nThe existing view policy and manager instance gate are unchanged, with no new request kind. A seat\nmissing either `run` input keeps ordinary legacy commands. Without signed `run`, the broker refuses\n`run-start`; with signed `run` but no eligible parent, the manager refuses with\n`ai.cotal.ep.unbound-caller-authority`. A failed issuance never retries on that rail.\n\n## Declared capabilities\n\nControl-plane power is a **declared capability**, not a default. An agent file carrying\n`capabilities: [spawn]` gets the privileged control subject minted into its cred: spawn,\nplus stop/despawn of its *own* children, plus persona definition. On a static or open mesh its own\nchildren include what it launches with `cotal spawn --detach` from its own shell, since that\ncommand runs as the seat. Without it, an agent can\nonly self-despawn and pull or yield the run turns addressed to it. `capabilities: [run]` mints\nthe manager's workflow-run commands (start, resume, answer, status, list) together with the spawn\nset, since a program the agent starts may spawn; the manager drives the run under a per-run\n`run-driver` credential of its own, never the caller's. The tool surface mirrors the grant:\n`cotal_spawn` / `cotal_persona` / `cotal_personas` are injected only for `spawn`, and `cotal_run`\nonly for `run` ([agent files](agent-files.md)). Destructive\noperator ops (history purge, cross-agent stop) live on a third tier no agent credential\nreaches. Persona redefinition separates content from policy; the write path takes only\n`model`/`persona`, so a peer cannot grant itself a capability by redefining a file.\n\n## Per-user authentication\n\n`cotal up --user-auth --idp <auth base URL>` (or manifest `broker.auth: \"user\"`) puts a\n**human identity plane** above the per-agent one: people sign in to an external IdP once,\nand every connect is authorized live against the operator's **actor ledger**. No creds\nfiles to hand out, and revoking a grant actually bites.\n\nThe first enable pins the space to that IdP. A later `--idp` naming a different IdP is refused,\nbecause the IdP is part of every derived owner. Two first enables that race on one state dir leave\none pin: the other caller adopts it when it names the same IdP and is refused when it does not.\n\n**The flow.** Each person runs `cotal login --idp <url>` once per machine. The IdP URL, the JWKS\nURL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a\nloopback IP literal such as `127.0.0.1`, never on `localhost`, whose resolution would choose the\nkeys the mesh trusts. After that,\nany command works: cached IdP session \u2192 fresh IdP proof per connect (so IdP-side\nrevocation bites here too) \u2192 the configured exchange turns it into a short-lived Cotal bearer \u2192\nthe broker's **auth callout** checks the bearer and the ledger at connect time and mints\na scoped credential on the spot. Every bearer also names a **root credential** row in the\nspace's credential ledger, proved live at each connect, so revoking that one credential\nbites at the very next connect. The operator grants access with\n`cotal actor grant <actor> --sub <their id> --full` for the full envelope (all\nchannels; scope `spawn,role:default`, so it may spawn and may delegate the default role), or names\n`--scope`, `--allow-subscribe` and `--allow-publish` for a narrow row. A grant with any of the\nthree left off and no `--full` is refused.\nNo ledger row, no access; there is no allow-by-default.\n\n**Space catalogs.** A successful authenticated `GET <idp>/token` may advertise one catalog with:\n\n```http\nLink: <https://idp.example/spaces>; rel=\"https://cotal.ai/relations/space-catalog\"\n```\n\nThe target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may\nuse HTTP for local development. A missing, foreign-origin, or insecure link records that this account\nhas no catalog. The client never guesses a path.\n\nThe catalog request carries the opaque cached session as its bearer and returns a complete snapshot:\n\n```json\n{\n \"v\": 1,\n \"account\": { \"idpUrl\": \"https://idp.example/api/auth\", \"issuer\": \"https://idp.example\", \"sub\": \"user-id\" },\n \"spaces\": [\n { \"id\": \"space-id\", \"slug\": \"shared_project\", \"name\": \"Shared project\", \"kind\": \"hosted\", \"role\": \"owner\", \"registration\": {} }\n ]\n}\n```\n\nThe client checks every `registration` with the same `checkUserBundle` validator used by `cotal\nmeshes add`. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the\ncatalog's `ETag`; a transport error, non-success response, or invalid candidate leaves the prior\nsnapshot intact and reports the failure. Only a 401 response for the saved session recommends signing\nin again. Transport errors and server failures report that account's refresh error without discarding\nthe session. The registry is reconciled under the provider's catalog lock, and a snapshot whose\nreconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or\nnot modified.\n\nThe user-auth registration document may include one closed policy object:\n\n```json\n{ \"policy\": { \"events\": \"required\" } }\n```\n\nNo other key under `policy` and no other value for `policy.events` is accepted. The registry preserves\nthis field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is\nrefreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a\njoin, or a manager start, after that command's own local refusals; the returned space, broker,\ntransport, IdP, issuer, audience, and exchange pins must all match before only the policy is added.\nA failed expired refresh refuses that operation. Read-only commands such as `status` and `meshes`\nnever refresh. The five-second warm window makes no request.\n\nEvery registration is also bound to the proved account. Its IdP URL must match the account and its\nissuer must match the exact JWT `iss` pin. Its exchange, provisioning, and manager-authority\nendpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole\ncandidate snapshot.\n\nThe `slug` is the space identity resolved by `--space`, `use`, registry roots, and collisions. The\n`name` is a display label only.\n\nDiscovered registry entries are owned by the normalized IdP origin plus the proved `sub`. That key is\nstored as an opaque digest, so accounts on one machine never union their spaces and the registry does\nnot persist the subject. A manual or locally started record with the same name is never overwritten.\nLogout removes only the entries owned by the account whose session was revoked. Local teardown,\ncleanup, and liveness pruning do not remove discovered entries.\n\nAn account that previously advertised no catalog is checked again by explicit `cotal sync`. The\nordinary lazy path checks again after its five-second capability window, so an IdP can enable the\nLink for an existing login without making the person sign in again.\n\n**One auth service per space** hosts both halves: the NATS auth callout and the token\nexchange. Its default HTTP listener remains loopback-only and requires the per-start capability\nstored in the owner-only `auth-service.json` file. An operator may add a second listener with\n`cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example`.\nThat listener still binds `127.0.0.1`; put a reverse proxy in front of it and terminate TLS there.\nIn-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal\nand fork proxy-based deployments.\n\nThe loopback face also serves four host-only doors, all capability-gated and never on the public\nface. Two retire a lifecycle: `/interactive-lifecycle/retire` (used by `cotal actor grant/revoke`)\nand `/managed-lifecycle/retire`, which finishes a managed agent's terminal retirement after its\nremote manager is gone. The third decides one:\n`POST /manager-service-authority/verify-enrollment` answers whether a remote manager may have a\nmanaged agent enrolled or released under its authenticated owner. The body is\n`{ owner, request }` and nothing else. The caller's capability scope is read from this machine's\nledger, never taken from the body, so a host that forwarded a participant-supplied scope could not\ngrant itself `supervise`. The door reads the manager gate and checks the registration proof inside\nthe service process, so no signing material reaches the caller, and it returns\n`{ authorized: true, owner, actor, instanceId, serveEpoch }` or maps its refusal to 400, 401, 403,\n409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock\n`dispatchManagerAuthorityRequest` refuses both request kinds with `unimplemented` rather than\nanswering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted\nruntime create and status kinds. For those it reads the manager actor's ledger row itself and returns\n`{ authorized: true, owner, instanceId, actor, target }`.\nThe fourth, `POST /tool-result-human-proof/verify`, decides a pinned password proof for the\nhost's authenticated session subject without issuing consent or credentials.\nThe opt-in `POST /tool-result-consent/issue` door signs and records a same-owner consent\nunder a host-custodied scoped anchor. It returns no result content, private key or capability.\n[embedding.md](embedding.md) documents these host doors' contracts.\n\nThe public listener has a closed surface: `GET /health`, `GET /jwks`, `POST /exchange`,\n`POST /manager-service-authority`, `POST /manager-instances`, and `GET /.well-known/cotal-mesh`;\nevery other path is 404. It\ndoes **not** require the loopback\ncapability. That capability proves same-uid access to a 0600 local file and has no remote meaning;\non the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against\nthe pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must\nmatch a fresh managed-ledger row. The public face mints two elevated views, both still gated on\nledger scope `admin`: `channel-writer` (`cotal channels set/default`) and `channel-purger` (the\ndashboard's per-click channel delete). It also mints the narrowing `manager-caller` view for humans\nor managed agents. That view binds the bearer to one live registered manager instance. It adds no\ncapability, except that a `spawn` holder's view may read that manager's `status`, `models` and\n`resolve-cwd`, which a host-local launch there needs. A manager using a delegated runtime refuses\nhost cwd resolution before filesystem lookup because its seats execute outside that host.\nGod-view (`admin`), space-history `purger`, `deployer`, and `manager-service` stay\nloopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: `cotal web` still\nmints the read-only admin view at startup, so a remote dashboard that needs that god-view still\nfails even when a later delete would mint `channel-purger`.\n\nThe well-known response contains the IdP pins and the actual deny-all sentinel credential remote\nagents need before the bearer-driven auth callout. The pins ride a `userAuth` arm that names the\nauth provider, and that name is the same one the local arm registers under. A document naming a\ndifferent provider than the one serving it would register an entry nothing can resolve, so both read\none constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but\nconsumers must still take the bundle only from the intended HTTPS origin and must verify TLS.\n`--exchange-trusted-proxy` opts into peer attribution by the **last** `X-Forwarded-For` hop; use it\nonly when the listener is reachable solely through a proxy you control.\nWithout it, forwarded headers are ignored and the socket address is the peer key. Public failure\nbuckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at\nmost 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling\nmore than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid\ncredential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape\nmatters to the deployment. A refusal at `POST /manager-service-authority` counts against the same\nbudget as one at `/exchange` on that face, so after 30 refusals in a minute either route answers a\nfurther refusal with 429. An unpinned manager-caller exchange that finds no single live manager for\nits owner is refused only after its credential verified. It does not count against the budget and\nalways answers with its own reason, because a session that no manager launched polls its manager this way.\n\n### Enrollment redeem\n\nA remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached\nIdP login. The enrollment is a secret-bearing URL. The client performs one request:\n\n```http\nGET <enrollment URL>\n```\n\nIt sends no `Authorization` header and no request body. The URL must be HTTPS, except for plain HTTP\nto a loopback IP literal. The client redeems only an enrollment URL that is already in canonical\nform and contains none of `\\ @ ? #`. That is checked on the raw string before parsing, so every\nrewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case\nfolding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal\nrather than a redeem of a URL the owner never minted. Redirects are refused. The client never\nretries because a successful claim deletes the server-side token row. The token expires five minutes\nafter mint.\n\nSuccess is `200` with this JSON object:\n\n```text\nspace\nbrokerAccess { kind, ... }\nowner\nactor\nlifecycleUid\nactorToken\nsentinelCreds\nauthServiceUrl\nidp { url, issuer, audience }\nsubscribe[]\nallowSubscribe[]\nallowPublish[]\n```\n\nThe grant arrays are informational; the broker row remains authoritative. A stock-dialable\ndeployment also includes `server`, `tlsRequired`, `userAuth`, and optional `policy`, forming the same user-bundle\nsuperset that `cotal meshes add --user-auth-file` accepts. That lets a bare seat register the mesh\nfrom the enrollment response before launch. For `brokerAccess.kind: \"direct\"`, the stock `server`\nmust equal `brokerAccess.url` byte for byte or the client refuses the bundle before registration. A\ntunnel kind carries no dial address, so its `brokerAccess` is not compared to the operator-asserted\nstock `server` face.\n\nUnknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They\nall return `404 {\"error\":\"unknown, expired, or already-used enrollment\"}`. The client reports only\n`enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one`. It does not\nguess which case occurred.\n\nAfter redeem, the seat stores only the normal remote user-mesh and agent material. The actor token\nis exchanged at `authServiceUrl` through the existing `agent-bearer --exchange-url` path. The\nenrollment URL is not logged, persisted, or forwarded into any child process, including the bearer\npreflight and harness.\n\nThe service starts with the broker, is torn down by `cotal down`, and holds the\ndata-account signing key for the callout (a running manager is the other standing holder, for\nthe creds it mints); the operator seed never enters it. It also owns the space's two authority\nstores (lifecycle records and the credential ledger), provisions them at boot, and refuses\nconnects it cannot credential-check against them; there is no fallback path. If it\ndies while the broker lives, re-running `cotal up` heals it, and a boot whose auth\nservice never became ready exits non-zero, so automation never reads a dead identity\nplane as success. Changing any public-listener flag requires `cotal down` followed by `cotal up`\nwith the new values; a refresh adopts an already-running auth service rather than silently replacing\nits listener policy. \"One per space\" is enforced, not assumed (SPEC \xA713.13): at boot the\nservice takes a broker-backed ownership claim, so a second same-space auth process refuses\nwith instructions instead of silently splitting the plane, and a crashed one's claim is\nreclaimed only once the broker confirms its connections are gone. That verdict is trusted only\non a standalone broker (a clustered one refuses the reclaim, since a partitioned member\ncould still hold them). If the claim's connections die mid-run, the service downs itself\nloudly instead of serving from a half-dead plane.\n\nCtrl-C on a foreground `up`, and a broker that exits under it, stop the service with the same stop\n`cotal down auth` uses. It holds the reservation `down` takes, so a concurrent `cotal down auth` is\nrefused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after\nSIGTERM.\n\n**Your agents are yours.** `cotal spawn` on a user mesh grants a managed actor under the\n*spawning operator's* owner and launches the agent with a bearer command instead of a\ncreds file. The agent exchanges its spawn-time secret for short bearers (five minutes or\nless) and refreshes ahead of each expiry. A locally provisioned foreground launch refuses an\nexisting actor rather than rotating another launch's secret. Its exit and failed-start cleanup\nrevoke only the grant matching the launch's token and lifecycle. Broker-footprint deletion follows\na successful owned revoke; credential files are removed only while they still hold this launch's\nmaterial. Cleanup reports failures without deleting a successor's state. A crashed launcher can\nleave a grant that needs operator recovery. Remote foreground launches remove their own local\nmaterial but leave revocation to the issuer. Managed despawn follows the retirement sequence below.\nManifest deploys (`up -f`) stamp the logged-in owner into the launch, so those agents are yours too.\n\n**Despawn tears the lifecycle down, then frees the name.** When you despawn an agent, the manager\ndrives the *full* teardown of that lifecycle: it shreds the local credential files, revokes the\nagent's standing mint authority (its ledger row, so a copied token can no longer mint a fresh\ncredential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks\nthe auth service to *retire* the lifecycle (settle in-flight work, evict the departed credentials,\nrecord it retired). The name is held *reserved pending retirement* until **all** of that completes,\nthe broker-footprint cleanup, the standing-authority revoke, **and** the lifecycle retirement, not the\nretirement alone, so a same-name respawn in the gap is refused with\na plain reason and a retry hint rather than quietly handing the alias to a new agent while\nthe old lifecycle's teardown is still running. Only once the broker footprint is gone, the standing\nauthority is revoked, and the retirement is confirmed does the name free, and `cotal spawn <same-name>`\ngives you a fresh agent cleanly. This is what makes reusing an agent's name safe: the old lifecycle is\nfully torn down before the new one takes the alias. A local file that cannot be removed does not\nstop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and\nthe name stays held. If the auth service is unreachable or the standing-authority revoke fails,\nthe despawn still stops the agent and *holds* the name. **A\nsame-name `cotal spawn` re-drives the whole teardown** and finishes it. Retrying the despawn has no\neffect because the agent is already stopped. The operator copy tells you to recover the stack\n(`cotal supervise`) rather than reusing the name over an unretired predecessor.\n\n**A crash mid-retirement resumes at the next boot.** The retirement's last two steps (recording the\nissuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash\nbetween them leaves the gate retired while the head is still `retiring`: an alias that can neither\nmint nor be replaced. The auth service's boot crash-resume decides what it owes across *both*\nobjects (the gate *and* the alias head), so the next boot finishes that tail from the durable\noperation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal\nlanded, or a successor already took the alias) is left skipped. A retry of the despawn converges on\nthe same recovery.\n\n**Delegation only narrows (the envelope rule).** A user's grant is their envelope:\neverything under their owner (their CLI, every agent they spawn, every agent those\nspawn) stays within its channel lists and its capability scope. Handing a role to a\nspawned agent needs the matching `role:<r>` capability in the spawner's scope. The whole\ndelegation chain is checked, not just the last link, and re-checked at every bearer\nexchange, so narrowing a user's grant reaches their agents within minutes, and revoking\nthe user revokes everything under them, grandchildren included. A spawn beyond the\nenvelope is refused with the exact widening re-grant to ask the operator for.\n\n**Control ops ride your own login**, gated by ledger scope. `spawn` covers launching,\n`ps`, and stop/attach of the agents under **your own owner**: the owner is the\nadministrative boundary of its own subtree, so you (and your agents) manage what you own\nwithout any extra grant. `admin` is the explicit opt-in for touching **other owners'**\nagents; it is never part of a default grant and never accepted from a manifest.\n\n**Elevated operator surfaces ride the same login** through a short-lived *view*: the\nexchange stamps a server-authored view claim into the bearer, and the callout mints that\nconnection as the matching non-agent profile instead of `agent`. `cotal web` and\n`cotal console` ask for the read-only admin view, `clean history` for the purger,\n`channels set/default` for the channel-writer (all gated on ledger scope `admin`);\n`up -f` deploys over the deployer view, gated on `spawn`, because deploying your own team\nis spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist\nonly on a signed-in human exchange. The `manager-caller` view is the one managed-exchange exception\nbecause it narrows the agent's existing manager command set to one server-selected instance and adds\nno capability. All views are authorized against the fresh ledger row at every connect and expire\nwith the bearer, so narrowing or revoking a grant bites within minutes here too. On the public\nexchange face only `channel-writer`, `channel-purger`, `manager-caller`, `session-caller`, and\n`transfer-writer` are served; `admin`, `purger`, `deployer`, and `manager-service` remain loopback-only.\n\nThe `session-caller` view is how `cotal attach` opens a seat's session on a user-auth mesh. It needs\nno ledger scope, because the session grant is the authority. The exchange takes the grant with the\nlogin proof and leader-reads the redeemed `session.<id>` row. It issues the bearer only when the row\nis active and unexpired, its signature equals the presented grant's, its holder is this owner and\nactor at this lifecycle, its endpoint and serving epoch match, and the serving manager's gate is open\nat that epoch. The callout repeats the same check at connect and mints the session's caller rails\nwith the grant's expiry instead of the bearer's.\n\nThe `transfer-writer` view is how `cotal spawn --resume <id> --detach --on <instance>` uploads a\nsession this host holds on a user-auth mesh. It needs scope `admin`, as the `transcript-receive`\ncall it serves does, and names one object: the target instance and the transcript's SHA-256. The\ncallout mints writes to that object of that instance's transfer bucket and nothing else. The bearer,\nand with it the broker connection, lives at most five minutes, the lifetime of the static\n`transfer-writer` credential, and never past the login proof it was exchanged for.\n\n### Remote manager authority\n\nA registered user remains an ordinary `agent` bearer by default. Running a detached manager\non a remote user-auth mesh needs the closed server-authored **`manager-service`** view, which\nis distinct from every general-purpose profile. The operator grants it only by adding\n`supervise` to that user's actor-ledger scope. `supervise` is deliberately distinct from\n`spawn` and `admin`: spawn controls your agents, admin permits the separate cross-owner\noperations, and neither grants persistent manager registration authority.\n\nOnly a signed-in human may request this view from the loopback/operator exchange. The public\nexchange and every managed-agent secret exchange refuse it. At exchange and each connection,\nthe auth service re-reads the actor row; revoking or removing `supervise` therefore denies the\nnext view exchange and connection. A grant must carry the whole requested row just like every\nother actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not\nonly `supervise`.\n\nThe service is one opaque manager instance for the user's derived owner and a fixed\nserver-selected manager actor. Its authority is limited to that instance's manager\nregistration, contracts, status, endpoint rails, gate and credential family; it cannot read or\nwrite another owner or instance. It never exposes a signer, static provisioner credential, owner\nsecret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the\npublic-nkey JWT material through the typed lifecycle-bound protocol: **prepare \u2192 activate \u2192\nrenew**, plus host-owned **evict-family-principal** and **reconcile-registration** maintenance\noperations and a one-shot **retire** phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation\ncoordinate; the host writes its credential ledger row and finalizes the gate before it releases\nusable material. The retire phase fresh-checks the current manager instance, server-derived serve\nprincipal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester\ncredential pinned to that target. The manager invokes the registered `auth` endpoint's\n`retire-lifecycle` command through the generic client, resolving the service and calling it with\nan exact target and the operation id derived from the target lifecycle UID. The endpoint\nrecomputes that id from the broker-pinned target before any durable access. A caller cannot\nsubstitute another valid operation identity for the same target, and retries plus auth-service\nboot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.\n\nRegistration maintenance stays on the host. One eviction request carries up to 256 holders, and the\nhost accepts it only when its single sealed scan of the caller instance's\n`epcred.manager.<instanceId>.*` family finds every one of them. Reconciliation may\ntarget a foreign manager slot holder in the same space, but it runs only after the delivery daemon\nproves the frozen gate's holder gone under a complete sweep. The participant receives neither an\nevictor credential nor authority over another instance's records or gate. A clean stop refreshes an\nunhealthy executor before deregistration. A restart verify-evicts its old family, and a manager\nblocked by a foreign governance slot whose holder's gate is still frozen at the slot's stamp asks\nthe host to reconcile that holder and retries the registration once. The goal-writer and session-ledger\ncredentials carry a random `issuance:` tag, so a restart that re-mints them within the second of an\nearlier issuance gets new ledger rows and never re-signs a credential its barrier revoked.\n\nA remote manager can provision only descendants of the same derived owner, and the host\nvalidates that relation and the current manager grant for every provision. It cannot broaden the\nuser's envelope or provision a sibling owner's agent. Renewals are bounded. If login, the\n`supervise` grant, or the host manager authority service is unavailable, the manager reports a\ndegraded state and refuses new agents, restarts, or replacement credentials rather than\nsubstituting local/static authority. Existing live agents remain running only while their own\nvalid authority permits it; recovery requires the host service and a fresh successful renewal.\n\nThe manager-authority protocol is closed, so a host whose auth service predates a field the\nmanager sends refuses the whole request. The manager reports that refusal as version skew after the\nhost's reason: it names its own Cotal version and the refused field, and says it needs a host at\nthat version or later. It never drops the field to fit the older host. Upgrade the host first;\n[Upgrading](UPGRADING.md) promises no rolling upgrade between versions.\n\nA manager on remote authority mints from that authority alone; it consults the local root's\nrecords only to refuse a conflict, and only the supervised space's own trust records count as\none. A workspace that hosts an unrelated static space beside the participant sign-in is a\nnormal configuration.\n\n**User authentication has one path.** On a user-auth space, commands never fall back to\nstatic minting or credless connects: a missing login or a down auth service is one\nsentence naming the exact recovery, and static agent/observer/admin minting is refused\noutright. The refusal is deny-new: a static cred signed before the space flipped stays\nbroker-valid until the signing key is rotated ([security model](security.md)).\n\n## The IdP callout contract\n\nAny OIDC identity provider that issues **EdDSA/Ed25519** JWTs plugs in here directly; a provider that\nissues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or\nre-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The\nreference implementation ships **Better Auth** as a\ndev and test fixture only (it is a `devDependency` of `@cotal-ai/auth`; the only code that imports\nit is the `dev-idp.ts` harness and the smoke tests, never the runtime `src`). The one runtime\ncoupling to an IdP is the `idp.ts` bridge plus the `auth-provider` extension. The bridge core\n(`createIdpBridge`) is IdP-generic for **EdDSA** tokens (issuer, audience, JWKS as configuration).\nThe stock end-to-end flow around it, though, is **Better-Auth-shaped**: `cotalAuthProvider` pins\n`<base>/jwks` and issuer/audience to the IdP origin, and the login client speaks Better Auth's\ndevice-code endpoints (`/device/code`, `/device/token`, `/token`) with an opaque revocable session.\nSo a Better-Auth-shaped EdDSA IdP uses the stock flow directly; **any other production IdP is a\nhosted-composability gap, not a configuration change**. A host integrates it by building its own\nlogin and provider wiring on the low-level primitives (`createIdpBridge`, `createUserTokenIssuer`),\nnot by reusing the stock provider. Note that importing `@cotal-ai/auth` self-registers\n`cotalAuthProvider`, and `resolveAuthProvider()` throws when two providers are registered, so a host\non the registry-resolution path must not also register its own. Whatever the path, never loosen the\nissuer/audience/JWKS pins to force-fit an IdP.\n\nThe bridge (`createIdpBridge`) exchanges a verified IdP token for a Cotal bearer in three steps:\n\n1. **Bearer validation.** Verify the IdP's JWT offline against its **pinned JWKS**, with the token\n algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded\n key material (`jku`/`jwk`/`x5u`/`x5c`) is rejected, so the token can never influence key\n resolution. Issuer and audience are checked. The minted Cotal bearer never expires after the\n upstream proof: the issuer signs the earlier of the requested lifetime and the proof's `exp`,\n read when it signs, and refuses once the proof has expired. Time spent in the bridge's hooks\n shortens the bearer instead of extending it.\n2. **Owner derivation.** The opaque per-space owner derives deterministically from the JSON-array\n encoding of `[idp issuer, sub]`, namespaced by issuer so no issuer/sub pair can straddle a\n delimiter, and re-login re-lands the same person in the same lanes. The owner-token *format*\n (`u_` followed by 26 base32-lower characters) is normative\n ([SPEC section 2](../SPEC.md#2-identity)). At the contract level the *derivation* from an\n identity is a pluggable edge, but the reference `createIdpBridge` fixes it\n (`deriveOwnerForIdpSubject`) and takes no derivation callback, so what a host configures is the\n IdP, not the derivation. **The encoding is frozen:** changing it, or changing the IdP issuer\n string, re-keys every owner in the space, which is a migration on the order of rotating the space\n secret.\n3. **Actor authorization and mint.** The operator's ledger hook authorizes the `(owner, actor)` pair\n and is the only source of the bearer's `scope`/`parent`; the issuer then mints the Cotal bearer,\n re-asserting every claim shape.\n\nA host wires this with the IdP's own coordinates and nothing from `@cotal-ai/auth` changes:\n\n```ts\nimport { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from \"@cotal-ai/auth\";\nconst bridge = createIdpBridge({\n idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP\n space,\n spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime\n issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer\n authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant\n});\n```\n\nFor private-result consent, `verifyToolResultHumanProof` applies the separate password-freshness\nrule to the same pinned signature check. A signed integer-second `auth_time` must be no later\nthan the decision and at most 300 seconds old, and signed `amr` must contain `pwd`. Neither a\nrecent JWT `iat` nor a session's creation time supplies password provenance. Missing, malformed,\nfuture or stale provenance refuses this decision without changing ordinary bearer exchange.\nThe host-only proof door and the remaining activation limits are documented under\n[private tool results](embedding.md#private-tool-results).\n\n## Joining\n\nA single **join link** carries server, auth, and space\n([SPEC \xA710](../SPEC.md#10-connection-and-onboarding)):\n\n```\ncotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)\n```\n\nHumans: `cotal join --link \u2026`. Agents: `COTAL_LINK=\u2026 ` in the environment. The connector\nexpands it and auto-joins. Token/user-pass links are the open-mode path; the default\nauthed path threads a minted creds file, and the endpoint adopts the credential's identity\nas its card id. A seat the manager spawned reaches that file through its **launch\nmaterial** rather than through `COTAL_CREDS` in an environment every descendant process\ninherits (see [Configuration](config.md#launch-material)); a session you drive by hand\nstill sets `COTAL_CREDS` itself.\n\n## Honest limitations (v0)\n\n- **The signing key is hot** on the mint/manager box of a static-auth mesh; the \"real\n boundary\" holds given operator-controlled cred distribution. On a per-user-auth mesh\n the data-account signing key is held by the auth service (the callout stage) and by any\n running manager, which loads the trust bundle and self-mints its supervisor cred and\n renewals from it; a copied signing *seed* still stays valid for its identity until the\n signing key is rotated. Rotation remains the revocation lever for trust material.\n- **The two `$SYS` creds renew through rotation.** `membership-observer` and\n `connection-evictor` are signed by the system-account seed, which is never persisted, so no\n running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new\n system account (`cotal down` then `cotal up --rotate-sys`), which leaves the data account,\n every agent cred and the store untouched but does invalidate earlier full backups (they bind to\n 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\n membership feed and live eviction stop; `cotal doctor auth` and the manager warn from the 75%\n point onward.\n- **A copied static agent cred outlives its session.** One-shot command creds expire in\n minutes and the standing daemon creds in 24h with the manager renewing them (`cotal doctor\n auth` is the one diagnosis and repair surface). A manager-spawned agent cred also expires\n after 24h, the manager renews it only while the agent is live, and despawn retires it. A\n static mesh has no auth callout, so a compromised agent that copied its creds can still\n reconnect until they expire, and a `cotal mint` cred without `--expires-in` or `--expires-at`\n stays valid until the signing key is rotated ([security](security.md)). Per-user-auth spaces\n close this: bearers live minutes, `cotal actor revoke` denies the next exchange and the next\n connect and evicts the principal's live connections immediately.\n- **Not non-repudiation.** Authenticity is broker-enforced, not portable proof; it does\n not survive an untrusted relay. Signed envelopes are reserved\n ([SPEC \xA711](../SPEC.md#11-versioning-and-extensibility)).\n- **Chat metadata leaks in-space.** Content reads are ACL-bounded; stream metadata\n (channel names, per-subject counts) is not yet ([security model](security.md)).\n\n**Denials are loud, never silent.** A publish outside an ACL surfaces as a logged denial\n(\"denied, not absent\") on the endpoint's error path; an over-tight ACL never looks like a\nmissing peer ([run a mesh](run-a-mesh.md)).\n"
|
|
64422
64422
|
},
|
|
64423
64423
|
{
|
|
64424
64424
|
"slug": "agent-files",
|
package/dist/mcp.js
CHANGED
|
@@ -57940,10 +57940,10 @@ registerAguiFramePartRenderer();
|
|
|
57940
57940
|
import { execFileSync } from "node:child_process";
|
|
57941
57941
|
|
|
57942
57942
|
// ../connector-core/dist/docs-bundle.generated.js
|
|
57943
|
-
var DOCS_VERSION = "0.90.
|
|
57943
|
+
var DOCS_VERSION = "0.90.1";
|
|
57944
57944
|
function loadDocsBundle() {
|
|
57945
57945
|
return {
|
|
57946
|
-
"version": "0.90.
|
|
57946
|
+
"version": "0.90.1",
|
|
57947
57947
|
"generatedFrom": "docs/*.md + SPEC.md + spec/cotal-lang.md + spec/cotal.schema.json",
|
|
57948
57948
|
"pages": [
|
|
57949
57949
|
{
|
|
@@ -57986,7 +57986,7 @@ function loadDocsBundle() {
|
|
|
57986
57986
|
"title": "Identity",
|
|
57987
57987
|
"kind": "Concept (informative)",
|
|
57988
57988
|
"summary": "Who can do what on a mesh, and how it is enforced.",
|
|
57989
|
-
"body": "# Identity\n\n> **Concept** (informative) \xB7 **For:** operators and implementers \xB7 **Normative:** [SPEC \xA72](../SPEC.md#2-identity), [\xA79](../SPEC.md#9-nats--jetstream-security-and-authorization), [\xA710](../SPEC.md#10-connection-and-onboarding), [Appendix B](../SPEC.md#appendix-b-profile-acls)\n\nWho can do what on a mesh, and how it is enforced. The design goal: the mesh is a **real\nboundary against untrusted peers in a shared space**; an agent can only speak as itself\nand only where its declared permissions allow, enforced by the broker, not by agent\ngoodwill. What that boundary does and does not protect is the\n[security model](security.md); the exact ACLs are\n[SPEC Appendix B](../SPEC.md#appendix-b-profile-acls).\n\n## On by default\n\n`cotal up` provisions a JWT-authed space; `cotal up --open` runs an unauthenticated dev\nmesh instead. Both bind loopback by default. `--host 0.0.0.0` widens the bind\nindependently, so \"network-reachable\" never silently means \"unauthenticated\". Open mode\nis for quick local experiments and sits outside every security claim\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)).\n\n## Shared identity\n\nAn agent's wire identity is a **principal**: an `owner.actor` pair, where the owner is\nthe account (a human, or an organization) the agent acts on behalf of, and the actor is\nthe agent's own handle under that owner ([SPEC \xA72](../SPEC.md#2-identity)). The same pair\nis the card id, the sender tokens in every subject it publishes, the presence key, and\nits durable-consumer names. On an open dev mesh the owner is the literal `local`; on a\nper-user-auth mesh it is a derived token (`u_` plus 26 characters, so no PII rides the\nwire). The connection still authenticates with an **nkey**, generated locally (the signer\nonly ever sees the public half), but the nkey is the transport credential, not the\nidentity: it scopes only the per-connection reply inbox.\n\n**The sender is encoded in the subject.** Every publish carries the sender's owner and\nactor in positions the broker's permissions pin to that connection, so an agent *cannot*\nemit as anyone else: not as another owner, and not as a sibling actor under its own\nowner. Receivers verify the payload's `from.id` against the subject sender and reject\nmismatches; sender authenticity is broker-enforced end to end\n([SPEC \xA73](../SPEC.md#3-subject-layout), [\xA75](../SPEC.md#5-envelopes)).\n\n**Account = space, user = agent.** A space is one NATS account, a server-enforced\nisolation boundary. An operator signs the account; an account **signing key** mints\nper-agent user JWTs.\n\n## Provisioner\n\nThe **provisioner** is whoever holds the account signing key. It mints profile-scoped\ncredentials and pre-creates the durables agents may only *bind* (their DM inbox, their\nrole's task queue). The manager hosts it today, but nothing is manager-special about it;\nprivilege attaches to the signer, and a space can run without a manager.\n`cotal mint <name> --profile <agent|observer|admin>` is the out-of-band path; spawn calls\nthe same library ([CLI](cli.md)). The out-of-band profiles carry no default TTL: pass\n`--expires-in <seconds>` (or `--expires-at`) for a bounded credential, which a\nstanding-renewal consumer requires; pass `--identity <creds>` to re-mint for the nkey a\nfile already carries, keeping the principal and its durables. Minting static creds is a\n**static-auth** surface: a\nper-user-auth space refuses it, because agents there join under a logged-in user, never\nvia a handed-out file (see *Per-user auth* below).\n\nAgent-profile minting resolves one mesh root for the persona ACL, account signer and default\ncredential storage. If the current folder holds trust for a different space or account, mint\nrefuses and names both roots. It never signs one root's persona policy with another root's authority.\n\n## Profiles\n\nEvery credential is a profile: an explicit allow-list built from the same\nsubject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The\nnormative shapes are [SPEC Appendix B](../SPEC.md#appendix-b-profile-acls); in brief:\n\n| Profile | Is |\n|---|---|\n| **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. |\n| **observer** | Read-only chat + presence; DMs invisible. What `cotal console` runs. Its only consumer deletes name its own history readers (`hist_<readerId>_<n>` on CHAT, keyed on its nkey or on the connection the server assigned), so each history read removes its consumers when it ends, and it cannot remove another principal's watch or the delivery daemon's fan-out consumer. The broker removes its finished watch consumers. |\n| **admin** | Elevated *read-only* god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (`cotal web`). Deletes its own history readers on CHAT and DM, and no other consumer. |\n| 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](security.md)). |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n\n**An agent's channel scope is three verbs**: `subscribe` (reads at boot),\n`allowSubscribe` (read ACL), `allowPublish` (post ACL, default-deny), declared in its\n[agent file](agent-files.md) or [manifest](manifest.md), minted into its cred. One card\nwith the recipes: [Channels & permissions](channels-and-permissions.md).\n\n**DM confidentiality** holds against peers by construction: deliveries ride per-identity\ninbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an\nagent cannot create a consumer filtered to someone else's inbox\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization) items 1\u20135).\n\n## Issued authority\n\nA credential says who is calling. It does not, by itself, say what the caller was granted, and a\nhost that acts on a caller's behalf (a workflow run, [workflows](workflows.md)) needs that from\nthe issuer, not from a ledger that may have changed since. So a static agent credential is an\n**issuance** ([SPEC \xA713.15](../SPEC.md#1315-issued-authority)): before the material is handed\nout, the issuer records the credential's final permission ceiling, as evidence keyed by a fresh\n**generation**, in `cotal_issued_<space>`. That store is append-only at the broker and the\nevidence is read as the first message on its key, so nothing written later on the same key, by\nanyone, changes what a resolver sees. The credential's endpoint rows then ride a versioned\nrail, `cotal.<space>.ep.v1.\u2026`, with that generation pinned beside the caller triple, so the\nbroker binds every request to the ceiling the issuer accepted. The legacy `ep.` rail and the\n`ep.v1.` rail are disjoint subject spaces; a credential holds rows on one of them, and every\nendpoint serves both.\n\nA connected client learns its generation by reading one row in `cotal_accepted_<space>` under a\ntoken the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts\nwhat the file says. A renewal keeps the generation only while the ceiling is byte-identical to the\nevidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection.\nA static agent's evidence names its credential ledger family as the source it depends on, and the\nlifecycle terminal that retires that family retires its issuances with it.\n\nThe manager, `cotal spawn`, and the CLI's control-caller instruments all mint through an `issuer`\nsession. The deployer instrument does not: it is the one instrument with no default expiry, and an\nissuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own\nlifecycle uid. Only workflow `run-start` requires the binding today: a request for it on the legacy\nrail is refused with `permission-denied` and the detail `ai.cotal.ep.unbound-caller-authority`\nnaming the caller. Every other command serves both rails.\n\nA user-auth `manager-caller` view is issued at the auth callout for an interactive row, or for a\nmanaged seat whose current persona-derived scope contains `run` and whose direct parent's fresh\nsame-owner grant also contains `run`. An admin parent's containment bypass does not count. The\nsingle source is the child's own actor row and lifecycle, and evidence records the signed ceiling\nwithout adding capabilities. Same-nonce renewal requires the same triple and byte-identical ceiling.\nThe existing view policy and manager instance gate are unchanged, with no new request kind. A seat\nmissing either `run` input keeps ordinary legacy commands. Without signed `run`, the broker refuses\n`run-start`; with signed `run` but no eligible parent, the manager refuses with\n`ai.cotal.ep.unbound-caller-authority`. A failed issuance never retries on that rail.\n\n## Declared capabilities\n\nControl-plane power is a **declared capability**, not a default. An agent file carrying\n`capabilities: [spawn]` gets the privileged control subject minted into its cred: spawn,\nplus stop/despawn of its *own* children, plus persona definition. On a static or open mesh its own\nchildren include what it launches with `cotal spawn --detach` from its own shell, since that\ncommand runs as the seat. Without it, an agent can\nonly self-despawn and pull or yield the run turns addressed to it. `capabilities: [run]` mints\nthe manager's workflow-run commands (start, resume, answer, status, list) together with the spawn\nset, since a program the agent starts may spawn; the manager drives the run under a per-run\n`run-driver` credential of its own, never the caller's. The tool surface mirrors the grant:\n`cotal_spawn` / `cotal_persona` / `cotal_personas` are injected only for `spawn`, and `cotal_run`\nonly for `run` ([agent files](agent-files.md)). Destructive\noperator ops (history purge, cross-agent stop) live on a third tier no agent credential\nreaches. Persona redefinition separates content from policy; the write path takes only\n`model`/`persona`, so a peer cannot grant itself a capability by redefining a file.\n\n## Per-user authentication\n\n`cotal up --user-auth --idp <auth base URL>` (or manifest `broker.auth: \"user\"`) puts a\n**human identity plane** above the per-agent one: people sign in to an external IdP once,\nand every connect is authorized live against the operator's **actor ledger**. No creds\nfiles to hand out, and revoking a grant actually bites.\n\nThe first enable pins the space to that IdP. A later `--idp` naming a different IdP is refused,\nbecause the IdP is part of every derived owner. Two first enables that race on one state dir leave\none pin: the other caller adopts it when it names the same IdP and is refused when it does not.\n\n**The flow.** Each person runs `cotal login --idp <url>` once per machine. The IdP URL, the JWKS\nURL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a\nloopback IP literal such as `127.0.0.1`, never on `localhost`, whose resolution would choose the\nkeys the mesh trusts. After that,\nany command works: cached IdP session \u2192 fresh IdP proof per connect (so IdP-side\nrevocation bites here too) \u2192 the configured exchange turns it into a short-lived Cotal bearer \u2192\nthe broker's **auth callout** checks the bearer and the ledger at connect time and mints\na scoped credential on the spot. Every bearer also names a **root credential** row in the\nspace's credential ledger, proved live at each connect, so revoking that one credential\nbites at the very next connect. The operator grants access with\n`cotal actor grant <actor> --sub <their id> --full` for the full envelope (all\nchannels; scope `spawn,role:default`, so it may spawn and may delegate the default role), or names\n`--scope`, `--allow-subscribe` and `--allow-publish` for a narrow row. A grant with any of the\nthree left off and no `--full` is refused.\nNo ledger row, no access; there is no allow-by-default.\n\n**Space catalogs.** A successful authenticated `GET <idp>/token` may advertise one catalog with:\n\n```http\nLink: <https://idp.example/spaces>; rel=\"https://cotal.ai/relations/space-catalog\"\n```\n\nThe target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may\nuse HTTP for local development. A missing, foreign-origin, or insecure link records that this account\nhas no catalog. The client never guesses a path.\n\nThe catalog request carries the opaque cached session as its bearer and returns a complete snapshot:\n\n```json\n{\n \"v\": 1,\n \"account\": { \"idpUrl\": \"https://idp.example/api/auth\", \"issuer\": \"https://idp.example\", \"sub\": \"user-id\" },\n \"spaces\": [\n { \"id\": \"space-id\", \"slug\": \"shared_project\", \"name\": \"Shared project\", \"kind\": \"hosted\", \"role\": \"owner\", \"registration\": {} }\n ]\n}\n```\n\nThe client checks every `registration` with the same `checkUserBundle` validator used by `cotal\nmeshes add`. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the\ncatalog's `ETag`; a transport error, non-success response, or invalid candidate leaves the prior\nsnapshot intact and reports the failure. Only a 401 response for the saved session recommends signing\nin again. Transport errors and server failures report that account's refresh error without discarding\nthe session. The registry is reconciled under the provider's catalog lock, and a snapshot whose\nreconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or\nnot modified.\n\nThe user-auth registration document may include one closed policy object:\n\n```json\n{ \"policy\": { \"events\": \"required\" } }\n```\n\nNo other key under `policy` and no other value for `policy.events` is accepted. The registry preserves\nthis field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is\nrefreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a\njoin, or a manager start, after that command's own local refusals; the returned space, broker,\ntransport, IdP, issuer, audience, and exchange pins must all match before only the policy is added.\nA failed expired refresh refuses that operation. Read-only commands such as `status` and `meshes`\nnever refresh. The five-second warm window makes no request.\n\nEvery registration is also bound to the proved account. Its IdP URL must match the account and its\nissuer must match the exact JWT `iss` pin. Its exchange, provisioning, and manager-authority\nendpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole\ncandidate snapshot.\n\nThe `slug` is the space identity resolved by `--space`, `use`, registry roots, and collisions. The\n`name` is a display label only.\n\nDiscovered registry entries are owned by the normalized IdP origin plus the proved `sub`. That key is\nstored as an opaque digest, so accounts on one machine never union their spaces and the registry does\nnot persist the subject. A manual or locally started record with the same name is never overwritten.\nLogout removes only the entries owned by the account whose session was revoked. Local teardown,\ncleanup, and liveness pruning do not remove discovered entries.\n\nAn account that previously advertised no catalog is checked again by explicit `cotal sync`. The\nordinary lazy path checks again after its five-second capability window, so an IdP can enable the\nLink for an existing login without making the person sign in again.\n\n**One auth service per space** hosts both halves: the NATS auth callout and the token\nexchange. Its default HTTP listener remains loopback-only and requires the per-start capability\nstored in the owner-only `auth-service.json` file. An operator may add a second listener with\n`cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example`.\nThat listener still binds `127.0.0.1`; put a reverse proxy in front of it and terminate TLS there.\nIn-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal\nand fork proxy-based deployments.\n\nThe loopback face also serves four host-only doors, all capability-gated and never on the public\nface. Two retire a lifecycle: `/interactive-lifecycle/retire` (used by `cotal actor grant/revoke`)\nand `/managed-lifecycle/retire`, which finishes a managed agent's terminal retirement after its\nremote manager is gone. The third decides one:\n`POST /manager-service-authority/verify-enrollment` answers whether a remote manager may have a\nmanaged agent enrolled or released under its authenticated owner. The body is\n`{ owner, request }` and nothing else. The caller's capability scope is read from this machine's\nledger, never taken from the body, so a host that forwarded a participant-supplied scope could not\ngrant itself `supervise`. The door reads the manager gate and checks the registration proof inside\nthe service process, so no signing material reaches the caller, and it returns\n`{ authorized: true, owner, actor, instanceId, serveEpoch }` or maps its refusal to 400, 401, 403,\n409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock\n`dispatchManagerAuthorityRequest` refuses both request kinds with `unimplemented` rather than\nanswering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted\nruntime create and status kinds. For those it reads the manager actor's ledger row itself and returns\n`{ authorized: true, owner, instanceId, actor, target }`.\nThe fourth, `POST /tool-result-human-proof/verify`, decides a pinned password proof for the\nhost's authenticated session subject without issuing consent or credentials.\nThe opt-in `POST /tool-result-consent/issue` door signs and records a same-owner consent\nunder a host-custodied scoped anchor. It returns no result content, private key or capability.\n[embedding.md](embedding.md) documents these host doors' contracts.\n\nThe public listener has a closed surface: `GET /health`, `GET /jwks`, `POST /exchange`,\n`POST /manager-service-authority`, `POST /manager-instances`, and `GET /.well-known/cotal-mesh`;\nevery other path is 404. It\ndoes **not** require the loopback\ncapability. That capability proves same-uid access to a 0600 local file and has no remote meaning;\non the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against\nthe pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must\nmatch a fresh managed-ledger row. The public face mints two elevated views, both still gated on\nledger scope `admin`: `channel-writer` (`cotal channels set/default`) and `channel-purger` (the\ndashboard's per-click channel delete). It also mints the narrowing `manager-caller` view for humans\nor managed agents. That view binds the bearer to one live registered manager instance. It adds no\ncapability, except that a `spawn` holder's view may read that manager's `status`, `models` and\n`resolve-cwd`, which a host-local launch there needs. A manager using a delegated runtime refuses\nhost cwd resolution before filesystem lookup because its seats execute outside that host.\nGod-view (`admin`), space-history `purger`, `deployer`, and `manager-service` stay\nloopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: `cotal web` still\nmints the read-only admin view at startup, so a remote dashboard that needs that god-view still\nfails even when a later delete would mint `channel-purger`.\n\nThe well-known response contains the IdP pins and the actual deny-all sentinel credential remote\nagents need before the bearer-driven auth callout. The pins ride a `userAuth` arm that names the\nauth provider, and that name is the same one the local arm registers under. A document naming a\ndifferent provider than the one serving it would register an entry nothing can resolve, so both read\none constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but\nconsumers must still take the bundle only from the intended HTTPS origin and must verify TLS.\n`--exchange-trusted-proxy` opts into peer attribution by the **last** `X-Forwarded-For` hop; use it\nonly when the listener is reachable solely through a proxy you control.\nWithout it, forwarded headers are ignored and the socket address is the peer key. Public failure\nbuckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at\nmost 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling\nmore than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid\ncredential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape\nmatters to the deployment. A refusal at `POST /manager-service-authority` counts against the same\nbudget as one at `/exchange` on that face, so after 30 refusals in a minute either route answers a\nfurther refusal with 429.\n\n### Enrollment redeem\n\nA remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached\nIdP login. The enrollment is a secret-bearing URL. The client performs one request:\n\n```http\nGET <enrollment URL>\n```\n\nIt sends no `Authorization` header and no request body. The URL must be HTTPS, except for plain HTTP\nto a loopback IP literal. The client redeems only an enrollment URL that is already in canonical\nform and contains none of `\\ @ ? #`. That is checked on the raw string before parsing, so every\nrewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case\nfolding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal\nrather than a redeem of a URL the owner never minted. Redirects are refused. The client never\nretries because a successful claim deletes the server-side token row. The token expires five minutes\nafter mint.\n\nSuccess is `200` with this JSON object:\n\n```text\nspace\nbrokerAccess { kind, ... }\nowner\nactor\nlifecycleUid\nactorToken\nsentinelCreds\nauthServiceUrl\nidp { url, issuer, audience }\nsubscribe[]\nallowSubscribe[]\nallowPublish[]\n```\n\nThe grant arrays are informational; the broker row remains authoritative. A stock-dialable\ndeployment also includes `server`, `tlsRequired`, `userAuth`, and optional `policy`, forming the same user-bundle\nsuperset that `cotal meshes add --user-auth-file` accepts. That lets a bare seat register the mesh\nfrom the enrollment response before launch. For `brokerAccess.kind: \"direct\"`, the stock `server`\nmust equal `brokerAccess.url` byte for byte or the client refuses the bundle before registration. A\ntunnel kind carries no dial address, so its `brokerAccess` is not compared to the operator-asserted\nstock `server` face.\n\nUnknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They\nall return `404 {\"error\":\"unknown, expired, or already-used enrollment\"}`. The client reports only\n`enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one`. It does not\nguess which case occurred.\n\nAfter redeem, the seat stores only the normal remote user-mesh and agent material. The actor token\nis exchanged at `authServiceUrl` through the existing `agent-bearer --exchange-url` path. The\nenrollment URL is not logged, persisted, or forwarded into any child process, including the bearer\npreflight and harness.\n\nThe service starts with the broker, is torn down by `cotal down`, and holds the\ndata-account signing key for the callout (a running manager is the other standing holder, for\nthe creds it mints); the operator seed never enters it. It also owns the space's two authority\nstores (lifecycle records and the credential ledger), provisions them at boot, and refuses\nconnects it cannot credential-check against them; there is no fallback path. If it\ndies while the broker lives, re-running `cotal up` heals it, and a boot whose auth\nservice never became ready exits non-zero, so automation never reads a dead identity\nplane as success. Changing any public-listener flag requires `cotal down` followed by `cotal up`\nwith the new values; a refresh adopts an already-running auth service rather than silently replacing\nits listener policy. \"One per space\" is enforced, not assumed (SPEC \xA713.13): at boot the\nservice takes a broker-backed ownership claim, so a second same-space auth process refuses\nwith instructions instead of silently splitting the plane, and a crashed one's claim is\nreclaimed only once the broker confirms its connections are gone. That verdict is trusted only\non a standalone broker (a clustered one refuses the reclaim, since a partitioned member\ncould still hold them). If the claim's connections die mid-run, the service downs itself\nloudly instead of serving from a half-dead plane.\n\nCtrl-C on a foreground `up`, and a broker that exits under it, stop the service with the same stop\n`cotal down auth` uses. It holds the reservation `down` takes, so a concurrent `cotal down auth` is\nrefused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after\nSIGTERM.\n\n**Your agents are yours.** `cotal spawn` on a user mesh grants a managed actor under the\n*spawning operator's* owner and launches the agent with a bearer command instead of a\ncreds file. The agent exchanges its spawn-time secret for short bearers (five minutes or\nless) and refreshes ahead of each expiry. A locally provisioned foreground launch refuses an\nexisting actor rather than rotating another launch's secret. Its exit and failed-start cleanup\nrevoke only the grant matching the launch's token and lifecycle. Broker-footprint deletion follows\na successful owned revoke; credential files are removed only while they still hold this launch's\nmaterial. Cleanup reports failures without deleting a successor's state. A crashed launcher can\nleave a grant that needs operator recovery. Remote foreground launches remove their own local\nmaterial but leave revocation to the issuer. Managed despawn follows the retirement sequence below.\nManifest deploys (`up -f`) stamp the logged-in owner into the launch, so those agents are yours too.\n\n**Despawn tears the lifecycle down, then frees the name.** When you despawn an agent, the manager\ndrives the *full* teardown of that lifecycle: it shreds the local credential files, revokes the\nagent's standing mint authority (its ledger row, so a copied token can no longer mint a fresh\ncredential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks\nthe auth service to *retire* the lifecycle (settle in-flight work, evict the departed credentials,\nrecord it retired). The name is held *reserved pending retirement* until **all** of that completes,\nthe broker-footprint cleanup, the standing-authority revoke, **and** the lifecycle retirement, not the\nretirement alone, so a same-name respawn in the gap is refused with\na plain reason and a retry hint rather than quietly handing the alias to a new agent while\nthe old lifecycle's teardown is still running. Only once the broker footprint is gone, the standing\nauthority is revoked, and the retirement is confirmed does the name free, and `cotal spawn <same-name>`\ngives you a fresh agent cleanly. This is what makes reusing an agent's name safe: the old lifecycle is\nfully torn down before the new one takes the alias. A local file that cannot be removed does not\nstop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and\nthe name stays held. If the auth service is unreachable or the standing-authority revoke fails,\nthe despawn still stops the agent and *holds* the name. **A\nsame-name `cotal spawn` re-drives the whole teardown** and finishes it. Retrying the despawn has no\neffect because the agent is already stopped. The operator copy tells you to recover the stack\n(`cotal supervise`) rather than reusing the name over an unretired predecessor.\n\n**A crash mid-retirement resumes at the next boot.** The retirement's last two steps (recording the\nissuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash\nbetween them leaves the gate retired while the head is still `retiring`: an alias that can neither\nmint nor be replaced. The auth service's boot crash-resume decides what it owes across *both*\nobjects (the gate *and* the alias head), so the next boot finishes that tail from the durable\noperation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal\nlanded, or a successor already took the alias) is left skipped. A retry of the despawn converges on\nthe same recovery.\n\n**Delegation only narrows (the envelope rule).** A user's grant is their envelope:\neverything under their owner (their CLI, every agent they spawn, every agent those\nspawn) stays within its channel lists and its capability scope. Handing a role to a\nspawned agent needs the matching `role:<r>` capability in the spawner's scope. The whole\ndelegation chain is checked, not just the last link, and re-checked at every bearer\nexchange, so narrowing a user's grant reaches their agents within minutes, and revoking\nthe user revokes everything under them, grandchildren included. A spawn beyond the\nenvelope is refused with the exact widening re-grant to ask the operator for.\n\n**Control ops ride your own login**, gated by ledger scope. `spawn` covers launching,\n`ps`, and stop/attach of the agents under **your own owner**: the owner is the\nadministrative boundary of its own subtree, so you (and your agents) manage what you own\nwithout any extra grant. `admin` is the explicit opt-in for touching **other owners'**\nagents; it is never part of a default grant and never accepted from a manifest.\n\n**Elevated operator surfaces ride the same login** through a short-lived *view*: the\nexchange stamps a server-authored view claim into the bearer, and the callout mints that\nconnection as the matching non-agent profile instead of `agent`. `cotal web` and\n`cotal console` ask for the read-only admin view, `clean history` for the purger,\n`channels set/default` for the channel-writer (all gated on ledger scope `admin`);\n`up -f` deploys over the deployer view, gated on `spawn`, because deploying your own team\nis spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist\nonly on a signed-in human exchange. The `manager-caller` view is the one managed-exchange exception\nbecause it narrows the agent's existing manager command set to one server-selected instance and adds\nno capability. All views are authorized against the fresh ledger row at every connect and expire\nwith the bearer, so narrowing or revoking a grant bites within minutes here too. On the public\nexchange face only `channel-writer`, `channel-purger`, `manager-caller`, `session-caller`, and\n`transfer-writer` are served; `admin`, `purger`, `deployer`, and `manager-service` remain loopback-only.\n\nThe `session-caller` view is how `cotal attach` opens a seat's session on a user-auth mesh. It needs\nno ledger scope, because the session grant is the authority. The exchange takes the grant with the\nlogin proof and leader-reads the redeemed `session.<id>` row. It issues the bearer only when the row\nis active and unexpired, its signature equals the presented grant's, its holder is this owner and\nactor at this lifecycle, its endpoint and serving epoch match, and the serving manager's gate is open\nat that epoch. The callout repeats the same check at connect and mints the session's caller rails\nwith the grant's expiry instead of the bearer's.\n\nThe `transfer-writer` view is how `cotal spawn --resume <id> --detach --on <instance>` uploads a\nsession this host holds on a user-auth mesh. It needs scope `admin`, as the `transcript-receive`\ncall it serves does, and names one object: the target instance and the transcript's SHA-256. The\ncallout mints writes to that object of that instance's transfer bucket and nothing else. The bearer,\nand with it the broker connection, lives at most five minutes, the lifetime of the static\n`transfer-writer` credential, and never past the login proof it was exchanged for.\n\n### Remote manager authority\n\nA registered user remains an ordinary `agent` bearer by default. Running a detached manager\non a remote user-auth mesh needs the closed server-authored **`manager-service`** view, which\nis distinct from every general-purpose profile. The operator grants it only by adding\n`supervise` to that user's actor-ledger scope. `supervise` is deliberately distinct from\n`spawn` and `admin`: spawn controls your agents, admin permits the separate cross-owner\noperations, and neither grants persistent manager registration authority.\n\nOnly a signed-in human may request this view from the loopback/operator exchange. The public\nexchange and every managed-agent secret exchange refuse it. At exchange and each connection,\nthe auth service re-reads the actor row; revoking or removing `supervise` therefore denies the\nnext view exchange and connection. A grant must carry the whole requested row just like every\nother actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not\nonly `supervise`.\n\nThe service is one opaque manager instance for the user's derived owner and a fixed\nserver-selected manager actor. Its authority is limited to that instance's manager\nregistration, contracts, status, endpoint rails, gate and credential family; it cannot read or\nwrite another owner or instance. It never exposes a signer, static provisioner credential, owner\nsecret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the\npublic-nkey JWT material through the typed lifecycle-bound protocol: **prepare \u2192 activate \u2192\nrenew**, plus host-owned **evict-family-principal** and **reconcile-registration** maintenance\noperations and a one-shot **retire** phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation\ncoordinate; the host writes its credential ledger row and finalizes the gate before it releases\nusable material. The retire phase fresh-checks the current manager instance, server-derived serve\nprincipal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester\ncredential pinned to that target. The manager invokes the registered `auth` endpoint's\n`retire-lifecycle` command through the generic client, resolving the service and calling it with\nan exact target and the operation id derived from the target lifecycle UID. The endpoint\nrecomputes that id from the broker-pinned target before any durable access. A caller cannot\nsubstitute another valid operation identity for the same target, and retries plus auth-service\nboot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.\n\nRegistration maintenance stays on the host. One eviction request carries up to 256 holders, and the\nhost accepts it only when its single sealed scan of the caller instance's\n`epcred.manager.<instanceId>.*` family finds every one of them. Reconciliation may\ntarget a foreign manager slot holder in the same space, but it runs only after the delivery daemon\nproves the frozen gate's holder gone under a complete sweep. The participant receives neither an\nevictor credential nor authority over another instance's records or gate. A clean stop refreshes an\nunhealthy executor before deregistration. A restart verify-evicts its old family, and a manager\nblocked by a foreign governance slot whose holder's gate is still frozen at the slot's stamp asks\nthe host to reconcile that holder and retries the registration once. The goal-writer and session-ledger\ncredentials carry a random `issuance:` tag, so a restart that re-mints them within the second of an\nearlier issuance gets new ledger rows and never re-signs a credential its barrier revoked.\n\nA remote manager can provision only descendants of the same derived owner, and the host\nvalidates that relation and the current manager grant for every provision. It cannot broaden the\nuser's envelope or provision a sibling owner's agent. Renewals are bounded. If login, the\n`supervise` grant, or the host manager authority service is unavailable, the manager reports a\ndegraded state and refuses new agents, restarts, or replacement credentials rather than\nsubstituting local/static authority. Existing live agents remain running only while their own\nvalid authority permits it; recovery requires the host service and a fresh successful renewal.\n\nThe manager-authority protocol is closed, so a host whose auth service predates a field the\nmanager sends refuses the whole request. The manager reports that refusal as version skew after the\nhost's reason: it names its own Cotal version and the refused field, and says it needs a host at\nthat version or later. It never drops the field to fit the older host. Upgrade the host first;\n[Upgrading](UPGRADING.md) promises no rolling upgrade between versions.\n\nA manager on remote authority mints from that authority alone; it consults the local root's\nrecords only to refuse a conflict, and only the supervised space's own trust records count as\none. A workspace that hosts an unrelated static space beside the participant sign-in is a\nnormal configuration.\n\n**User authentication has one path.** On a user-auth space, commands never fall back to\nstatic minting or credless connects: a missing login or a down auth service is one\nsentence naming the exact recovery, and static agent/observer/admin minting is refused\noutright. The refusal is deny-new: a static cred signed before the space flipped stays\nbroker-valid until the signing key is rotated ([security model](security.md)).\n\n## The IdP callout contract\n\nAny OIDC identity provider that issues **EdDSA/Ed25519** JWTs plugs in here directly; a provider that\nissues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or\nre-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The\nreference implementation ships **Better Auth** as a\ndev and test fixture only (it is a `devDependency` of `@cotal-ai/auth`; the only code that imports\nit is the `dev-idp.ts` harness and the smoke tests, never the runtime `src`). The one runtime\ncoupling to an IdP is the `idp.ts` bridge plus the `auth-provider` extension. The bridge core\n(`createIdpBridge`) is IdP-generic for **EdDSA** tokens (issuer, audience, JWKS as configuration).\nThe stock end-to-end flow around it, though, is **Better-Auth-shaped**: `cotalAuthProvider` pins\n`<base>/jwks` and issuer/audience to the IdP origin, and the login client speaks Better Auth's\ndevice-code endpoints (`/device/code`, `/device/token`, `/token`) with an opaque revocable session.\nSo a Better-Auth-shaped EdDSA IdP uses the stock flow directly; **any other production IdP is a\nhosted-composability gap, not a configuration change**. A host integrates it by building its own\nlogin and provider wiring on the low-level primitives (`createIdpBridge`, `createUserTokenIssuer`),\nnot by reusing the stock provider. Note that importing `@cotal-ai/auth` self-registers\n`cotalAuthProvider`, and `resolveAuthProvider()` throws when two providers are registered, so a host\non the registry-resolution path must not also register its own. Whatever the path, never loosen the\nissuer/audience/JWKS pins to force-fit an IdP.\n\nThe bridge (`createIdpBridge`) exchanges a verified IdP token for a Cotal bearer in three steps:\n\n1. **Bearer validation.** Verify the IdP's JWT offline against its **pinned JWKS**, with the token\n algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded\n key material (`jku`/`jwk`/`x5u`/`x5c`) is rejected, so the token can never influence key\n resolution. Issuer and audience are checked. The minted Cotal bearer never expires after the\n upstream proof: the issuer signs the earlier of the requested lifetime and the proof's `exp`,\n read when it signs, and refuses once the proof has expired. Time spent in the bridge's hooks\n shortens the bearer instead of extending it.\n2. **Owner derivation.** The opaque per-space owner derives deterministically from the JSON-array\n encoding of `[idp issuer, sub]`, namespaced by issuer so no issuer/sub pair can straddle a\n delimiter, and re-login re-lands the same person in the same lanes. The owner-token *format*\n (`u_` followed by 26 base32-lower characters) is normative\n ([SPEC section 2](../SPEC.md#2-identity)). At the contract level the *derivation* from an\n identity is a pluggable edge, but the reference `createIdpBridge` fixes it\n (`deriveOwnerForIdpSubject`) and takes no derivation callback, so what a host configures is the\n IdP, not the derivation. **The encoding is frozen:** changing it, or changing the IdP issuer\n string, re-keys every owner in the space, which is a migration on the order of rotating the space\n secret.\n3. **Actor authorization and mint.** The operator's ledger hook authorizes the `(owner, actor)` pair\n and is the only source of the bearer's `scope`/`parent`; the issuer then mints the Cotal bearer,\n re-asserting every claim shape.\n\nA host wires this with the IdP's own coordinates and nothing from `@cotal-ai/auth` changes:\n\n```ts\nimport { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from \"@cotal-ai/auth\";\nconst bridge = createIdpBridge({\n idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP\n space,\n spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime\n issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer\n authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant\n});\n```\n\nFor private-result consent, `verifyToolResultHumanProof` applies the separate password-freshness\nrule to the same pinned signature check. A signed integer-second `auth_time` must be no later\nthan the decision and at most 300 seconds old, and signed `amr` must contain `pwd`. Neither a\nrecent JWT `iat` nor a session's creation time supplies password provenance. Missing, malformed,\nfuture or stale provenance refuses this decision without changing ordinary bearer exchange.\nThe host-only proof door and the remaining activation limits are documented under\n[private tool results](embedding.md#private-tool-results).\n\n## Joining\n\nA single **join link** carries server, auth, and space\n([SPEC \xA710](../SPEC.md#10-connection-and-onboarding)):\n\n```\ncotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)\n```\n\nHumans: `cotal join --link \u2026`. Agents: `COTAL_LINK=\u2026 ` in the environment. The connector\nexpands it and auto-joins. Token/user-pass links are the open-mode path; the default\nauthed path threads a minted creds file, and the endpoint adopts the credential's identity\nas its card id. A seat the manager spawned reaches that file through its **launch\nmaterial** rather than through `COTAL_CREDS` in an environment every descendant process\ninherits (see [Configuration](config.md#launch-material)); a session you drive by hand\nstill sets `COTAL_CREDS` itself.\n\n## Honest limitations (v0)\n\n- **The signing key is hot** on the mint/manager box of a static-auth mesh; the \"real\n boundary\" holds given operator-controlled cred distribution. On a per-user-auth mesh\n the data-account signing key is held by the auth service (the callout stage) and by any\n running manager, which loads the trust bundle and self-mints its supervisor cred and\n renewals from it; a copied signing *seed* still stays valid for its identity until the\n signing key is rotated. Rotation remains the revocation lever for trust material.\n- **The two `$SYS` creds renew through rotation.** `membership-observer` and\n `connection-evictor` are signed by the system-account seed, which is never persisted, so no\n running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new\n system account (`cotal down` then `cotal up --rotate-sys`), which leaves the data account,\n every agent cred and the store untouched but does invalidate earlier full backups (they bind to\n 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\n membership feed and live eviction stop; `cotal doctor auth` and the manager warn from the 75%\n point onward.\n- **A copied static agent cred outlives its session.** One-shot command creds expire in\n minutes and the standing daemon creds in 24h with the manager renewing them (`cotal doctor\n auth` is the one diagnosis and repair surface). A manager-spawned agent cred also expires\n after 24h, the manager renews it only while the agent is live, and despawn retires it. A\n static mesh has no auth callout, so a compromised agent that copied its creds can still\n reconnect until they expire, and a `cotal mint` cred without `--expires-in` or `--expires-at`\n stays valid until the signing key is rotated ([security](security.md)). Per-user-auth spaces\n close this: bearers live minutes, `cotal actor revoke` denies the next exchange and the next\n connect and evicts the principal's live connections immediately.\n- **Not non-repudiation.** Authenticity is broker-enforced, not portable proof; it does\n not survive an untrusted relay. Signed envelopes are reserved\n ([SPEC \xA711](../SPEC.md#11-versioning-and-extensibility)).\n- **Chat metadata leaks in-space.** Content reads are ACL-bounded; stream metadata\n (channel names, per-subject counts) is not yet ([security model](security.md)).\n\n**Denials are loud, never silent.** A publish outside an ACL surfaces as a logged denial\n(\"denied, not absent\") on the endpoint's error path; an over-tight ACL never looks like a\nmissing peer ([run a mesh](run-a-mesh.md)).\n"
|
|
57989
|
+
"body": "# Identity\n\n> **Concept** (informative) \xB7 **For:** operators and implementers \xB7 **Normative:** [SPEC \xA72](../SPEC.md#2-identity), [\xA79](../SPEC.md#9-nats--jetstream-security-and-authorization), [\xA710](../SPEC.md#10-connection-and-onboarding), [Appendix B](../SPEC.md#appendix-b-profile-acls)\n\nWho can do what on a mesh, and how it is enforced. The design goal: the mesh is a **real\nboundary against untrusted peers in a shared space**; an agent can only speak as itself\nand only where its declared permissions allow, enforced by the broker, not by agent\ngoodwill. What that boundary does and does not protect is the\n[security model](security.md); the exact ACLs are\n[SPEC Appendix B](../SPEC.md#appendix-b-profile-acls).\n\n## On by default\n\n`cotal up` provisions a JWT-authed space; `cotal up --open` runs an unauthenticated dev\nmesh instead. Both bind loopback by default. `--host 0.0.0.0` widens the bind\nindependently, so \"network-reachable\" never silently means \"unauthenticated\". Open mode\nis for quick local experiments and sits outside every security claim\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization)).\n\n## Shared identity\n\nAn agent's wire identity is a **principal**: an `owner.actor` pair, where the owner is\nthe account (a human, or an organization) the agent acts on behalf of, and the actor is\nthe agent's own handle under that owner ([SPEC \xA72](../SPEC.md#2-identity)). The same pair\nis the card id, the sender tokens in every subject it publishes, the presence key, and\nits durable-consumer names. On an open dev mesh the owner is the literal `local`; on a\nper-user-auth mesh it is a derived token (`u_` plus 26 characters, so no PII rides the\nwire). The connection still authenticates with an **nkey**, generated locally (the signer\nonly ever sees the public half), but the nkey is the transport credential, not the\nidentity: it scopes only the per-connection reply inbox.\n\n**The sender is encoded in the subject.** Every publish carries the sender's owner and\nactor in positions the broker's permissions pin to that connection, so an agent *cannot*\nemit as anyone else: not as another owner, and not as a sibling actor under its own\nowner. Receivers verify the payload's `from.id` against the subject sender and reject\nmismatches; sender authenticity is broker-enforced end to end\n([SPEC \xA73](../SPEC.md#3-subject-layout), [\xA75](../SPEC.md#5-envelopes)).\n\n**Account = space, user = agent.** A space is one NATS account, a server-enforced\nisolation boundary. An operator signs the account; an account **signing key** mints\nper-agent user JWTs.\n\n## Provisioner\n\nThe **provisioner** is whoever holds the account signing key. It mints profile-scoped\ncredentials and pre-creates the durables agents may only *bind* (their DM inbox, their\nrole's task queue). The manager hosts it today, but nothing is manager-special about it;\nprivilege attaches to the signer, and a space can run without a manager.\n`cotal mint <name> --profile <agent|observer|admin>` is the out-of-band path; spawn calls\nthe same library ([CLI](cli.md)). The out-of-band profiles carry no default TTL: pass\n`--expires-in <seconds>` (or `--expires-at`) for a bounded credential, which a\nstanding-renewal consumer requires; pass `--identity <creds>` to re-mint for the nkey a\nfile already carries, keeping the principal and its durables. Minting static creds is a\n**static-auth** surface: a\nper-user-auth space refuses it, because agents there join under a logged-in user, never\nvia a handed-out file (see *Per-user auth* below).\n\nAgent-profile minting resolves one mesh root for the persona ACL, account signer and default\ncredential storage. If the current folder holds trust for a different space or account, mint\nrefuses and names both roots. It never signs one root's persona policy with another root's authority.\n\n## Profiles\n\nEvery credential is a profile: an explicit allow-list built from the same\nsubject/stream/durable builders as the wire layout, so ACLs cannot drift from it. The\nnormative shapes are [SPEC Appendix B](../SPEC.md#appendix-b-profile-acls); in brief:\n\n| Profile | Is |\n|---|---|\n| **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. |\n| **observer** | Read-only chat + presence; DMs invisible. What `cotal console` runs. Its only consumer deletes name its own history readers (`hist_<readerId>_<n>` on CHAT, keyed on its nkey or on the connection the server assigned), so each history read removes its consumers when it ends, and it cannot remove another principal's watch or the delivery daemon's fan-out consumer. The broker removes its finished watch consumers. |\n| **admin** | Elevated *read-only* god-view: sees DMs and anycast live, still writes nothing. A deliberate opt-in (`cotal web`). Deletes its own history readers on CHAT and DM, and no other consumer. |\n| 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](security.md)). |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n| **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. |\n\n**An agent's channel scope is three verbs**: `subscribe` (reads at boot),\n`allowSubscribe` (read ACL), `allowPublish` (post ACL, default-deny), declared in its\n[agent file](agent-files.md) or [manifest](manifest.md), minted into its cred. One card\nwith the recipes: [Channels & permissions](channels-and-permissions.md).\n\n**DM confidentiality** holds against peers by construction: deliveries ride per-identity\ninbox prefixes, and the DM/task consumers are provisioner-pre-created and bind-only, so an\nagent cannot create a consumer filtered to someone else's inbox\n([SPEC \xA79](../SPEC.md#9-nats--jetstream-security-and-authorization) items 1\u20135).\n\n## Issued authority\n\nA credential says who is calling. It does not, by itself, say what the caller was granted, and a\nhost that acts on a caller's behalf (a workflow run, [workflows](workflows.md)) needs that from\nthe issuer, not from a ledger that may have changed since. So a static agent credential is an\n**issuance** ([SPEC \xA713.15](../SPEC.md#1315-issued-authority)): before the material is handed\nout, the issuer records the credential's final permission ceiling, as evidence keyed by a fresh\n**generation**, in `cotal_issued_<space>`. That store is append-only at the broker and the\nevidence is read as the first message on its key, so nothing written later on the same key, by\nanyone, changes what a resolver sees. The credential's endpoint rows then ride a versioned\nrail, `cotal.<space>.ep.v1.\u2026`, with that generation pinned beside the caller triple, so the\nbroker binds every request to the ceiling the issuer accepted. The legacy `ep.` rail and the\n`ep.v1.` rail are disjoint subject spaces; a credential holds rows on one of them, and every\nendpoint serves both.\n\nA connected client learns its generation by reading one row in `cotal_accepted_<space>` under a\ntoken the launching party chose at mint time, through a per-key read grant its own ceiling carries. It never trusts\nwhat the file says. A renewal keeps the generation only while the ceiling is byte-identical to the\nevidence; a changed scope is a fresh issuance on a fresh generation, adopted by a new connection.\nA static agent's evidence names its credential ledger family as the source it depends on, and the\nlifecycle terminal that retires that family retires its issuances with it.\n\nThe manager, `cotal spawn`, and the CLI's control-caller instruments all mint through an `issuer`\nsession. The deployer instrument does not: it is the one instrument with no default expiry, and an\nissuance with no lifecycle gate must carry one, so a deploy rides the legacy rail under its own\nlifecycle uid. Only workflow `run-start` requires the binding today: a request for it on the legacy\nrail is refused with `permission-denied` and the detail `ai.cotal.ep.unbound-caller-authority`\nnaming the caller. Every other command serves both rails.\n\nA user-auth `manager-caller` view is issued at the auth callout for an interactive row, or for a\nmanaged seat whose current persona-derived scope contains `run` and whose direct parent's fresh\nsame-owner grant also contains `run`. An admin parent's containment bypass does not count. The\nsingle source is the child's own actor row and lifecycle, and evidence records the signed ceiling\nwithout adding capabilities. Same-nonce renewal requires the same triple and byte-identical ceiling.\nThe existing view policy and manager instance gate are unchanged, with no new request kind. A seat\nmissing either `run` input keeps ordinary legacy commands. Without signed `run`, the broker refuses\n`run-start`; with signed `run` but no eligible parent, the manager refuses with\n`ai.cotal.ep.unbound-caller-authority`. A failed issuance never retries on that rail.\n\n## Declared capabilities\n\nControl-plane power is a **declared capability**, not a default. An agent file carrying\n`capabilities: [spawn]` gets the privileged control subject minted into its cred: spawn,\nplus stop/despawn of its *own* children, plus persona definition. On a static or open mesh its own\nchildren include what it launches with `cotal spawn --detach` from its own shell, since that\ncommand runs as the seat. Without it, an agent can\nonly self-despawn and pull or yield the run turns addressed to it. `capabilities: [run]` mints\nthe manager's workflow-run commands (start, resume, answer, status, list) together with the spawn\nset, since a program the agent starts may spawn; the manager drives the run under a per-run\n`run-driver` credential of its own, never the caller's. The tool surface mirrors the grant:\n`cotal_spawn` / `cotal_persona` / `cotal_personas` are injected only for `spawn`, and `cotal_run`\nonly for `run` ([agent files](agent-files.md)). Destructive\noperator ops (history purge, cross-agent stop) live on a third tier no agent credential\nreaches. Persona redefinition separates content from policy; the write path takes only\n`model`/`persona`, so a peer cannot grant itself a capability by redefining a file.\n\n## Per-user authentication\n\n`cotal up --user-auth --idp <auth base URL>` (or manifest `broker.auth: \"user\"`) puts a\n**human identity plane** above the per-agent one: people sign in to an external IdP once,\nand every connect is authorized live against the operator's **actor ledger**. No creds\nfiles to hand out, and revoking a grant actually bites.\n\nThe first enable pins the space to that IdP. A later `--idp` naming a different IdP is refused,\nbecause the IdP is part of every derived owner. Two first enables that race on one state dir leave\none pin: the other caller adopts it when it names the same IdP and is refused when it does not.\n\n**The flow.** Each person runs `cotal login --idp <url>` once per machine. The IdP URL, the JWKS\nURL pinned from it and the catalog link below must use HTTPS. Plain HTTP is accepted only on a\nloopback IP literal such as `127.0.0.1`, never on `localhost`, whose resolution would choose the\nkeys the mesh trusts. After that,\nany command works: cached IdP session \u2192 fresh IdP proof per connect (so IdP-side\nrevocation bites here too) \u2192 the configured exchange turns it into a short-lived Cotal bearer \u2192\nthe broker's **auth callout** checks the bearer and the ledger at connect time and mints\na scoped credential on the spot. Every bearer also names a **root credential** row in the\nspace's credential ledger, proved live at each connect, so revoking that one credential\nbites at the very next connect. The operator grants access with\n`cotal actor grant <actor> --sub <their id> --full` for the full envelope (all\nchannels; scope `spawn,role:default`, so it may spawn and may delegate the default role), or names\n`--scope`, `--allow-subscribe` and `--allow-publish` for a narrow row. A grant with any of the\nthree left off and no `--full` is refused.\nNo ledger row, no access; there is no allow-by-default.\n\n**Space catalogs.** A successful authenticated `GET <idp>/token` may advertise one catalog with:\n\n```http\nLink: <https://idp.example/spaces>; rel=\"https://cotal.ai/relations/space-catalog\"\n```\n\nThe target must use HTTPS and the same origin as the normalized IdP URL. Loopback IP literals may\nuse HTTP for local development. A missing, foreign-origin, or insecure link records that this account\nhas no catalog. The client never guesses a path.\n\nThe catalog request carries the opaque cached session as its bearer and returns a complete snapshot:\n\n```json\n{\n \"v\": 1,\n \"account\": { \"idpUrl\": \"https://idp.example/api/auth\", \"issuer\": \"https://idp.example\", \"sub\": \"user-id\" },\n \"spaces\": [\n { \"id\": \"space-id\", \"slug\": \"shared_project\", \"name\": \"Shared project\", \"kind\": \"hosted\", \"role\": \"owner\", \"registration\": {} }\n ]\n}\n```\n\nThe client checks every `registration` with the same `checkUserBundle` validator used by `cotal\nmeshes add`. One invalid entry refuses the whole candidate snapshot. Conditional refresh uses the\ncatalog's `ETag`; a transport error, non-success response, or invalid candidate leaves the prior\nsnapshot intact and reports the failure. Only a 401 response for the saved session recommends signing\nin again. Transport errors and server failures report that account's refresh error without discarding\nthe session. The registry is reconciled under the provider's catalog lock, and a snapshot whose\nreconciliation was interrupted is reconciled again by the next refresh before it counts as fresh or\nnot modified.\n\nThe user-auth registration document may include one closed policy object:\n\n```json\n{ \"policy\": { \"events\": \"required\" } }\n```\n\nNo other key under `policy` and no other value for `policy.events` is accepted. The registry preserves\nthis field for manual, discovered, and enrollment-created entries. A pre-policy manual entry is\nrefreshed from its own pinned exchange origin by the command that consumes the policy, a spawn, a\njoin, or a manager start, after that command's own local refusals; the returned space, broker,\ntransport, IdP, issuer, audience, and exchange pins must all match before only the policy is added.\nA failed expired refresh refuses that operation. Read-only commands such as `status` and `meshes`\nnever refresh. The five-second warm window makes no request.\n\nEvery registration is also bound to the proved account. Its IdP URL must match the account and its\nissuer must match the exact JWT `iss` pin. Its exchange, provisioning, and manager-authority\nendpoints must be same-origin with that IdP. One foreign pin or endpoint refuses the whole\ncandidate snapshot.\n\nThe `slug` is the space identity resolved by `--space`, `use`, registry roots, and collisions. The\n`name` is a display label only.\n\nDiscovered registry entries are owned by the normalized IdP origin plus the proved `sub`. That key is\nstored as an opaque digest, so accounts on one machine never union their spaces and the registry does\nnot persist the subject. A manual or locally started record with the same name is never overwritten.\nLogout removes only the entries owned by the account whose session was revoked. Local teardown,\ncleanup, and liveness pruning do not remove discovered entries.\n\nAn account that previously advertised no catalog is checked again by explicit `cotal sync`. The\nordinary lazy path checks again after its five-second capability window, so an IdP can enable the\nLink for an existing login without making the person sign in again.\n\n**One auth service per space** hosts both halves: the NATS auth callout and the token\nexchange. Its default HTTP listener remains loopback-only and requires the per-start capability\nstored in the owner-only `auth-service.json` file. An operator may add a second listener with\n`cotal up --user-auth ... --exchange-public-port <port> --exchange-public-url https://auth.example`.\nThat listener still binds `127.0.0.1`; put a reverse proxy in front of it and terminate TLS there.\nIn-process TLS is deliberately not another deployment mode: it would duplicate certificate renewal\nand fork proxy-based deployments.\n\nThe loopback face also serves four host-only doors, all capability-gated and never on the public\nface. Two retire a lifecycle: `/interactive-lifecycle/retire` (used by `cotal actor grant/revoke`)\nand `/managed-lifecycle/retire`, which finishes a managed agent's terminal retirement after its\nremote manager is gone. The third decides one:\n`POST /manager-service-authority/verify-enrollment` answers whether a remote manager may have a\nmanaged agent enrolled or released under its authenticated owner. The body is\n`{ owner, request }` and nothing else. The caller's capability scope is read from this machine's\nledger, never taken from the body, so a host that forwarded a participant-supplied scope could not\ngrant itself `supervise`. The door reads the manager gate and checks the registration proof inside\nthe service process, so no signing material reaches the caller, and it returns\n`{ authorized: true, owner, actor, instanceId, serveEpoch }` or maps its refusal to 400, 401, 403,\n409, or 412. It decides only. A platform that intercepts these requests owns every write, and stock\n`dispatchManagerAuthorityRequest` refuses both request kinds with `unimplemented` rather than\nanswering a manager-lifecycle phase for an agent-lifecycle request. The same door decides the hosted\nruntime create and status kinds. For those it reads the manager actor's ledger row itself and returns\n`{ authorized: true, owner, instanceId, actor, target }`.\nThe fourth, `POST /tool-result-human-proof/verify`, decides a pinned password proof for the\nhost's authenticated session subject without issuing consent or credentials.\nThe opt-in `POST /tool-result-consent/issue` door signs and records a same-owner consent\nunder a host-custodied scoped anchor. It returns no result content, private key or capability.\n[embedding.md](embedding.md) documents these host doors' contracts.\n\nThe public listener has a closed surface: `GET /health`, `GET /jwks`, `POST /exchange`,\n`POST /manager-service-authority`, `POST /manager-instances`, and `GET /.well-known/cotal-mesh`;\nevery other path is 404. It\ndoes **not** require the loopback\ncapability. That capability proves same-uid access to a 0600 local file and has no remote meaning;\non the public face the credential is the proof. A human presents an EdDSA IdP JWT checked against\nthe pinned JWKS, issuer, and audience. An agent presents its spawn-time actor token, whose hash must\nmatch a fresh managed-ledger row. The public face mints two elevated views, both still gated on\nledger scope `admin`: `channel-writer` (`cotal channels set/default`) and `channel-purger` (the\ndashboard's per-click channel delete). It also mints the narrowing `manager-caller` view for humans\nor managed agents. That view binds the bearer to one live registered manager instance. It adds no\ncapability, except that a `spawn` holder's view may read that manager's `status`, `models` and\n`resolve-cwd`, which a host-local launch there needs. A manager using a delegated runtime refuses\nhost cwd resolution before filesystem lookup because its seats execute outside that host.\nGod-view (`admin`), space-history `purger`, `deployer`, and `manager-service` stay\nloopback-only. A managed-agent secret exchange refuses every other view. This is not full remote channel management: `cotal web` still\nmints the read-only admin view at startup, so a remote dashboard that needs that god-view still\nfails even when a later delete would mint `channel-purger`.\n\nThe well-known response contains the IdP pins and the actual deny-all sentinel credential remote\nagents need before the bearer-driven auth callout. The pins ride a `userAuth` arm that names the\nauth provider, and that name is the same one the local arm registers under. A document naming a\ndifferent provider than the one serving it would register an entry nothing can resolve, so both read\none constant. Treat it as bootstrap material: the sentinel cannot publish or subscribe, but\nconsumers must still take the bundle only from the intended HTTPS origin and must verify TLS.\n`--exchange-trusted-proxy` opts into peer attribution by the **last** `X-Forwarded-For` hop; use it\nonly when the listener is reachable solely through a proxy you control.\nWithout it, forwarded headers are ignored and the socket address is the peer key. Public failure\nbuckets are per-source and separate from loopback exchange budgets. The in-process LRU retains at\nmost 1024 peer buckets: that bounds memory and isolates ordinary sources, but an attacker cycling\nmore than 1024 trusted-proxy last hops can evict earlier 429 state. It is not a mint bypass; a valid\ncredential is still required, so use upstream reverse-proxy rate limiting when that throttle-escape\nmatters to the deployment. A refusal at `POST /manager-service-authority` counts against the same\nbudget as one at `/exchange` on that face, so after 30 refusals in a minute either route answers a\nfurther refusal with 429. An unpinned manager-caller exchange that finds no single live manager for\nits owner is refused only after its credential verified. It does not count against the budget and\nalways answers with its own reason, because a session that no manager launched polls its manager this way.\n\n### Enrollment redeem\n\nA remote owner may pre-mint a one-time enrollment for a seat that has no browser, TTY, or cached\nIdP login. The enrollment is a secret-bearing URL. The client performs one request:\n\n```http\nGET <enrollment URL>\n```\n\nIt sends no `Authorization` header and no request body. The URL must be HTTPS, except for plain HTTP\nto a loopback IP literal. The client redeems only an enrollment URL that is already in canonical\nform and contains none of `\\ @ ? #`. That is checked on the raw string before parsing, so every\nrewrite a URL parser would perform, backslash folding, userinfo erasure, scheme or host case\nfolding, default-port removal, dot-segment resolution, and short-host canonicalization, is a refusal\nrather than a redeem of a URL the owner never minted. Redirects are refused. The client never\nretries because a successful claim deletes the server-side token row. The token expires five minutes\nafter mint.\n\nSuccess is `200` with this JSON object:\n\n```text\nspace\nbrokerAccess { kind, ... }\nowner\nactor\nlifecycleUid\nactorToken\nsentinelCreds\nauthServiceUrl\nidp { url, issuer, audience }\nsubscribe[]\nallowSubscribe[]\nallowPublish[]\n```\n\nThe grant arrays are informational; the broker row remains authoritative. A stock-dialable\ndeployment also includes `server`, `tlsRequired`, `userAuth`, and optional `policy`, forming the same user-bundle\nsuperset that `cotal meshes add --user-auth-file` accepts. That lets a bare seat register the mesh\nfrom the enrollment response before launch. For `brokerAccess.kind: \"direct\"`, the stock `server`\nmust equal `brokerAccess.url` byte for byte or the client refuses the bundle before registration. A\ntunnel kind carries no dial address, so its `brokerAccess` is not compared to the operator-asserted\nstock `server` face.\n\nUnknown, expired, revoked, and already-used enrollments are intentionally indistinguishable. They\nall return `404 {\"error\":\"unknown, expired, or already-used enrollment\"}`. The client reports only\n`enrollment refused: unknown, expired, or already-used; ask the owner for a fresh one`. It does not\nguess which case occurred.\n\nAfter redeem, the seat stores only the normal remote user-mesh and agent material. The actor token\nis exchanged at `authServiceUrl` through the existing `agent-bearer --exchange-url` path. The\nenrollment URL is not logged, persisted, or forwarded into any child process, including the bearer\npreflight and harness.\n\nThe service starts with the broker, is torn down by `cotal down`, and holds the\ndata-account signing key for the callout (a running manager is the other standing holder, for\nthe creds it mints); the operator seed never enters it. It also owns the space's two authority\nstores (lifecycle records and the credential ledger), provisions them at boot, and refuses\nconnects it cannot credential-check against them; there is no fallback path. If it\ndies while the broker lives, re-running `cotal up` heals it, and a boot whose auth\nservice never became ready exits non-zero, so automation never reads a dead identity\nplane as success. Changing any public-listener flag requires `cotal down` followed by `cotal up`\nwith the new values; a refresh adopts an already-running auth service rather than silently replacing\nits listener policy. \"One per space\" is enforced, not assumed (SPEC \xA713.13): at boot the\nservice takes a broker-backed ownership claim, so a second same-space auth process refuses\nwith instructions instead of silently splitting the plane, and a crashed one's claim is\nreclaimed only once the broker confirms its connections are gone. That verdict is trusted only\non a standalone broker (a clustered one refuses the reclaim, since a partitioned member\ncould still hold them). If the claim's connections die mid-run, the service downs itself\nloudly instead of serving from a half-dead plane.\n\nCtrl-C on a foreground `up`, and a broker that exits under it, stop the service with the same stop\n`cotal down auth` uses. It holds the reservation `down` takes, so a concurrent `cotal down auth` is\nrefused while it runs, and it sends SIGKILL to a service that has not exited 15 seconds after\nSIGTERM.\n\n**Your agents are yours.** `cotal spawn` on a user mesh grants a managed actor under the\n*spawning operator's* owner and launches the agent with a bearer command instead of a\ncreds file. The agent exchanges its spawn-time secret for short bearers (five minutes or\nless) and refreshes ahead of each expiry. A locally provisioned foreground launch refuses an\nexisting actor rather than rotating another launch's secret. Its exit and failed-start cleanup\nrevoke only the grant matching the launch's token and lifecycle. Broker-footprint deletion follows\na successful owned revoke; credential files are removed only while they still hold this launch's\nmaterial. Cleanup reports failures without deleting a successor's state. A crashed launcher can\nleave a grant that needs operator recovery. Remote foreground launches remove their own local\nmaterial but leave revocation to the issuer. Managed despawn follows the retirement sequence below.\nManifest deploys (`up -f`) stamp the logged-in owner into the launch, so those agents are yours too.\n\n**Despawn tears the lifecycle down, then frees the name.** When you despawn an agent, the manager\ndrives the *full* teardown of that lifecycle: it shreds the local credential files, revokes the\nagent's standing mint authority (its ledger row, so a copied token can no longer mint a fresh\ncredential), deletes its broker footprint (the lifecycle-keyed durables + read-ACL row), and asks\nthe auth service to *retire* the lifecycle (settle in-flight work, evict the departed credentials,\nrecord it retired). The name is held *reserved pending retirement* until **all** of that completes,\nthe broker-footprint cleanup, the standing-authority revoke, **and** the lifecycle retirement, not the\nretirement alone, so a same-name respawn in the gap is refused with\na plain reason and a retry hint rather than quietly handing the alias to a new agent while\nthe old lifecycle's teardown is still running. Only once the broker footprint is gone, the standing\nauthority is revoked, and the retirement is confirmed does the name free, and `cotal spawn <same-name>`\ngives you a fresh agent cleanly. This is what makes reusing an agent's name safe: the old lifecycle is\nfully torn down before the new one takes the alias. A local file that cannot be removed does not\nstop the rest of the teardown: the broker footprint is still deleted, the failure is reported, and\nthe name stays held. If the auth service is unreachable or the standing-authority revoke fails,\nthe despawn still stops the agent and *holds* the name. **A\nsame-name `cotal spawn` re-drives the whole teardown** and finishes it. Retrying the despawn has no\neffect because the agent is already stopped. The operator copy tells you to recover the stack\n(`cotal supervise`) rather than reusing the name over an unretired predecessor.\n\n**A crash mid-retirement resumes at the next boot.** The retirement's last two steps (recording the\nissuance gate terminal, then the lifecycle head terminal) are separate durable writes, and a crash\nbetween them leaves the gate retired while the head is still `retiring`: an alias that can neither\nmint nor be replaced. The auth service's boot crash-resume decides what it owes across *both*\nobjects (the gate *and* the alias head), so the next boot finishes that tail from the durable\noperation intent: nothing is re-revoked or re-drained, and a completed retirement (its head terminal\nlanded, or a successor already took the alias) is left skipped. A retry of the despawn converges on\nthe same recovery.\n\n**Delegation only narrows (the envelope rule).** A user's grant is their envelope:\neverything under their owner (their CLI, every agent they spawn, every agent those\nspawn) stays within its channel lists and its capability scope. Handing a role to a\nspawned agent needs the matching `role:<r>` capability in the spawner's scope. The whole\ndelegation chain is checked, not just the last link, and re-checked at every bearer\nexchange, so narrowing a user's grant reaches their agents within minutes, and revoking\nthe user revokes everything under them, grandchildren included. A spawn beyond the\nenvelope is refused with the exact widening re-grant to ask the operator for.\n\n**Control ops ride your own login**, gated by ledger scope. `spawn` covers launching,\n`ps`, and stop/attach of the agents under **your own owner**: the owner is the\nadministrative boundary of its own subtree, so you (and your agents) manage what you own\nwithout any extra grant. `admin` is the explicit opt-in for touching **other owners'**\nagents; it is never part of a default grant and never accepted from a manifest.\n\n**Elevated operator surfaces ride the same login** through a short-lived *view*: the\nexchange stamps a server-authored view claim into the bearer, and the callout mints that\nconnection as the matching non-agent profile instead of `agent`. `cotal web` and\n`cotal console` ask for the read-only admin view, `clean history` for the purger,\n`channels set/default` for the channel-writer (all gated on ledger scope `admin`);\n`up -f` deploys over the deployer view, gated on `spawn`, because deploying your own team\nis spawn-grade (the manager still refuses a manifest claiming another owner). Elevated views exist\nonly on a signed-in human exchange. The `manager-caller` view is the one managed-exchange exception\nbecause it narrows the agent's existing manager command set to one server-selected instance and adds\nno capability. All views are authorized against the fresh ledger row at every connect and expire\nwith the bearer, so narrowing or revoking a grant bites within minutes here too. On the public\nexchange face only `channel-writer`, `channel-purger`, `manager-caller`, `session-caller`, and\n`transfer-writer` are served; `admin`, `purger`, `deployer`, and `manager-service` remain loopback-only.\n\nThe `session-caller` view is how `cotal attach` opens a seat's session on a user-auth mesh. It needs\nno ledger scope, because the session grant is the authority. The exchange takes the grant with the\nlogin proof and leader-reads the redeemed `session.<id>` row. It issues the bearer only when the row\nis active and unexpired, its signature equals the presented grant's, its holder is this owner and\nactor at this lifecycle, its endpoint and serving epoch match, and the serving manager's gate is open\nat that epoch. The callout repeats the same check at connect and mints the session's caller rails\nwith the grant's expiry instead of the bearer's.\n\nThe `transfer-writer` view is how `cotal spawn --resume <id> --detach --on <instance>` uploads a\nsession this host holds on a user-auth mesh. It needs scope `admin`, as the `transcript-receive`\ncall it serves does, and names one object: the target instance and the transcript's SHA-256. The\ncallout mints writes to that object of that instance's transfer bucket and nothing else. The bearer,\nand with it the broker connection, lives at most five minutes, the lifetime of the static\n`transfer-writer` credential, and never past the login proof it was exchanged for.\n\n### Remote manager authority\n\nA registered user remains an ordinary `agent` bearer by default. Running a detached manager\non a remote user-auth mesh needs the closed server-authored **`manager-service`** view, which\nis distinct from every general-purpose profile. The operator grants it only by adding\n`supervise` to that user's actor-ledger scope. `supervise` is deliberately distinct from\n`spawn` and `admin`: spawn controls your agents, admin permits the separate cross-owner\noperations, and neither grants persistent manager registration authority.\n\nOnly a signed-in human may request this view from the loopback/operator exchange. The public\nexchange and every managed-agent secret exchange refuse it. At exchange and each connection,\nthe auth service re-reads the actor row; revoking or removing `supervise` therefore denies the\nnext view exchange and connection. A grant must carry the whole requested row just like every\nother actor update, so re-grant its channel envelope, role, and all wanted scope tokens, not\nonly `supervise`.\n\nThe service is one opaque manager instance for the user's derived owner and a fixed\nserver-selected manager actor. Its authority is limited to that instance's manager\nregistration, contracts, status, endpoint rails, gate and credential family; it cannot read or\nwrite another owner or instance. It never exposes a signer, static provisioner credential, owner\nsecret, raw stream/KV/consumer authority, or a generic credential-mint API. The host creates the\npublic-nkey JWT material through the typed lifecycle-bound protocol: **prepare \u2192 activate \u2192\nrenew**, plus host-owned **evict-family-principal** and **reconcile-registration** maintenance\noperations and a one-shot **retire** phase for one exact managed lifecycle. Each request is replay-safe and idempotent at its lifecycle/instance operation\ncoordinate; the host writes its credential ledger row and finalizes the gate before it releases\nusable material. The retire phase fresh-checks the current manager instance, server-derived serve\nprincipal, serve epoch, same-owner target and lifecycle UID. It returns only a short-lived requester\ncredential pinned to that target. The manager invokes the registered `auth` endpoint's\n`retire-lifecycle` command through the generic client, resolving the service and calling it with\nan exact target and the operation id derived from the target lifecycle UID. The endpoint\nrecomputes that id from the broker-pinned target before any durable access. A caller cannot\nsubstitute another valid operation identity for the same target, and retries plus auth-service\nboot recovery finish the same terminal barrier. It never exposes the barrier executor or a general mint surface.\n\nRegistration maintenance stays on the host. One eviction request carries up to 256 holders, and the\nhost accepts it only when its single sealed scan of the caller instance's\n`epcred.manager.<instanceId>.*` family finds every one of them. Reconciliation may\ntarget a foreign manager slot holder in the same space, but it runs only after the delivery daemon\nproves the frozen gate's holder gone under a complete sweep. The participant receives neither an\nevictor credential nor authority over another instance's records or gate. A clean stop refreshes an\nunhealthy executor before deregistration. A restart verify-evicts its old family, and a manager\nblocked by a foreign governance slot whose holder's gate is still frozen at the slot's stamp asks\nthe host to reconcile that holder and retries the registration once. The goal-writer and session-ledger\ncredentials carry a random `issuance:` tag, so a restart that re-mints them within the second of an\nearlier issuance gets new ledger rows and never re-signs a credential its barrier revoked.\n\nA remote manager can provision only descendants of the same derived owner, and the host\nvalidates that relation and the current manager grant for every provision. It cannot broaden the\nuser's envelope or provision a sibling owner's agent. Renewals are bounded. If login, the\n`supervise` grant, or the host manager authority service is unavailable, the manager reports a\ndegraded state and refuses new agents, restarts, or replacement credentials rather than\nsubstituting local/static authority. Existing live agents remain running only while their own\nvalid authority permits it; recovery requires the host service and a fresh successful renewal.\n\nThe manager-authority protocol is closed, so a host whose auth service predates a field the\nmanager sends refuses the whole request. The manager reports that refusal as version skew after the\nhost's reason: it names its own Cotal version and the refused field, and says it needs a host at\nthat version or later. It never drops the field to fit the older host. Upgrade the host first;\n[Upgrading](UPGRADING.md) promises no rolling upgrade between versions.\n\nA manager on remote authority mints from that authority alone; it consults the local root's\nrecords only to refuse a conflict, and only the supervised space's own trust records count as\none. A workspace that hosts an unrelated static space beside the participant sign-in is a\nnormal configuration.\n\n**User authentication has one path.** On a user-auth space, commands never fall back to\nstatic minting or credless connects: a missing login or a down auth service is one\nsentence naming the exact recovery, and static agent/observer/admin minting is refused\noutright. The refusal is deny-new: a static cred signed before the space flipped stays\nbroker-valid until the signing key is rotated ([security model](security.md)).\n\n## The IdP callout contract\n\nAny OIDC identity provider that issues **EdDSA/Ed25519** JWTs plugs in here directly; a provider that\nissues RS256 or ES256 tokens (many managed OIDC services do) needs a host-side normalization or\nre-issuance adapter first, because the reference bridge pins the token algorithm to EdDSA. The\nreference implementation ships **Better Auth** as a\ndev and test fixture only (it is a `devDependency` of `@cotal-ai/auth`; the only code that imports\nit is the `dev-idp.ts` harness and the smoke tests, never the runtime `src`). The one runtime\ncoupling to an IdP is the `idp.ts` bridge plus the `auth-provider` extension. The bridge core\n(`createIdpBridge`) is IdP-generic for **EdDSA** tokens (issuer, audience, JWKS as configuration).\nThe stock end-to-end flow around it, though, is **Better-Auth-shaped**: `cotalAuthProvider` pins\n`<base>/jwks` and issuer/audience to the IdP origin, and the login client speaks Better Auth's\ndevice-code endpoints (`/device/code`, `/device/token`, `/token`) with an opaque revocable session.\nSo a Better-Auth-shaped EdDSA IdP uses the stock flow directly; **any other production IdP is a\nhosted-composability gap, not a configuration change**. A host integrates it by building its own\nlogin and provider wiring on the low-level primitives (`createIdpBridge`, `createUserTokenIssuer`),\nnot by reusing the stock provider. Note that importing `@cotal-ai/auth` self-registers\n`cotalAuthProvider`, and `resolveAuthProvider()` throws when two providers are registered, so a host\non the registry-resolution path must not also register its own. Whatever the path, never loosen the\nissuer/audience/JWKS pins to force-fit an IdP.\n\nThe bridge (`createIdpBridge`) exchanges a verified IdP token for a Cotal bearer in three steps:\n\n1. **Bearer validation.** Verify the IdP's JWT offline against its **pinned JWKS**, with the token\n algorithm pinned to EdDSA. Keys resolve only through the pinned JWKS: a token carrying embedded\n key material (`jku`/`jwk`/`x5u`/`x5c`) is rejected, so the token can never influence key\n resolution. Issuer and audience are checked. The minted Cotal bearer never expires after the\n upstream proof: the issuer signs the earlier of the requested lifetime and the proof's `exp`,\n read when it signs, and refuses once the proof has expired. Time spent in the bridge's hooks\n shortens the bearer instead of extending it.\n2. **Owner derivation.** The opaque per-space owner derives deterministically from the JSON-array\n encoding of `[idp issuer, sub]`, namespaced by issuer so no issuer/sub pair can straddle a\n delimiter, and re-login re-lands the same person in the same lanes. The owner-token *format*\n (`u_` followed by 26 base32-lower characters) is normative\n ([SPEC section 2](../SPEC.md#2-identity)). At the contract level the *derivation* from an\n identity is a pluggable edge, but the reference `createIdpBridge` fixes it\n (`deriveOwnerForIdpSubject`) and takes no derivation callback, so what a host configures is the\n IdP, not the derivation. **The encoding is frozen:** changing it, or changing the IdP issuer\n string, re-keys every owner in the space, which is a migration on the order of rotating the space\n secret.\n3. **Actor authorization and mint.** The operator's ledger hook authorizes the `(owner, actor)` pair\n and is the only source of the bearer's `scope`/`parent`; the issuer then mints the Cotal bearer,\n re-asserting every claim shape.\n\nA host wires this with the IdP's own coordinates and nothing from `@cotal-ai/auth` changes:\n\n```ts\nimport { createIdpBridge, pinnedJwksResolver, createUserTokenIssuer } from \"@cotal-ai/auth\";\nconst bridge = createIdpBridge({\n idp: { issuer: idpIssuer, audience, key: pinnedJwksResolver(jwksUri) }, // your production IdP\n space,\n spaceSecret, // identity-plane owner-derivation secret (>=32 bytes), held by the auth service at runtime\n issuer: createUserTokenIssuer({ issuer: cotalIssuer, key: signingKey }), // mints the Cotal bearer\n authorizeActor: (owner, actor) => grantFromLedger(owner, actor), // your ledger, returns an ActorGrant\n});\n```\n\nFor private-result consent, `verifyToolResultHumanProof` applies the separate password-freshness\nrule to the same pinned signature check. A signed integer-second `auth_time` must be no later\nthan the decision and at most 300 seconds old, and signed `amr` must contain `pwd`. Neither a\nrecent JWT `iat` nor a session's creation time supplies password provenance. Missing, malformed,\nfuture or stale provenance refuses this decision without changing ordinary bearer exchange.\nThe host-only proof door and the remaining activation limits are documented under\n[private tool results](embedding.md#private-tool-results).\n\n## Joining\n\nA single **join link** carries server, auth, and space\n([SPEC \xA710](../SPEC.md#10-connection-and-onboarding)):\n\n```\ncotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS required; cotal:// = TLS not required (downgrade-tolerant)\n```\n\nHumans: `cotal join --link \u2026`. Agents: `COTAL_LINK=\u2026 ` in the environment. The connector\nexpands it and auto-joins. Token/user-pass links are the open-mode path; the default\nauthed path threads a minted creds file, and the endpoint adopts the credential's identity\nas its card id. A seat the manager spawned reaches that file through its **launch\nmaterial** rather than through `COTAL_CREDS` in an environment every descendant process\ninherits (see [Configuration](config.md#launch-material)); a session you drive by hand\nstill sets `COTAL_CREDS` itself.\n\n## Honest limitations (v0)\n\n- **The signing key is hot** on the mint/manager box of a static-auth mesh; the \"real\n boundary\" holds given operator-controlled cred distribution. On a per-user-auth mesh\n the data-account signing key is held by the auth service (the callout stage) and by any\n running manager, which loads the trust bundle and self-mints its supervisor cred and\n renewals from it; a copied signing *seed* still stays valid for its identity until the\n signing key is rotated. Rotation remains the revocation lever for trust material.\n- **The two `$SYS` creds renew through rotation.** `membership-observer` and\n `connection-evictor` are signed by the system-account seed, which is never persisted, so no\n running process re-signs them: they carry a 30-day expiry and are renewed by issuing a new\n system account (`cotal down` then `cotal up --rotate-sys`), which leaves the data account,\n every agent cred and the store untouched but does invalidate earlier full backups (they bind to\n 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\n membership feed and live eviction stop; `cotal doctor auth` and the manager warn from the 75%\n point onward.\n- **A copied static agent cred outlives its session.** One-shot command creds expire in\n minutes and the standing daemon creds in 24h with the manager renewing them (`cotal doctor\n auth` is the one diagnosis and repair surface). A manager-spawned agent cred also expires\n after 24h, the manager renews it only while the agent is live, and despawn retires it. A\n static mesh has no auth callout, so a compromised agent that copied its creds can still\n reconnect until they expire, and a `cotal mint` cred without `--expires-in` or `--expires-at`\n stays valid until the signing key is rotated ([security](security.md)). Per-user-auth spaces\n close this: bearers live minutes, `cotal actor revoke` denies the next exchange and the next\n connect and evicts the principal's live connections immediately.\n- **Not non-repudiation.** Authenticity is broker-enforced, not portable proof; it does\n not survive an untrusted relay. Signed envelopes are reserved\n ([SPEC \xA711](../SPEC.md#11-versioning-and-extensibility)).\n- **Chat metadata leaks in-space.** Content reads are ACL-bounded; stream metadata\n (channel names, per-subject counts) is not yet ([security model](security.md)).\n\n**Denials are loud, never silent.** A publish outside an ACL surfaces as a logged denial\n(\"denied, not absent\") on the endpoint's error path; an over-tight ACL never looks like a\nmissing peer ([run a mesh](run-a-mesh.md)).\n"
|
|
57990
57990
|
},
|
|
57991
57991
|
{
|
|
57992
57992
|
"slug": "agent-files",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cotal-ai/connector-jcode",
|
|
3
3
|
"description": "Cotal connector for Jcode: a host-mode mesh peer driven through Jcode's Harness API bridge.",
|
|
4
|
-
"version": "0.90.
|
|
4
|
+
"version": "0.90.1",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"smol-toml": "^1.8.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@cotal-ai/connector-core": "0.90.
|
|
30
|
-
"@cotal-ai/core": "0.90.
|
|
29
|
+
"@cotal-ai/connector-core": "0.90.1",
|
|
30
|
+
"@cotal-ai/core": "0.90.1",
|
|
31
31
|
"@cotal-ai/smoke-kit": "0.0.0",
|
|
32
32
|
"esbuild": "^0.28.0",
|
|
33
33
|
"tsx": "^4.22.4",
|