diff --git a/cell/shell/apps-page.js b/cell/shell/apps-page.js index bb557e991..f975d03a5 100644 --- a/cell/shell/apps-page.js +++ b/cell/shell/apps-page.js @@ -1,4 +1,4 @@ -// The Apps page (`/apps`; docs/api.md, The shell): every fragment in the +// The Apps page (`/apps`): every fragment in the // person's list but their chats, agents and skills (apps, brains, a kind // this page does not know), each with what they may change of it here, as // the server says it is: diff --git a/cell/shell/launcher.js b/cell/shell/launcher.js index 5d559b34d..435914c08 100644 --- a/cell/shell/launcher.js +++ b/cell/shell/launcher.js @@ -22,7 +22,7 @@ // the person gives the middle column another view (a chat, settings, // Back), the launch changes nothing on screen: its message is still sent, // and one not sent is a notice, its words and files waiting in the -// launcher's next opening (docs/api.md, The launcher). +// launcher's next opening. import { icon } from "./icons.js"; diff --git a/cell/src/levers.rs b/cell/src/levers.rs index 1905d4931..aff7474c8 100644 --- a/cell/src/levers.rs +++ b/cell/src/levers.rs @@ -1,4 +1,4 @@ -//! The test levers, `/api/test/*` (docs/api.md, `FRAGMENT_TEST_SECRET`): +//! The test levers, `/api/test/*` (docs/api.md, Test levers): //! the router sends a request here only once it has checked the fleet's //! test secret is set and that the request carries it //! (`fragment_core::levers`); to anyone else they are no route. diff --git a/cell/src/ops.rs b/cell/src/ops.rs index 395dc8be7..3207f6341 100644 --- a/cell/src/ops.rs +++ b/cell/src/ops.rs @@ -420,7 +420,7 @@ impl FragmentCell { /// `POST /api/test/fragment {fragment, op, …}`, the router's, on fleets /// with test hooks only: the levers the e2e pulls on one fragment - /// (docs/api.md, `FRAGMENT_TEST_SECRET`). + /// (docs/api.md, Test levers). pub(crate) async fn test_fragment(&self, body: &Value) -> CellResult { assert!(self.cfg.test_hooks, "the route answers only on fleets with test hooks"); if body["op"] == "ended" { diff --git a/crates/core/src/levers.rs b/crates/core/src/levers.rs index 7606a38a9..fb2f79806 100644 --- a/crates/core/src/levers.rs +++ b/crates/core/src/levers.rs @@ -1,4 +1,4 @@ -//! Test levers (docs/api.md, `FRAGMENT_TEST_SECRET`; docs/secrets.md): the +//! Test levers (docs/api.md, Test levers; docs/secrets.md): the //! `/api/test/*` routes the e2e pulls, and the sign-in of its people, //! exist only on a fleet given a test secret, and answer only a request //! that carries it. A local fleet (dev's and the e2e's, under `wrangler diff --git a/crates/e2e/src/lanes/shell.rs b/crates/e2e/src/lanes/shell.rs index a816ffd87..8975dad23 100644 --- a/crates/e2e/src/lanes/shell.rs +++ b/crates/e2e/src/lanes/shell.rs @@ -5252,7 +5252,7 @@ fn say_in(s: &Suite, b: &mut Browser, page: &Page, host: &str, text: &str) -> bo s.eventually(std::time::Duration::from_secs(30), || b.eval_in_frame(page, host, &typed).ok() == Some(Value::Bool(true))) } -/// Unread (docs/api.md, The shell): a chat not open counts on its row what +/// Unread (docs/api.md, Control API): a chat not open counts on its row what /// came since its person saw it, as their list does; opening it marks it /// seen, here and in their list; and the open chat counts nothing as its /// messages come. The first agent's chat, while the Reader's is open. @@ -5294,7 +5294,7 @@ fn unread_ui(s: &mut Suite, api: &Api, b: &mut Browser, page: &Page, me: &Person Ok(()) } -/// A new chat (docs/api.md, The shell, the launcher): from the Reader's +/// A new chat: from the Reader's /// chat, the launcher's Enter makes a chat with the person's one agent /// (not the open chat's), labelled beside its own (`-chat-2`), the /// words posted whole as its first message: one record though the first @@ -5847,7 +5847,7 @@ fn fresh_shown(b: &mut Browser, page: &Page, before: &[String]) -> Option Result<()> { quiet_poll(s, api) } -/// The poll backstop (docs/api.md, `FRAGMENT_POLL_INTERVAL_S`): a fragment +/// The poll backstop (`FRAGMENT_POLL_INTERVAL_S`, cell/src/config.rs): a fragment /// whose pins may lag its repo (a storage token was minted for it in the /// last day) is polled every interval, so an editor's push through /// code.storage syncs within it; one nothing touches is never polled, its diff --git a/crates/proto/src/computer.rs b/crates/proto/src/computer.rs index 52d3cc3bf..7303da238 100644 --- a/crates/proto/src/computer.rs +++ b/crates/proto/src/computer.rs @@ -383,7 +383,7 @@ pub struct ComputerView { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notices: Vec, /// Its agents, in the order it took them: its owner's first is first - /// (the shell's one agent: docs/api.md, The shell). + /// (the shell's main agent). pub agents: Vec, /// Its own origin, where its ports are served. pub origin: String, diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index d88c94543..d6e5b2063 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -141,19 +141,19 @@ const SHELL_PUBLISHED: [(&str, &str); 47] = [ ("icons.js", "text/javascript; charset=utf-8"), // an app's own icon in the dock, and the sheet that makes one (docs/api.md, Icons) ("app-icon.js", "text/javascript; charset=utf-8"), - // the Apps page: each app's dock, card, icon and who has access (docs/api.md, The shell) + // the Apps page: each app's dock, card, icon and who has access ("apps-page.js", "text/javascript; charset=utf-8"), ("activity.js", "text/javascript; charset=utf-8"), ("fragment-title.js", "text/javascript; charset=utf-8"), ("tooltips.js", "text/javascript; charset=utf-8"), // Settings' Billing: a seat, credit, an org (docs/billing.md) ("billing.js", "text/javascript; charset=utf-8"), - // the launcher: ⌘K, to start a chat or find one again (docs/api.md, The launcher) + // the launcher: ⌘K, to start a chat or find one again ("settings.js", "text/javascript; charset=utf-8"), ("chat-list.js", "text/javascript; charset=utf-8"), // the Session Archive under the chats (templates/sessions/README.md) ("session-archive.js", "text/javascript; charset=utf-8"), - // delegation threads: a line each over the chat an agent asked from (docs/api.md, The shell) + // delegation threads: a line each over the chat an agent asked from ("threads.js", "text/javascript; charset=utf-8"), ("session-refresh.js", "text/javascript; charset=utf-8"), ("onboarding.js", "text/javascript; charset=utf-8"), diff --git a/docs/api.md b/docs/api.md index c1e8811b4..dc559788b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,1384 +1,794 @@ # fragment wire contract -The cell (`cell/`, Rust on Cloudflare Workers) answers everything below; -the CLI and the browser library are its clients. Errors are `{"error": -"", "message": "..."}` (codes in `crates/proto`). Billing's, seats' -and orgs' refusals add `"reason"`, where one status means several causes -a person acts on differently (`proto::Reason`: a trial code unknown, -ended, used or taken, a seat not sold, Stripe refusing, who pays staying -until their subscription ends, the last admin, an offer gone, the day's -offers used up, the last paid seat, …); the shell picks its plain words -by it. It is add-only: absent elsewhere, and a reason a client does not -know reads as none. A platform object -that refuses its code's schema (docs/MODEL.md, Schemas) answers every -call 500 `schema_refused`, its message naming it (its class and id) and -why: a newer version than its code knows (a rollback), another code's -step, or tables from before versions that need a reset (the operator's -call). A fragment's delete and a wipe still end one (the Registry -aside: docs/MODEL.md, Schemas). While a deploy goes out, any route may -answer 503 `deploying` ("a deploy is going out; try again in a few -seconds", naming the two releases): a call between the platform's -Worker and one of its objects met another release on the other side, -which refused it before doing anything, and was asked again for about -two seconds first (docs/MODEL.md, Calls between releases). Nothing it -asked for was done, so the same request again is safe, whatever it -does. A page navigation (Fetch Metadata's navigate mode or document/iframe -destination, or, without that metadata, Accept preferring HTML to JSON) -gets a small “Updating — back in a moment” HTML page instead of the JSON: -still 503, with `Retry-After: 3` and `Cache-Control: no-store`, reloading -after three seconds by meta refresh. Fetches and JSON clients keep the -JSON refusal; the shell's page, one of them, says the same words in its -place, names the releases only in its console, and asks again by -itself. `cargo xtask e2e` proves every route. The TypeScript runtime this replaced was deleted -(its contract is in git history, last at `35f5e18`). The desktop and the -personal agent's chat went at the cut (docs/cloudflare-v1.md, decision -33; their contract is at the tag `celld-final`); computers came back -(Computers, below), with Hermes as our image's agent runtime. +The cell (`cell/`, Rust on Cloudflare Workers) answers every route below; +the CLI, the browser library and computers' images are its clients, and +`cargo xtask e2e` proves each. A limit in capitals is +`fragment_proto::limits`'s unless another module is named; its value is +there. + +Errors are `{"error": "", "message": "..."}` (`proto::ErrorCode`). +Billing's, seats' and orgs' refusals add `"reason"` (`proto::Reason`) +where one status has several causes a person acts on differently. It is +add-only: a reason a client does not know reads as none. + +Any route may answer: + +- 503 `deploying` while a deploy goes out: a call between the Worker and + one of its objects met another release, which refused it before doing + anything (docs/MODEL.md, Calls between releases). Nothing was done, so + the same request again is safe. A page navigation (by Fetch Metadata, + else its `Accept`) gets a small HTML page instead: still 503, with + `Retry-After: 3` and `no-store`, reloading itself after three seconds. +- 500 `schema_refused`: a platform object refuses its code's schema + (docs/MODEL.md, Schemas); the message names the object and why. ## Configuration -Worker variables, rendered from the deployment's config by `cargo xtask -deploy` (`deploy/example.jsonc`; `.dev.vars` in dev). None is a secret: -the deployment's secrets are Worker secrets (below). +The Worker's variables are `cell/src/config.rs`'s, each documented +there, rendered from the deployment's config by `cargo xtask deploy` +(`deploy/example.jsonc`; `.dev.vars` in dev). Its secrets are Secrets +Store bindings read only by `cell/src/keys.rs` (docs/secrets.md), and its +other bindings are `cell/wrangler.jsonc`'s. An app's isolate gets an env +the platform builds, so no author code can name a binding. -| Variable | Meaning | -|---|---| -| `CODESTORAGE_ORG` | the code.storage org (required) | -| `CODESTORAGE_API_URL` | the API base (default `https://api..code.storage`) | -| `FRAGMENT_HOST_SUFFIX` | fragments are served from `.` (any other name under it is 404, never the platform; the suffix's own name is the platform's, or redirects to it: Hosts, below). Every deployment names one: an isolate without it does not start | -| `FRAGMENT_HOST_LABEL_SUFFIX` | a branch deployment's mark, `--` (a branch is 1 to 16 of lowercase letters, digits, and single dashes inside, as `cargo xtask deploy` makes them; an isolate with another does not start): its fragments are `--.`, one DNS label beside the other branches' in one zone, and the mark takes from the room its labels have (Names, below) | -| `CODESTORAGE_REPO_PREFIX` | what this deployment's repos are named with first (a branch's `--`), so deployments sharing an org never share a repo | -| `FRAGMENT_POLL_INTERVAL_S` | how often a busy fragment's pass runs (default 300): one whose pins may lag its repo (a storage token was minted for it, or a move failed to follow, in the last day), which polls code.storage for its branches (the backstop for a push no one refreshed), or with a run in flight (checked against its Workflow), or a template or the agent it declares still to land. Any other fragment's pass is daily, and polls nothing: every move the platform makes or is told of (`refresh`) is followed at once | -| `FRAGMENT_JOB_RETRY_DELAY_S` | a failed job step's first retry delay, doubling over 4 retries (default 10) | -| `FRAGMENT_EGRESS_LOCAL` | `allow` lets jobs fetch loopback and private addresses, and takes a connection's http:// consent URL (dev and e2e fakes); never on a shared fleet. It also makes a fleet local for its test levers (Test levers, below) | -| `FRAGMENT_BLOB_GRACE_S` | how long a blob no branch names is kept before it is deleted (default 7 days) | -| `FRAGMENT_DELIVERY_RETRY_S` | test fleets: every wait before a delivery, or a preview card's failed shot, is tried again, pinned. Unset, a delivery's wait is 10 s growing with its age to an hour, and a shot's 10 s doubling to an hour (Cards, below) | -| `AI_GATEWAY_ID` | the AI Gateway the model route and image steps call through (Models, below): the deployment's own, named (`default` is refused: it makes one that logs); unset, models and images are off | -| `FRAGMENT_AI_URL` | dev and the e2e only: the model route POSTs the AI binding's input to `/run/` instead of calling the binding (the Workers AI fake, a lower rung) | -| `FRAGMENT_MAIL_FROM` | the sender the platform's mail comes from: a bare address or unquoted `Name
` (Mail, below; the deploy config's `mail_from`), on a domain onboarded to Email Sending; unset, the deployment sends no mail | -| `FRAGMENT_MAIL_URL` | dev and the e2e only: the platform's mail is POSTed to `/send` (the `EMAIL` binding's input) instead of sent (the mail fake, a lower rung, which keeps it; `cargo xtask dev` prints it) | -| `FRAGMENT_DEFAULT_PLAN` | a new person's plan (Ledger, below): `guest` (the default and production's), `seat`, or `seat_always_on`; dev and the e2e set `seat` | -| `FRAGMENT_COMPUTER_UNSAVED_MAX_MS` | how long a computer whose sleep's save keeps failing stays awake, its container kept, before it sleeps unsaved (the deploy config's `computers.unsaved_max_ms`; default 1800000, thirty minutes; docs/computers.md, "Saves and what a wake restores") | -| `FRAGMENT_SUPPORT_URL` | where a person whose computer will not start gets help (the deploy config's `support_url`): an `https:` page or a `mailto:` address, at most 512 visible characters, refused at the node's first request otherwise; `GET /api/computers` gives it as `support`, and the shell links it (docs/computers.md, "What its owner is told"). Unset, none | -| `FRAGMENT_OPERATORS` | identities and keys that grant credit and set plans, seats and overdrafts, and wipe a person (Operators, below); a key listed is an operator's whether or not anyone holds it | -| `FRAGMENT_DEPLOY_ID` | which deployment this is (default `dev`); every answer of the platform's host names it in `x-fragment-deploy` (`GET /healthz`'s among them, which CD checks), and the shell's page in its meta, so a page left open reloads once the platform under it changed (The shell, "A new release"). `cargo xtask deploy` sets the deploy's commit (`git rev-parse --short=12 HEAD`; a tree with uncommitted changes adds `-dirty-`, so two such deploys differ). It is the release every call between the Worker and its objects carries, and each object admits only its own (docs/MODEL.md, Calls between releases): 1 to 64 of letters, digits, `.`, `_` and `-`, or the isolate does not start | -| `FRAGMENT_CLI_VERSION` | optional stable `major.minor.patch` CLI release for this deployment's exact CLI inputs, sent on platform answers as `x-fragment-cli`. CI sets `FRAGMENT_RELEASED_CLI_VERSION` for `xtask deploy` only after finding or publishing and verifying that release (docs/computers.md). Failed release jobs leave it unset and do not block deploy. Invalid or missing values send no header; this is independent of the platform's commit | -| `FRAGMENT_DEPLOYED_AT` | when the release was deployed, whole seconds since 1970 (default none; dev and the e2e: their node's start). `cargo xtask deploy` sets the deploy's second. The shell's page names it in its `fragment-release-deployed` meta, which Settings shows (The shell). Every call between the Worker and its objects carries it beside the release, and an object that refuses a call from a release deployed after its own restarts onto it (docs/MODEL.md, Calls between releases, "An object behind restarts"): 1 to 12 digits, or the isolate does not start | -| `WORKOS_API_URL` | where WorkOS is (default https://api.workos.com; dev and the e2e: the fake); sign-in is on where the `WORKOS_CLIENT` secret is bound (below), and answers 500 where it is not | -| `FRAGMENT_PLATFORM_URL` | the platform's origin, where sign-in and the platform session live (required; production's is https://finite.chat, on no fragment's domain, and the CLI's default host). Every push's VAPID token names it as its contact (`sub`, RFC 8292) | -| `FRAGMENT_SIGNINS_PENDING_MAX` | sign-ins begun and not finished that the registry keeps (default 100000; at least 1): a sign-in is kept through this many later starts, so the oldest is let go only past this many starts in its ten minutes (Sign-in, below) | -| `FRAGMENT_CLI_BEGINS_PER_IP_MINUTE` | CLI codes one address may ask for a minute, `POST /cli/begin` (default 30; at least 1): past it, 429 until the next minute. The fleet as a whole hands out at most 5000 a minute (`limits::CLI_BEGINS_PER_MINUTE_MAX`), so in a code's ten minutes a flood from many addresses is refused before it can push out the codes people are typing | -| `FRAGMENT_PROVIDERS` | the provider catalog a computer's swap offers (`fragment_core::catalog`; docs/computers.md, Connections and operator keys): a JSON list of `{name, kind: connection\|operator\|own, hosts, placements, env, price?}`, rendered from the config's `providers`; none by default. A malformed one is refused at the node's first request (the deploy checks it first). An operator key's price is the price book's `keys`: a row added or a price changed reaches every ledger at its next call (`FRAGMENT_PRICE_BOOK_VERSION`, the book's version, is informational; docs/ledger.md, The price book) | - -Secrets Store bindings (docs/secrets.md): each a secret in the account's -Cloudflare Secrets Store, named by the deployment's config and bound by -`cargo xtask deploy` (wrangler's local store, seeded by devstack, in dev -and the e2e), read only by `cell/src/keys.rs`, each value used for a -minute at most before it is read again. The names are -`fragment_core::secrets_store`'s. An app's isolate gets an env the -platform builds, so no author code can name one: - -| Binding | Meaning | -|---|---| -| `HOST_SECRET` | seals values at rest, per Durable Object (at least 32 bytes); the key computers' placeholders are tagged with is derived from it | -| `HOST_SECRET_PREVIOUS` | the host secret before a rotation, bound while it runs; values sealed under it open and come back resealed | -| `CODESTORAGE_KEY` | the org's PKCS#8 P-256 key, which signs code.storage tokens (a one-line PEM may carry literal `\n`) | -| `WORKOS_CLIENT` | sign-in: fragment's WorkOS environment's client id; unbound, sign-in answers 500 | -| `WORKOS_KEY` | WorkOS's API key, for its code exchange and Pipes | -| `OPERATOR_KEY_` | an operator key of the catalog (`perplexity` is `OPERATOR_KEY_PERPLEXITY`, `google-places` `OPERATOR_KEY_GOOGLE_PLACES`), the store secret its row's `key` names | +Every answer of the platform's host names its release in +`x-fragment-deploy` (`FRAGMENT_DEPLOY_ID`), and, when CI published and +verified a CLI for the release's inputs, that version in `x-fragment-cli`. -One Worker secret is left, the platform Worker's: - -| Secret | Meaning | -|---|---| -| `FRAGMENT_TEST_SECRET` | the test levers' secret (Test levers, below): honoured on a branch deployment (`FRAGMENT_HOST_LABEL_SUFFIX`) or a local fleet (`FRAGMENT_EGRESS_LOCAL`) alone, 32 to 256 bytes of printable ASCII; one set on a deployment of its own, or one too short, is logged (`levers.refused`) and honoured nowhere. `cargo xtask deploy` uploads it from the config's `test_secret_file`, for a `--branch` deploy only (`.dev.vars` in the local e2e) | +A request body is at most what the zone's Cloudflare plan takes (100 MB +on Free and Pro), below the `BLOB_MAX_BYTES` a blob route allows (debt +ledger). ## Test levers -`/api/test/*` (cell/src/levers.rs) exists only on a fleet with a test -secret, and answers only a request that carries it in -`x-fragment-test-secret` (compared in constant time: both sides are -hashed first). Without the secret configured, or without it on the -request, or with another, a lever is the 404 any missing route is, -word for word (`{"error":"not_found","message":"no route "}`), as on -production. The local e2e makes a secret per run; a preview has one when -deployed with `test_secret_file`; the hosted e2e reads that file -(crates/e2e/src/hosted.rs). On a branch deployment (`Config::levers_scoped`) -the levers reach the e2e's own things alone: a fragment lever answers -only for a fragment labelled `e2e-…` (403 otherwise), a ledger lever or a -computer lever only for an e2e person's (403 otherwise), and the -registry's are no route (404); a local fleet's reach everything. - -| Route | Body → answer | +`/api/test/*` (cell/src/levers.rs) exists only where the Worker secret +`FRAGMENT_TEST_SECRET` is honoured: a branch deployment +(`FRAGMENT_HOST_LABEL_SUFFIX`) or a local fleet (`FRAGMENT_EGRESS_LOCAL`), +and only when it is 32 to 256 bytes of printable ASCII. One set anywhere +else, or too short, is logged (`levers.refused`) and honoured nowhere. A +lever answers only a request carrying the secret in +`x-fragment-test-secret` (compared in constant time); any other request +gets the 404 a missing route gets, word for word. On a branch the levers +reach the e2e's own things alone: a fragment lever only an `e2e-…` +fragment, a ledger or computer lever only an e2e person's (403 +otherwise); the registry's and the mail lever are no route. + +| Route | Body | |---|---| -| `POST /api/test/signin` | `{email, paidCalls?}` → `{session, identity, created, paidCalls}`: an e2e person's platform session (the `fragment_session` cookie's value). `email` is `@e2e.test` (a name of 1-64 of `[a-z0-9._-]`; any other is 400); its person is keyed by it under the issuer `e2e.test`, which no real sign-in has, so they are never anyone WorkOS signs in. The first sign-in makes them (`created`), and each makes them a seat (one ledger command, `e2e-seat`) and caps their paid calls (model calls, operator keys' calls and AI steps: every one its ledger has charged or holds, by its reference) at `paidCalls`, at most 200; a sign-in with no `paidCalls` leaves the cap as it is (a new person's: 0), so a sweep's never stops a run still spending; once they reach it their ledger's gate refuses the next (402 `budget_used_up`), as at zero credit, so the cap is approximate by what starts within the gate's minute (docs/ledger.md, "The gate"). On a branch a day (UTC) makes at most 1000 e2e people and lends them at most 2000 paid calls (429 past either) | -| `POST /api/test/people` | `{after?}` → `{people: [{identity, email}], next}`: the e2e people by identity, 100 a page (`next`: the identity to ask after, `null` on the last page); the hosted e2e's sweep signs each in again and deletes their fragments of one run (`e2e--…`), or with `--sweep-all` every `e2e-…` one at least an hour old (crates/e2e/src/hosted/sweep.rs) | -| `POST /api/test/ledger` | `{identity, op, …}`: a lever on that person's ledger: `clock {offsetMs}` moves its clock, `sweep` runs its sweep now, `entries {prefix}` lists its references under a prefix (at most 500), `totals` answers what moved its balance, `book` answers the price book its head holds as of its last call (`{book}`), `paid-calls {max}` caps its paid calls (`{max, used}`: `used` counts every one it has charged or holds), and `legacy-seat {seat, seq}` sets its seat at that order, as an operator's ledger `seat` command did before it went (2026-10-16), answering its status (an order no newer than its own is refused, 400, as the registry's would be), `schema` answers the steps its schema holds, as the list's `schema` does, and `fail-grants {times}` (at most 10) fails its next grants before it takes them, as a ledger not reached once an org's grant is recorded. On a fleet whose levers reach everyone, `identity` may name an org's ledger, `org:` | -| `POST /api/test/keys` | `{fragment, op, plaintext\|sealed}`: seals or opens as that fragment | -| `POST /api/test/list` | `{identity, op}`: a lever on that identity's list (on a preview, an e2e person's or their agent's): `before-searched` makes it a list from before schema versions and before its table named the channels searched (#186), as the lists p5 made before then are: every newer change to it is refused (500), and its next start refuses it (`schema_refused`, a reset required) → `{columns}`; `schema` → `{class, known, steps: [{version, name, sha, how, at}]}`: the steps its schema holds (`how`: `ran` or `adopted`; none from before versions) and how many its code declares, answered by a list that refuses its schema too | -| `POST /api/test/fragment` | `{fragment, op, …}` pulls a lever on that fragment: `connectors {policy}` has an agent fragment's doors ask `policy` (a `connectors` list, as a deploy config names one) in place of the deployment's, `null` giving it back, `fail-deliveries {times}` fails its next queue sends, `fail-outbox {times}` fails its next records' outbox writes just after their append, `fail-triggers {times}` fails its next trigger steps just before their last run starts, `drop-effects {times}` loses its next job step answers on their way back to the Workflow (after the step ran and its answer was kept), `forget-steps` forgets the kept answers of its runs in flight, `hold-advances {on}` holds each advance after a run's first step while on (at most 20 s), and `advance-held` answers `{run}`, the last run it held, `forget-live` makes it forget what it knows of its live sockets beyond their attachments (as waking from hibernation does), `age-live {ms}` makes every live socket's identity check `ms` older (as if that long had passed), `drop-live {code}` drops its live sockets, `ledger {ms \| null}` shortens (or restores) its operation ledger's window, `age {ms}` forgets its write keys as if `ms` had passed, `members {fill}` adds placeholder members until there are `fill`, `legacy-member-kind {principal}` makes that member's row one from before member rows named their kind (its kind NULL), its schema's levers (cell/src/schema.rs, answered by a fragment that refuses its schema too): `schema` answers its steps as the list's `schema` does, with the columns of its `members` and `runs`, `schema-rerun` runs its migration again as a start does (`{ran}` or `{refused}`), `schema-unversion {shape?}` makes it a fragment from before versions (no `_schema`), as `people-only` one from the days of people-only shares (`members.people_only`) and as `before-code` one from before `runs.code` (#178), answering those columns, and `schema-try {step}` runs its steps and one more of the e2e's (`adds` a column to `members`; `fails` once it has made a table), answering `{ran}` or `{refused}`, and `probeTable` (whether the table `fails` makes is there), `code-builds` answers `{builds}`: how many times the fragment's activation built its app's worker code for the loader, `alarm` answers `{alarmAt, pollAt, now}` (ms): when its alarm and its next poll are set for, `age-outside {ms}` makes the last sign its pins may lag (a storage token minted, a move that failed to follow) `ms` older, `age-invites {ms}` makes every invite waiting run out `ms` sooner and arms its alarm for the soonest (`{aged, invites}`: how many), `fail-after-paid {times}` fails its next paid AI steps just after their call was paid and kept (so the step is tried again), `fail-meter-acks {times}` loses its next meter batches' acknowledgements (so the queue delivers them again), `meter-now {sample?, resend?}` closes every counted minute, takes a storage sample (unless `sample: false`) and sends its outbox's batch now (a waiting one again with `resend`), answering the outbox, `meter` answers the outbox, `receipt-before {run}` makes that run's paid receipts as the release before decision 62 left them (kept, not marked charged, charged under an attempt's reference: docs/ledger.md, "Who pays"), `forget-standing` makes its isolate's gate forget every answer it heard, its owner's standing among them (docs/ledger.md, "The gate"; locally one isolate runs the router and every object, so it is the whole node's), `cron-now` makes each of its cron schedules, and each of its scheduled records not paused, due at once (`{due, records}`: how many of each), so a test need not wait for a schedule's minute, `poll-now` makes its next alarm a poll pass (the blob collection's), `fail-cards {times}` makes its next card shots open a page nothing serves (`http://127.0.0.1:9/`, which Chrome refuses), so they fail as an unreachable page does, `cards` answers `{cards, failCardsLeft}`: its card and schedule as kept (`fragment_core::card::Cards`) and the shots the lever still fails, `repo {repo}` makes it name the repo `repo` (`[a-z0-9-]`), as a fragment made before repos were named for their owner does (a wipe deletes what it names), and `ended` answers `{ended: [{incarnation, name, stored, attempts, lists, repos, agents, tries, held, error}], dueAt}`: each life a delete ended whose cleanup is not done (at most 8; `lists`: the members' lists it has still to tell; `stored`: 1 while its app's database or blobs remain; `repos`: 1 while a wipe's end of it has its repo still to delete; `agents`: 1 while an agent fragment's agent has still to leave its computer and the registry's names; `tries`: the most failed tries of a part left; `held`: one failed ten times or was refused, so it is tried daily, and by each wipe call; `error`: the last error of a part left, `part: error`), and when its next pass is due. `ended` alone answers on a name with no fragment (deleted, and not made again) | -| `POST /api/test/computer` | `{computer, op, times?, on?, ms?, freeze?}` pulls a lever on that computer (docs/computers.md): `kill` → `{computer, killed}` sends SIGKILL to the guest's PID 1 (from outside its PID namespace), so its container exits as a crash does and its real exit is reported (`killed`: the start it was); one not running (asleep, or won't wake) is 400. `saves` → what its Computer DO keeps of its saves: `saves` (newest first, at most three: `{number, id, generation, atMs, held, records, unusable}`, each with its `DirectoryBackup` records), `numbered` (the last save's number), `snapshot` (`{id, image, save}`, the cache of a save for one image, or `null`), `restored` (what its last start that came up restored, as the view has it), `rollbacks`, `ended` (`{generation, by, saved}`: how the last life ended, until the next start that comes up reads it), `starting` (`{generation, from}`: the start under way), `running` (`{generation, image}`: the image's reference the last start that came up runs), `generation` (its lifecycle's last start), `saving` (the save under way, by its step: `{step: hold \| save \| stop, since_ms, …}`, or `null`), `unsavedSince` (since when a sleep's save has kept failing, or `null`: the bound counts from it), `freeSince` (since when its awake time is free, the start of the try whose save failed first, or `null`), `keepalives` (the guest's keepalives it counts open: its turns running), `failSaves` (the lever's failures still to come) and `hangBackups` (the lever's bound on its backups, or 0). `fail-saves` with `times` (0 to 100) → `{computer, failSaves}`: its next that many saves fail before they start (a sleep's included, which then keeps its container); 0 fails none more. `hang-backups` with `ms` (0 to 480000) → `{computer, hangBackups}`: a sick container's backups (r4, r6): its awake container's `sandbox-shim` swapped for one that never answers, and its saves' backups bounded by `ms` in place of their 8 minutes, so each fails at it (`no answer within ms`); 0 puts its shim back and the bound. One not awake is 400 (0 aside). `alive-check` with `ms` (1 to 45000) and `freeze` → `{computer, aliveCheck}`: its next start waits `ms` for its guest to read its view, as a start from a snapshot does (local workerd takes none); with `freeze` its guest's PID 1 is stopped (SIGSTOP) once its container is up, so it never does: the start came up dead, and the same wake starts it again from its save (#362). `always-on` with `on` → `{computer, alwaysOn, view}`: its owner's plan made always-on, or not (decision 25: the plan itself does not reach a computer yet). `simplex` with `on` → `{computer, simplex}`: its bridge's word that its image does SimpleX, taken as `GET /api/computer?simplex=1` takes it (one not running is 400), or forgotten (`on: false`); its owner's pages are told either way. `expire-sign-ins` → `{computer, expiredSignIns}`: every own-key sign-in under way (Connections) is past its time, as ten minutes would leave it. `schema` answers the steps its schema holds, as the list's `schema` does (on a computer no one made too). Another op, or one without its argument, is 400; a computer no one made is 404 | -| `POST /api/test/mail` | a local fleet's only (a preview's mail is real): `{to, subject, text}` → `{messageId}`, one message as the platform sends it (Mail, below), to the mail fake | -| `POST /api/test/export` | `{identity, op, steps?}` (on a preview, an e2e person's): a lever on that person's export (Export, below). `pace` with `steps` lets their build take at most that many more steps (a copy's piece, a page of records, a fragment's facts…), then wait, a second at a time, until it is let go (`steps: null`): a build held part way, as a crash or a restart finds one. `piece` with `steps` (1024 to 8 MiB, or `null`) holds an entry of unknown size that many bytes at a time before putting it aside, so a small export puts its entries aside in many pieces, as a large one does. `cap` with `steps` (bytes, or `null`) makes their archive's limit that many bytes, so a small export meets it → `{pace}`, `{piece}` or `{cap}` | -| `POST /api/test/registry` | a local fleet's only: `{down}` makes the registry answer 503 (until it is set back, or the registry restarts), `{calls: null}` answers `{calls, skewed}`, how many calls the registry has had since it started (a test counts a request's round trips by the difference), and how many it refused for their release (not among `calls`), `{skewFor: {path, times}}` makes it answer the next `times` calls (at most 100; 0 answers them again) to one of its inner paths (`/login/exchange`: a sign-in's callback) as a registry of another release would, each refused unread (503 `deploying`) by the check every call into an object meets, as a deploy going out does (docs/MODEL.md, Calls between releases), `{hold: ms}` makes its next call wait that long (at most 10 s) before it is answered, while other calls go on, `{downFor: {path, times}}` makes it answer 503 to the next `times` calls (at most 100; 0 answers them again) to one of its inner paths (`/orgs/sync`: a computer's making asking for its seat's push), as `{down}` does, the rest answered, and `{signins: "count"\|"expire"\|"sweep"\|{expireSession: token}}` counts sign-in's rows (`{logins, redemptions, sessions}`), expires every pending sign-in and unspent redemption, runs its sweep now, or expires the one session a cookie's token names (a platform session's site sessions end with it), `"schema"` answers the steps its schema holds, as the list's `schema` does, on a registry that refuses its schema too, and `{invitesBefore: email}` makes every invite waiting on that email one mailed before invites waited for a yes (decision 63: its role, title and sharer forgotten), which a sign-in as it still meets, answering `{before}`, how many | - -One hook is no route: on a local fleet with a test secret, a request -whose `fragment_e2e_release` cookie names a release's id is answered as -that release (cell/src/levers.rs `answered_release`): the -`x-fragment-deploy` of every answer of the platform's host, and the -shell's page's meta, name it, so a browser meets a deploy (The shell, -"A new release") and no one else's answers change. A cookie naming no -release is the node's own; calls between the Worker and its objects keep -its own; a preview's and production's answers are always their own. - -A request body is at most what the zone's Cloudflare plan takes (100 MB -on Free and Pro), below the 256 MiB a blob route allows (debt ledger). - -Bindings (`cell/wrangler.jsonc`): `FRAGMENT`, `PRINCIPAL`, `REGISTRY` and -`LEDGER` (Durable Objects), `LOADER` (the Worker Loader), `JOBS` (the -Workflow that runs jobs), `BLOBS` (the deployment's R2 bucket: the bytes -of large files), `DELIVERIES` (the `fragment-deliveries` queue, and its -dead-letter queue `fragment-deliveries-dead`), `METERS` (the -`fragment-ledger` queue: each fragment's meter batches, consumed by the -cell), `AI` (Workers AI, through `AI_GATEWAY_ID`), `EMAIL` (Email -Sending, the `send_email` binding: Mail, below), and `BROWSER` -(Browser Rendering: preview cards, Cards below). A local node runs -without `AI`, which `wrangler dev` would only reach remotely: dev and the -e2e set `FRAGMENT_AI_URL`. Nor does it send mail: they set -`FRAGMENT_MAIL_URL`. `BROWSER` runs locally under `wrangler dev` -(its own local mode: a Chrome for Testing it downloads into its cache, -`$XDG_CACHE_HOME/.wrangler/chrome`, on the first shot; dev and the e2e -set `XDG_CACHE_HOME` to the repo's `target/cache`), a lower rung than -Cloudflare's browsers. - +| `POST /api/test/signin` | `{email, paidCalls?}` → `{session, identity, created, paidCalls}`: a platform session for the e2e person `@e2e.test` (issuer `e2e.test`, which no real sign-in has), made a seat, their paid calls capped at `paidCalls` (at most `levers::E2E_PAID_CALLS_MAX`; absent, unchanged). A branch makes at most `levers::E2E_PEOPLE_DAILY_MAX` e2e people and lends `levers::E2E_PAID_CALLS_DAILY_MAX` paid calls a UTC day (429) | +| `POST /api/test/people` | `{after?}` → `{people: [{identity, email}], next}`, 100 a page | +| `POST /api/test/ledger` | `{identity, op, …}` | +| `POST /api/test/keys` | `{fragment, op, plaintext \| sealed}` | +| `POST /api/test/list` | `{identity, op}` | +| `POST /api/test/fragment` | `{fragment, op, …}` | +| `POST /api/test/computer` | `{computer, op, times?, on?, ms?, freeze?}` | +| `POST /api/test/export` | `{identity, op, steps?}` | +| `POST /api/test/mail` | `{to, subject, text}` → `{messageId}` | +| `POST /api/test/registry` | `{…}` | + +Each op is its cell's to define; an unknown one, or one without its +argument, is 400. One hook is no route: on a local fleet with a test +secret, a request whose `fragment_e2e_release` cookie names a release is +answered as that release (`x-fragment-deploy`, the shell's meta), so a +browser meets a deploy (`answered_release`). ## Mail -The platform's own mail (cell/src/mail.rs): transactional only, one -plain-text message to one person, from the deployment's sender -(`FRAGMENT_MAIL_FROM`), through Cloudflare Email Sending's `EMAIL` -binding. `mail_from` accepts a bare address (`mail@finite.place`) or an -unquoted name and address (`Fragment `). A name is 1 -to 200 characters, without `<>,;"()[]\@` or control characters; quoted -names, comments and lists are refused. Invalid senders are refused at -config read (the node's first request), and before a deploy. A bare -sender stays a string; a named sender becomes `{email, name}`, as the -[Workers API documents](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/). -A message goes to one plain address (no second recipient, no -display name), with a subject of 1 to 200 characters on one line and a -text of at most 16 KiB (`fragment_core::mail`); anything else is 400 and -nothing is sent. The service's refusals keep its code (`E_…`) in the -message: too many sent is 429, a message it will not take (an address -that bounced before, a field it refuses) 400, and the rest (an unverified -sender, an outage) 502. Dev and the e2e send to the mail fake instead -(`FRAGMENT_MAIL_URL`). On a preview, whose mail is real, a message to an -e2e person (`@e2e.test`, an address with no mailbox) goes nowhere -rather than bounce. What it sends: invites by email (Sharing), seats -and trials (Billing), and a key added to a person (Sign-in: the key's ending, the whole key, how it came (a CLI's code typed: where, when and by what it was asked for, and never to type a code someone sent; or by which of their keys, `POST /api/identities/{id}/keys`), and `fragment keys revoke`; sent on every key added, after the answer, and a key is added whatever becomes of its mail). Each names the platform as its shell does -(`fragment_core::mail::PRODUCT`, Finite.Computer, held to the shell's -page and web manifest by a test). The sender, its address and any name, -is the deployment's `mail_from`: a name there should say the same. +The platform's own mail (cell/src/mail.rs, `fragment_core::mail`): +transactional only, one plain-text message to one plain address, from the +deployment's `FRAGMENT_MAIL_FROM`, through Email Sending's `EMAIL` +binding. A sender is a bare address or an unquoted `Name
`; any +other is refused at config read and before a deploy. A subject is 1 to +200 characters on one line and a text at most 16 KiB; anything else is +400 and nothing is sent. The service's refusals keep its `E_…` code in the +message: too many sent is 429, a message it will not take 400, the rest +502. Unset, the deployment sends no mail. A message to an e2e person +(`@e2e.test`) on a preview goes nowhere. + +It sends invites (Sharing), seats and trials (Billing), a finished export +(Export), and a key added to a person (Sign-in): the key's ending, the +whole key, how it was added, and `fragment keys revoke`, after the answer, +whatever becomes of the mail. Each names the product as the shell does +(`fragment_core::mail::PRODUCT`). ## Principals and access A principal is an identity (a person or an agent, named by the npub of -the key it was made with: docs/cloudflare-v1.md, decision 45) or an -anonymous visitor (`anon:` + 32 hex, the hash of a random cookie on the -fragment's origin). Grants, records, runs, and the ledger name -principals. A request is signed by a key (64 hex inside, an npub in -answers; requests may use either); the router asks the registry which -identity holds it, live, on every signed request to the control API, -and passes the fragment both. A key no one registered, or a revoked one, -is 401; when the registry cannot answer, a signed request is 503 -`registry_unavailable`, never let through (docs/finite-integration.md, -rule 7). Control routes need NIP-98; site requests may carry it. A site -request's signature (checked by the router: a bad one is 401) or session -cookie is resolved only when the answer depends on who is asking: a page -or a file that everyone who may see the fragment gets alike (a `public` -fragment's, or a `link` fragment's with its share link) is served -without asking the registry, so neither a revoked key nor a registry that -cannot answer changes it. An operation, a socket, an invite, push, the -owner's fragments, an app route, and any read the anonymous standing may -not see ask it, live, and are refused as the control API is (401, or 503 -when the registry cannot answer). A fragment's own key is the principal -of its triggered runs and is not registered. - -NIP-98 (`crates/nip98`): `Authorization: Nostr `, an -event of kind 27235 with empty content and the tags `["u", ]`, `["method", ]`, and, when the body is not -empty, `["payload", ]`; `created_at` within 60 -seconds of the cell's clock; `id` and a BIP-340 `sig` as NIP-01 defines. -Every field is required and typed (a tag is an array of strings). A -`payload` tag binds the body even when it is empty: a signature over a -body, sent without one, is 401. A blob upload's bytes stream past the -router, so its signature binds its URL, which names the bytes' hash (the -cell checks the bytes as they arrive): a `payload` tag may name that same -hash, or be absent, and one naming any other hash is 401. Every other -body the router reads is at most 2 MiB (`limits::BODY_MAX_BYTES`), but a -model call's (`/api/models/v1/chat/completions`, `/api/models/v1/decide`), at -most 6 MiB (`MODEL_BODY_MAX_BYTES`: an image's 5 MiB data URL and 1 MiB -more), and a transcription's, at most 10 MiB of audio and 64 KiB more -(`transcribe::BODY_MAX_BYTES`); each is measured as it arrives: a -longer declared `content-length`, or a chunked body that runs past it, -is 413 before anything is authenticated. +the key it was made with: decision 45) or an anonymous visitor (`anon:` + +32 hex, the hash of a random cookie on the fragment's origin). A fragment's +own key is the principal of its triggered runs, and is not registered. + +A request is signed by a key (64 hex, or an npub). The router asks the +registry, live, which identity holds it on every signed control request: a +key no one registered, or a revoked one, is 401; a registry that cannot +answer is 503 `registry_unavailable`, never let through. A site request's +signature or session cookie is resolved only when the answer depends on +who asks: a page or file everyone who may see the fragment gets alike (a +`public` fragment's, a `link` fragment's with its share link) is served +without the registry. An operation, a socket, an invite, push, an app +route, and any read the anonymous standing may not see ask it live. + +NIP-98 (`crates/nip98`): `Authorization: Nostr `, kind 27235, +empty content, tags `["u", ]`, `["method", ]` and, +for a body, `["payload", ]`; `created_at` within +`AUTH_WINDOW_S` of the cell's clock; `id` and BIP-340 `sig` as NIP-01. +Every field is required and typed. A `payload` binds the body even when it +is empty. A blob upload's signature binds its URL, which names the bytes' +hash: its `payload` may name that hash or be absent; any other hash is 401. + +Bodies the router reads are at most `BODY_MAX_BYTES`; a model call's +(`chat/completions`, `decide`) `models::MODEL_BODY_MAX_BYTES`; a transcription's +`transcribe::BODY_MAX_BYTES`; an embedding's `embed::BODY_MAX_BYTES`. Each is measured as it arrives: a longer +`content-length`, or a chunked body past it, is 413 before anything is +authenticated. Roles, weakest first: `public`, `viewer`, `editor`, `owner`. A member's role is their membership. Otherwise visibility decides: on a `public` -fragment everyone holds `public`; on a `public` or `link` fragment the -share link (`?view=`, which sets an HttpOnly cookie on the -fragment's origin) counts as `viewer`; a `members` fragment gives -non-members nothing. A page's navigation that brings the share link (a -GET or HEAD that its Fetch Metadata names one, `Sec-Fetch-Mode: -navigate`, whose `Accept` names HTML) is answered `302` (`no-store`) -with the cookie, on to the same path (with one leading `/`, so never -another host: `site::way_on`) and query without `view` -(`site::query_without_view`), so the token is never in a URL a page's -code reads (`location`, a card's error report): an editor's page code -cannot capture it. Nor can an app's service worker, which would see a -navigation before the origin does: a fragment's origin serves no worker -script but the platform's `__sw.js` (a request with `Service-Worker: -script` for any other path is 404), so an app registers none. A fetch, -an image, or a socket that names `?view=` still counts, as its sender -holds the token; an app's `fetch` is never handed `view`. A request with -no Fetch Metadata (a link unfurler, a browser from before 2023) reads -the page where it asked, so a share link's preview unfurls with its Open -Graph tags; in such a browser alone the token stays in the page's URL. A -frame's navigation goes on too and opens nothing, since `fragview` does -not count in a frame (Which cookies count): a share link is never framed -(no page but the platform's and the fragment's own may frame one anyway: -`frame-ancestors`). A `?view=` that opens nothing goes nowhere: its page -says the link has changed. A refusal is 401 for an unsigned caller and -403 for a signed one. - -An agent's owner reads what the agent reads (FIN-11): someone who is not -a member but owns an agent that is reads the fragment as a `viewer` -(status, files, channels, events, runs, its queries, its site), and never -acts through it: a mutation or a job needs a membership of their own -(403, saying so), and they get nothing the agent's own role opens beyond -`viewer` (an editor's channels, tokens, or secrets). - -An agent acts for whoever asked, capped (docs/cloudflare-v1.md, R17). A request -signed by an agent may name an identity in `for=` in its URL's -query (inside the signed URL, so the signature covers it): the request -acts with the lower of the role that identity holds in the fragment (its -membership, an agent of its own that is a member, or the visibility -floor; never the share link, which the agent does not hold) and the -agent's cap: the agent's own role there, or its owner's membership role -if higher, and never above `editor`. The owner's part is the owner's own -role, so an agent never reaches further than its owner could. The -principal is still the agent (records, runs, and the ledger name it), -but an operation it calls `for` someone tells the app so: `call.principal` -is them and `call.agent` the agent, so what it does for them is theirs. -`for` is honored on a fragment's routes (`/api/f/{name}/…`), on `GET` -and `POST /api/fragments` (a `POST` for anyone but the agent's owner is -403: what an agent makes is its owner's), and on the model routes -(`/api/models/v1/…`: Models, below); on any other route it is 400. -A person's request naming `for` is 403, as is more than one `for` (400) -or one that is not an identity (400). A call without `for` acts as the -agent's own membership. A site request's query is its app's: `for` -there means nothing to the platform. - -An agent shares for its owner (Paul, 2026-10-04: "your agent can share -on your behalf"; `fragment_core::access::agent_shares`). Sharing is -members (other than leaving with `DELETE members/me`), invites, -visibility, and rotation. An agent acting `for` its own owner, and not -held below them, shares a fragment its owner owns as its owner would, -under the same rules (it grants viewer or editor, never owner, and never -changes or removes the owner). Every other agent's sharing request is -403, saying why: one acting as itself or for anyone else, one its owner -holds (at any hold), and one on a fragment its owner does not own (an -editor's agent shares nothing there). Deleting a fragment and setting -its cap are 403 for every agent, whomever it acts for, as is sharing a -connector (An agent's doors, below). What an agent -shares names it: the member's `addedBy` and the invite's `createdBy` are -the agent, and each change's event (`member.set`, `member.removed`, -`invite.created`, `invite.revoked`, `visibility`, `tokens.rotated`) -carries `by` (who made it) and, for an agent, `for` (its owner), and its -summary says "(an agent, for …)", so the owner sees what their agent -shared. - -Membership is cell state: `fragment.json`'s `visibility`, `editors`, and -`viewers` grant nothing (the cell records a `manifest.ignored` event). -Only the owner, or their agent sharing for them, manages members, -invites, visibility, and tokens; a member may leave. Each identity's -list of fragments is kept in its `Principal` cell, fed from each -fragment's outbox. - -### Who can do what, where - -Each way in acts as a principal, held to that principal's role on the -fragment (above); what costs money is paid from an owner's ledger (a -fragment's owner for its jobs' paid steps, an agent's owner for its -turns: Ledger, below). The same table is in cli/GUIDE.md, so -`/llms-full.txt` carries it (Agent docs, below). - -| | call operations | read or write records | use models | decide | share and invite | deploy | -|---|---|---|---|---|---|---| -| **the person, in a browser** (a session: the shell, a fragment's pages) | `__op` and the socket, from a page (`fragment.call`), at their role; `public` ones signed out too | `__live`: the channels their role reads; a post where a channel's `post` role admits them | through their agents and their apps' jobs | through their apps' jobs | the owner, from the share sheet: members, invites by email, visibility, the share link | no route: what the shell makes (an agent, its chats, a skills fragment) is a blessed template, live as it is made; an app is an agent's (the shell's Add suggests what to ask for, `APP_PROMPTS` in cell/shell/shell.js) | -| **their agent, on its computer** (signed by its computer's egress, `for` its owner) | `POST /api/f/{name}/ops/{op}` (`fragment call`), at its own grants, or `for` its owner at the lower of theirs and its cap (at most `editor`) | channels and posts at that role; its chats are records (docs/chat-records.md) | the model route (Models), on its tier or its owner's own model, paid by its owner | the decision route (Decisions: `POST /api/models/v1/decide`, `/v1/decide` on its intercept), its media inline, paid by its owner | `for` its owner, on what its owner owns (above): members, invites, visibility, rotation; never a delete or a cap | `files` and `deploy` at that role: an editor's; what it creates is its owner's | -| **an app's own jobs** (a run of a fragment's job, or a triggered run) | its own operations (`job.call`), as the run's principal: the caller, or the fragment (an editor) for a triggered run | `job.publish` to its own channels, and a text step's draft to them as it streams (no `post` role needed); a channel trigger hands it the record | `job.ai.text` (with tools, run by the job), `job.ai.image` (AI, below) | `job.ai.decide`, about text, JSON and the fragment's blobs (Decisions, below) | none | none: `job.files.write` writes `main`, which a deploy puts live | -| **a coding agent, with the CLI** (a key a person approved: `fragment login`) | `fragment call`, as that person | `fragment channel [--follow]`, `fragment post` | its own; the platform's only through the jobs it calls | only through the jobs it calls | `fragment members`, `invite`, `visibility`, `rotate`: the owner's | `fragment sync`, `write`, `deploy`, `rollback`: an editor's | -| **an MCP client, through `fragment mcp`** (that CLI, over stdio) | the fragment's operations that have a `description`, as tools, at the key's role: its queries, and with `--write` its mutations and jobs | none but through operations | its own; the platform's through a described job | through a described job | none | none | +fragment everyone holds `public`; on a `public` or `link` fragment the share +link (`?view=`, which sets the HttpOnly `fragview` cookie on the +fragment's origin) is `viewer`; a `members` fragment gives non-members +nothing. A refusal is 401 to an unsigned caller and 403 to a signed one. + +The share link's token never stays in a URL a page's code reads: a page +navigation that brings it (Fetch Metadata `navigate`, `Accept` naming +HTML) is a `302` (`no-store`) setting the cookie, on to the same path +(`site::way_on`) and query without `view`. A request with no Fetch Metadata +(an unfurler) reads the page where it asked. A fetch, an image or a socket +naming `?view=` still counts. An app's `fetch` is never handed `view`. A +fragment's origin serves no worker script but the platform's `__sw.js` +(any other `Service-Worker: script` request is 404), so no app registers +one that would see the navigation first. `fragview` does not count in a +frame (Which cookies count). + +**An agent's owner reads what it reads**: an owner who is not a member, +but whose agent is, reads the fragment as a `viewer`, and acts through it +never (a write needs their own membership: 403). + +**An agent acts for whoever asked, capped** (R17). A request signed by an +agent may name `for=` in its signed URL's query: it acts with the +lower of that identity's role (its membership, an agent of its own that is +a member, or the visibility floor; never a share link) and the agent's cap +(its own role, or its owner's membership role if higher; never above +`editor`). The principal stays the agent; an operation called `for` +someone sees `call.principal` as them and `call.agent` as the agent. `for` +is honoured on `/api/f/{name}/…`, on `GET` and `POST /api/fragments` (a +`POST` for anyone but the agent's owner is 403) and on `/api/models/v1/…`; +anywhere else 400. A person naming `for` is 403; two, or one that is no +identity, 400. A site request's query is its app's. + +**An agent shares for its owner** (`fragment_core::access::agent_shares`): +acting `for` its owner, and not held below them, on a fragment its owner +owns, an agent manages members, invites, visibility and rotation as its +owner would (never granting `owner`, never touching the owner). Any other +agent's sharing request is 403, saying why. Deleting a fragment, setting +its cap and sharing a connector (An agent's doors) are 403 to every agent. +What an agent shares names it (`addedBy`, +`createdBy`), and each event (`member.set`, `member.removed`, +`invite.created`, `invite.revoked`, `visibility`, `tokens.rotated`) carries +`by` and, for an agent, `for`. + +Membership is cell state: `fragment.json`'s `visibility`, `editors` and +`viewers` grant nothing (a `manifest.ignored` event). Only the owner, or +their agent for them, manages sharing; a member may leave. Who may do what +through each door (a browser, an agent, a job, the CLI, `fragment mcp`) is +cli/GUIDE.md's "Who can do what, where". ## Identities -The registry (`cell/src/registry.rs`: fragment's BANKS, docs/cloudflare-v1.md, -decisions 45 to 50) holds identities, the public keys each has held, -each agent's owner, and sign-in subjects with their verified emails. It -holds no grant. An identity is named, for good, by the npub of the key -it was made with: a person's is a key the registry makes at their first -sign-in and keeps sealed for them (`person:`, under the host -secret), the only private key it holds, which it never signs with yet; -an agent's is the key it was registered with (an agent fragment's own -key, which its fragment keeps). A retired key still names its identity, -and a request may name an identity by its npub or by any active key of -it. A **key proof** is a NIP-98 event by a new -key for the same method and URL as the request that carries it, with -`["p", ]`: whoever sent the request also holds -the new key and meant it for this signer. +The registry (`cell/src/registry.rs`; decisions 45 to 50) holds +identities, the keys each has held, each agent's owner, and sign-in +subjects with their verified emails; no grant. An identity is named for +good by the npub of the key it was made with: a person's, a key the +registry makes at their first sign-in and keeps sealed (it never signs +with it yet); an agent's, its agent fragment's own key. A retired key +still names its identity. A **key proof** is a NIP-98 event by the new key +for the same method and URL as the request carrying it, with `["p", ]`. | method & path | who | body → answer | | --- | --- | --- | -| `POST /api/identities` | a person | `{kind: "agent", proof}` → a new agent identity they own, named by the proof's key and holding it; again, the same one; a key someone else holds is 409; an agent owns no agents (403) | -| `GET /api/identities/{id\|me}` | the identity, or its owner | → `{id, kind, owner?, email?, picture?, createdAt, keys: [{npub, addedAt, addedBy, revokedAt?}], agents: [id], subjects: [{issuer, email, linkedAt}]}` (`email`: a person's latest sign-in's); anyone else 404 | -| `POST /api/identities/{id\|me}/keys` | a person for themselves; an owner for their agent | `{proof}` → the identity with the key added (at most 64 keys, revoked ones included), a person mailed of each key added to them (Mail); a key someone else holds, or a revoked one, is 409 | -| `DELETE /api/identities/{id\|me}/keys/{npub}` | the same | → the identity; the key is 401 from the next request and never comes back; an agent's last active key cannot be revoked (400); a person who signs in may hold none | -| `GET /api/identities/{id}/keys/{npub}` | the identity, or an agent it owns | → `{active}` (an agent's runtime checks its owner's keys with it) | -| `PUT /api/identities/{agent}/held` | the agent's owner | `{held: "viewer" \| "editor" \| null}` → the agent: held below its owner (decision 36), it acts with at most that for whomever it acts, wherever its owner's access reaches; its own memberships (its chats, its agent fragment) are not held, so a held agent still answers and records its turns there; `null` lets it go; anyone else 403, `owner` or `public` 400 | -| `PUT /api/identities/me/picture` | a person | the image (PNG, JPEG, WebP, or GIF, told by its bytes; at most 256 KiB) → `{sha, mime}` | -| `GET /api/identities/{npub}/picture` | anyone | the picture's bytes, `public, max-age=300`: a person's (the identity's view names it, `picture`), or an agent's (An agent's name and picture, below); one with none, 404 | +| `POST /api/identities` | a person | `{kind: "agent", proof}` → a new agent identity they own, named by the proof's key; again, the same one. A key someone else holds 409; an agent making one 403; `kind: "person"` 400 (people come from sign-in) | +| `GET /api/identities/{id\|me}` | the identity, or its owner | → `{id, kind, owner?, email?, picture?, createdAt, keys: [{npub, addedAt, addedBy, revokedAt?}], agents, subjects: [{issuer, email, linkedAt}]}`; anyone else 404 | +| `POST /api/identities/{id\|me}/keys` | a person for themselves; an owner for their agent | `{proof}` → the identity, the key added (at most `KEYS_PER_IDENTITY_MAX`, revoked ones counted); a person is mailed (Mail). Held by someone else, or revoked: 409 | +| `DELETE /api/identities/{id\|me}/keys/{npub}` | the same | → the identity; the key is 401 from the next request, for good. An agent's last active key 400 | +| `GET /api/identities/{id}/keys/{npub}` | the identity, or an agent it owns | → `{active}` | +| `PUT /api/identities/{agent}/held` | its owner | `{held: "viewer" \| "editor" \| null}` → the agent, held below its owner (decision 36): it acts with at most that wherever its owner's access reaches; its own memberships are not held. Anyone else 403; `owner` or `public` 400 | +| `PUT /api/identities/me/picture` | a person | the image (PNG, JPEG, WebP or GIF by its bytes; at most `PICTURE_MAX_BYTES`) → `{sha, mime}` | +| `GET /api/identities/{npub}/picture` | anyone | the bytes, `public, max-age=300`; none, 404 | ### An agent's name and picture -An agent is called by its agent fragment, as live has it: its **name** is -the fragment's title (`fragment.json`'s `meta.title`, 1 to 120 -characters), its **picture** the fragment's `avatar.png` (a PNG, JPEG, -WebP or GIF, told by its bytes, whatever the path says; at most 256 KiB, -`fragment_core::face`). They are the one source every page and the -platform show the agent by. The fragment's cell reads them at each -install of live into its face (`{kind, title, picture?}`: `picture` the -address it is served at, below), and that face is what is shown: - -- its owner's list (`GET /api/fragments`: the agent fragment's `title` - and `picture`), so the shell's sidebar, Settings, and the roster the - shell hands a chat's page (a chat's own title is the chat's, never its - agent's: The shell, below); -- `__people` and `job.people` on any fragment (an agent's `title`, and - its `picture` whole), so a chat's authors and faces, its header, its - `@` list, and its push notifications (their `title` and `icon`: docs/ - chat-records.md, "Push"); -- the share sheet and the access view (an agent's `title`), and an - export (its members' `title`); -- its computer's runtime: our Hermes image tells the agent its name at - every turn, and its teammates its title (docs/computers.md, "Its name, - in our Hermes image" and "Teammates in our Hermes image"); -- its owner's SimpleX app: the bridge names the agent's SimpleX profile - by its title, which the core sends to the linked app (docs/bridge.md, - "SimpleX"). It has no picture either. - -Its **role** ("chief of staff", Paul, 2026-10-30: several agents, each -with a name and a role) is its fragment's `meta.role`, at most 48 -characters (`limits::ROLE_MAX_CHARS`; one past it, or no string, makes the -manifest invalid, refused at its install as a long title is), the one -source of it: the install puts it in the fragment's face beside its -title, trimmed (a role of only spaces is none, `fragment_core::face::role`), -so its owner's list and its status carry it (`agentRole`) live, as they carry its name, -and the shell shows it as a tag beside the name. Only an agent fragment's -face carries one. It is written as its name is (below), never copied -into its job. - -Its label (`juniper` of `juniper--k3x9`) stays its `name` in a profile -and its computer's view: the word that `@mentions` it, and its runtime's -profile, which no rename changes. Its fragment deleted, its agent is -named by it no more (a profile without `name` or `fragment`: a page shows -an agent), so a life made again under the name never lends its face, nor -its `@` word, to the agent that was. - -**No copy of a name, and no name it was not given** (Paul, 2026-10-26: -"be systematic about referring to the user's chosen name"). An agent -made with no title has none (the agent template gives none), and a title -of only spaces is none (`fragment_core::face::title`): every reader then -shows its label alike, never a blank or "Agent". Whatever shows a name -reads it where it is shown, and again on a rename: -- the shell draws every name of a fragment it shows outside the sidebar - (Settings' rows, labels and buttons, a window's title, a dialog) through - `named` (cell/shell/shell.js), made again at every change of the - person's list, and tells the pages it frames (their roster, a screen's - and a SimpleX link page's `name`); -- the agent's own page takes its name from its owner's roster as it - changes, and alone from its Open Graph title, which says any - fragment's name as every reader shows it (its title, trimmed, else its - label: `site::inject_og`), so a title that is its name in full is told - apart from none; -- the CLI says an agent's name, else its label - (`fragment_core::face::agent_name`: `fragment agent`, `fragment ask`, - `fragment access`), and a chat `fragment ask` makes is titled as the - shell titles a new one, never by its agents' names; -- a computer's view names an agent by its label alone - (`ComputerAgent::label`, `name` on the wire), which nothing shows for - its name. - -The shell-ui lane renames an agent with Settings drawn and its windows -open, then finds no old name anywhere in the shell or the pages it -frames (`OLD_NAME_SWEEP`, crates/e2e/src/lanes/shell.rs). - -**Changing them** is writing those two files and putting them live, as -any of its files: `POST /api/f/{agent}/files` (`fragment.json` with the -new `meta.title`; `avatar.png` as `base64`, or `{path, delete: true}` to -take it away), then `POST /api/f/{agent}/deploy`, by its owner or by the -agent itself (an editor of its own fragment). Settings' Agents (The -shell, below) and `fragment agent [] [--name N] [--picture FILE | ---no-picture]` (cli/GUIDE.md) do just that. A write of an `avatar.png` -past the limit (413) or that is no picture (400) to an agent fragment is -refused, saying why, and nothing is committed; one pushed through git is -refused at its install, which keeps the last good face (its name too) -and says why in `code.error`, as `fragment deploy` prints it. A title -past 120 characters is refused the same way. Nothing restarts: the shell -follows its person's list (its watch), a chat's page reads its owner's -roster as it changes and asks `__people` again as each of an agent's -turns ends, our Hermes image reads the title again every 10 s, and so -does the bridge while the agent's SimpleX core runs. A -runtime's own files never hold either (Hermes' `profile.yaml`, and the -line of its profile's config that tells it its name, are written from -the title, and written again), nor does the agent's job: its `SOUL.md` -says what it does, never who it is (the shell's first agent's names -none; one an owner or agent wrote may, as the shell's first did from -2026-10-03 to 2026-10-16: "You are , 's default agent: …"). - -**A rename by its owner says the new name in its job too** (Paul, -2026-10-27: an agent's page showed its old name under "Its job"): -Settings' Rename and `fragment agent --name` write the job again in the -same commit as the title, each whole-word mention of an old name (case -and all; whole when no letter, digit or `_` touches it) said as the new -one, longest first, in one pass (`fragment_core::face::job_renamed`, -which the shell's `jobRenamed` matches). The old names are the title at -main and the one the shell's first job named it by (its first line, as -that template wrote it: `job_first_name`), so an agent renamed before -this, whose job still names it so, is told its name there by its owner's -next rename, even to the name it has. A job past 64 KiB, before or after -(`JOB_RENAME_MAX_BYTES`), or one that names no old name, is left as it -is, and asked again the rename changes nothing. No old name is guessed: -one the job says some other way stays until its owner or the agent edits -it. - -**Served** at `GET /api/identities/{npub}/picture`, `{npub}` the agent's -(its fragment's own npub): the bytes of the commit whose install checked -them, so a later commit serves nothing no install checked. Its address -names the picture (`?v=` its SHA-256's first 12 digits), so a new -picture is a new address. Who may see the agent may see its picture: it -is public, as a person's is. An agent fragment's own page shows it from -its files (`__file?path=avatar.png`). +An agent is shown by its agent fragment at live: its **name** is +`fragment.json`'s `meta.title` (1 to `TITLE_MAX_CHARS`), its **role** +`meta.role` (at most `ROLE_MAX_CHARS`, a tag beside its name; only an +agent's), its **picture** the fragment's `avatar.png` (PNG, JPEG, WebP or +GIF by its bytes, at most `PICTURE_MAX_BYTES`; `fragment_core::face`). A +title or role past its limit makes the manifest invalid. They are the one +source, kept nowhere else: the fragment's cell reads them into its face +(`{kind, title, role?, picture?}`) at each install of live, and every surface +reads that face (`GET /api/fragments`, `__people`, `job.people`, the share +sheet, `GET /api/access`, an export, a computer's runtime, the shell). +An agent with no title, or one of only spaces, has no name +(`fragment_core::face::title`): every reader shows its label instead +(`face::agent_name`). Its label (`juniper` of `juniper--k3x9`) stays its +`name` in a profile and in its computer's view, the word an `@` mention +uses, which no rename changes. Once its fragment is deleted, its profile +has neither `name` nor `fragment`, so a life made again under the name +never lends it a face. + +**Changing them** is writing those files and deploying (`POST +/api/f/{agent}/files`, then `POST /api/f/{agent}/deploy`), by its owner or +by the agent itself; `fragment agent [--name N] [--picture FILE | +--no-picture]` does that. An `avatar.png` past its limit (413) or that is +no picture (400), or a title past its limit, is refused and nothing is +committed; one pushed through git is refused at its install, which keeps +the last good face and says why in `status.code.error`. A rename by its +owner (Settings, `fragment agent --name`) rewrites, in the same commit, +each whole-word mention of an old name in its `SOUL.md` as the new one +(`fragment_core::face::job_renamed`); a job past `face::JOB_RENAME_MAX_BYTES` is +left as it is. + +**Served** at `GET /api/identities/{npub}/picture`, the agent fragment's +npub, public as a person's is: the bytes an install checked, at an address +naming them (`?v=`, the first 12 hex of their SHA-256). ## Names (docs/cloudflare-v1.md, decision 47) There are no usernames. A fragment's **name** is one DNS label: its -**label**, the part its maker gives it, then `--` (which no label holds, -so no label reads as a name) and a suffix of four -characters the platform draws at random (`todo--k3x9`; the alphabet is -lowercase letters and digits less `l`, `o`, `0` and `1`), unique in the -fleet and fixed for the fragment's life. A label is lowercase letters, -digits and single dashes, not starting or ending with one, at most 57 -bytes, so a name is at most 63. A name is served at `.` (a -branch's at `--.`: on a branch a name and the mark -fit 63 bytes together, or the create is 400), under the suffix's one -wildcard certificate. Its code.storage repo is `--<12 hex>`, after -the deployment's prefix (a branch's `--`): the 12 hex are a -digest of its owner's identity (`fragment_core::codestorage::repo_name`, -the one place a repo's name is derived; the create keeps the repo it -made, and nothing rebuilds the name). So the same owner making a deleted -name again finds its repo, and another identity making it never finds -the repo its earlier maker made: a wiped person's repos are deleted, and -never made again for anyone. - -A create names a label (`{label}`: the platform draws the suffix, three -draws at most, a draw already taken drawn again) or a name in full -(`{name}`, for a client that must know it beforehand; one taken is 409). -Every path names a fragment in full: a bare label there is 404, saying -so. The CLI finds a bare label among the caller's fragments (its list: -the one with it, or of several, the one they own), so `fragment status -todo` works; a person's labels need not be unique. - -An app or brain may also have one optional **public address**, claimed -by its owner from Apps: `