~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
884 lines · 77.5 KB Raw History
# AGENTS.md - working rules for this repository

Read this file first. It is the one guide for agents in this repository. `CLAUDE.md` imports it.

This repository (socialweb-computer-kcp) is the monorepo of the socialweb-computer project. It is the
single source of truth for its Go code. The only TypeScript left is `scripts/*.ts` and the policy
actions under `internal/policyengine/bundled-actions/tangy/`.

`docs/ARCHITECTURE.md` holds the decisions, the measurements and the invariants. This file points at it
and states the working rules. Open a section of `docs/ARCHITECTURE.md` by its heading when a change
needs the argument behind it. Do not read it end to end.

## Orientation: which file answers which question

| Question | File |
| --- | --- |
| What is this, in one paragraph? | `README.md` |
| The objective, stated once | `docs/ARCHITECTURE.md` section 1 |
| Where a concept lives; the package layout | `docs/ARCHITECTURE.md` section 12, and Layout below |
| Rules a change must not break | `docs/ARCHITECTURE.md` section 13 |
| Why a decision was made, and what it closed | `docs/ARCHITECTURE.md` section 15 |
| The plans and the status of each item | `docs/plans/*.yaml` |
| The live backlog (what is open now) | `NEXT.yaml` |
| The TypeScript that is left | `scripts/*.ts`, and the policy actions in `internal/policyengine/bundled-actions/tangy/` |
| Env the clusterprovider reads | `internal/providermain/config.go`; the gateway's defaults for it in `internal/stack/gatewaykcp/env.go` |
| Env the gateway reads | `internal/gatewaymain/config.go`, `deploy/socialweb-gateway.env.example` |
| How prod is installed and upgraded | `scripts/deploy.sh` (gateway: `install gateway`, then `upgrade`), `socialweb-computer install bidder`, `socialweb-computer install requester` |
| What a host must provide | `socialweb-computer check-host -role gateway`, `-role bidder` or `-role requester`, `docs/host-contract.md` |
| The market network | `deploy/market-network/README.md` |
| The operator handoff | `handoff.yaml` |
| What CI runs on a push, and its runners | `.github/workflows/ci.yml`: the gate, the guest image (fetched from `guest-images/<target>/<pinned image.json sha256>` in the bucket when it is there, built otherwise) and the Spaces publish on GitHub-hosted runners; the pin check (`pins`) and the release builds (`binaries`) on the self-hosted runner of `scripts/gh-runner-install.sh` (account socialweb-ci) |
| The integration scenarios (no Go tests until plan 0027) | `docs/spec/integration-catalog.yaml` |

## Layout

Go:

| Directory | What it holds |
| --- | --- |
| `cmd/` | one directory per binary (`socialweb-computer`, `socialweb-node`, `socialweb-vmexec`, `socialweb-guest-agent`, `socialweb-apigen`, the two OpenBao auth plugins) |
| `internal/` | the Go packages, in the plan 0026 layers (`docs/ARCHITECTURE.md` section 12) |
| `pkg/` | the packages another module imports: `pkg/embedded`, the requester an app runs in its own in-process kcp (plan 0038); its `Config.OnSnapshot` bytes hold every vault secret in clear JSON: store them encrypted (Keychain, Data Protection Complete, no backup), never log them |
| `api/` | the kcp API types; `go generate ./api/...` writes the schemas, the APIExports and the generated Go |
| `deploy/` | manifests, sets, the generated schemas and exports, `check.sh` |
| `docs/` | `ARCHITECTURE.md`, `plans/`, inventories, research notes |
| `rust/` | socialweb-meshd |

TypeScript:

| Directory | What it holds |
| --- | --- |
| `scripts/*.ts` | one-off operator scripts (`oauth-signin.ts`, `associate-bidder.ts`, `publish-spaces.ts`), run with `deno run` |
| `internal/policyengine/bundled-actions/tangy/<action>/` | one policy action: `index.ts` and its built `dist/index.js`, embedded by the policy engine |

There is no deno workspace, no lockfile and no vendored TypeScript repository here any more.

## The TypeScript that is left

- `scripts/*.ts` are one-off operator scripts, run with `deno run -A`. No Go code runs them; the
  `publish` job of `.github/workflows/ci.yml` runs `scripts/publish-spaces.ts`.
- The policy actions under `internal/policyengine/bundled-actions/tangy/<action>/` are gha-lite assets
  that the policy engine embeds: `action.yml`, `index.ts` and its built `dist/index.js`. Edit them as
  such assets, with the workflows beside them in `internal/policyengine/bundled-actions/workflows/`.
- No comments in code. ASCII only.

## The kcp paradigm, port 0, and the RFP flow

- kcp is part of each application, not infrastructure. An application instance is a kcp of its own (or,
  in a market network, a workspace of the network's one kcp), a manifest set and a profile. State is kcp
  objects. Components (Pds, Plc, Relay, Bidder, Requester and the other component kinds) run in
  process in the core, in the component runtime of `internal/component`, and host processes are
  HostProcess units; both are reconciled from those objects. Only the virtual Kubernetes clusters
  project uses kcp in the traditional way.
- Which kcp, by profile: a prod profile (the gateway, `run bidder`, `run request` and `run requester`
  without `--network`) runs a strict kcp per application. The market network profile (dev, CI, soak) runs every application
  in one kcp, each in its own workspace `root:<app>` (components in `root:<app>:app`), from the same
  manifest set: the applier rebases the set's workspaces under the application's workspace. Either way
  applications talk only over the atproto market protocol; the shared kcp is composition and isolation,
  never a data channel. Write service names in manifests as `<name>.default.{{.ServiceSuffix}}`.
- Port 0 behind the edge: every listener binds port 0 and reports its bound address into the status of
  its kcp object. Peers find it by service name through kcp. Only the stable public entry points
  (caddy on 443, an application's front) keep a known address.
- The RFP flow is the only way a guest comes up: the requester posts a `compute.vm` record and a signed
  `market.rfp`, bidders bid, the requester accepts, and the winner boots the guest from cloud-init
  `user_data` (`BuildUserData` and the module registry in `internal/atproto/cloudinit`).
  The requester reaches the guest only through the guest transports below. Never start a container or VM by hand in
  code or in a test, and never install a guest agent outside cloud-init. A live run that needs a
  guest brings up its own market network,
  and `socialweb-computer run request` or a tenant cluster of the network's gateway rents the guest.
  `run request` composes a MarketRequester root (`internal/app/marketrequester`), and its Requester
  component (`internal/services/requester` over `internal/market/requester`) runs the RFP, the
  accept and the release; `run request list`, `run request release` and `run request resume` manage
  the invocations it recorded.
- `run requester --name <name>` (plan 0039 A-RUN-REQUESTER, `cmd/socialweb-computer/requester.go`) is the
  long-lived form: the same MarketRequester root and the same bring-up (standalone: bringUp profile quick,
  role requester; `--network <market network>`: a child application), state under
  `<state-dir>/requesters/<name>` (never `requests/`) with the owner flock of `internal/stack/request/owner.go`,
  no contract of its own. Its options after `--` are the MarketRequesterSpec part of `run request`'s, parsed
  by one shared function (`parseRequesterSpec`); a contract or CLI option there is refused. Once the root is
  Ready it writes a workspace kubeconfig (`<dir>/kubeconfig`, 0600) and the ready file (`<dir>/ready.json`,
  plus `--ready-file` when given; tmp then rename, 0600; `internal/stack/request/ready.go`) {schema 1, name,
  pid, startedAt, kcpUrl, kubeconfig, workspace, did, handle, identityMode, pdsUrl, ingress {pds, requester},
  sshPublicKey}: paths and public values only. It prints that as a JSON summary, rewrites the file when a
  field changes (a watch on the MarketRequester), stays in the foreground (`--fg` is a no-op for the unit
  form), and on Ctrl-C or SIGTERM removes the ready file and stops its kcp keeping its state (a network
  child is stopped, not removed). `<dir>/requester.json` records the first start's network, `--identity` and
  spec; a restart with no options after `--`, or the same ones, reuses it (other options are refused),
  adopts the units and keeps the DID, the vault and every contract (Held ones stay Held). `kubectl apply`
  Infra, EphemeralInfra or ComputeContract against the ready file's kubeconfig. Helpers on a running one
  read its ready file: `run requester env <name>` (KUBECONFIG, the kcp URL, the workspace, the DID, the ssh
  public key; `--json`) and `run requester ssh <name> --contract <cc> [--transport webrtc] [--exec ...]`
  (the `run request ssh` code with the requester's own key `<dir>/ssh/id_ed25519`; put its `.pub` in the
  contract's `sshAuthorizedKeys`); `run requester proxy` is its webrtc ProxyCommand. Every ssh the CLI runs gets `SHELL=/bin/sh`, because ssh runs its
ProxyCommand with `$SHELL -c` and an installed account's shell is nologin (sudo and the user manager set SHELL from passwd). With `--host-backend
  systemd-user` (plan 0039 B-INSTALL-REQUESTER, the installed form; refused with `--network`) the process is the core
  itself (`request.RunCore`, the `bidder.RunCore` pattern over `devcore.Run` with the bootstrap app
  `table.Requester`, key `req-<sha256(name)[:10]>`, layout `install.RequesterLayout` under `<dir>/dev/<name>`): it places
  kine, front and slot as user units, applies the root, signs in and sends READY=1 only after the ready file; a stop
  leaves those units and the HostProcesses running for the next start to adopt. `install requester --name <n>`
  (default account `swc-requester`) writes the unit template `socialweb-computer-requester@.service` (Type=notify,
  TimeoutStartSec the ready budget plus `install.RequesterStartGrace`, 1 h for a sign-in) and, once per name,
  `<dir>/requester.json` from the options after `--` (default `--atproto-oauth-qr`). The account is a system account, so
journald puts its user units' lines in the system journal (only root reads them: `journalctl _UID=<uid>
_SYSTEMD_USER_UNIT=<unit>`); `run requester signin <name>` prints the MarketIdentity's `pendingAction.url` from the
requester's own kcp, and `install` prints both. A password never goes into requester.json:
  `run requester password <name>` reads one from stdin into `identity/requester/keys` of the requester's vault.
  With `--atproto-identity-mode pdsAccount`, `run request` and `run requester` (standalone or `--network`, where the
  network child takes only that mode, `--atproto-handle` and `--atproto-pds-url`, default the network's bob PDS) put
  `ATPROTO_PASSWORD` from their own environment into that path before the sign-in; a missing or unreadable keysRef keeps
  the MarketIdentity Pending (`WaitingForCredential`, `VaultUnavailable`), never Failed. A pdsAccount requester signs its
  market callbacks with its PDS service auth (`signing: owner-identity`) and publishes its badge.blue signer key.
  `upgrade` takes the role from the install record. The same-account form is a kubectl-applied HostProcess
  (`examples/infra/requester-hostprocess.yaml`); no root prunes a HostProcess outside its set.
- `run requester --with-gateway` (plan 0039 C-GATEWAY-COMPOSE, `cmd/socialweb-computer/requestergateway.go`,
  `internal/stack/gatewaykcp/requester.go`, `internal/app/gateway/requester.go`) applies a Gateway root `gateway` of
  profile `requester` with `spec.requesterRef` (the MarketRequester `app` of the same workspace) and runs its
  clusterprovider in the `run requester` process. With requesterRef the Gateway composes no PlanAccount, Pds,
  Requester, PolicyEngine, root vault or web app: only the rbac phase of the gateway set (the NodeClaim agent) and a
  nodes-only GatewayAPI (`spec.nodesOnly`, the NodeClaim binding and the downloads route) whose node bundles go to
  the requester's vault (`nodeMarket.vaultMount` requester); the nodes' Infra is in the requester workspace and the
  MarketRequester's Requester rents them as the user's DID. A Gateway without requesterRef composes what it did
  before. Its clusterprovider serves only the clusters of the requester's DID (`SOCIALWEB_CLUSTERPROVIDER_ACCOUNT`:
  the registry and the Cluster controller skip every other account), and `--with-gateway` is refused in a market
  network that has a gateway child (that provider serves every Cluster of the network's kcp). The cluster's k3s,
  kine and OpenBao are HostProcesses of the requester's core; in a market network the
  child core runs the gateway's outbound trust and egress (`gatewaykcp.NetworkOutbound`) when
  `<instance state>/gateway` exists, which `run requester --with-gateway` makes before the network places it.
  `requester.json` records `withGateway`; the ready file is first written only once the Gateway is Ready too, with
`gateway {workspace, name, clusters}`;
  `run requester cluster <name> --cluster <c>` makes the home workspace `root:<account>:<c>` and its Cluster, and
  `run requester kubeconfig <name> --cluster <c>` prints its admin kubeconfig path once the Cluster is Ready (exit 1 before). A node fetches socialweb-meshd and
  socialweb-node from `<base>/downloads/<sha256>/<name>` and checks the pinned sha256: the base is the nodes-only
  GatewayAPI's `spec.publicOrigin`, set from `--node-download-base` (default the public bucket, where the CI
  `publish` job puts the pinned socialweb-computer-k8s-downloads tree content-addressed under `downloads/`); with
  `--network` and the default it is the local Exposure.
- Comms modes (`spec.comms.mode` of the MarketRequester and its Requester; `run request -- --comms-mode
  XRPC|Firehose|Both`). XRPC takes bids and events on the requester ingress only. Firehose has no
  inbound endpoint. Both (the requester default) is the ingress plus the same streams as Firehose. In
  Firehose and Both the requester composer (`internal/app/compose/streams.go`, the same code in the
  browser build) composes EventStreams from the network relay, `spec.comms.eventSourceRefs` and the
  default public firehoses; it composes no per-bidder stream (no subscribeRepos of a bidder PDS: a
  did:key bidder requestCrawls our relay and a bsky-hosted one is on the Jetstreams, and a shared
  host replays every repo it holds), and it prunes the old `bidder-pds-*` sources by their label
  `socialweb.computer/bidder-source`. The defaults (`api/v1alpha1` `DefaultFirehoseRelayURLs`,
  `DefaultFirehoseJetstreamURLs`): our relay
  `https://relay.mini-cloud-0002.chadig.com` (subscribeRepos; on a network with a relay host the network
  relay replaces it) and the two Jetstream v2 instances Bluesky documents,
  `wss://jetstream.us-east.bsky.network` and `wss://jetstream.us-west.bsky.network` at
  `/xrpc/network.bsky.jetstream.subscribeEvents` (`collections`, `dids`, `kinds=commit`). bsky.network
  is not a default. A Jetstream URL whose path ends in `/subscribe` is spoken as v1 (`wantedCollections`,
  `wantedDids`); a bare host is v2. Every relay subscribe sends the stream's filter as repeated
  `wantedCollections` and `wantedDids` (at most 100 and 10000, else that param is left out); our Go relay
  (`internal/atproto/relay`, the network relay and prod) filters on them, other relays ignore them, and
  the client filter (`events.Filter.Narrow`) always runs too. Each subscribe logs `relay_subscribe_dial`
  or `jetstream_subscribe_dial` (endpoint without query, the filter params, the cursor), and the
  EventStream's `status.serverFilter {collections, dids, supported: true|false|unknown}` is written on
  connect only. Each is a RelayEventSource or JetstreamEventSource with its
  EventSource (`firehose-relay-<host>`, `firehose-jetstream-<host>`, label
  `socialweb.computer/market-source=public-requester`) and an EventStream with `spec.resume: Live`
  (no stored cursor; a reconnect resumes from the cursor in memory). `spec.comms.relayUrls` and
  `spec.comms.jetstreamUrls` replace a default list; an empty list turns it off. A requester stream
  carries only `market.bid`, `market.receipt` and `market.event`; a bidder stream only `market.rfp`,
  `market.accept` and `market.event` (`compose.MarketStreamCollections`); payload records
  (`bids.free`, `compute.events.*`, ...) are read through the strongRef, never taken as stream input.
  Every market EventStream of a role delivers to one handler sink (`internal/eventstream/market.go`),
  which drops a `uri|cid` it took in the last 15 s (at most 10000 keys) and logs
  `market_event_duplicate_dropped` once per key with the running count. The requester tracks only the
  records of its own ComputeContracts (`internal/atproto/market/addressee.go`; the sink's index
  `internal/eventstream/addressees.go` is fed by a ComputeContract watch, no kcp read per record, and
  no DID allowlist): a bid whose `rfp` is the RFP of one of its contracts that has no accept yet, a
  receipt whose `accept` is one of its accepts, an event whose `receipt` is one of its receipts or,
  before that receipt is in the contract status, whose `receipt` resolves to a receipt of one of its
  accepts. Before the fetch it drops a bid while no contract takes bids, and a receipt or event whose
  repo is not the repo of an accepted bid of a contract that is not settled. Every StateSync and
  inbound ContractEvent it creates has an ownerReference to that ComputeContract, so deleting the
  contract (or the Infra or EphemeralInfra that owns it) deletes them. The bidder takes every RFP,
  an accept whose `bid` is in its repo and an event whose `receipt` is in its repo (its MarketIdentity
  DIDs). The rest is dropped before any object and counted in `market_foreign_dropped` (every 1000).
  The roles prune their market inbox StateSyncs (Outbound, Retain: the role adapter is the target,
  and a delete has no remote side): one that matches no own contract at once; the requester's of a
  Released or Failed contract once Acked and 10 min old (`MarketInboxRetention`); the bidder's
  every Acked one at 10 min (`requester_inbox_pruned`, `bidder_inbox_pruned`, every 100). No
  inbox StateSync is a dedup fact. A bidder bids only inside an RFP's bid window
  (`market.RFPBidWindow`, 10 min from its `createdAt`, else from its TID rkey; the lexicon has no
  expiry), which the bidder's inbox retention equals: its sink drops a closed RFP before the record
  fetch and again after it (`market_rfp_closed_dropped`, with the source, every 100), submitRfp
  answers 409 `RFPClosed`, and a Deciding Bid of a closed RFP fails `BidWindowOver`
  (`bidder_rfp_closed`). It never bids twice on one RFP: the handled fact is the Bid object (name
  `market.BidRKey`) and, after its TTL, the bidder's own `market.bid` record at that rkey
  (`bidder_rfp_already_bid`). A relay stream reconnects 1000 events back, a Jetstream 30 s back,
  but never before the cursor its last connection started at, after a 1 s backoff that doubles to
  30 s and resets only after a connection that stayed up 30 s; each close logs `event_stream_closed`
  (the cursor, `openSeconds`, `retryIn`, `restored`, the error) and a failed dial `event_stream_subscribe_failed`.
  A close the browser page fires after a memory snapshot restore (code 1006, reason
  `wsconn.RestoredCloseReason` "restored from a memory snapshot", the page's `RESTORE_CLOSE_REASON`;
  `wsconn.Restored`) redials at once from the cursor in memory and resets the backoff to 1 s; the
  pdsclient subscribeRepos stream does the same. Any other close keeps the backoff. Our relay answers a subscribeRepos
  without a cursor with live events only (the atproto sync spec); a cursor replays its backlog. The
  js/wasm WebSocket (`internal/kit/wsconn/conn_js.go`) has no receive backpressure, so it queues
  frames up to 16 MiB (`MaxBufferedBytes`), never by a frame count.
- The bidder's requester streams (any comms mode, `internal/app/compose/requesterstreams.go`): requester
  events reach the bidder through submitEvent first; as a record-only fallback, while the bidder has
  live ComputeVMs (not deleted, not Lost or Failed), it composes EventStreams with the bidder's market NSIDs
  and `dids` = the requester DIDs of those ComputeVMs (at most 100, read at runtime, never configured):
  sources `requesters-relay-<host>` (the network relay, else `spec.comms.relayUrls` or our relay; the
  relay filters on the server) and `requesters-jetstream-<host>` (`spec.comms.jetstreamUrls`, else
  `wss://jetstream.us-east.bsky.network` when the network has no relay). They deliver to the bidder's
  market sink (the same intake as submitEvent, deduplicated by `uri|cid`). A ComputeVM watch
  reconciles the MarketBidder only when that DID set changes; a new set is a spec change, so the stream
  reconnects with the new filter. Their `spec.resume` is `Recent`: after a restart or a spec change the
  stream resumes from its stored cursor only when it was saved in the last
  `delivery.RecentResumeWindow` (10 min), else it starts live. No live ComputeVM, no stream.

## Guest transports, guest events and the host-loopback fence

- Three guest transports, and no other. Never add one.
  - iroh is the default and required transport: every guest gets the iroh module, the requester's
    ssh and `status.contract.vmAddress` are iroh, and a contract is Held on the iroh address and the
    host keys alone.
  - opentunnel https, opt-in per entry of the ComputeContract's `spec.tunnels` with transport
    `opentunnel` (the default): one https URL per tag for the guest's `127.0.0.1:<port>`, back as a
    `vm.onNetwork` kind `https` in `status.contract.networks` (plans 0032, 0034).
  - webrtc, opt-in per entry with transport `webrtc`: socialweb-guest-agent (`cmd/socialweb-guest-agent`,
    `internal/guestagent`, pion and age) bridges each datachannel labelled with the tag to
    `127.0.0.1:<port>`; its DTLS fingerprint comes back as a `vm.onNetwork` kind `webrtc`. The CLI
    side is `run request proxy <name> --tag <tag>` (`internal/webrtcproxy`), and `run request ssh
    --transport webrtc` uses it as the ssh ProxyCommand; `run request --tunnel <tag>:<port>[:<transport>]`
    asks for tunnels. STUN is `stun:stun.cloudflare.com:3478` only; no TURN (plan 0034).
  - The browser shell: ssh over webrtc comes first; wootty is the fallback. An opentunnel entry with
    `shell {envFile}` (default `/etc/wootty/wootty.env`) runs `wootty-<tag>.service` on
    `127.0.0.1:<port>` behind that tag's https URL; a shell on transport webrtc is invalid. The shell
    needs `spec.secretsRef`: its `WOOTTY_AUTH_TOKEN` (32 characters or more, else it does not start)
    comes only in the secrets bundle, and the unit waits for `setup-guest-binaries.service` and the
    secrets ready file. `run request --tunnel <tag>:<port>:opentunnel:shell[=<envFile>]` (or the
    older `<tag>:<port>:<envFile>`) asks for one.
  - The atirc agent (plan 0035): `spec.agent {envFile}` (default `/etc/atirc/atirc.env`) needs
    `spec.secretsRef`. The requester mints a long atirc token after the receipt is verified (only for
    spec.agent), leased to the contract (chat.atirc.mint `lease`: the receipt and the bidder's
    vm.terminated at-uri, plus the association for the r2 token), and writes `ATIRC_TOKEN` into the
    bundle entry at envFile; getSecrets and the sealed fallback serve the bundle without that entry
    until it is minted, and every fresh mint publishes guest.secretsUpdated so the guest fetches
    again; user_data installs `atirc-agent.service`
    and `/etc/socialweb-guest-agent/agent.json` but never starts the unit; it enables `atirc-agent-env.path`
    on the envFile, which runs `systemctl try-restart --no-block atirc-agent.service` (never a start)
    when the envFile's sha256 differs from the one the agent's run script read. A requester-originated
    `guest.agentStart` makes socialweb-guest-agent run `systemctl start --no-block atirc-agent.service`.
    `run request --agent[=<envFile>]` asks for one; `run request agent <name>` starts it. atirc is
    linux/amd64 only. The mint signs with `atircSigning` of the `requester_service_auth` log (atirc only; the market
    callbacks keep `signing`): the OAuth session, the owner MarketIdentity for pdsAccount (`owner-identity`), or for
    localRepo its own Pds component (`local-pds`, a `pds.serviceauth` ComponentLink Requester -> Pds the MarketRequester
    composes only when the DID is on `https://plc.directory` and the Pds is public through its ingress,
    `role.LocalPdsSignsAtirc`); else `none` with `atircReason`. Before the RFP of a spec.agent contract (no
    `spec.atircParentRef`) the requester writes the placeholder bundle (one entry at envFile, `ATIRC_TOKEN=`, 0600) when
    the secretsRef path holds none (`requester_agent_bundle_placeholder`), then refuses the contract (Failed `refused:
    ... --atproto-handle ...`, no record) when its handle has a disallowed TLD (`.local`) or does not resolve to its DID. An ssh gateway session (`internal/market/requester/sessionatirc.go`) whose
    command starts with `atirc`, or whose client sends `LC_ATIRC=1` (`LC_ATIRC=0` turns it off), gets,
    when it is not an `LC_SESSION` held one, the agent module in its user_data (so the bidder seeds
    `/usr/local/bin/atirc`), and after the receipt the requester mints, with its own service auth
    (lxm chat.atirc.mint, aud `did:web:irc.fedfork.com#atirc`; the ssh gateway's sign-in scope carries
    `rpc:chat.atirc.mint` and `rpc:chat.atirc.revoke`), the child `ssh-<rfp rkey>.<its handle>` leased
    on the session's receipt and vm.terminated, exp at most the session cap; the command runs with
    `ATIRC_TOKEN=<child>` (sent as the ssh session variable `LC_ATIRC_TOKEN` and exported by the
    command, never in the command line) and the child is revoked when the command ends; a held
    `LC_SESSION` session gets none. A failed mint runs the
    command without it and writes one stderr line. A command of exactly the argv `atirc agent run <sub>`
    instead gets a token the requester mints itself the same way for the agent: `<sub>` when it is
    the user's handle or ends in `.<handle>`, else `<sub>.<handle>` (DNS labels of letters, digits and
    `-`, at most 253 chars; anything else is refused with one stderr line before provisioning), and
    the guest runs plain `atirc agent run` with that token, so the agent joins atirc as
    `<sub>.<handle>`; its mint failing fails the command, and the token is revoked at command end.
    A session command with a minted token runs as the atirc agent user, not root (the guest's claude
    refuses `--dangerously-skip-permissions` as root): the agent module writes
    `/usr/local/bin/atirc-agent-user.sh` (creates the user named by `atirc nick` for the token, in
    wheel with NOPASSWD sudo, lingering, and prints its name; `atirc-agent-run.sh` uses it too) and
    `/usr/local/bin/atirc-agent-exec.sh '<command>'`, which runs the command with `runuser -u <nick>`
    and `bash -c` from that user's HOME; the session waits for both scripts like the binary, and
    ATIRC_TOKEN reaches only that process environment (never argv or a file).
    A requester with no `vaultRef` (the per-user ssh requesters) mints into no bundle, so it skips the
    atirc orphan sweep (`requester_atirc_orphan_sweep_skipped`, logged once).
    The SshGateway's `spec.ssh.publicListen` (`run gateway ssh -- --ssh-public-listen host:port`,
    standalone only) makes the SshServer bind that address itself (the edge; a fixed bind, reported as
    listener `ssh-public`, next to the loopback Exposure); `spec.ssh.webListen` (`--ssh-web-listen
    host:port`) binds the SshServer's web page and API the same way (listener `http-public`, the
    address caddy proxies the public origin to). With the option `public-origin` (and no
    owner sessions file) the SshServer serves the web sign-in at `/` over the same OpenBao atproto
    plugin as GatewayAPI (`gatewayhttp.NewOAuth` over `internal/gateway/custody`, the session at
    `auth/atproto session/<did>` of the ssh-gateway vault) and `/keys` (badgeBlueKeys at keyRkey in
    the user's own repo, through the custody agent). A sign-in first runs the policy
    `bidder-tangled-vouch` (scope lane, self `spec.ssh.operatorDid`, `--ssh-operator-did`; no operator
    admits nobody); a refused or failed sign-in is signed out again and the page reads "this account
    was not admitted". An admitted sign-in asks the SshServer-only handle `market.sessionowners`
    (EnsureSessionOwner(did), built server side from the SshServer's vault and root labels, refused
    past `spec.ssh.maxAccounts`, default 64) for the session owner MarketIdentity `owner-<stamp(did)>`
    (oauthSessionProxy, `auth/atproto session/<did>`); sign-out deletes it. The SshGateway composer
    applies each session owner (compose.ApplySessionOwner), prunes the kcp objects of the ones whose
    session expired or past the cap (compose.PruneSessionOwners; never a repo record), and, once one
    is Ready, composes a Requester `requester-<stamp(did)>` on it with its own ingress, policy, service
    auth, repo links and the SshServer's stream link to it. An ssh session of DID X runs on X's
    requester (which refuses any other DID), so its RFP, accept and records are X's; with no link to
    it the client gets "sign in at <public-origin> first", any other failure "ssh gateway error", exit
    1 either way. The owner sessions file (market network seam) composes the same per-DID requesters,
    one per account in the file, signed in with its password. In both modes the shared Requester
    `requester` and its identity are not composed, the SshServer has `spec.accountRequesters`, and the
    SshGateway is Ready without one. Every ssh gateway requester names its ingress Exposure in
    `spec.marketExposureRef`, so its RFPs and accepts carry the submitBid and submitEvent callbacks,
    and a per-DID requester has `spec.contractOwnerDid`: it runs only the ComputeContracts whose
    `spec.ownerDid` is that DID and their StateSyncs and ContractEvents, so many requesters share one
    workspace. Every ssh session, transient (`ssh-run-<hash>`, TTL 1 h, released when the command
    ends unless `LC_KEEP_VM`) or held, is a ComputeContract, so bids and events reach it through the
    addressee index and the ingress like any contract. A session ends with its ssh channel: when the
    client closes it or the connection drops (the server sends keepalive@openssh.com every 15 s and
    closes a connection after 3 missed replies), the command's context is cancelled, the guest
    session closes, the session's atirc token is revoked and then the contract released (a release
    is tried 6 times, 1 s doubling to 30 s). A requester's Shutdown ends its live transient sessions
    the same way, and its first sync releases every `ssh-run-` contract it owns that is not Released,
    Failed, already releasing, labelled `socialweb.computer/ssh-keep-vm` (`LC_KEEP_VM`), created
    after its start or held by a live session (`requester_stale_session_release`); a VM whose token
    could not be revoked then ends it through the token's vm.terminated lease.
    A held `LC_SESSION` contract (`ssh-<hash>`) records `status.heldSession` {name, guestKeyPath
    (in the requester state dir), until}: until is the session cap plus idle when a command starts
    and the idle when it ends. Shutdown keeps held sessions (`requester_held_session_kept`); the first sync adopts
    one whose recorded expiry is ahead, whose guest address and key are still there, with its idle
    timer at the time left (`requester_held_session_adopted`), and releases the rest
    (`requester_stale_session_release`), sparing `socialweb.computer/ssh-keep-vm`.
    The iroh dialer socket lives in the SshServer
    state dir, or in the instance runtime dir when that path is over 107 bytes; `run gateway ssh`
    refuses a layout where both are. The set `ssh-gateway` binds marketidentities in root. A restart
    keeps the path whole: every app root (gateway, bidder, requester, network, ssh gateway), when it
    is not suspended, renders its set's HostProcesses and OpenBaoServers each pass
    (compose.SetWorkloads, CheckWorkloads). A missing one, or a HostProcess Lost because its unit is
    gone, the host rebooted, its manager or devsup restarted or its identity mismatched (the
    supervisor already stopped a mismatched unit, and a unit name runs once), which the root deletes,
    voids the applied-set shortcut and the set is applied again; one that is being deleted holds the
    pass until it is gone. A per-DID account requester stays composed, with the repo and RemotePDS
    links of its last applied PDS URL, while its session owner is Pending or unresolved; it is
    dropped only when the session expired for a reason that is not one the plugin keeps active, the
    owner signed out or the cap is reached. In per-user mode the SshServer has no requesterRef and
    the shared requester no links. The SshServer's Start waits, without polling, until its vault's
    OpenBaoServer is Ready (`sshserver_waits_for_vault`; woken by the vault provider's bus NotifyBound,
    whose binding fingerprint carries the server's readiness, so a Starting to Ready change notifies)
    instead of exiting. A requester that publishes its signer key binding (owner-session and OAuth
    identities) retries that write on every Vault, MarketIdentity or AtprotoRepo bind
    (`requester_signer_key_waits_for_vault`) instead of exiting while its identity's vault is
    Starting, and fails the start only when no bind comes within 2 min; the MarketIdentity controller
    sends a bus NotifyBound when an identity turns Ready, because the session it serves comes up
    after the Vault bind that woke the requester. The SshServer's web sign-in follows its vault: a rebound Vault
    (bus NotifyBound from the vault provider) rebuilds the custody sign-in on the new address, with
    at most 6 attempts at 1 s doubling to 30 s when the open fails; the MarketIdentity controller
    reconciles on the same signal and never drops it. The atproto plugin keeps a session active when
    a refresh is refused with invalid_client, invalid_client_metadata, unauthorized_client,
    use_dpop_nonce, server_error, temporarily_unavailable, or HTTP 429 or 5xx without a code, and
    when the refresh never reaches the server; every other answer (invalid_grant after the rotated
    token check, invalid_dpop_proof, invalid_request, any other code) expires it. A session an older
    plugin expired for one of the kept codes is refreshed again on a read or a proof, at most once a
    minute per DID. A release whose destroy record or submitEvent keeps failing settles as Released
    once the bidder's vm.terminated for that receipt appears, whatever its reason.
  - The requester association of a guest (plan 0036 A5): `spec.association {subject | fromGuest, refFile}`
    (default refFile `/etc/socialweb-computer/association-ref`, under `/etc/socialweb-computer/` only)
    needs `spec.secretsRef`. With a subject the requester writes a contract-scoped market.association
    after the accept; with fromGuest, user_data writes `/etc/socialweb-guest-agent/association.json`,
    the bundle carries `/etc/socialweb-computer/association-request.json` {authority, contract (the
    accept uri), nonce}. The bundle files are root 0600 and a dev requester refuses root, so
    socialweb-guest-agent makes the group `socialweb-r2` (user_data does too; the atirc agent user joins
    it) and `/run/socialweb-r2` (root:socialweb-r2 2770) and mirrors `association-request.json`,
    `r2-atirc.env` and then `association-ref` (empty until both the ref and the env are written, so the
    requester sees its parent token when the ref lands) into it 0640 on start, on a bundle change and
    after a refetch. The requester inside the guest (`run request --association-ref`, bare, as the
    agent user; it reads `/run/socialweb-r2` when that dir exists, `/etc/socialweb-computer` otherwise),
    finding the ref missing or empty, writes the request as a market.associationRequest record in its
    own repo at rkey `requester_associate-<sha256(authority)[:32]>-<accept rkey>`, then its DID to
    `requester-did` in that dir (`/run/socialweb-guest-agent/requester-did` in the root case);
    socialweb-guest-agent posts guest.requesterDid (did:plc
    only, never the requester's own DID; a did:web is refused because its resolution fetches a host the
    guest names); the requester records the first DID as Pending in `status.contract.guestRequester`
    before it proves it (a second DID is refused, a give-up turns it Refused); the requester reads that request record by one getRecord in the reported
    repo and writes nothing unless authority, contract and nonce match exactly; then it writes the
    record at rkey `requester_associate-<sha256(subject)[:32]>-<accept rkey>` (never over a record of another
    contract, and release deletes only its own), the ref into the bundle, publishes
    guest.secretsUpdated, and the guest agent fetches the bundle again (`SECRETS_REFETCH=1
    setup-secrets.sh`, or the sealed fallback). A failed step retries with a bounded backoff and shows in
    `status.associationMessage`. chat.atirc.mint never gets the association as `assoc` (atirc wants the
    standing rkey and the minting account as subject); every token is minted with an explicit exp
    (at most 24h, the parent token's exp and, for the guest requester, the association expiresAt). The
    agent token is one token per subhandle (named after the RFP rkey) minted after the receipt. Once a fromGuest association is
    written, the requester also mints the guest requester its own child token, subhandle
    `r2-<accept rkey>.<the requester's handle or subhandle>`, into the bundle at
    `/etc/socialweb-computer/r2-atirc.env` (`ATIRC_TOKEN`, `ATIRC_SUBHANDLE`, `ATIRC_EXP`, mode 0600);
    `source` it to run `atirc sub` / `atirc pub` as that subhandle in the owner's channel. A requester
    whose root names `spec.atircParentRef` (a vault secret {token, subhandle, exp}; the guest requester's
    `run request` writes it from that file) mints its own agents' tokens below that subhandle with the
    parent token as bearer instead of service auth. Release revokes the agent and the guest requester
    tokens in the background with a backoff capped at 5 min until each is revoked or expired (24h at
    most after release, from the release time kept in a marker in the bundle secret), starts only
    once that marker is written, stops with the requester and resumes after a restart from the marker;
    every mint is also recorded (path, subhandle, token, exp) under `atircMinted` in the bundle secret,
    and a minted subhandle counts as revoked only on a 200, a 401 for its own token, or its exp; a 401
    counts for an unrecorded (rc126) token only for a subhandle read from the env file. Every revoke
    of an `atircMinted` entry (release, orphan sweep, a refused mint) and of an env-file token whose
    subhandle the env file names is sent, when the subhandle equals the token's own `subhandle` claim
    exactly (the server answers 200 for any name in the caller's scope), first with a service auth
    for chat.atirc.revoke (lxm `chat.atirc.revoke`) when the requester identity is OAuth, the
    subhandle is below the requester's handle and the session's granted scopes (the token response,
    never the access token's scope claim) include `rpc:chat.atirc.revoke` (RequesterOAuthScope asks
    for it, but it is in `oauth.OptionalScopes`, so the identity's scope check does not require it
    and a session granted without it is not asked to sign in again; such a session never asks for
    that service auth; the MarketIdentity stays Ready with `status.pendingAction {kind: signInAgain,
    reason: ScopeUpgrade, message, missingScopes, handle}`, plus `url` and `expiresAt` for mode
    oauthBrowser (an authorize URL for the full requested scope, reissued when it expires), cleared
    once a session grants the full requested set), then, for an `atircMinted`
    entry, with the minting parent's
    bearer, the `spec.atircParentRef` long token, when the subhandle is below it; an entry revoked
    by a 200 is stamped `revoked` there and not revoked again, while a 401 counts as Done but leaves
    it unstamped: an unstamped entry stays in the bundle and its bundle in the orphan-sweep index
    until its exp, the sweep revokes it again on each requester start and sweeps once more at the
    latest such exp, and a bundle is finished for the sweep only when every entry of its contract is
    stamped or expired (the release marker stays until then). A derived subhandle, a claim mismatch, no grant and no parentRef, or each of them
    answered 401/403 uses the token itself; a leased token is revoked before
    vm.delete, while its lease is alive, and a 401 saying the lease is not alive counts as revoked
    (`requester_atirc_revoked` byLease). The first
    attempt runs before vm.delete is recorded when the bundle holds a leased token (after the guest
    requester's release), else before Released is written, or Failed when no guest can still run (no
    receipt, an acknowledged release, or the release bound), and its outcome is `status.atircRevokes` (Done only
    when every token path is revoked that way or never held a token; Pending while retries run;
    Abandoned after 24 h); only that first write and the retries set it, a later status write never
    does, and no token is minted once it is set. `run request` and `run request release` share one
    settle step before they remove an invocation: they wait up to `cloudinit.RequestRevokeWait`
    (15 min) while it is Pending or unset on a contract that names token paths; on that timeout they
    exit non-zero and keep the invocation's state, and a rerun of `run request release` starts the kcp
    so the requester resumes the revokes, from the marker or, with no marker, over every token path
    again. The process that starts an invocation's kcp (`run request`, `resume`, or a `release` with no
    other process) owns it through `requests/<name>/owner.lock` (flock) and is the only one that
    stops it. A `run request release` that finds the lock held asks for the release through the
    running kcp, waits for Released (exit non-zero otherwise), then for the owner to exit, and stops
    nothing; a `--hold` owner whose contract leaves Held with `spec.release` set keeps its kcp until
    the release settles, then stops (and removes) it as its own release would. A refFile, agent envFile or `--secrets` path equal to a token path is refused (CLI and
    requester admission). Every release request stamps `spec.releaseRequestedAt` (SSA, the release
    field manager) and the outcome it answers is `status.release.requestedAt`; a denied accept whose
    receipt never arrives leaves the contract Failed with ReleaseFailed, and a new release request
    restarts its runner, looks for the receipt and releases again; the CLI waits for an outcome of its
    own request (with the receipt waits added to its release wait). Every read-modify-write of a
    contract's bundle holds one lock per contract on the requester. Revoking a subhandle revokes every
    token below it. `spec.atircParentRef` must name `identity/atirc-parent` in the requester's own
    vault and mount.
    `run request --associate <did>|guest` asks for one. Inside such a guest, the agent user starts the
    guest requester with one command, `socialweb-computer run request --name <name> --guest-requester
    [--agent --hold]`: it implies `--association-ref` on `/run/socialweb-r2`, `--skip-ssh` and the
    parent atirc token, which it uses only to mint its own agents' tokens (the guest requester runs no
    `atirc sub` of its own). The atirc agent run script enables linger for the agent user and runs the agent
    with its `XDG_RUNTIME_DIR`, so a dev requester can run as that user. With no command in the guest,
    `run request r2 <name> [--r2-name r2] [--agent] [--hold]` publishes guest.r2Start and the guest agent
    starts `socialweb-r2.service` as the user `r2`; releasing the parent publishes guest.r2Release first
    and waits for guest.r2Released after `socialweb-r2-release.service` released the guest requester's
    contracts. The bounds nest, from the constants in `internal/atproto/cloudinit`: each guest
    `run request release` gets RequestReleaseWait + RequestRevokeWait + 2 min, the release unit 1 min
    more, the cascade (GuestRequesterReleaseTimeout) the unit stop plus 1 min more, and the parent's
    `run request release` waits the cascade plus `--contract-timeout`; the outcome is
    `status.contract.r2Release` and the GuestRequesterReleased condition. A bidder releases a VM whose
    requester's contract-scoped association expired 10 min ago (ComputeVM `spec.associationExpiresAt`,
    at most 24 h after the accept, only from a proof in another repo than the requester's that names
    the requester as subject and the authority's own accept as contract; each skip is logged).
- The bidder installs the guest binaries, never the requester and never the internet. The requester's
  tunnel module carries config only; the bidder adds `setup-guest-binaries.service`
  (`internal/guestseed`), which fetches opentunnel, wootty, atirc or socialweb-guest-agent from its own
  GuestControlPlane `GET /v1/downloads/<sha256>/<name>` at `http://192.0.2.1:<port>` (a local
  Exposure), checks the sha256 and installs to `/usr/local/bin`. The binaries are the release asset
  `socialweb-guest-downloads` (`scripts/build-guest-downloads.sh`, which takes wootty from its pinned
  ghcr.io image layer at build time). A guest never fetches from GitHub or GHCR. The did:key ingress cannot carry binaries (JSON string bodies, a frame cap).
  The one opt-in exception to that asset: a contract whose `spec.guestTools` names `socialweb-computer`
  (`run request --guest-tools socialweb-computer`) gets the bidder's own running binary
  (`/proc/self/exe`, hashed once per process, linux/amd64 bidders only; any other bidder refuses at
  provisioning), served streamed at `/v1/downloads/<sha256>/socialweb-computer-x86_64` and installed by
  the same unit as `/usr/local/bin/socialweb-computer`. The requester marks it in user_data as
  `/etc/socialweb-computer/guest-tools/<tool>`.
- Guest events live under `com.publicdomainrelay.temp.compute.events.guest.*` in the market.event
  envelope. The guest holds no repo key: it POSTs `{acceptUri, acceptCid, record}` to GuestControlPlane
  `POST /v1/events` (vm.onNetwork, guest.publicKey, guest.signalWebRTC, guest.secretsRequested,
  guest.secretsApplied, guest.requesterDid; never guest.secrets, guest.agentStart or
  guest.secretsUpdated) and the bidder signs and publishes them; requester events to the guest
  (guest.publicKey, guest.signalWebRTC, guest.secrets, guest.agentStart, guest.secretsUpdated) reach it through the bidder's per-ComputeVM queue
  and the XRPC subscription `com.publicdomainrelay.temp.compute.guest.subscribeEvents` (WebSocket,
  `cursor`, JSON text frames, an empty binary keepalive every 25 s). The guest bearer is the
  `Authorization` header only. The bidder routes by NSID prefix and never opens a sealed body.
- Signaling is sealed. Each side publishes a `guest.publicKey` (kind `age-x25519`) per tag; an offer
  is age ciphertext sealed to the guest's latest key, with plaintext `{sdp, replyTo}`; the answer is
  sealed to `replyTo`. A signal names no recipient: a reader trial-opens every signal for its tag.
  No private key, SDP, ICE candidate or IP address is ever in a record, a ContractEvent, a StateSync,
  kcp or a log. The requester side of each event is a cluster-scoped `ContractEvent`
  (`socialweb.computer/v1alpha1`, outbound ones made by the CLI or the browser, inbound ones by the
  requester), owned by its ComputeContract.
- Secrets. `spec.secretsRef` names a bundle in the requester's OpenBao (`[{path, value, mode?}]`,
  mode octal and at most 0600). The primary path is getSecrets: the guest's `setup-secrets.service`
  exchanges its workload identity token and GETs `/xrpc/com.publicdomainrelay.temp.compute.secrets.getSecrets/<contract>`
  from the requester's ingress. A sealed fallback exists for a requester whose ingress may not work
  (the browser page): it runs only when the requester's `spec.secretsFallback` is `Sealed`
  (MarketRequester, Requester; default `None`; `run request -- --secrets-fallback Sealed`), and only
  on demand. With `Sealed`, user_data also writes `/etc/socialweb-guest-agent/secrets.json`
  `{"fallback": "sealed", "getSecrets": <bool>}`, so the bidder installs socialweb-guest-agent and
  `socialweb-guest-secrets.service`. setup-secrets gives getSecrets 90 s
  (`cloudinit.SecretsFetchFallbackDeadlineSec`), then touches `/run/socialweb-guest-agent/secrets.request`
  and exits 0; without an inbound endpoint (comms mode Firehose) setup-secrets is not in user_data and
  the agent asks at once. The agent publishes a `guest.publicKey` with tag `secrets` and posts
  `guest.secretsRequested`; the requester then, and only then, seals the bundle to that key as a
  controller-owned outbound ContractEvent `secrets-<contract>` (`guest.secrets`, reseals for a newer
  key while the request is unanswered). The agent writes each file atomically (root:root, 0600 or
  the bundle's mode, missing parents 0700) and posts `guest.secretsApplied {via}`. Either path ends in
  `/run/socialweb-guest-agent/secrets.ready`; a unit that consumes secrets orders itself
  `After=setup-secrets.service socialweb-guest-secrets.service` with `Wants=socialweb-guest-secrets.service`
  (`cloudinit.SecretsReadyUnits`). The ComputeContract shows `status.contract.secretsRequested`,
  `status.contract.secretsApplied` and the condition `SecretsApplied` (reason `GetSecrets` or
  `SealedFallback`). Never send a sealed bundle unasked: published ciphertext stays on the protocol
  and can be broken later. A Firehose requester with `None` and a secretsRef fails the contract
  ("secrets need the requester ingress (getSecrets) or secretsFallback: Sealed").
- The guest fence. socialweb-vmexec loads the nf_tables bridge table `swcguest` in the guest's
  namespace before the tap exists, and the VM does not start without it (`internal/guestnet`,
  `fence_linux.go`). On frames from the guest's tap it keeps, in order: TCP to the host's loopback at
  `192.0.2.1` only on the ports of the ComputeVM's `spec.hostPorts` (every other TCP connection to it
  is reset, the rest dropped); DNS (udp and tcp 53) to the pasta gateway 172.30.0.1; ICMPv6 router and
  neighbor discovery. It then drops everything to the bidder host's own addresses (`vmruntime`
  lists them in the host namespace at Prepare and writes them into the vmexec params) and to the
  guest link 172.30.0.0/30, and to 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24,
  192.168/16, 198.18/15, 224/4, 255.255.255.255, ::1, fc00::/7, fe80::/10 and ff00::/8, and every
  ethertype but IPv4, IPv6 and ARP. TCP to the LAN is always closed. One allowance, on by default:
  UDP to 10/8, 172.16/12, 192.168/16 and fc00::/7 on destination ports 1024 and up, except 1900,
  5353 and 5355, so same-LAN WebRTC can use a LAN candidate; it never reaches the host's own
  addresses, 100.64/10, 169.254/16 or 127/8. It is the ComputeVM's and FirecrackerVM's
  `spec.guestNetwork.lanUDP` (unset: true), which the bidder writes from its own
  `spec.guestNetwork.lanUDP` (MarketBidder, Bidder, NetworkApp), the bidder operator's choice and
  never the requester's: `run bidder`, `install bidder` and `dev up` take `--no-guest-lan-udp`, the
  hono-bidder passthrough takes `--guest-lan-udp=false`. A second allowance, `hostUDP`, comes before
  the drop of the host's own addresses: UDP to those addresses (never loopback or link-local, never
  TCP) on the same ports, so WebRTC works when the requester runs on the bidder's own host (the
  router does not hairpin). It is the ComputeVM's and FirecrackerVM's `spec.guestNetwork.hostUDP`
  (unset: false), which the bidder always writes as its effective value: its own
  `spec.guestNetwork.hostUDP` (MarketBidder, Bidder, NetworkApp) when set, else on when its
  `spec.policy` is `bidder-only-me` and off otherwise; the Bidder's `status.guestNetwork` reports
  `{lanUDP, hostUDP, hostUDPReason}` (`spec`, `policy only-me`, `policy not only-me`). `run bidder`,
  `install bidder` and `dev up` take `--guest-host-udp true|false|auto`, the hono-bidder passthrough
  `--guest-host-udp`. The one inbound path is the forwarded webrtc UDP port: for a guest with a
  webrtc tunnel the bidder leases one UDP port per ComputeVM from its `spec.guestNetwork.webrtcUDPPorts
  {min, max}` (MarketBidder, Bidder, NetworkApp; default 31000-31999; a udp port registry under its
  state dir, `webrtc-udp-ports`), writes it as the ComputeVM's and FirecrackerVM's
  `spec.guestNetwork.webrtcUDPPort`, and writes `{"webrtc": {"udpPort", "hostAddresses"}}` (the
  host's RFC1918 and ULA addresses, `guestnet.HostLANAddresses`) into
  `/etc/socialweb-guest-agent/bidder.json`. pasta forwards `-u <lan addr>/<port>` to the same guest
  port, where socialweb-guest-agent listens with one pion UDPMux and rewrites its host candidates to
  those addresses, so a same-LAN peer (mDNS-only host candidates, no hairpin) reaches the guest. The
  fence accepts UDP from that source port to the host's own non-loopback addresses and to the LAN
  prefixes on ports 1024 and up except 1900, 5353 and 5355, with `lanUDP` and `hostUDP` off too; no
  other UDP port is forwarded, and the addresses never go into kcp. A guest-facing local listener the bidder hands to a guest goes into
  `spec.hostPorts` (`GuestHostPorts`, `guestseed.HostPortsOf`); never widen the fence to all ports,
  and never open TCP to the LAN or to the host's own addresses.
- After a change to `cmd/socialweb-vmexec` or `internal/guestnet`, run `make pins` and commit the
  `socialweb-vmexec` pin with the change, then `dev down` and `dev up` the network instead of a
  `dev restart ... core`.

## Tests: none until plan 0027

Plan 0026 deleted every Go test. Write no Go test of any kind until plan 0027.

- No tests. Do not write unit tests, ratchets, allow-lists, inventories, golden files, snapshots,
  source-grep checks, size lines or any other check of an arbitrary rule. Do not work test-first.
  Ignore the `test_first` field of a plan item. Do not restore a deleted test.
- Proof is the build gates plus live runs. A manual live run is proof, not a test.
- From plan 0027 on, tests are specification tests written from `docs/spec/integration-catalog.yaml`:
  each states what a component must do (actors, inputs, observable outcome), never how it does it.
  Only high-level integration tests are written.
- Rust tests under `rust/` are outside this rule and stay.
- The policy engine specification tests are authorized by the owner (2026-10-30) and must pass:
  `internal/policyengine/spec`, the policy decision tests in `internal/market/bidder` and
  `internal/market/requester`, and their harness `internal/policyengine/policytest` (real engine, real
  bundled actions in the pinned deno, a fake PDS, no network). Every case asserts that the selected
  policy ran for the decision and that its verdict and the decision are right. No skip.

## Prod deployments

A prod deployment guarantees nothing; the routine path preserves most things.

- The routine deploy (`scripts/deploy.sh` on an installed gateway) upgrades in place: state dir, kcp
  objects, OpenBao contents, the requester's and bidder's DIDs, association records, web sessions and
  running HostProcess units, which the new core adopts by unit name and InvocationID, all survive
  (read on prod: web session across rc8 to rc9, DIDs and association
  across rc11 to rc21). A fresh install, or a lost or purged state dir, preserves nothing and needs
  one association per identity again.
- Losing sessions, VMs, clusters, claims, receipts or any other prod state during a deploy is
  acceptable. Do not plan migration, carry-over or rollback for a change of shape. Deploy fresh when
  the shape changed.
- The atproto credentials of the two prod accounts are on the deploy host, owner-only (mode 0600):
  `~/.aliceoa_bsky_social_source` (requester account alice; exports `ALICE_ATPROTO_HANDLE`,
  `ALICE_ATPROTO_PASSWORD`) and `~/.bobvmbuilder_bsky_social_source` (bidder account bobvmbuilder;
  exports `BOB_ATPROTO_HANDLE`, `BOB_ATPROTO_PASSWORD`). When a deploy loses a session, sign in again
  from these files. Source them into the one command that needs them; never print, log, commit or
  copy their values, and never put them in a kcp object, a HostProcess argument or a plan.
- OAuth sign-ins are scripted; never hand a sign-in link to the user. `scripts/oauth-signin.ts` drives
  a headless Chrome (`/usr/bin/google-chrome`, puppeteer-core) through the atproto OAuth flow with the
  handle and password from a credential file: the QR session-transfer page (qr.fedfork.com, a bidder's
  `prod-qr` MarketIdentity `pendingAction.url`) or the web app's sign-in page, the PDS's authorize
  page, then "Authorize". Example, for the hat-8 bidder:
  `deno run -A --no-lock scripts/oauth-signin.ts --creds ~/.bobvmbuilder_bsky_social_source --account BOB --url "<pendingAction.url>"`.
  It reads the file itself (values never on the command line or in logs) and writes step screenshots
  to `~/.tmp/oauth-signin/<ts>/` (mode 0700) for debugging. A QR link expires after 5 minutes; read the
  current one from the MarketIdentity status right before running it.
- After a fresh gateway deploy, the requester alice has no web app session: the cluster provider logs
  "no oauth session stored for did:plc:..." on every claim. Sign alice in with the same script and
  `--url https://k8s.socialweb.computer/ --start-api` (the gateway is API-only: `--start-api` POSTs
  `/oauth/start` with the handle and drives the returned authorization URL).
- A bidder's guest image comes from `--guest-image-source` (a `file://` or `https://` base that
  `install bidder` writes to config.json), or, when that is unset, from the published artifacts:
  `run bidder` reads `guest-images/<target>/<pinned image.json sha256>`, then `releases/<version>` and
  `main/<commit>` under `--guest-image-release-base`, the public Spaces bucket by default. CI uploads a
  guest image once, to that content-addressed key, and only when the key does not hold it yet. On hat-8 the pinned image is in
  `/var/lib/socialweb-computer/guest-image` (root-owned; the bidder verifies every file by the sha256
  pinned in internal/versions/versions.json).
- The did:key ingress relay at xrpc.fedproxy.com is the Go relay `socialweb-computer internal
  ingress-relay` (`internal/atproto/ingress`), behind caddy on a unix socket as the
  `30-go.conf` drop-in of `xrpc-relay-server.service`. Deploy it only with
  `scripts/deploy-xrpc-relay.sh` (build, ship, restart, health check, rollback on a failed check);
  `--rollback` removes the drop-in and goes back to the deno relay, `--status` only reads. Never run
  `atproto-reverse-proxy/scripts/deploy.sh`: it prints `CF_API_TOKEN`. A change to the ingress
  protocol (`internal/atproto/ingress`) ships to both ends: the core and this relay.
- The atproto relay at relay.mini-cloud-0002.chadig.com (bidders' requestCrawl, a firehose source) is
  the Go relay `socialweb-computer internal atproto-relay` (`internal/atprotorelaymain` on
  `internal/relay/local`), on 127.0.0.1:2584 behind caddy as the `30-go.conf` drop-in of
  `atproto-relay.service`, state in `/var/lib/atproto-relay-go`. Deploy it only with
  `scripts/deploy-atproto-relay.sh` (`--dry-run` prints the plan, preflight on 127.0.0.1:2585, rollback
  on a failed check); `--rollback` removes the drop-in and goes back to the deno relay, `--status` only
  reads. Caddy is not touched.
- Every prod bidder runs the policy `bidder-tangled-vouch`, the default when MarketBidder
  `spec.policy` is unset (`install bidder` writes `bidder-policy` to config.json); `open` is insecure
  and runs only when set explicitly, never on prod. The acceptance requester alice must be vouched by
  the bidder operators bob and john (`sh.tangled.graph.vouch`, docs/acceptance/0001-auth-modes.md).
- A market policy always runs, on both sides and in both lanes. No policy named means the default
  (`requester-tangled-vouch` on a requester, every contract and RFP carries it, owner-session mode
  included; `bidder-tangled-vouch` on a bidder). The bidder's full lane runs its own workflow and then
  every policy the RFP attaches, and allows only if all allow; no verdict is cached across decisions,
  and a stored bid verdict never skips it. The requester gate (scope and full, contract and session)
  runs the body of the attached policy record, which must be in the requester's own repo and, for a
  bundled name, equal the bundled workflow; it refuses a contract or session whose policy names
  another. On the bidder the requester is the subject. A run whose workflow declares `cache-key` gets
  `runs.CacheKey` (workflow, mode, perspective, every DID, the records, the args), so a memoized
  verdict never crosses parties. Free winners get the full run too. A missing evaluator, run client
  or engine, or an `allow` output that is not a strict boolean, denies. `open` runs the open workflow
  only when named; it never skips. A run whose PolicyEngine is not bound yet (a core start) waits
  for the binding (`policyengine_run_waits_for_engine`, then `policyengine_run_engine_bound`) within
  the caller's run timeout instead of failing at once. A requester restart never fails a contract
  for a transient start-up failure: when a resumed contract's recorded-winner policy run does not
  complete, or its attached policy record, its secrets bundle or its ingress url cannot be read, it
  keeps its phase and tries again (`requester_contract_waits_transient`, a bind or the backoff 2 s
  doubling to 30 s) and fails only on a deny or after `TransientResumeGiveUp` (2 min).
- Only the lead runs a prod deployment (plan 0006 M5-FRESH-DEPLOY). Task agents never touch prod.

## How to verify

- While iterating, build only what you touched: `go build ./internal/<pkg>/... ./cmd/socialweb-computer`.
  Do not run the full gate per change.
- The full gate runs ONCE, right before "ready": `go build ./...`, `go vet ./...` and
  `deploy/check.sh --fast`. GitHub CI runs the same three on every push to `main` and `task/**`
  (`.github/workflows/ci.yml`); read the branch's run on GitHub.
  - `deploy/check.sh --fast`, in order: `gofmt -l` of `api`, `internal`, `cmd`, `hack` and `lexicons`;
    `go build ./...`; apigen into a scratch directory and a diff against every file
    `go run ./cmd/socialweb-apigen -root . -list` names (generated code must match its source);
    `go generate ./...` must leave `api`, `internal/atproto/market` and `deploy` unchanged;
    `go vet ./...`; `scripts/check-architecture.sh`; the policy engine specification tests
    (`go test ./internal/policyengine/... ./internal/market/bidder/ ./internal/market/requester/`; they
    find deno through `SOCIALWEB_POLICYTEST_DENO`, the embedded deno, the build-staged pinned zip in
    `~/.tmp/socialweb-computer-build/cache/deno-embed`, or PATH, and fail without one); `go build` and `go vet` of each Go asset module
    in `internal/versions/versions.json`; `go build` and `go vet` for darwin/arm64, darwin/amd64,
    windows/amd64, windows/arm64 and linux/arm64, each with a `-tags release` build of
    `cmd/socialweb-computer`; and the `-tags release` build of every release platform.
  - `deploy/check.sh --local` adds `cargo fmt --check` and `cargo test` of `rust/`, and
    `socialweb-computer dev verify-assets` on a release build when the host's blobs are staged; it
    lists what it skipped. `deploy/check.sh --full` makes a skip a failure.
- The binary for live runs: `scripts/build-socialweb-computer.sh --dev --publish /var/tmp/<work>`
  (`make dev` is the same with `/var/tmp/swc`). It builds one socialweb-computer of the working tree
  (uncommitted changes included) for the host platform, or for each `--platform`. It embeds every
  pinned asset, staged once per `versions.json` entry; the first `--dev` build on a host stages every
  asset (about 3.5 minutes), later builds only an asset whose entry changed. `socialweb-computer
  version` says "dev build, not reproducible" with the commit and the dirty state. `--publish`
  copies the host binary to `<publish>/bin/socialweb-computer` and mirrors the working tree to
  `<publish>/src`, both readable by test-0001. Output: `~/.tmp/socialweb-computer-build/dev/<worktree>/`.
- A Go change in a running dev instance or market network: rebuild with `--dev --publish`, then
  `socialweb-computer dev restart <instance> core --binary /var/tmp/<work>/bin/socialweb-computer`
  (as the account that runs the instance). It stops the core unit, places it again with the new
  binary, waits until it is ready, prints a JSON report and fails unless every unit was adopted
  unchanged. A child application of a market network takes `dev restart <network> <app>/core` (or
  `dev restart <network>-<app> core`); the network's supervisor places that core through its
  HostProcess object. Then rerun `run request`. Do not `dev down`/`dev up` for a code change.
- One component: `socialweb-computer dev restart <instance> [<app>/][<workspace>:][<kind>/]<component>`.
  It writes `spec.restartedAt` on that component's kcp object and waits until the component reports
  the restart. The PolicyEngine restarts only with `core`. A kcp roll is `dev roll kcp`; on an installed host,
  `units roll kcp`, `units roll cluster` and `units roll link`.
- The release binary (prod only; the lead builds it): `scripts/build-socialweb-computer.sh` without
  `--dev` builds one commit once per release platform of `versions.json` from a clean checkout:
  every pinned asset staged by `scripts/stage-assets.sh` and checked against its pin, the embedded
  Deno of the policy engine, then `go build -tags "release embed_deno"` with `CGO_ENABLED=0`.
  Each step is keyed by its inputs and does not run again when they did not change: asset blobs by
  their pinned inputs (assets built here by their tar bytes), socialweb-meshd by rust/ and the
  toolchain, plus the go build cache and the cargo target dir. Tens of seconds warm; minutes with
  empty caches. Every phase prints a `timing` line. `make` builds with `--update-pins`: assets built
  from this repository or from pinned upstream source are rebuilt and their new pins written to
  `internal/versions/versions.json`; downloaded assets still verify their pins and never move.
  `make pins` re-stages and re-pins only the owned assets. Commit the pin change with the source
  change. The `pins` CI job runs `make check-pins`, which runs `make pins` and fails when
  `versions.json` differs from HEAD. Checkouts, caches and outputs stay under
  `~/.tmp/socialweb-computer-build`; `release/<commit>/` holds the binaries and `SHA256SUMS` that
  `install` and `upgrade --binary --sha256` take. Never build it in the inner loop.

## Where and how to work

- Work in a plain git worktree or branch of this repository. Do not put a worktree inside another
  worktree.
- Commit small logical commits and push as you go.
- Before you mark a goal complete cut a release candidate
  - Babysit CI every time you tag a release candidate. You are only allowed to
    tag rc's, never full releases.
  - Cut more rc's until CI is green and you have validated that they install and
    work on all bidder hosts and prod gateway.
- Never use `/tmp`; it is tmpfs and spills to swap. Use `~/.tmp` (set `TMPDIR` under it).
- A one-off script is a `.ts` file under `$TMPDIR`, run with `deno run -A`, using `Deno.Command` for git
  and go. Keep each shell call to one plain command.
- Plans live in `docs/plans`. Flip an item's status only. Do not add evidence lists; the git history is
  the evidence.
- Rebase on `origin/main` often (`git fetch && git rebase origin/main`). When a rebase stops on
  conflicts, run `scripts/regen-after-rebase.sh` (no arguments). It stops first when a generator
  input is conflicted (`api/*.go`, `internal/apigen`, `cmd/socialweb-apigen`, `hack/codegen.sh`):
  resolve those by hand, `git add` them and run it again. Otherwise it drops the conflicted
  `api/**/zz_generated.*.go` files, reruns `hack/codegen.sh`, takes one side of every conflicted file
  that `go run ./cmd/socialweb-apigen -root . -list` names (the schemas and exports under `deploy/`,
  the zz_generated register and conditions files), reruns `go generate ./api/...`, stages the
  outputs and prints the hand-written conflicts left, or the command to continue. Never hand-merge
  generated output. `deploy/apiresourceschema-revisions.txt` merges itself (`merge=union`); when it
  names one schema twice, the script keeps the HEAD line, and the generator may ask for a Revision
  bump in `api/v1alpha1/resources.go`.
- Live runs: your own market network only, run as the VM account (test-0001) from the binary and the
  mirror that `--dev --publish /var/tmp/<work>` wrote, with an instance name and bidder handle unique to
  your work. Keep the network up for the whole item; clean up only your own units and VMs.

## The fast loop for one item

- Compile: `go build ./cmd/socialweb-computer` (about 4 s in a new worktree, the Go caches are
  shared).
- Binary and mirror: `scripts/build-socialweb-computer.sh --dev --publish /var/tmp/<work>` (about 21 s the
  first time in a worktree, about 6.5 s after). Later changes: build again, then
  `dev restart <instance> core --binary /var/tmp/<work>/bin/socialweb-computer`.
- One network for the whole item: `dev up` once (80 to 95 s, mostly waiting, about one core), then every
  proof is a `run request --network <instance>` (80 to 90 s each, 35 s of it the requester's own kcp).
  `dev down` costs 70 to 95 s (one-kcp network with a gateway). Do not `dev up` per proof.
- A held guest burns one host core for as long as it lives (an idle firecracker vCPU does not halt), so
  release a `--hold` invocation as soon as you have read it.
- Take a live slot before `dev up` and release it after `dev down`:
  `deno run -A ~/.tmp/swc-lead/live-slot.ts acquire|beat|release <name>`. The default is 5 slots
  (`SWC_LIVE_SLOTS`), and `acquire` also waits while MemAvailable is under 12000 MB
  (`SWC_LIVE_MIN_AVAIL_MB`) or cpu pressure avg10 is over 90 (`SWC_LIVE_MAX_CPU_PSI`).

## The loop

- There is no container. Read the state of an instance with `socialweb-computer units list` and its
  units' journals (`journalctl --user -u <unit>`); the processes already log a status line.
- NEVER write a sleep-poll loop such as `for i in $(seq ...); do sleep ...; journalctl ...; done`.
  It blocks a turn for minutes and cannot fail. Watch the log with the Monitor tool instead (below).

## Turns and background work

- Never end a turn with a build, test or live run in flight. Background a long command and
  keep working, or wait for it, but do not hand back a turn that says "still running".
- Never block the foreground on a long run. Start a live run (`dev up`, `run bidder`, `run request`)
  or a build with run_in_background and wait for its completion notification, or watch its log with
  the Monitor tool (filter for the success line AND the failure lines). Foreground `until` loops
  only for waits under about a minute; no `sleep N; tail` chains. Do other work (docs, the next
  commit) while it runs.
- Parallelize reads, greps and edits. Live runs go in parallel too: every listener binds port 0, and
  the VM account test-0001 has 16 VM slots that vmruntime hands out under a per-account lock (no
  whole-run lock; test-0001 linger stays on). Keep heavy builds few: 16 cores.
- Launch subagents for parallel work, then verify the artefact (the file, the exit code, the log
  line) rather than the summary.
- Nested subagents hit a depth limit. Do not spawn a subagent from inside a subagent; do that
  reading inline.

## Edit discipline

- Read the exact bytes before editing. The edit tool matches text, and a guessed string is a
  failed edit.
- A block that repeats: use `replaceAll`, not one edit per copy.
- One failed match is fixed by re-reading and retrying. Do not guess the same string again.
- Do not revert another agent's work. Run `git status` first; the tree is shared.

## Architecture rules (plan 0026)

- Layout: the TypeScript ABC layers in Go. Imports point down this list only;
  `scripts/check-architecture.sh` enforces it:
  - `api/<group>/v1alpha1` (common): types, contract status, refs.
  - `internal/contract/<concept>` (abc): small Go interfaces and pure types; api and stdlib only.
  - `internal/kit/<name>` (common helpers): no concept; api, contract, kit and third-party only.
  - `internal/controllers/runtime/<pkg>` (transport): the kcp transport (`kube` typed client and
    awaits, `kcpapply`, `manifests`); the only home of the dynamic client and unstructured objects.
  - `internal/<concept>/<impl>` (impl): one sibling package per backend, the real work, no kcp.
  - Adapters: `internal/controllers/<kind>` turns kcp objects into contract calls (one reconciler per
    kind on `internal/controllers/runtime`); `internal/services/<svc>` turns XRPC/HTTP routes into
    contract calls and holds no role logic.
  - `internal/app/<root>` (factory): one composition controller per app root; wires impls into the
    bus and picks a backend by the `backendRef` kind, never by a flag. Composition code
    (`internal/stack/**`, `internal/devcore/**`, `internal/<x>main`) is this layer too.
  - `cmd/` (CLI); tools (`hack/`, `internal/apigen`, `internal/lexgen`) rank with it.
- Gate. `scripts/check-architecture.sh` (`go run ./hack/archcheck`) fails on any violation of: the
  layer order; backend deps; a ticker or periodic requeue site missing from `deploy/obligations.txt`;
  a System caller missing from `deploy/bus-bootstrap-callers.txt`; a dynamic client or unstructured
  import outside the transport and apigen; a GroupVersionResource literal outside `api/` (foreign
  groups are vars in `api/kcpgvr`); an annotation read as a signal (kcp and kubelet metadata
  conventions are listed in the checker); an endpoint field in spec without `apigen:"userEndpoint"`;
  an impl package without a compile-time assertion against an `internal/contract` interface.
- Go idiom. No factory types: `New(cfg, deps)` takes explicit dependencies and returns a concrete
  struct; callers accept the contract interface. Every impl states its contracts at compile time
  (`var _ compute.Provider = (*Provider)(nil)`). One package, one concept; no util package outside
  `internal/kit`. kcp is a transport like XRPC or the CLI: an adapter translates it.
- No polling. Controllers are driven by watches on their own kind and every kind they ref. Deadlines
  use delayed requeue; errors use the runtime's rate-limited backoff. A ticker is allowed only for an
  outbound obligation and must be listed in `deploy/obligations.txt` with its owner and period.
  systemd units are followed through D-Bus signals, processes through wait.
- Bus. Components and controllers get typed contract handles with `bus.Resolve[T]`. The caller is the
  reconciled object or the component instance, never a controller and never System; System is allowed
  only for the callers in `deploy/bus-bootstrap-callers.txt`. The callee holds credentials; a handle
  never exposes a token. A call with no accepted ComponentLink fails with `ErrLinkDenied`.
- Testable backends. A backend package (`internal/<concept>/<impl>` and the host libraries it drives)
  runs without kcp, the bus or a controller runtime: it takes and returns `internal/contract` and api
  value types, and host effects (systemd, processes, filesystem root, clock) come in through the small
  interfaces of `internal/contract/host`. The controller translates objects to contract values.
  `scripts/check-architecture.sh` reports a backend whose deps reach kcp.
- Writers. Every write is server-side apply with field manager `socialweb.computer/<controller>`. A
  spec is written only by its composer; a status only by the controller of its kind; a field a second
  controller reports is its own status subfield with its own field manager. Every child and intent
  object has an ownerReference to its composer or creator. No annotation carries a control signal.

## Standing constraints

- No explanatory comments in code. The code explains itself through its structure: names, layers,
  contracts and types. Two kinds of comment are allowed: the `ponytail:` form naming a ceiling and its
  upgrade path, and functional comments that a tool reads, such as `//go:` directives, generated-code
  headers and code generator markers (`// +k8s:deepcopy-gen=package`, `// +groupName=`,
  `// +k8s:deepcopy-gen:interfaces=...`). A functional comment carries no prose.
- ASCII only, everywhere, including this file and `docs/ARCHITECTURE.md`.
- Decisions, measurements and invariants live in `docs/ARCHITECTURE.md`, never in code comments.
  An item opens in section 14 and moves to section 15, with its before/after readings, when it
  closes.
- The invariants in section 13 are load-bearing; a change must not break one.
- Runtime runs as USER 1000:1000. Nothing on the L0 host needs root.

## Names: use the real name

Write the real name of a thing: the repository, directory, package, binary, command or plan item id.
Do not make up a short name. Write plain, short sentences, with one meaning for each word. The plans
and older notes use the short names below; read them as the real names.

| Short name in the plans | Real name |
| --- | --- |
| the door | the gateway's web app and tenant API: the GatewayAPI component (`internal/services/gatewayapi`) and `internal/gatewayhttp`, served by `socialweb-computer serve gateway xrpc` |
| the ssh door | the SshServer component (`internal/services/sshserver`) of the SshGateway root (`internal/app/sshgateway`), run by `socialweb-computer run gateway ssh` |
| the core | the `socialweb-computer` process of an instance that runs the controllers (`internal/stack/componentcore`) and supervises the components and HostProcesses; `internal dev-core` in a dev instance |
| a fold, folded | one pass that merged a set of decisions into the plans |
| the spine | the RFP flow: requester, RFP, bid, accept, cloud-init `user_data` |
| the front | the stable entry point in front of a service: the kcp front before the kcp slots (0006 M2-KCP-SLOTS), the TLS front of the market network |
| the edge | caddy and the other stable public entry points that keep a known port |
| a stand-in | a small test executable used in place of k3s, kine or postgres |
| the soak | the last run against the real accounts (the bobvmbuilder bidder and alice), after the market network passes |
| a gate | a check that must pass before an item or a merge goes on (`deploy/check.sh`, a plan's done_when) |
| the seed | the first example that a model grows from |