docs(chord): slice 10 as built (design 23, spec, explainer)

This commit is contained in:
Mario Zechner 2026-10-31 20:43:29 +00:00
parent 8959ad6e68
commit b22c5f6941
6 changed files with 221 additions and 212 deletions

View file

@ -1877,7 +1877,7 @@ validation and copies the v2 kernel added around Delta.
reload, swapping the committed plugin back silently. Rejected: resolving with the report after the commit despite
the abort (it would change the rule that an abort after the commit rejects). Examples for 7c (owner): full coverage,
each example a folder with the host code and real plugin directories (35-39).
- **Slice 10's points (owner, 2026-10-30; `slice-10.md`):** (D1, "a") the HTTP component defines the registry `Http`
- **Slice 10's points (owner, 2026-10-30; `slice-10.md`):** (D1, "a"; `httpServer` **superseded** by `createHttp`, decisions "The HTTP core is a fetch handler" and "Settings are arguments") the HTTP component defines the registry `Http`
(`"chord.http"`) and serves it through a facet of its own (`http.facet`, plugin name `chord.http`) that the app loads
with its plugins; its `compose` builds the router; the server reads it with `host.registry(Http, ctx)` per request.
(D2, "a") Wraps are built in slice 10 as designed (decision "HTTP routes and wraps", design §23): a contribution's
@ -1885,7 +1885,7 @@ validation and copies the v2 kernel added around Delta.
phase in contributor order, by default around their own plugin's routes (`scope: "own"`; `"all"` explicit; `route`
narrows to one, a missing one a reported conflict). The server's `headers` option stays for every response, also
those no route makes (a 404, a refused request).
(D3, "a") Authentication is one app function, `authenticate(request, ctx) => P | undefined`, called once per request
(D3, "a"; **superseded** by "The HTTP server is a native object": the `Authenticators` registry) Authentication is one app function, `authenticate(request, ctx) => P | undefined`, called once per request
and per upgrade; no Chord registry of resolvers (p3 composes its own from sharing, devices and the relay, with its
own precedence rules).
(D4, "k": a) Request bodies: `request.json(limit?)` (parsed JSON, 1 MiB by default, malformed: 400) and
@ -1948,7 +1948,7 @@ validation and copies the v2 kernel added around Delta.
(R3, "a"; refines D9) The child reports only the flip: `{ idle: true }` when every count (`host.activity` and `busy`)
becomes zero, `{ idle: false }` when one becomes nonzero; the server's idle clock starts at `true` (a status message
costs about 6 µs on each side, a local call 2.9 µs: no report per call).
(R4, "a if configurable at runtime so allowed domains can be added and removed by a plugin") Before `authenticate`, the
(R4; **superseded in part** by "allowed hosts only through contributions" and "The HTTP component's security baseline": no `allowedHosts` option, provenance by the socket; "a if configurable at runtime so allowed domains can be added and removed by a plugin") Before `authenticate`, the
component refuses (403) a `Host` that is neither loopback nor allowed, and an upgrade (`/chord`, a plugin socket)
whose `Origin` is not the request's own origin; `request.origin` comes only from an accepted `Host` (`http://` on
loopback, `https://` for an allowed host); `headers(request, principal)` gets the principal. Allowed hosts are the

View file

@ -243,7 +243,7 @@ host). The workers component (§23) does this.
| `hold(Type, params, context, { readyTimeout? })` | a reference at once that follows its object until released (section 7) |
| `close(context)` | closes the links (children, routes, then the parent), then ends every object at once and tears them down, the last to go live first (reason `close`); rejects with an `AggregateError` of the native closes that threw |
| `revoke(r, context)` | revocation (section 4; slice 4b) |
| `root(fn, context, { principal? })` | a call scope for code outside objects (a Durable hook, a CLI): `fn` gets a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes (slice 8 D3, section 8) |
| `root(fn, context, { principal? })` | a call scope for code outside objects (a Durable hook, a CLI, an HTTP request): `fn` gets a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes (slice 8 D3, section 8); an omitted `principal` acts as the host, an explicit `undefined` runs anonymous (slice 10) |
| `registry(R, context, { readyTimeout? })` | registry `R`'s composed value for code outside objects once it is usable; waits for the publication, then while it has none up to its `readyTimeout`, then rejects with the last error (slice 8, 7a, section 17) |
**Options of `createHost`:** `address`, `principal`, `principalKey` (section 4; the same function on every host),
@ -2113,32 +2113,70 @@ public API; nothing in the kernel depends on them.
### HTTP (`@earendil-works/chord/next/http`)
`httpServer(host, { listen, allowedHosts?, authenticate, headers?, links?, closeTimeout?, onError }, ctx)` listens
first; requests and upgrades wait only for the router's publication (`host.registry(Http)`, resolved once), never for
the start barrier. `Host` (loopback or allowed: the option plus contributed `hosts`) and an upgrade's `Origin` are
checked before anything else (R4); connections through a host no longer allowed close. `authenticate(request, ctx) => P | undefined` is the app's one hook (D3: who a
request is, and which credential wins, is app policy; no Chord registry of resolvers); `headers` are added to every
response, also those no route makes; `links.path` (`/chord`) upgrades to `host.accept(webSocketLink(ws), { principal,
limits })`, a socket without a principal refused. `ws` is an optional peer dependency (D7).
The registry `Http` (`"chord.http"`) is defined by the component and served by its own facet (`http.facet`, loaded
with the plugins, D1); plugins contribute `routes`, `wraps` and `sockets`. A route is `(req, ctx) =>
Promise<HttpResponse>`, run in a `host.root` scope with the request's principal (`ctx.principal`) and a signal aborted
when the client disconnects; `req` has `method`, `path`, `params`, `query`, `headers`, `origin`, `json(limit?)` and
`body(limit?)` (D4: nothing read until asked; 1 MiB by default; past the limit 413). Patterns (D8): exact segments,
`:name`, a trailing `/*`; the most specific match wins, segment by segment from the left; one pattern from two
contributions is a reported conflict, the first by plugin name wins; `HEAD` falls back to `GET`. A request uses the
router as it was when it started.
The core is one Web-standard handler built from the registry `Http` (decision "The HTTP core is a fetch handler"):
```ts
type HttpResponse = { readonly headers?: Readonly<Record<string, string>> } & ( // D5
const http = createHttp(server, { // settings are arguments, fixed for the component's life
listen: { host: "127.0.0.1", port: 8788 }, // Chord's own Node listener; none: the app mounts `fetch`
links: { path: "/chord", options: (request, principal) => ({ limits, maxPayload }) },
closeTimeout: 30_000,
onError, // HttpError
}, ctx);
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, appCore], modules }, ctx);
// an app with its own server: hono.all("/*", (c) => http.fetch(c.req.raw, { localAddress, localPort, remoteAddress }))
// plain Node: nodeRequest(message, res) converts with the listener's checks; server.on("upgrade", http.upgrade)
await http.close(ctx); // before host.close: drains running requests
```
- **The facet** (plugin `chord.http`, passed as the same object at every reload) serves the registries `Http`
(`compose` builds the router) and `Authenticators`, and with `listen` the listener: a host-lifetime object whose
native is Chord's Node server, opened right after `load`'s publication, so a reload never closes it (decisions "The
HTTP server is a native object", "Settings are arguments, extensions are registries"). `fetch` resolves the
registries once; requests before the publication wait (503 past `readyTimeout`).
- **Contributions to `Http`:** `{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }`, a value
or a writable (a change recomposes the router). A request uses the router as it was when it started.
- **The security baseline** (decision "The HTTP component's security baseline", on for every request and upgrade
before any plugin code): (1) **provenance**: local only when the connection's local address is loopback, `Host` is
a loopback name with the port and no `Forwarded`/`X-Forwarded-*` header; a forwarded request on loopback only
through a contributed relay whose `verify` names its origin (`{ kind: "relayed", relay, origin }`); otherwise only a
contributed host (https); duplicate or missing `Host`, userinfo in it, absolute-form targets: 403; an unparsable
target 400; `TRACE`, `TRACK`, `CONNECT` 501 (a route for them is refused at compose). A mounted handler without
`localAddress` and `localPort` is never local. (2) **Foreign requests** (Fetch Metadata) never reach the
authenticators: anonymous, except a top-level `GET` navigation to a `page: true` route. (3) A foreign `Origin` is
403; an unsafe method from a browser needs the app's own `Origin`; a `crossSite: true` route opts out and always
runs anonymous. (4) **Default headers** on every answer, the component's own included: `no-store`, `nosniff`,
`no-referrer`, `X-Frame-Options: DENY` (without framers), a CSP (`default-src 'none'`, scripts `'self'` and a
per-response nonce `request.nonce`, `connect-src` self and its WebSocket, `frame-ancestors` the framers or
`'none'`, plus contributed `csp` sources); a response's own headers replace same-named ones. (5) `cookie(name,
value, { secret, request, sameSite?, maxAge? })` and `readCookie(request, name, secret)`: `HttpOnly`, `Path=/`,
`SameSite=Strict` or `Lax`, `Secure` on a remote origin, value and expiry signed. (6) `bytes` refuse foreign
requests, are attachments unless `inline` (then with a `sandbox` CSP), download names stripped of control and
bidirectional characters.
- **Who:** the `Authenticators` registry, in contributor order (an app needing precedence contributes one that
decides); the first principal wins, a throwing one is reported and skipped, none: anonymous. They run raced against
the request's signal and stop once it aborts. Who someone is stays app policy (D3); the registry replaces D3's
single function so plugins (a device pairing, a share) can add credentials.
- **A request's scope:** one `host.root` with its principal (an explicit `undefined`: anonymous; an anonymous scope
gets no remote object served here, `forbidden` → 403) spans matching, wraps, the route and the response until its
body is written or abandoned; its signal aborts on disconnect. `HttpRequest`: `method`, `path`, `params`, `query`,
`headers`, `origin`, `provenance`, `nonce`, `json(limit?)` (1 MiB; 400, 413), `body(limit?)`.
- **Patterns** (D8): exact segments, `:name`, a trailing `/*`; decoded segments with `/`, `\`, NUL, `.` or `..` are
400; the most specific match wins segment by segment; a pattern from two contributions is a reported conflict, the
first by plugin name wins; `HEAD` falls back to `GET`; no route: 404. A route is a function or `{ handler, page?,
crossSite? }`. Wraps (D2) as below.
```ts
type HttpResponse = { readonly headers?: Readonly<Record<string, string | readonly string[]>> } & ( // D5
| { kind: "json"; value: JsonValue; status?: number } | { kind: "html"; body: string; status?: number }
| { kind: "bytes"; mime: string; size?: number; read(offset, length, ctx): Promise<Uint8Array>; download?: string; ranges?: boolean } // no size: chunked (R6)
| { kind: "bytes"; mime: string; size?: number; read(offset, length, ctx): Promise<Uint8Array>; download?: string; inline?: true; ranges?: boolean }
| { kind: "redirect"; to: string; status?: 302 | 303 | 307 } | { kind: "error"; status: number; message?: string });
```
The component does ranges (206/416), `HEAD`, safe download headers, 64 KiB reads with backpressure stopped on
disconnect, and a 500 without the message for a throwing route; `fileResponse(path, options)` serves a file.
`bytes` are pulled in 256 KiB reads as the consumer reads (ranges 206/416, `HEAD`, chunked without `size`, R6); no
read starts once the request's scope is sealed; a short or throwing read destroys the connection. 204, 205 and 304
carry no body. `fileResponse(path, { download?, inline?, ranges?, mime?, root? })` stats at once (missing: 404; with
`root`, a real path outside it: 403), opens its handle at the first read, closes it when the scope seals. A throwing
route or wrap is a 500 without its message.
```ts
type Wrap = {
@ -2152,26 +2190,47 @@ type Wrap = {
Wraps (D2) run within a phase in contributor order (no numeric priorities: plugins have no single registration
order; Fastify's lifecycle phases and encapsulation are the model). Wraps may add a security boundary on top of a
route's checks, never instead of them. `sockets` (D6): `{ "/terminal": (socket, req, ctx) => … }`, a `ws` WebSocket
after the origin check and `authenticate` (no principal: 403 before plugin code), for a plugin's own protocol; wraps do
not apply; closed (1012) when the contribution that handed them out leaves the table (§17).
route's checks, never instead of them.
- **Sockets** (D6): `sockets: { "/terminal/:id": (socket, request, ctx) => … }`, a `ws` WebSocket after admission
and the authenticators (no principal: 403 before plugin code), `maxPayload` per contribution (1 MiB), pinged after
15 s of silence and terminated 15 s later. **Links:** `links.path` upgrades to `host.accept(webSocketLink(socket), {
principal, limits })`; a client that stops reading is cut off past 4 MiB waiting. **What is removed is closed at
once** (decisions Q6, "allowed hosts only through contributions"): a link or socket whose host, relay or socket
handler leaves the router is terminated and its scope sealed; an upgrade is checked again against the current router
after its authenticators answered.
- **`close(ctx)`:** new requests and upgrades refused (503), sockets and links closed (1001, terminated at
`closeTimeout`), running requests waited for up to `closeTimeout`, then sealed; before `host.close`.
- **Cost** (decision "a route's overhead accepted"): +20-21 µs per request over a plain Node handler (standard bench),
+12-13 µs warmed. `ws` is an optional peer dependency (D7).
### Workers (`@earendil-works/chord/next/workers`)
`createWorkers(host, Kind, { kind, entry, start, plugins, lock, idleMs?, onOutput?, onError }, ctx)` routes `Kind`
itself (a forked worker per address over IPC, the link made at the fork, a start message with the app's start data and
the worker kind's complete scripts; the 60 s start timer covers fork, lock and connect only, Q4), with a `state`
watchable per address, `stop`, `retry` and `close`; `workerMain(fn, { hostPackages, redact, onError })` is the child's
side. One live worker per address (a kernel lock the child takes, waiting for a draining old worker without polling).
Keep-alive (D9, R3, Q5): the child reports when `host.activity`'s references, jobs and lingering objects and the app's
`busy` counts flip between all zero and not (local calls do not count; the first state with `ready`); the server stops
a worker idle (all zero) for `idleMs` (5 min). Workers start on demand and never restart by themselves (R2): a crash,
a `fatal`, a link lost while its child lives (killed after the grace) or a failed start leaves the address failed with
its error; its holders see `failed { waiting }` and stay on the path (Q1); `workers.retry(address)` or a rollout
reaching workers clears it and starts the held ones (Q2). `workers.stop` suspends the address while it has holders
(R1, Q3). Workers never build (D11): the server's plugins component builds every plugin; workers evaluate shipped
complete sets (`createPluginRuntime`, no esbuild), and `workers.step(name)` ships a rollout's set to every worker (D12:
one result per host; a worker stopped cleanly during it is listed `stopped`).
`createWorkers(host, Kind, { kind, entry, execArgv?, env?, authorize, principal, start, lock, idleMs?, plugins,
onOutput?, onError }, ctx)` routes `Kind` itself (`host.route`, keeping its `RouteHandle`): a forked worker per
address over IPC, the link made at the fork, a start message `{ address, start, scripts, lock }` with the app's start
data and the kind's complete current scripts (`plugins.scripts(kind)`); the 60 s start timer covers fork, lock and
connect only (Q4). `workers.state` (a JSON watchable per address key), `stop`, `retry`, `step(name)`, `close`;
`workerMain(fn, { hostPackages, redact, onError })` is the child's side (`fn({ address, start, parent, plugins,
onClose }, ctx)`, `plugins` a `WorkerPlugins` evaluating the shipped scripts, closers in reverse order). One live
worker per address (`processLock`: an abstract socket on Linux, a named pipe on Windows, a socket file elsewhere that
fails closed when stale; a start waits for a draining old holder without polling).
- **Keep-alive** (D9, R3, Q5): the child reports when `host.activity`'s references, jobs and lingering objects and the
app's `busy` counts flip between all zero and not (local calls do not count; the first state with `ready`, which
also carries the load report); the server stops a worker idle for `idleMs` (5 min).
- **Never restarted by itself** (R2): a crash, a `fatal`, a link lost while its child lives (killed after a 5 s grace)
or a failed start leaves the address failed; its holders see `failed { waiting }` and stay on the path (Q1);
`retry(address)` or a rollout reaching workers clears it and starts the held ones (Q2). `stop` suspends the address
while it has holders (R1, Q3).
- **Errors in public and private** (decision "worker errors in public"): `state` and holders get a fixed message
(`worker failed to start`, `worker exited (SIGSEGV)`) or the child's redacted `fatal`; the full error and the stderr
tail go only to `onError` (`worker_failed`). Every app hook of a start (`start`, `principal`, `lock`, the fork) is
inside that boundary; a throwing `authorize` refuses the hold and is reported `authorize_failed`. `worker_exited` is
reported only for an exit nobody asked for.
- **Workers never build** (D11): the server's plugins component builds every plugin; workers evaluate shipped
complete sets (`createPluginRuntime`, no esbuild); `workers.step(name)` ships a rollout's set to every worker (D12:
`{ host, report } | { host, stopped: true } | { host, error }` per worker).
### Channels

View file

@ -11,8 +11,7 @@ stats:
note: |
Written by the redesign session from `slice-10.md`, the decision entries "Slice 10's points", "Origins only from
relays", "The HTTP component's security baseline" and their neighbours in `decisions.md`, design §3 and §23, and the
build in `/home/badlogic/pi-7b`. The build exists; its fix round (the security baseline among it) is next. Where the
build and the decisions differ, this book says what is decided.
build after its four fix rounds (security baseline, `fetch` core, error boundaries).
colophon: Serving and Spawning · first edition · 2026-10-31 · Chord v2 slice 10
---
@ -75,13 +74,14 @@ const plugins = createPlugins({
},
onError,
}, ctx);
const http = await httpServer(server, { listen: { host: "127.0.0.1", port: 8788 }, authenticate, onError }, ctx);
const http = createHttp(server, { listen: { host: "127.0.0.1", port: 8788 }, onError }, ctx); // settings are arguments
const workers = createWorkers(server, Session, {
kind: "worker", entry: "./worker.ts", plugins, principal: (a) => ({ kind: "worker", session: a.params.id }),
start: (address, ctx) => ({ id: address.params.id, dir: sessionDir(address) }),
lock: (address) => `chat:${dataDir}:${address.key}`, authorize: mayReach, onError,
}, ctx);
await plugins.load(server, { kind: "server", core: [core, { name: "chord.http", facet: http.facet }], modules }, ctx);
// `core` contributes the app's authenticator: self.contribute(Authenticators, authenticate)
```
```ts
@ -198,27 +198,29 @@ A route that throws is a 500 without its message: error text often holds paths o
# Who Is Asking {#who lede="The app's one security function, and everything the component does so the app does not have to."}
The app writes exactly one function about identity:
The app writes one function about identity and contributes it to the registry `Authenticators` (plugins may add
more, a device pairing for instance; the first principal wins):
```ts
async function authenticate(request: AuthRequest, ctx: Context): Promise<P | undefined> {
const device = verifyCookie(request.cookies.device, secret);
if (device) return { kind: "owner", access: "device", device: device.id };
const guest = verifyCookie(request.cookies.guest, secret);
if (guest) return { kind: "guest", session: guest.session, grant: guest.id };
const authenticate: Authenticator = async (request, ctx) => {
const device = readCookie(request, "device", secret); // signature and expiry checked
if (device) return { kind: "owner", access: "device", device };
const guest = readCookie(request, "guest", secret);
if (guest) return { kind: "guest", grant: guest };
if (request.provenance.kind === "local") return { kind: "owner", access: "local" };
return undefined; // anonymous
}
return undefined; // anonymous
};
self.contribute(Authenticators, authenticate); // in the app's core facet
```
It never parses `Host`, never checks `Origin` and never sees a foreign request: the component already did all of it.
It may read `request.provenance` (`local`, `remote` with the host, or `relayed` through a verified relay such as
Radius). Cookies are written with the component's helper, which sets `HttpOnly`, `SameSite`, `Secure` on a remote
origin and a signature.
Radius). Cookies are written with `cookie(name, value, { secret, request, maxAge })`, which sets `HttpOnly`,
`SameSite`, `Secure` on a remote origin, and signs the value with its expiry.
Every response, the component's own 404s and 403s included, carries the same headers: no caching, no MIME sniffing, no
referrer, no framing (`X-Frame-Options: DENY` and `frame-ancestors 'none'`, except for framers the app allowed), and a
Content Security Policy with a per-server script nonce, so an injected `<script>` does not run.
Content Security Policy with a fresh script nonce per response (`request.nonce`), so an injected `<script>` does not run.
| Attack | What stops it |
| --- | --- |
@ -230,10 +232,8 @@ Content Security Policy with a per-server script nonce, so an injected `<script>
| a path like `/files/..%2F..%2Fsecret` | decoded `/`, `.`, `..`, `\` in a path segment are 400 |
| a crafted request target crashes the server | the target is parsed by the component, a failure is 400 |
:::note | Decided, not yet built
The security baseline was decided on 2026-10-31 after the security audit and comes with the build's fix round. The
build as reviewed checks `Host` and `Origin` on upgrades only.
:::
The core is a Web `fetch` handler, so an app with its own server (Hono, Express, plain Node with `nodeRequest`) mounts
it and gets the same checks; without the socket's addresses a mounted request is never local.
# Sockets and Links {#sockets lede="Chord's own WebSocket for tabs, a plugin's own WebSocket for its protocol, and what closes them."}
@ -346,29 +346,30 @@ self.contribute(Http, hosts);
self.on(tunnel.publicHost, ({ value }) => hosts.set({ hosts: value ? [value] : [] }));
```
Functions the components call at each use (`authenticate`, the security headers' extra sources, a worker's start
data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
Authenticators, framers and extra CSP sources are contributions too; functions the workers component calls at each
use (a worker's start data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
bootstrap: the port, the `/chord` path, the worker's entry file and its Node flags.
# What's Next {.epilogue lede="The fix round, and the decisions it carries."}
# What's Next {.epilogue lede="What the reviews found, and what comes after this slice."}
The build exists and passed its tests; four build reviews and three security audits found the issues the fix round now
addresses: a malformed request crashing the server, the `Host`-only provenance, origins invented by a tab (a kernel
hole since slice 4a, now refused on tab links), secrets reaching guests through worker errors, several close and
shutdown races in workers and HTTP, and two leaks. With it come the decisions of 2026-10-31: the security baseline,
relays, allowed hosts only through contributions, public worker errors as fixed messages.
Four build reviews, three security audits and three rounds of rechecks shaped the slice. They found a malformed request
crashing the server, `Host`-only provenance, origins invented by a tab (a kernel hole since slice 4a, now refused on tab
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
what it buys: a scope per request, admission and the baseline headers.
Still to decide: `fileResponse` and symbolic links leading out of a served directory; the worker lock on macOS, where
a stale lock file cannot be taken over safely; and a route's overhead, measured at 11 to 19 percent of a plain Node
handler against a bar of 10 percent.
Next comes the app kit (`chord/next/app`), which wires these components with Chord's conventions so an app writes
`startApp` and `startWorker`, then slice 11: the browser page and the terminal UI as hosts.
# API in Brief {.appendix}
| Call | What it does |
| --- | --- |
| `httpServer(host, { listen, authenticate, links?, closeTimeout?, onError }, ctx)` | listens, admits, serves `Http` |
| `createHttp(host, { listen?, links?, closeTimeout?, onError }, ctx)` | the component: `facet`, `fetch`, `upgrade`, `close` |
| `Authenticators` contribution | `(request, ctx) => P \| undefined`: who a request is |
| `nodeRequest(message, res)` | a Node request as a Web `Request`, with the listener's checks |
| `http.facet` | the facet serving `Http`; load it with the plugins (in `core`) |
| `Http` contribution | `{ routes?, wraps?, sockets?, hosts?, framers?, relays? }` |
| `Http` contribution | `{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }` |
| route | `(request, ctx) => Promise<HttpResponse>`, or `{ handler, page?, crossSite? }` |
| `fileResponse(path, options)`, `cookie(name, value, options)` | a file as bytes; a safe cookie |
| `createWorkers(host, Kind, { kind, entry, start, lock, principal, authorize, plugins, idleMs?, onError }, ctx)` | a worker per address |

View file

@ -115,8 +115,7 @@ table{max-width:100%}
<div class="cover-stats"><div class="stat"><b>1</b><span>registry plugins contribute routes, wraps, sockets and hosts to</span></div><div class="stat"><b>0</b><span>automatic worker restarts</span></div><div class="stat"><b>5 min</b><span>idle before a worker stops (default)</span></div><div class="stat"><b>6</b><span>security rules on by default</span></div></div>
<div class="cover-note"><p>Written by the redesign session from <code>slice-10.md</code>, the decision entries &quot;Slice 10&#39;s points&quot;, &quot;Origins only from
relays&quot;, &quot;The HTTP component&#39;s security baseline&quot; and their neighbours in <code>decisions.md</code>, design §3 and §23, and the
build in <code>/home/badlogic/pi-7b</code>. The build exists; its fix round (the security baseline among it) is next. Where the
build and the decisions differ, this book says what is decided.</p>
build after its four fix rounds (security baseline, <code>fetch</code> core, error boundaries).</p>
</div>
</section><div class="inner">
<nav class="toc"><h2>Contents</h2><ol>
@ -185,13 +184,14 @@ workers component, then loads its plugins:</p>
},
onError,
}, ctx);
<span class="tk-k">const</span> http = <span class="tk-k">await</span> <span class="tk-f">httpServer</span>(server, { listen: { host: <span class="tk-s">"127.0.0.1"</span>, port: <span class="tk-n">8788</span> }, authenticate, onError }, ctx);
<span class="tk-k">const</span> http = <span class="tk-f">createHttp</span>(server, { listen: { host: <span class="tk-s">"127.0.0.1"</span>, port: <span class="tk-n">8788</span> }, onError }, ctx); <span class="tk-c">// settings are arguments</span>
<span class="tk-k">const</span> workers = <span class="tk-f">createWorkers</span>(server, <span class="tk-t">Session</span>, {
kind: <span class="tk-s">"worker"</span>, entry: <span class="tk-s">"./worker.ts"</span>, plugins, principal: (a) =&gt; ({ kind: <span class="tk-s">"worker"</span>, session: a.params.id }),
start: (address, ctx) =&gt; ({ id: address.params.id, dir: <span class="tk-f">sessionDir</span>(address) }),
lock: (address) =&gt; <span class="tk-s">`chat:${dataDir}:${address.key}`</span>, authorize: mayReach, onError,
}, ctx);
<span class="tk-k">await</span> plugins.<span class="tk-f">load</span>(server, { kind: <span class="tk-s">"server"</span>, core: [core, { name: <span class="tk-s">"chord.http"</span>, facet: http.facet }], modules }, ctx);</code></pre>
<span class="tk-k">await</span> plugins.<span class="tk-f">load</span>(server, { kind: <span class="tk-s">"server"</span>, core: [core, { name: <span class="tk-s">"chord.http"</span>, facet: http.facet }], modules }, ctx);
<span class="tk-c">// `core` contributes the app's authenticator: self.contribute(Authenticators, authenticate)</span></code></pre>
<pre><code><span class="tk-c">// worker.ts: one per session</span>
<span class="tk-k">await</span> <span class="tk-f">workerMain</span>(<span class="tk-k">async</span> ({ address, start, parent, plugins, onClose }, ctx) =&gt; {
<span class="tk-k">const</span> store = <span class="tk-k">await</span> <span class="tk-f">openStore</span>(<span class="tk-f">sqliteBackend</span>(<span class="tk-k">await</span> <span class="tk-f">openNodeSqliteDatabase</span>(<span class="tk-f">join</span>(start.dir, <span class="tk-s">"session.sqlite"</span>))), ctx);
@ -266,22 +266,24 @@ chunks for an upload streamed to disk. A body past its limit gets 413; nothing i
</section>
<section class="chapter" id="who"><header class="opener"><div class="numeral">IV</div><h2>Who Is Asking</h2><p class="lede">The app&#39;s one security function, and everything the component does so the app does not have to.</p></header>
<p class="dropcap">The app writes exactly one function about identity:</p>
<pre><code><span class="tk-k">async</span> <span class="tk-k">function</span> <span class="tk-f">authenticate</span>(request: <span class="tk-t">AuthRequest</span>, ctx: <span class="tk-t">Context</span>): <span class="tk-t">Promise</span>&lt;P | <span class="tk-k">undefined</span>&gt; {
<span class="tk-k">const</span> device = <span class="tk-f">verifyCookie</span>(request.cookies.device, secret);
<span class="tk-k">if</span> (device) <span class="tk-k">return</span> { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"device"</span>, device: device.id };
<span class="tk-k">const</span> guest = <span class="tk-f">verifyCookie</span>(request.cookies.guest, secret);
<span class="tk-k">if</span> (guest) <span class="tk-k">return</span> { kind: <span class="tk-s">"guest"</span>, session: guest.session, grant: guest.id };
<p class="dropcap">The app writes one function about identity and contributes it to the registry <code>Authenticators</code> (plugins may add
more, a device pairing for instance; the first principal wins):</p>
<pre><code><span class="tk-k">const</span> authenticate: <span class="tk-t">Authenticator</span> = <span class="tk-k">async</span> (request, ctx) =&gt; {
<span class="tk-k">const</span> device = <span class="tk-f">readCookie</span>(request, <span class="tk-s">"device"</span>, secret); <span class="tk-c">// signature and expiry checked</span>
<span class="tk-k">if</span> (device) <span class="tk-k">return</span> { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"device"</span>, device };
<span class="tk-k">const</span> guest = <span class="tk-f">readCookie</span>(request, <span class="tk-s">"guest"</span>, secret);
<span class="tk-k">if</span> (guest) <span class="tk-k">return</span> { kind: <span class="tk-s">"guest"</span>, grant: guest };
<span class="tk-k">if</span> (request.provenance.kind === <span class="tk-s">"local"</span>) <span class="tk-k">return</span> { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"local"</span> };
<span class="tk-k">return</span> <span class="tk-k">undefined</span>; <span class="tk-c">// anonymous</span>
}</code></pre>
<span class="tk-k">return</span> <span class="tk-k">undefined</span>; <span class="tk-c">// anonymous</span>
};
self.<span class="tk-f">contribute</span>(<span class="tk-t">Authenticators</span>, authenticate); <span class="tk-c">// in the app's core facet</span></code></pre>
<p>It never parses <code>Host</code>, never checks <code>Origin</code> and never sees a foreign request: the component already did all of it.
It may read <code>request.provenance</code> (<code>local</code>, <code>remote</code> with the host, or <code>relayed</code> through a verified relay such as
Radius). Cookies are written with the component&#39;s helper, which sets <code>HttpOnly</code>, <code>SameSite</code>, <code>Secure</code> on a remote
origin and a signature.</p>
Radius). Cookies are written with <code>cookie(name, value, { secret, request, maxAge })</code>, which sets <code>HttpOnly</code>,
<code>SameSite</code>, <code>Secure</code> on a remote origin, and signs the value with its expiry.</p>
<p>Every response, the component&#39;s own 404s and 403s included, carries the same headers: no caching, no MIME sniffing, no
referrer, no framing (<code>X-Frame-Options: DENY</code> and <code>frame-ancestors &#39;none&#39;</code>, except for framers the app allowed), and a
Content Security Policy with a per-server script nonce, so an injected <code>&lt;script&gt;</code> does not run.</p>
Content Security Policy with a fresh script nonce per response (<code>request.nonce</code>), so an injected <code>&lt;script&gt;</code> does not run.</p>
<table>
<thead>
<tr>
@ -318,9 +320,8 @@ Content Security Policy with a per-server script nonce, so an injected <code>&lt
<td>the target is parsed by the component, a failure is 400</td>
</tr>
</tbody></table>
<div class="note"><b class="tag">Decided, not yet built</b><p>The security baseline was decided on 2026-10-31 after the security audit and comes with the build&#39;s fix round. The
build as reviewed checks <code>Host</code> and <code>Origin</code> on upgrades only.</p>
</div>
<p>The core is a Web <code>fetch</code> handler, so an app with its own server (Hono, Express, plain Node with <code>nodeRequest</code>) mounts
it and gets the same checks; without the socket&#39;s addresses a mounted request is never local.</p>
</section>
<section class="chapter" id="sockets"><header class="opener"><div class="numeral">V</div><h2>Sockets and Links</h2><p class="lede">Chord&#39;s own WebSocket for tabs, a plugin&#39;s own WebSocket for its protocol, and what closes them.</p></header>
@ -392,20 +393,19 @@ writable, and the router recomposes on each change:</p>
<pre><code><span class="tk-k">const</span> hosts = self.<span class="tk-f">writable</span>({ hosts: [] <span class="tk-k">as</span> string[] });
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, hosts);
self.<span class="tk-f">on</span>(tunnel.publicHost, ({ value }) =&gt; hosts.<span class="tk-f">set</span>({ hosts: value ? [value] : [] }));</code></pre>
<p>Functions the components call at each use (<code>authenticate</code>, the security headers&#39; extra sources, a worker&#39;s start
data, its idle time) can read the app&#39;s own registries, which plugins contribute to. What needs a restart is only the
<p>Authenticators, framers and extra CSP sources are contributions too; functions the workers component calls at each
use (a worker&#39;s start data, its idle time) can read the app&#39;s own registries, which plugins contribute to. What needs a restart is only the
bootstrap: the port, the <code>/chord</code> path, the worker&#39;s entry file and its Node flags.</p>
</section>
<section class="chapter epilogue" id="what-s-next"><header class="opener"><div class="numeral">∴</div><h2>What&#39;s Next</h2><p class="lede">The fix round, and the decisions it carries.</p></header>
<p class="dropcap">The build exists and passed its tests; four build reviews and three security audits found the issues the fix round now
addresses: a malformed request crashing the server, the <code>Host</code>-only provenance, origins invented by a tab (a kernel
hole since slice 4a, now refused on tab links), secrets reaching guests through worker errors, several close and
shutdown races in workers and HTTP, and two leaks. With it come the decisions of 2026-10-31: the security baseline,
relays, allowed hosts only through contributions, public worker errors as fixed messages.</p>
<p>Still to decide: <code>fileResponse</code> and symbolic links leading out of a served directory; the worker lock on macOS, where
a stale lock file cannot be taken over safely; and a route&#39;s overhead, measured at 11 to 19 percent of a plain Node
handler against a bar of 10 percent.</p>
<section class="chapter epilogue" id="what-s-next"><header class="opener"><div class="numeral">∴</div><h2>What&#39;s Next</h2><p class="lede">What the reviews found, and what comes after this slice.</p></header>
<p class="dropcap">Four build reviews, three security audits and three rounds of rechecks shaped the slice. They found a malformed request
crashing the server, <code>Host</code>-only provenance, origins invented by a tab (a kernel hole since slice 4a, now refused on tab
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
what it buys: a scope per request, admission and the baseline headers.</p>
<p>Next comes the app kit (<code>chord/next/app</code>), which wires these components with Chord&#39;s conventions so an app writes
<code>startApp</code> and <code>startWorker</code>, then slice 11: the browser page and the terminal UI as hosts.</p>
</section>
<section class="chapter appendix" id="api-in-brief"><header class="opener"><div class="numeral">A</div><h2>API in Brief</h2></header>
@ -417,8 +417,16 @@ handler against a bar of 10 percent.</p>
</tr>
</thead>
<tbody><tr>
<td><code>httpServer(host, { listen, authenticate, links?, closeTimeout?, onError }, ctx)</code></td>
<td>listens, admits, serves <code>Http</code></td>
<td><code>createHttp(host, { listen?, links?, closeTimeout?, onError }, ctx)</code></td>
<td>the component: <code>facet</code>, <code>fetch</code>, <code>upgrade</code>, <code>close</code></td>
</tr>
<tr>
<td><code>Authenticators</code> contribution</td>
<td><code>(request, ctx) =&gt; P | undefined</code>: who a request is</td>
</tr>
<tr>
<td><code>nodeRequest(message, res)</code></td>
<td>a Node request as a Web <code>Request</code>, with the listener&#39;s checks</td>
</tr>
<tr>
<td><code>http.facet</code></td>
@ -426,7 +434,7 @@ handler against a bar of 10 percent.</p>
</tr>
<tr>
<td><code>Http</code> contribution</td>
<td><code>{ routes?, wraps?, sockets?, hosts?, framers?, relays? }</code></td>
<td><code>{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? }</code></td>
</tr>
<tr>
<td>route</td>

View file

@ -1,6 +1,6 @@
# Slice 10: HTTP and workers (spec)
Status: **approved, building** (owner, 2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the
Status: **built** (2026-10-31; build reviews by Fable, Opus, Astra and Sol, security audits by Fable, Opus and Astra, four fix rounds with rechecks; the shape after the fix rounds is in §2, §3 and design §23). Approved by the owner (2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the
decision "Slice 10's points"; spec reviews round 1 by Fable and Opus, `/tmp/s10-spec-review-{fable,opus}.md`, round 2
by Fable, Opus, Astra and Sol, `/tmp/s10-spec-review2-*.md`, folded in).
Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): `chord/next/http` (the
@ -39,119 +39,48 @@ and port, the links path, the worker entry with its `execArgv` and `env`, the ho
## 2. `chord/next/http`
As built after the fix rounds (decisions "The HTTP core is a fetch handler", "The HTTP server is a native object",
"Settings are arguments, extensions are registries", "The HTTP component's security baseline", "allowed hosts only
through contributions", "A CSP nonce per response", "three small points", "a route's overhead accepted"); design §23
states every rule, `src/next-node/http/README.md` the API.
```ts
const http = await httpServer(server, {
listen: { host: "127.0.0.1", port: 8788 }, // bootstrap
allowedHosts: () => app.allowedHosts(), // besides loopback and contributed `hosts` (R4)
authenticate: (request, ctx) => app.principalOf(request, ctx), // D3: the app's policy, per request and upgrade
headers: (request, principal) => app.securityHeaders(request, principal), // every response, also a 404 or a 403
const http = createHttp(server, {
listen: { host: "127.0.0.1", port: 8788 }, // settings: arguments, a restart to change
links: { path: "/chord", options: (request, principal) => ({ limits: linkLimits(principal), maxPayload: 64 * MiB }) },
closeTimeout: 30_000, // `close`'s bound on running requests
closeTimeout: 30_000,
onError: (error) => log(error), // `HttpError`, below
}, ctx);
// the component's facet goes into the plugins component's core, so every reload passes it unchanged (7c)
await plugins.load(server, { kind: "server", core: [{ name: "@app/core", facet: core }, { name: "chord.http", facet: http.facet }] }, ctx);
const app = defineFacet((_host, self) => {
self.contribute(Authenticators, (request, ctx) => principalFromCookie(readCookie(request, "session", secret)));
self.contribute(Http, { hosts: ["notes.example.com"], relays: [radiusRelay] });
});
await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, { name: "@app/core", facet: app }], modules }, ctx);
…
await http.close(ctx); // first (below): no new requests, sockets closed, running requests waited for up to `closeTimeout`
await http.close(ctx); // first: no new requests, sockets closed, running requests waited for up to `closeTimeout`
await workers.close(ctx);
await server.close(ctx);
```
- **Order:** `httpServer` listens at once (plugins see the real port; a page reconnecting while plugins load is not
lost). The component resolves `host.registry(Http, ctx)` once (the publication and the router's first `compose`);
requests and upgrades arriving before it wait for it (503 after `readyTimeout`), later ones read the router's `.value`
per request (no getter, timer or listener per request). Nothing waits for the start barrier: a route's `ctx.get`
waits for its object, and `/chord` holds are placed by the publication gate. The plugin name `chord.http` is the
component's.
- **Checks before anything else** (R4), once per request and per upgrade at its start, against the router of that
moment: the `Host` header is loopback (`127.0.0.1:<port>`, `localhost:<port>`, `[::1]:<port>`, without a port when it
is 80) or allowed (`allowedHosts()` plus every contribution's `hosts`), else 403; an upgrade's `Origin`, when sent,
equals the request's origin, else 403; `request.origin` is built only from an accepted `Host` (`http://` on loopback,
`https://` for an allowed host). Then `authenticate`. A hook that throws (`allowedHosts`, `authenticate`, `headers`,
`links.options`) is a 500, or a refused upgrade (403), reported `hook_failed { hook, error }`.
- **`Http`** (D1): the registry `"chord.http"` (version 1), defined and served by the component's own facet; its
`compose` builds the router from every contribution `{ routes?, wraps?, sockets?, hosts? }`; a contribution may be a
writable (a plugin that adds and removes its tunnel host at runtime). Each request uses the router as it was when it
started.
```ts
// plugins/notes/server.ts
export default defineFacet((host, self) => {
self.contribute(Http, {
routes: {
"GET /notes/:id/export": async (request, ctx) => {
if (ctx.principal?.kind !== "owner") return { kind: "error", status: 403 };
const notes = await ctx.get(NoteStore, {});
return { kind: "json", value: await notes.export({ id: request.params.id }, ctx) };
},
"GET /notes/template.md": async () => fileResponse(pluginFile("template.md"), { download: "template.md" }),
},
wraps: [{ name: "audit", phase: "observe", wrap: async (request, next, ctx) => audited(await next(request), ctx) }],
sockets: { "/terminal": (socket, request, ctx) => terminals.attach(socket, ctx) },
});
});
```
- **A request's scope:** one `host.root` scope with the request's principal spans matching, wraps, the route and the
whole write of its response; its signal aborts when the client disconnects. `ctx.principal` is `undefined` when
`authenticate` found none: the kernel's `host.root(fn, ctx, { principal: undefined })` runs anonymous (§6), never as
the server. A socket handler's scope lasts until the socket closes.
- **`HttpRequest`:** `method`, `path`, `params`, `query` (`URLSearchParams`), `headers` (lowercased, read-only),
`origin`, `json(limit?)`, `body(limit?)` (D4). `json` parses at most `limit` bytes (1 MiB by default) into a
`JsonValue`; `body` is an async iterable of `Uint8Array` chunks, failing past `limit`. Both throw an `HttpFailure`
carrying its status (400 malformed, 413 too large); if it escapes the route, that status is the answer (not 500). A
body nobody read is drained up to 64 KiB after the response, else the connection closes (`Connection: close`).
- **Patterns** (D8): exact segments, `:name` (one non-empty segment, percent-decoded) and a trailing `/*` (the rest,
possibly empty: `/files/*` matches `/files`); `params["*"]` is decoded per segment, and a segment that decodes to
contain `/` or is `.` or `..` is answered 400. The most specific match wins, segment by segment from the left (exact
before `:name` before `*`); one pattern from two contributions is a reported conflict (`registry_conflict`), the first
by plugin name wins. `HEAD` falls back to the `GET` route. No route: 404.
- **Wraps** (D2): `{ name, phase, scope?, route?, wrap(request, next, ctx) }` in the phases `observe`, `headers`,
`transform` (outermost first), within a phase in contributor order (plugin name, then type); `scope: "own"` (default:
around the contributing plugin's own routes) or `"all"`; `route` narrows to one pattern (a missing one is a reported
conflict). A wrap adds a boundary on top of a route's checks, never instead of them. A throwing wrap is a 500,
reported naming its plugin.
- **Responses** (D5, R6):
```ts
type HttpResponse = { readonly headers?: Readonly<Record<string, string>> } & (
| { readonly kind: "json"; readonly value: JsonValue; readonly status?: number }
| { readonly kind: "html"; readonly body: string; readonly status?: number }
| { readonly kind: "bytes"; readonly mime: string; readonly size?: number;
readonly read: (offset: number, length: number, ctx: Ctx) => Promise<Uint8Array>;
readonly download?: string; readonly ranges?: boolean }
| { readonly kind: "redirect"; readonly to: string; readonly status?: 302 | 303 | 307 }
| { readonly kind: "error"; readonly status: number; readonly message?: string });
```
Headers: the `headers` option's first, then the response's own, replacing same-named ones (case-insensitive: an
artifact preview sets its own CSP). `bytes` with a `size`: `Content-Length`; with `ranges`, one range gets 206 and an
unsatisfiable one 416, a multi-range request gets 200 with the whole body; `HEAD` sends headers only; `download`
sets `Content-Disposition: attachment` with an ASCII fallback and `filename*`. `bytes` without a `size` (R6): chunked
encoding, no ranges, `read` called with growing offsets until it returns an empty chunk. Reads are 64 KiB with the
socket's backpressure (`drain`), stopped when the client disconnects; a `read` returning fewer bytes than asked with a
`size` set destroys the connection (reported). A throwing route is a 500 without its message (reported).
- **`fileResponse(path, { download?, ranges?, mime? })`:** `stat`s at once, opens its file handle on the first `read`
and closes it when the request's scope seals, so an unwritten response (`HEAD`, a 416, a disconnect, a wrap that
replaced it) opens nothing or leaks nothing. Without `mime` it picks from a small fixed table by extension (text,
images, audio, video), else `application/octet-stream`; active types (HTML, SVG) only when the route names them.
- **Sockets** (D6, Q6): `sockets: { "/terminal": (socket, request, ctx) => … }`, patterns as routes. After the checks
and `authenticate` (no principal: 403 before plugin code), the component upgrades and hands the handler a `ws`
WebSocket with an `error` listener already attached (an oversized frame closes it, never the server); per
contribution options `maxPayload` (1 MiB by default) and no `perMessageDeflate`. Wraps do not apply. When the
contribution that handed it out leaves the table (a reload changing or removing it), the component closes its
sockets (1012, "service restart"): old code does not keep serving them (design §17).
- **The links endpoint:** `links.path` (bootstrap) upgrades to `host.accept(webSocketLink(socket), { principal,
limits }, ctx)`, its `limits` and `maxPayload` from `links.options(request, principal)` per socket (a socket keeps
what it got); a per-socket output cap (p2's `limitedSocket`: a client that stops reading past 4 MiB unsent is cut off).
- **Allowed hosts change** (Q6): when the allowed set changes, links and sockets accepted through a `Host` no longer
allowed close (1008, "policy").
- **`close(ctx)`:** new requests and upgrades are refused (503), plugin sockets and links closed (1001), running
requests waited for up to `closeTimeout`, then their scopes sealed. Run it before `server.close`, whose seal would
otherwise cut running requests mid-write.
- **The core** is `http.fetch(request, { localAddress?, localPort?, remoteAddress? })`, a Web `Request` to a Web
`Response`; `http.upgrade` is Node's upgrade adapter; `nodeRequest(message, res)` converts a Node request with the
listener's checks (`{ kind: "request", request, connection } | { kind: "refused", status }`, never a throw) for apps
mounting on their own Node server. Chord's own listener (with `listen`) is a host-lifetime object of `chord.http`
whose native is a Node server, on a fast path that skips the Web `Request` unless a route or authenticator reads
it.
- **Registries:** `Http` (`"chord.http"`, version 1: `routes`, `wraps`, `sockets`, `maxPayload`, `hosts`, `framers`,
`relays`, `csp`; a value or a writable) and `Authenticators` (in contributor order, the first principal wins).
Requests before the publication wait for it (503 past `readyTimeout`).
- **Admission, foreign requests, origins, default headers, cookies, downloads:** the security baseline (design §23).
Refusals before any plugin code: a non-loopback socket without an allowed host, forwarded headers without a
verified relay, duplicate or missing `Host`, userinfo in `Host`, absolute-form targets (403), unparsable targets
(400), `TRACE`/`TRACK`/`CONNECT` (501; a route for them refused at compose).
- **A request's scope, `HttpRequest`, patterns, wraps, responses, `fileResponse`, sockets, links, revocation,
`close`:** design §23. The spec's earlier text (an `httpServer` that listens first, `allowedHosts`, one
`authenticate` and a `headers` option, 64 KiB reads) is superseded by those decisions.
- **Errors** (`onError`, `HttpError`): `route_failed { pattern, plugin, error }`, `wrap_failed { name, plugin, error }`,
`socket_failed { pattern, plugin, error }`, `response_failed { pattern, error }` (a short `read`, a write error),
`hook_failed { hook, error }`.
`socket_failed { pattern, plugin, error }`, `response_failed { pattern, error }`, `hook_failed { hook, plugin?,
error }` (`authenticate`, `relay`, `links.options`).
- `ws` is an optional peer dependency (D7), imported only here.
## 3. `chord/next/workers`
@ -230,8 +159,11 @@ await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
default; checked every second), never before `ready`. A stop (idle, `stop`, `close`): `shutdown`; the child aborts its
`ctx`, runs the closers registered with `onClose` in reverse order and exits, forcing its own exit after 3 s; the
server kills the tree after 5 s.
- **`state`** (R5): per address key `{ status: "starting" | "running" | "stopping", pid, startedAt }` or `{ status:
"failed", error: { message, code?, reason? }, at, stderrTail? }`; no entry when stopped or never started.
- **`state`** (R5, decision "worker errors in public"): per address key `{ status: "starting" | "running" |
"stopping", pid, startedAt }` or `{ status: "failed", error: { message, code?, reason? }, at }` with a public error
(a fixed message, or the child's redacted `fatal`); no entry when stopped or never started. The full error and the
stderr tail go only to `onError` (`worker_failed`); every app hook of a start (`start`, `principal`, `lock`, the
fork) is inside that boundary.
- **`stop(address, ctx)`** (R1, Q3): `RouteHandle.suspend(address)`, then the stop. The suspension lasts while the
address has holders (they see `failed { waiting }`, "suspended"); with the last holder it goes, and a later hold
starts the worker on demand; `retry(address)` lifts it at once. Whether an archived session may be opened is the
@ -252,8 +184,9 @@ await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
`reloaded { report } | { error }`. One `reload` is outstanding per child at a time: a reload whose caller aborted
keeps the child's slot until its `reloaded` or the child's exit, and the next waits for it. Errors cross as `{
message, code?, reason? }`.
- **Errors** (`onError`, `WorkersError`, which includes `BatchError` for a `state` watch listener that throws): `worker_failed { address, error, stderrTail? }`, `worker_exited { address,
code, signal }` (a clean exit after a stop is not reported).
- **Errors** (`onError`, `WorkersError`, which includes `BatchError` for a `state` watch listener that throws): `worker_failed { address, error, stderrTail? }` (private),
`authorize_failed { address, error }` (a throwing `authorize`: the hold is refused `forbidden`), `worker_exited {
address, code, signal }` (only for an exit nobody asked for: not after a stop or a start the component failed).
### Plugins in workers (D11)
@ -310,6 +243,9 @@ channel component (D6); telemetry spans for requests (slice 9).
the last one.
- `host.root(fn, ctx, { principal: undefined })` runs anonymous (`ctx.principal` undefined); only an omitted
`principal` takes the host's (a public-API change of `RootOptions`: `"principal" in options`).
- Origins only from relays (decision, 2026-10-31, security audit): `origin` is read only on parent and route links;
on a child link it is a protocol error; a per-origin record goes once it has no holds, calls or messages in flight.
- An anonymous `host.root` scope gets no remote object served on its host (`forbidden`).
- Design §3: the paragraph on a `resolve` waiting for the worker's `load` is updated (done), and "Chord never closes a
route link itself except at `host.close`" becomes "except at `host.close`, the heartbeat and protocol errors".

View file

@ -32,6 +32,11 @@ slice spec's section citations are for orientation, not a reading limit (owner,
(Durable's 35-37); benchmarks in `benchmarks.md`.
- **Slice 7b** (built 2026-10-29, `slice-7b.md`): `host.reload`, staging (`host/staging.ts`), fates, adapted members
(`host/adapted.ts`), references, jobs and registries across a reload; examples 33-34; `test/next/bench/reload.ts`.
- **Slice 7c** (committed 2026-10-30, `slice-7c.md`): `src/next-node/plugins/` (discovery, builds, evaluation,
`pluginFile`, `mapStack`, `installDependencies`, the watcher, `rollout`), per-plugin `load` and `reload`; examples 35-39.
- **Slice 10** (built 2026-10-31, `slice-10.md`): `src/next-node/http/` (a `fetch` core with the security baseline,
Chord's Node listener, `nodeRequest`, plugin sockets, links), `src/next-node/workers/`, `createPluginRuntime`, kinds,
the kernel's `RouteHandle` and anonymous `host.root`, origins only from relays; examples 40-48.
- **Chord's shared code** (upstream pi): `src/watch.ts` (`Watch`, `NativeWatch`, `WatchHandle`, `WatchEnd`,
`NativeWatchable`), Delta (`track`, `diffRevisions`, `applyImmutable`, encoders, `PendingOps`, `appendText`),
context, JSON, output, SQLite drivers.