# Zoomies follow-on roadmap Version 3.14 · 8 November 2026 · derived from the owner's [follow-on roadmap v1.0](roadmap/source/2026-09-06-follow-on-roadmap-v1.0.md), reconciled against `main` at `6d12a72` on 6 September, at `9a80b31` on 19 September and at `88c41f6` on 8 October, when the owner set a new primary target, withdrew the real-use qualification programme that version 2 was built around, and the fifty-six pull requests merged since version 2.40 were read back into it. This is the sole active roadmap and delivery-order source of truth for the next programme: make Zoomies the controller a team can be given, with the team's own machines as its runner hosts (dependable, bounded at every edge, operable without a shell on either side) while the self-hosted product it already is keeps getting better. The ordered delivery plan in section 10 supersedes all earlier ordering. [roadmap/source/](roadmap/source/) contains historical inputs. It explains why work existed, but it may not add, reorder or authorise current work. [roadmap/progress.md](roadmap/progress.md) is the corresponding status/evidence record: it records delivery of packages defined here but does not define a competing plan. New or changed delivery work belongs in this file first. ## 1. Where it stands Zoomies is stable in real use. Three full releases shipped in a week (`v1.0.0` on 13 September, `V1.1.0` on 15 September and `v1.2.0` on 18 September, the last of the three) and since 18 September this repository's own CI, CodeQL, fuzzing and release set-up jobs run on a Zoomies fleet (`zoomies-linux-x64`) through the build under test, with only the arm64 and Windows legs left on GitHub's runners, and, since 19 September, the Scorecard job, whose publishing service accepts results from GitHub's own Ubuntu runners alone and had refused every run from the fleet; a test in `internal/docs` keeps CI on the fleet and another keeps Scorecard off it. The owner has been running it across repositories since at least 9 September ([rc1-triage.md](roadmap/rc1-triage.md)). The seven-day observation window, the two hundred attempts and the second operator that version 2 built its release gate around never happened and are no longer wanted: the product is proving itself by carrying work, every day, on the thing it is for. Since version 2.40 (15 September), fifty-six pull requests (#293 to #350) landed. Roughly in the order they matter to this document: * **Backups grew into a subsystem.** Scheduled copies (`backup.interval`, `backup.keep`), S3-compatible offsite destinations with a hand-rolled SigV4 client and sealed credentials (migration `0036`), a staged restore applied by restarting the controller, a Backups settings page, eighteen `/backups*` routes under three new actions, and a configuration export and import with a dry run (#297, #304, #306, #308, #312). ZF-203's text had cut every one of those; the package is closed as delivered, and larger. * **Every runner has a size, and a pool can lend it more.** A pool that sets no limits now means one slot's share of whichever host it lands on, enforced as a real cgroup limit; a fixed size is the alternative; per-pool overrides of the fleet's timings (migration `0037`); a docker-in-docker pair is one slot again; and elastic CPU bursting lends unpromised host CPU to a busy runner and takes it back under pressure (migration `0039`, #350). The wizard asks how much of a pool the operator wants to decide. * **Runner start-up under load is bounded.** Everything ZF-221 listed as prepared is merged, and more: a FIFO start queue with a cooldown, Docker readiness before a runner registers, per-installation registration admission (`scheduler.registration_concurrency`), queued starts that expire at the provision deadline, owned-container conflict recovery, a runner's resource sample with its age on the API, and deferred registration cleanup recovered by housekeeping (#343, #344, #345, #347, #348). * **Failures say whose they are.** `fault_kind` on jobs and runners (migrations `0034`, `0035`) tells a fleet fault from a workflow's own failure, the Overview counts them apart, and a job can be rerun from the drawer (#295). A cancelled workflow run now cancels its runners promptly, including the ones still provisioning (#348). * **Private hosts recover by themselves.** A Tailcat host re-announces itself on every dial, so it survives a controller restart without anyone restarting it, and a host that lost its private address is told to mint a new command rather than failing to dial (#327, #329). * **The installer meets the machine as it is.** It offers to install Docker or Podman when it finds neither, delegates cgroup controllers to a rootless daemon at set-up, pins `agent.work_dir` so a joined host survives dropping root, and takes a host's capacity from the machine it measured (#311, #322, #323, #324, #326). * **The UI has a settings rail, grouped navigation, per-account table layouts (migration `0040`), a fleet-chosen queue warning threshold and a redrawn usage report**, and the event bus numbers events where they are published and leaves a reconnecting subscriber room after its replay. * **Security**: the OIDC state cache is capped, a password hash nothing could have produced is refused rather than allowed to panic, and runner containers no longer set `no-new-privileges`, so the image's passwordless `sudo` works while the dropped capabilities remain the boundary. * **Releases**: assets upload one at a time, and the `dev` channel is published in a way a slow upload cannot defeat. One thing the reconciliation found still needs the owner's hand (section 11): `V1.1.0` (capital V) is published as a full release with no assets, because the release workflow's tag guard matched `v*` case-sensitively and so built nothing for it. The guard now refuses such a tag out loud (ZF-005, 19 September); the release and its tag are the owner's to delete or leave. The other, `deploy/marketplace/release.env` pinning `v1.0.0`, was repinned to `v1.2.0` with ZF-005, so a marketplace deployment gets the pressure holds, the throttle ladder, the start-up fixes and elastic CPU. ## 2. The primary target The programme's primary target is **an instance operated on a team's behalf, where the team connects its own runner hosts.** Two parties share one controller. The **platform** runs the process: the machine it binds on, its TLS, its database and encryption key, its timers, upgrades, backups and recovery. The **fleet** is the team the instance serves: their GitHub App installation, their pools, the hosts they join through the outbound agent (directly over HTTPS, or through a private connection when a machine cannot reach the public address) and their own users and API tokens. The controller runs with `agent.embedded: false`; every runner is on a machine the fleet owns. One instance serves one fleet. Everything the design already insists on is what makes this shape work. Agents connect outbound only, so a host behind the fleet's NAT needs no inbound rule. A job is attributed to one installation and a runner is minted in that installation alone. An agent is confined to the agent API and to its own host's rows; a report about another host's runner is refused. Version skew excludes a host rather than refusing it, and the host is handed the command that fixes it. Backups leave the machine on a schedule. What the shape lacks is the line between the two parties. Today the fleet's `admin` is the top rung: it reads the platform's bind address, TLS file paths and database path on `GET /settings`; changes `server.bind`, `server.tls.*`, `server.trusted_proxies`, `security.disable_auth`, `oidc.*` and every timer through `PATCH /settings`, because migration `0031` made almost every key an instance setting the database holds; takes a support bundle with the process's paths, OS and heap in it; lifts the recovery fence; and sees `bind.public_no_tls`, `crypto.*` and `controller.lease_lost` in a problems drawer whose fix sends it to a log it cannot read. Nothing bounds what one fleet's agents can do to the process. The usage figures the two parties would reconcile are recomputed from rows the prune loop deletes, on a retention the fleet can shorten. And the first administrator is still created by reading a setup token out of the controller's log, or from an answer file that carries a password. The packages that close those gaps already existed under decision 27, written for a platform team operating an instance for a product team, which is this shape with this trust boundary: **ZF-207** (two audiences), **ZF-208** (limits at the edges), **ZF-209** (a usage ledger), **ZF-210** (unattended bootstrap; export and purge). They move to the front, and each is re-read in section 8 against what the code does now, which in ZF-207's case is further from the package than when it was written. Around them: the remaining half of **ZF-221**, because a host the platform cannot log into has to explain its own runtime failures; the private-host packages **ZF-401 to ZF-404**, because the fleet's hosts are the untrusted edge and the join is the onboarding; **ZF-222**, because the fleet's developers have no account; **ZF-213**, because the fleet's pools are the one thing that should move between instances as a file. Three rules bind the target, and they are delivery rules 3, 15 and 16. One instance per fleet and one trust domain per instance: nothing here separates two fleets inside one process or one SQLite file, and nothing will, because the pure scheduler, the single writer and "every `*.updated` is the `GET` shape" all assume one fleet. The platform's hand on a fleet's host is the agent and nothing else. And none of this is documented: the public story stays the self-hosted, bring-your-own-infrastructure one, every package ships as a self-hosted feature with a self-hosted reason (a platform team operating an instance for a product team is real, and is the reason given) and this file is the only place the target is named. ## 3. How to use this document Each work package keeps the ID the source roadmap gave it, so historic material can be read side by side without becoming an alternative instruction. Each active package names its **classification** (*existing, needs validation*; *extension*; *new*), what to **implement**, what **acceptance** means, its **dependencies**, its **size** (S under a day of one session, M a few days, L a week or more, XL a phase in itself), and the **session** that should do it per [roadmap/agent-models.md](roadmap/agent-models.md). Section 6 lists what is delivered, with a line each; the [work-package record](roadmap/progress.md) carries the evidence. Section 7 lists what version 3.0 withdrew and what, if anything, each withdrawal costs. Section 8 is the active work, in full. Section 9 is what is kept for the day somebody asks. Section 10 orders it. The decisions in section 4 are the ones only the owner can take. Each has a recommendation, and the packages below are written as if the recommendation were accepted; a different answer changes the package it names and nothing else. ## 4. Decisions for the owner Numbered as they always were, because the numbers are the index that the [decision records](roadmap/decisions/) and the progress record cite. A decision that has been taken or overtaken says so in a line and keeps its number. ### Programme 1. **Where this record lives.** `ROADMAP.md` and `roadmap/` at the root, outside the published site, as [decision 0001](roadmap/decisions/0001-planning-documents-live-beside-the-code.md) says. *Accepted; unchanged.* 2. **Which model drives which stage.** Tiered by what the stage risks, per [decision 0002](roadmap/decisions/0002-choose-the-model-by-what-the-stage-risks.md) and [roadmap/agent-models.md](roadmap/agent-models.md). *Unchanged.* 3. **The reference configuration.** *Withdrawn on 19 September 2026.* The controller's reference configuration is the operated instance itself, and the agent-side table in [roadmap/support-and-measurement.md](roadmap/support-and-measurement.md), which says per host OS and backend what has actually run, stays as documentation. Nobody records a reference host's versions any more. 4. **Two assignments, not one.** *Superseded* by section 10 since version 2.34; both assignments' packages are delivered. Kept for the history. 5. **Keep this plan scoped to self-hosted development.** *Replaced by decision 30.* 6. **Gate F as measured.** The definitions are built into the product (`Job.EligibleAt`, `create_task_issued_at`, `fault_kind` on jobs and runners, a `cleaned_up_at` that needs both the host's and GitHub's confirmation) and stay in [roadmap/support-and-measurement.md](roadmap/support-and-measurement.md) and `docs/metrics.md` as the definitions of scheduling latency, of a Zoomies-caused failure and of a denominator. *The gate itself is withdrawn* (section 7); the definitions are what ZF-223 would report per installation. 7. **The schema rule.** Never edit or rename a shipped migration; the next file takes the next unused prefix alone (`0041` as this is written); prefer adding a column; a data-preserving rebuild in a new file is acceptable when SQLite forces it, with the reason in the file header. *Accepted; unchanged.* ZF-207 needs such a rebuild, because `users.role` and `api_tokens.role` carry a `CHECK` naming the three roles. 8. **What `:latest` means.** *Taken by events.* The release workflow moves `:latest` only on a full release (`v1.2.0` moved it on 18 September) and the join command pins the controller's own version, so an untagged pool follows the newest full release and a joining host is told which build to install. Nothing is left to decide. 9. **A tagged pre-release; prereleases marked; immutable releases.** *Discharged.* The upgrade job runs from the latest published release on every pull request; `v0.1-alpha`, `v0.2-beta` and the three release candidates are marked prereleases on GitHub. Whether immutable releases are enabled is one repository setting to check (section 11). 10. **The Enterprise Server claim.** *Taken.* README and FAQ say designed for it and not yet verified against one. Verification waits for a fleet that has one, and is not roadmap work. 11. **Real-runtime evidence in CI.** *Half taken.* The fake-GitHub drill tier runs every drill on every pull request, on amd64 and on arm64, and the whole CI runs on a Zoomies fleet. The credentialed nightly `e2e.yml` is withdrawn (section 7). 12. **Owner actions now.** *Discharged in substance.* The owner deployed `main`, used it, released from it and moved CI onto it; the written version record and the second operator's session are withdrawn. ### Per package Decisions 13 to 22 were taken as recommended and shipped; the migrations and the progress log are their record, and [roadmap/decisions/README.md](roadmap/decisions/README.md) says why they have no file of their own yet. They are kept here verbatim because this list is the only account of what was decided. 13. **ZF-101, the authoritative installation for a job.** The installation found by the job's repository (repository target before organisation target), not the one whose secret verified the delivery, which the code deliberately lets be another installation's; jobs no installation covers are recorded and marked ineligible with a reason, never rejected; the migration backfills every unfinished row, waiting as well as queued and in-progress; the rate-limit hold is per installation only; `installation_id` is exposed read-only on the job. *Taken.* 14. **ZF-102, how far to go.** No durable task queue (stamp the issue time on the row); adopt live workloads on agent start; a state-directory lock plus a controller lease with `--takeover`; a duplicated-agent fence that detects and warns rather than refuses; no resurrection of a terminal row when a lost host returns; the existing rate-bounded retry accepted as "bounded". *Taken.* 15. **ZF-103, the reservation model.** Split in two: 103a is the reporting half and 103b the admission half. A pool that sets no limits reserves its host's allocatable share per slot, so a host carrying only such pools admits exactly what it admitted before the upgrade; a DinD pool is charged twice its limits; the host reserve is per host with a small documented floor; an agent that predates the fields is placed by slots with a visible badge; the `process` backend's non-enforcement is documented and warned about, not fixed here; disk is a gate, not an evictor. *Taken*, then refined by the sizing work of 16 and 17 September: a defaulted pool's share is now enforced as a cgroup limit and a docker-in-docker pair on such a pool splits one slot rather than doubling it. 16. **ZF-104.** Streams re-check their credential on each heartbeat and end; a join token may not replace a host that is still heartbeating unless it is cordoned; the log viewer opens only `http(s)` links; forwarded-proto is believed only from a trusted proxy; per-identity stream caps wait for ZF-105 and evidence; the join route gets the login limiter. *Taken*, with one deviation recorded in the progress log: the join route got a counter of its own at the same setting, so an attacker hammering `/agent/join` cannot lock administrators out of the page they would use to stop it. 17. **ZF-105.** Failed cleanup lives on the runner row, not a new operations table; no maximum job duration (a drain timeout instead); the cache prune is guarded at runtime; Zoomies never deletes images and says so; disk-low handling lands with ZF-103; the ownership of `zoomies-*` registrations across two instances sharing one organisation is documented now and designed in ZF-101. *Taken.* 18. **ZF-201.** Verify reports the installation's repository selection and first names rather than taking a repository as input; a test-only fake GitHub program under `test/` rather than a hidden subcommand in the shipped binary; the real-job proof is the end-to-end test asserting the page shapes, not a browser-driven real job; Add-a-host shows a resume notice, never the token. *Taken.* 19. **ZF-202.** One JSON bundle from one admin route under a new `diagnostics.read` action; never workflow log bodies; the per-job explanation is its own endpoint rather than a field on every event frame; the stale-poller signals are fleet-wide; seeded failures come from an opt-in fixture, not the demo seed. *Taken.* ZF-207 moves the full bundle behind the platform role and gives the fleet one without the process in it. 20. **ZF-203.** Backup is a local command that opens the file, not an API route; the key is excluded unless asked for, with its fingerprint always in the manifest; restore invalidates sessions and unused join tokens by default, with flags for API and agent tokens; the store refuses a database newer than the binary; the fence lifts through one audited admin route. *Taken, then overtaken on 16 September*: backups have an API, a page, a schedule and offsite destinations now, and the command is one client of the shared `internal/backup` package. The key rule, the refusals and the fence are unchanged. 21. **ZF-204.** Protocol must match and an agent may lag one minor release; an agent found incompatible at heartbeat is flagged and excluded from placement like a cordon, never sent into a restart loop; the store takes a `VACUUM INTO` copy before pending migrations, using ZF-203's primitive; build-provenance attestations rather than signing keys; a GitHub-hosted path for the release and site workflows selectable by dispatch input; Dependabot weekly and grouped, actions pinned to commits. *Taken*, with the correction the progress log records: "one minor release" is not a rule the code can enforce, so what is enforced is the protocol, and lag beyond that is shown. 22. **ZF-205.** Audit rows stay unpruned, with a size-awareness problem; the load fixture is a test-only generator, never a knob on the demo seed; the p95 figures are recorded evidence, not a pull-request gate; the poller-pause gauge is fleet-wide; no stream caps until the drills show growth. *Taken*, except that the pause gauge carries the installation, because ZF-101 had already made the hold per installation. ### Noted, not yet due 23. **Licence and contributor terms.** Keep the AGPL-3.0 licence visible and document contributor expectations. No licence change is part of this programme. *Unchanged.* 24. **Whether the controller may dial a machine.** Refined. The controller now does dial one kind of machine, a private *provider*'s API, through `zoomies gateway` (ZF-214b), and that is an infrastructure API, not an agent; "the controller never dials an agent" holds everywhere it is stated. For the primary target it hardens into delivery rule 16: the platform never dials a fleet's host, so an SSH bootstrap is not optional but out, and ZF-402 is withdrawn with it. *Recommend: accept.* 25. **Host stewardship and bounded housekeeping.** The non-invasive default is the promise a fleet relies on when it runs the platform's enrolment command on its own machine, and the code keeps it with one exception the reconciliation found: `agent.docker_build_cache_mb` (default 5120) has every Docker-backed agent ask the daemon to prune *unused* builder cache above that size every five minutes, daemon-wide, not only layers Zoomies built. It is bounded and it touches nothing in use, and on a shared daemon it is still somebody else's cache. *Recommend: keep the default, name it on the security and private-hosts pages as the one thing the agent does to a shared daemon, and make `0` the shared-daemon advice* (ZF-404). ZF-404b, the opt-in dedicated-host maintenance slice, is deferred indefinitely: the hosts are the fleet's machines, and a platform upgrading or rebooting them is a liability, not a feature. Narrowed on 8 October 2026 by [decision 0011](roadmap/decisions/0011-an-operator-may-let-zoomies-update-itself.md): updating the agent's own binary, on a host that opted in, is ZF-232; the rest stays deferred. 26. **Windows runners, and which kind.** *Taken on 12 September 2026* as processes on a Windows host, per [decision 0003](roadmap/decisions/0003-windows-runners-are-processes-on-a-host.md). Built, vetted and unit-tested, and since 11 October 2026 tried by hand on a real Windows host, which joined and ran a job. The support-matrix row moves when the evidence is written up, and gates nothing. 27. **Separate platform administration from fleet operation.** *Now the primary target;* see decision 30. One instance remains one trust domain. 28. **A container per job on Proxmox, from the runner image we already publish.** Deferred. Not before a fleet with Proxmox asks for it, and not before the one-day spike the recommendation describes. 29. **Whether any fleet fact may be read without an account.** ZF-222's name-free, banded status projection, off by default. On the primary target the audience with no account is the fleet's own developers, whose jobs queue on hosts their team owns, and the projection is built on the fleet half of ZF-207's problem split, so it cannot carry a platform finding. *Recommend: adopt now, off by default, sequenced after ZF-207.* ### Taken on 19 September 2026, and the two it opens 30. **The primary target.** An instance operated on a team's behalf, where the team connects its own runner hosts; section 2 defines it. *Taken by the owner.* The public site may now offer this as the optional Zoomies Cloud hosted controller at launch; delivery rule 15. 31. **What a fleet may change on an operated instance.** ZF-207 splits the settings registry a fourth way. A key is *platform-scoped* when it changes what the process binds, trusts, stores, logs or dials from its own machine, or how much of that machine it spends: `server.*`, `security.*`, `log.*`, `backup.*`, `retention.*`, `updates.*`, `metrics.public`, `capacity_demand.*`, the `limits.*` ZF-208 adds, and the embedded agent's `agent.embedded`, `agent.backend`, `agent.work_dir` and `agent.docker_host`. A key is *fleet-scoped* when it changes how the fleet's own runners are placed, timed and sized: `scheduler.*`, `runners.*`, `images.*`, `github.*`, `oidc.*`, `ui.*` and the rest of `agent.*`. `database.*` and the encryption key stay bootstrap-only, as now. On a single-team instance nothing changes, because the account that installed it becomes the platform. *Recommend: adopt this split; a key the owner would move changes one table in `internal/config/settings.go` and nothing else.* 32. **The fleet's secrets under the platform's key.** An installation's private key and webhook secret are sealed with the instance's encryption key, which the platform holds; every backup carries them sealed, and a backup the fleet downloads is unusable without a key only the platform has. That is how every self-hosted instance already works and it is the right model (the process has to read the key to mint credentials) but it means a fleet cannot leave with its App on its own. *Recommend: accept the model, and give ZF-210b's per-installation export a passphrase re-seal, using the argon2id-and-AES-GCM pattern `backup_remotes.passphrase_enc` already uses, so offboarding never needs the instance key.* ## 5. Delivery rules The source roadmap's rules, corrected where the repository already answers them, with three added for the primary target. `CLAUDE.md` is the agent guidance file (there is no `AGENTS.md`) and it is tested: its layout block and `README.md`'s must name every top-level directory. Read it, `docs/architecture.md` and `docs/upgrading.md` before anything structural. 1. Preserve the single binary, SQLite, the pure scheduler and the controller-and-agent shape. No Kubernetes, no broker, no database service, no microservices. 2. Host provisioning and runner execution are different things. The backend interface is the per-host execution contract; the provider contract (`internal/provider`) is the controller-side one for renting a machine, and it may import the store's domain types and nothing else. 3. One instance is one administrative trust domain, and one instance serves one fleet. Two installations of that fleet still need correct target scoping, which is ZF-101. Nothing separates two fleets inside one process or one database, and no package may try: the pure scheduler, the single writer and the event stream's "every `*.updated` is the `GET` shape" all assume one fleet, and a second would have to be a second instance. 4. The UI, the CLI and any bundle are clients of the same REST API; nothing is reachable from one that is not reachable from the others. Build UI workflows on validated application operations; never duplicate orchestration in Svelte or in a command. 5. Network side effects are restart-safe, bounded and observable. Durable identity, ownership checks and reconciliation; never a claim of exactly-once across GitHub, agents and providers. 6. Reuse the components, the tokens, the generated clients, the error shapes, the auth and the audit conventions. Every endpoint change updates `api/openapi.yaml`, regenerates both clients and touches `docs/api-surface.md`; every new problem code, metric and command needs its row on the page that `internal/docs` tests. 7. The schema rule is decision 7: never edit or rename a shipped migration; the next file takes the next unused prefix alone; add columns by preference; rebuild a table in a new file only when SQLite forces it, saying why in the header. Two tests are part of that rule rather than a consequence of it. Every new file is appended to `shippedMigrations` (`internal/store/migrations_test.go`), which says so when it fails. One that touches `jobs` must also be added to `TestTheJobsRebuildKeepsEveryRowAndItsIndexes` (`internal/store/store_test.go`), which unwinds every later migration that touched that table; forgotten, it fails on a `SELECT` under "the completed row did not survive the rebuild", which blames the rebuild rather than the omission. 8. Small commits, small pull requests, one behaviour each, an imperative sentence in plain prose as the message (no `feat:` prefix, which one commit on 19 September carried and which the next reader should not copy) British spelling in prose, a test that reads as a sentence about the behaviour, and `make lint` and `make test` green before a push. Behavioural tests for concrete new failure modes, never tests that repeat implementation details. 9. New capabilities start disabled until their acceptance criteria are met. A disabled feature starts nothing, creates nothing and needs no credential on an existing installation. 10. Every dependency carries a one-line reason in `docs/dependencies.md`. A library for a VM runtime is reasonable and gets its row and a security note; a plugin framework for one integration is not. 11. Logs and bundles are sensitive. Platform-managed credentials are redacted by key name (`auth.Redact`); arbitrary secrets in workflow output cannot be recognised and `docs/security.md` says so; the bundle therefore never carries log bodies. 12. Never auto-upgrade a host, remove a customer-owned machine or weaken a trust check as a recovery shortcut. An update an operator has switched on is not a recovery shortcut; see [decision 0011](roadmap/decisions/0011-an-operator-may-let-zoomies-update-itself.md). 13. A skipped test is not a pass. A harness that cannot run reports "not run" or "blocked", and a gate that counts it as passed is lying. 14. A behavioural test is kept only once it has been run against the code with the rule it asserts removed and seen to fail; an assertion that cannot be made to fail is deleted rather than shipped, and the pull request says which assertions were checked this way. 15. **Cloud is an optional hosted controller.** The site and documentation may offer Zoomies Cloud alongside self-hosting. Keep the free software's licence and community support distinct from the paid service's terms. Pricing, billing and service support belong on the Cloud site; customers still supply and operate their runner machines. Every package below ships as a self-hosted feature with a self-hosted reason (a platform team running an instance for a product team, which is real) and is off or invisible on an instance that has only one team: a fleet admin on an instance with no separate platform identity sees exactly what it sees today, because the account that installed it is the platform. 16. **The platform's hand on a fleet's host is the agent, and nothing else.** No SSH, no controller-initiated dial to a host, no OS package, firewall or reboot, and no pruning beyond what the join command and the security page say the agent owns: its service, its work directory, the containers carrying its labels, its per-pool cache directory and the bounded builder-cache target of decision 25. 17. **Real-use confirmation is not a gate.** A package is complete when its code is merged with tests that were seen to fail, CI is green and its acceptance holds in a fixture or a drill. Evidence from a real fleet is welcome and recorded when it arrives, and it never blocks a release or a package. The two drill tiers stay, because they are regression coverage, not confirmation. ### The work-package record [roadmap/progress.md](roadmap/progress.md) has one row per package with its classification, status, dependencies, the session that did it and the evidence. Statuses are `not_started`, `in_progress`, `implemented`, `done`, `validated`, `blocked`, `superseded` and, from version 3.0, `withdrawn`, each defined in the record itself. `done` (every pull request the package names merged and its acceptance holding) is the terminal status; `validated` is kept for the rows that already carry evidence in [roadmap/validation/](roadmap/validation/) and is no longer a rung any package has to reach (rule 17). `withdrawn` is for work version 3.0 stopped authorising, with the reason and what it costs; `superseded` links to the thing that meets the same criteria. Keep it current in the pull request that changes it. Small design decisions go in [roadmap/decisions/](roadmap/decisions/), gate evidence in [roadmap/validation/](roadmap/validation/). ## 6. Delivered One line per package, with what shipped and the pull requests that carried it; the [work-package record](roadmap/progress.md) holds the evidence and the log holds the reasoning. Every package here is `done` unless the line says otherwise, and where version 2 left a real-use clause open it is named in section 7 rather than here. ### Phase 0, the baseline * **ZF-001 and ZF-002**: the reconciliation, the support matrix and the measurement contract, with the code slice that puts the migration ledger on `/readyz` (`schema.applied`, `schema.latest`), the `waiting` and `approved` timeline kinds and the four-way completed split. Extended on 16 September by `fault_kind` on jobs and runners (migrations `0034`, `0035`, #295), which made the contract's fleet-fault-versus-workflow-fault line a counted column. * **ZF-003**: every action commit-pinned and tested for it, Dependabot weekly and grouped, `govulncheck` on every change and weekly; CodeQL, Scorecard, fuzzing and package cleanup workflows followed ([scorecard-hardening.md](roadmap/validation/scorecard-hardening.md)). * **ZF-004**: six narrowings from reading the instance as a service; a demo id recognised by shape, installation targets folded, a held installation spending no more quota, `history_from` on the usage report, `retention.scaling_events`, and an unsigned webhook refused before its body is read. The first package written from the primary target's question. * **ZF-005**: the three pages corrected against the record (`docs/index.md` and the support matrix say what runs where now that CI runs in containers on a Zoomies fleet, and `docs/proxmox.md` says the harness has not been run) the marketplace package repinned to `v1.2.0`, and the release workflow's tag guard made loud (19 September). ### Phase 1, correctness and security under failure * **ZF-101**: a job belongs to one installation (migration `0012`), `scheduler.Eligible` before labels on every path, per-installation poll freshness and rate-limit holds, `pool.runner_group_unresolved` (#99, #100, #101). * **ZF-102**: invariants written and pinned, the state-directory lock and controller lease with `--takeover` (`0013`), adoption on agent start, `task_issued_at` and `host.duplicate_agent` (`0014`), the seven-boundary restart table (#82, #103, #104, #105). Since: GitHub calls no longer run under `reconcileMu`, and the log relay no longer queues tasks for a host that never comes back (#299). * **ZF-103**: hosts report CPUs, memory and disk (`0017`), the scheduler places by them and says "short of memory", operators set a reserve and see what is promised away (#115, #118). Since: every runner has a size (`runners.default_cpus`, `runners.default_memory_mb`; #305), per-pool timing overrides (`0037`, #311), a stranding edit answers 409 (#302), a docker-in-docker pair is one slot (#314), the runner's own resource sample reaches the API (`0038`, #344), and elastic CPU bursting (`0039`, #350). * **ZF-104**: every identity class walked positive and negative, the log relay bound to its host, streams that end on revocation, hostile input through the pages and the log viewer, forwarded-proto believed only from a trusted proxy (#90, #116, #118, #119, #120). Since: the OIDC state cache is capped (#317) and an impossible password hash fails closed (#309). * **ZF-105**: failed cleanup on the runner row and in the drawer (`0015`, `0022`, `0024`), orphaned sidecars, the cache prune guard, a drain timeout, GitHub's backoff honoured per installation, the poll-shedding channel finally set (#76, #108, #110, #111, #113, #120). Since: a busy registration is deferred cleanup rather than a lost permission (#342), housekeeping recovers deferred registration cleanup (#344), and a cancelled run stops its provisioning runners (#348). ### Phase 2, operable and usable * **ZF-201**: the journey's three defects, verify's repository selection, a standalone fake GitHub for Playwright and the connect-fail-then-recover spec. Since: the pool wizard's automatic and advanced paths (#311) and the installer's Docker-or-Podman offer (#322). * **ZF-202**: partial-failure tolerance in the drawer, the poller made visible, the runner's stages, `GET /jobs/{id}/explanation` rendered by the drawer and the CLI, the `ZOOMIES_SEED_STUCK` fixture and its Playwright project, and the admin-only, capped, secret-free support bundle. Since: fault categories and a rerun button (#295). * **ZF-203**: the store primitives and startup guards, `zoomies backup`, `zoomies restore` with every refusal before anything moves, the fence, and the mechanical drill in CI. Since, and beyond the package as written: the shared `internal/backup` package, scheduled copies, a staged restore applied by restart, eighteen `/backups*` routes under `backups.read`, `backups.write` and `backups.restore`, the Backups page, S3-compatible offsite destinations sealed under the instance key (`0036`), and a configuration export and import with a dry run (#297, #304, #306, #308, #312). * **ZF-204**: protocol checked on every heartbeat with an incompatible host excluded like a cordon, skew visible through one comparison, schema safety tested from both old releases, backup-before-migrate, provenance attestations, OCI labels, the published-tag guard, a GitHub-hosted dispatch path, and `make test-upgrade` from the latest published release on every pull request. Since: assets upload one at a time and the `dev` channel survives a slow upload (#315, #316). * **ZF-205**: the four series, the registry-wide label rule, prune tests, UI honesty about the window, the ten-thousand-job load fixture and its record ([load-7d9441b.md](roadmap/validation/load-7d9441b.md)), the audit index it found. Since: a fleet-chosen queue warning threshold (#330), the usage report on the shared trend plot (#300), and the event bus fixes of #309. * **ZF-206** (`implemented`): the Windows agent builds, installs as a service through `sc.exe`, unpacks the `.zip` runner behind the same traversal refusal, kills a job's tree through a job object and retries a locked work directory. Nothing has joined a Windows host or run a job on one when this was written; the owner has since tried it on a real Windows host (11 October 2026), and the evidence is still to be recorded. ### The September packages * **ZF-218** (`implemented`): `deploy/marketplace/`: the pinned release contract and image lock, the cloud-config renderer, the answer template, the first-boot script, five certificate arrangements, the operator and partner guide and `SUPPORT.md`. The pin is stale (section 11). * **ZF-220**: usage sampled through heartbeats (`0028`), a headroom score, pending-start accounting, pressure holds with hysteresis, the throttle ladder (`0029`), host history for the utilisation chart (`0032`), the host card's explanations and five gauges. Since: reserves that scale with the machine, a busy daemon retried and then avoided (#338), and `agent.bootstrap_cpu_grace` (#343). * **ZF-221** (`in_progress`; section 8): every bullet version 2.40 listed as prepared, plus registration admission, the FIFO start queue, queued-start deadlines, conflict recovery, deferred cleanup recovery and prewarm telemetry (#343, #344, #345, #347). What remains is the durable runtime-incident record. * **ZF-214a**: the provider contract, its fake, the conformance suite, the `providers` and `machines` schema (`0030`), the machine loop on its own clock, the pure machine-demand calculation and the scale-from-zero signal. **ZF-214b** (`implemented`): the Proxmox provider end to end, its preflight and discovery, ownership marks, the REST and CLI surface, the Providers and Machines pages, `zoomies gateway` for a private provider (`0033`), the runbook and the qualification harness. The harness has not run against a cluster, and section 7 says why that no longer gates anything. * **ZF-301a and ZF-301b**: the real-GitHub harness made honest (it can no longer skip green), and the drill tier (the built binary as controller and as a remote agent joined by token, a real workload on the machine) green on every pull request on amd64 and arm64. * **ZF-302**, five of six: the controller and agent killed mid-job, a dead Docker socket named, a rate-limited installation standing down and coming back, a failing JIT endpoint leaving nothing behind, and the restore-and-rollback drill. Each found something worth the row it wrote (change record, versions 2.28 to 2.32). * **Tailcat private hosts** (the delivered half of ZF-401 to ZF-404): a private connection built into the agent and the controller, the Add-a-host choice, the observed connection badge, `hosts join-token create --connection tailcat`, two real-relay tests in the ordinary suite, and the two recovery fixes of 18 September (#327, #329). ### The October packages * **ZF-232**: updating Zoomies from the web UI, off by default: `updates.mode` and `updates.soak`, a root-owned update helper that each host's owner installs, the controller and each host updated as recorded attempts, `auto`'s pure planner taking hosts to the controller's release one at a time and halting at the first failure, and the drills, `docs/upgrading.md` and the screenshots (#719, #734, #743, #757, #759, #786, #789, #797, #801, #803, #816 and the Part 6 pull request). ## 7. Withdrawn Version 3.0 stops authorising the following. Each entry says what it was for and what withdrawing it costs, so that the decision is one somebody can disagree with later rather than a silent omission. The code, harnesses and records they produced stay where they are. * **Gate F**, the trusted-workload beta gate: the seven-day window, the two hundred attempts with three over-capacity bursts, the twenty failures and twenty cancellations, the readiness verdict. *Cost*: no written beta verdict. *What survives*: every definition it forced into the product (decision 6), the restore drill on every pull request, and the fact that the product carries this repository's CI. * **ZF-303**, the readiness record and the second operator. *Cost*: the second operator's set-up-and-diagnose session, which was real usability feedback; the first fleet onboarding onto an operated instance is that session now. *What survives*: the evidence helper's substance becomes ZF-223, a report per installation that the platform owes a fleet. * **ZF-301c**, the credentialed real-GitHub scenarios and the nightly `e2e.yml` with a protected environment and a tunnel, with the `e2e.yml` half of decision 11. *Cost*: a run link per adversarial scenario (cancellation, an intended failure, a DinD build, a service container) against real GitHub. *What survives*: the harness under `test/e2e` and `make test-e2e` for a developer with an App; the drill tier covers the same fault shapes against the fake; CI on the fleet exercises real delivery, real Docker and DinD on every pull request. * **ZF-302's two remaining halves**, the small-filesystem drill and the daemon-stopped-then-restored drill, which needed the reference host. *Cost*: a recorded answer to what the fleet does when a host's disk fills or its daemon restarts. *What survives*: the product surfaces both through `host.*` problems and the disk gate; the drill tier now runs on a Docker-backed runner, so whoever wants the second drill can write it without a reference host. * **ZF-219**, the pristine-VPS verification and the friendly-provider pilot. *Cost*: no independent operator has followed `docs/marketplace.md` end to end, and it says so. *What survives*: the rendering tests in `internal/docs` and the evidence template ([marketplace-deployment.md](roadmap/validation/marketplace-deployment.md)) with every row `not run`, which is honest. * **ZF-211's measurement half**, the reproducible cold-and-warm workload with p50 and p95 per stage. *Cost*: no quotable queue-to-start figure with its setup. *What survives*: the histograms and stored timestamps that would produce one, and the documentation half of the package, which became ZF-005 and is done. * **The live-qualification clauses** of ZF-220 (a live fleet before release inclusion), ZF-221 (bursts of 1, 4, 8 and 16 on a reference Docker host; the runner-and-sidecar budget evaluation), ZF-214b (twenty cycles on a disposable cluster before the provider is called qualified) and ZF-206 (a job on a Windows host before the package closes; the owner has since run one by hand, 11 October 2026, without the written evidence). *Cost*: the support matrix keeps saying "built, not run" for Proxmox, and `docs/proxmox.md` stopped saying "qualified" (ZF-005). *What survives*: the harnesses, which a fleet with a cluster or a Windows host can run, and whose rows are welcome. * **ZF-203's GitHub half** (sign in, re-join an agent, run a job after lifting the fence, recorded once), **ZF-204's** "first real drill recorded in `roadmap/validation/`", **ZF-201's** real-credential run of the Go end-to-end tier, and **ZF-001's** reference-host version record. *Cost*: none that a CI drill or the upgrade job does not already cover. * **Decisions 3, 4, 9, 10 and 12** as owner actions, and **decision 11's** nightly half, each marked in section 4. * **ZF-402**, resumable operation ids, cancellation of an in-flight enrolment and the optional SSH bootstrap. *Cost*: none for the primary target. The enrolment already fails closed (a tunnel that will not start mints no token, an unused token can be revoked, and a re-join on a host that already joined needs `--yes`) and the platform never dials a fleet's host (rule 16), so an SSH bootstrap contradicts the target rather than serving it. * **ZF-404b**, dedicated-host maintenance windows, controller-driven agent upgrades and retention-driven cleanup of a host. Deferred indefinitely (decision 25) rather than withdrawn: it stays in section 9 with its reason. ## 8. Active packages In the order section 10 sequences them. Each was re-read against `main` at `9a80b31`; where the code has moved since the package was written, the package says so and describes the work from where the code is now. ### ZF-207: two audiences for one instance **Classification: extension; M, and the gate the primary target waits on.** Not started, and further from done than when it was written, because the instance-settings work moved the boundary the wrong way for this shape. What the code does today: `store.Role` has three values and `users.role` and `api_tokens.role` each carry a `CHECK` naming them (`0001_init.sql`); the three-role vocabulary is repeated in the CLI's `--role`, the OIDC group mapping, `web/src/lib/roles.ts`, `types.ts` and every `x-zoomies-role` in the OpenAPI document. `settings.read`, `settings.write`, `diagnostics.read`, `recovery.write` and the three `backups.*` actions all sit at `admin`, as do `/settings/export` and `/settings/import`. Migration `0031` and `internal/config/settings.go` split keys into *bootstrap* (the database path and the encryption key), *local* (a standalone agent's own keys) and *instance* (everything else, `server.bind`, `server.tls.*`, `server.trusted_proxies`, `security.disable_auth`, `security.rate_limit_logins` and `oidc.*` included) and any admin writes an instance key through `PATCH /settings`; the validator refuses only a change that would stop the next start. `GET /settings` returns `config_path`, `database_path` and the subscriber count; the bundle carries the paths, the OS, the CPU count and the heap. `Controller.Problems()` builds one list, every validator warning and error first, and `problems.updated` reaches every subscriber; the only per-identity redaction on the stream is the pool `env` blanking in `sse.go`, which is the pattern to copy. "This controller" appears on fifty-three lines under `web/src` and "the controller log" in nine places, `ErrorState` among them. The audit list withholds `before` and `after` by role and never the `ip`. The About panel already hides the database path below `admin`, and the Settings pages a role cannot use are shown locked with a reason, on purpose. **Do, in five pull requests:** 1. **The role.** A fourth role, `platform`, above `admin`, in `store.Role` and its rank, in a migration that rebuilds the two `CHECK` constraints (a data-preserving rebuild under decision 7, reason in the header), in the CLI's `--role`, in an `oidc.platform_groups` mapping, in the two TypeScript vocabularies and in the OpenAPI document. The migration promotes the oldest enabled `admin` to `platform`, because on every existing single-team instance the person who installed it is the platform and the alternative (an instance in which nobody can change a timer) is a lock-out. Every bootstrap path creates `platform`: the setup-token page (whoever can read the log is the platform), the answer file and ZF-210a's environment variables. An admin created by one is not. `ErrLastAdmin` gains a sibling: the last enabled `platform` cannot be demoted or deleted. 2. **The actions.** `platform.settings.read`, `platform.settings.write`, `platform.diagnostics.read`, `platform.recovery.write` and `platform.backups.read`, `.write`, `.restore`, with `GET`/`PATCH /settings` for platform-scoped keys, `/settings/export`, `/settings/import`, `/diagnostics/bundle`, `/recovery/unfence` and every `/backups*` route behind them. Backups move whole: a backup is the process's database sealed under the process's key, and a fleet that wants its data has ZF-210b. The walk tests gain the fourth fixture, and `TestEveryActionHasARole` keeps holding. 3. **The settings scope.** A fourth `config.Scope`, `ScopePlatform`, on the keys decision 31 lists. `Stored()` stays true for them, so the export, the import and the file seed keep working; `editable()` requires `platform`; a fleet identity's `GET /settings` omits platform-scoped keys, `config_path`, `database_path` and the subscriber count entirely, and the Configuration page renders what it is given. On a single-team instance the platform account sees what admin sees today. 4. **The problems split.** `Audience` (`platform` or `fleet`) on `controller.Problem`, set where each is made: every validator finding, the lease, loop panics, the update check, the capacity-demand receiver, the `backup.*` problems and the private-connection listener are the platform's; the fleet's are the rest. `GET /problems` filters by identity and `problems.updated` is rendered twice and chosen per subscriber, the way pool `env` values already are. ZF-222 builds on the fleet half. The full bundle is `platform.diagnostics.read`; `admin` gets a fleet bundle from the same handler with the instance section reduced to the version and the build, because "send me a bundle" is the platform's first support question and the fleet has to be able to answer it. 5. **Tokens, copy and audit.** An `owner_role` column on `api_tokens`, in the same migration as the role: a token minted by a `platform` identity is invisible to and irrevocable by anyone below `platform`, which is where the platform's metrics scraper token lives. "This controller" becomes Zoomies or the fleet; the nine "controller log" sentences become one `supportHint()` on each side that says to quote the request ID to whoever operates the instance; a test in `internal/docs` fails on either phrase in `web/src` outside an allowlist. The audit `ip` is blanked below `admin`, in the same place the documents are. The locked-with-a-reason Settings pages stay as they are: the package once asked for absence, the code chose a reason, and the reason is better. **Accept when:** a viewer, an operator and an admin fixture each see no bind address, file path, key location, process figure or another installation's example in any page, response or event frame, with a Playwright test per role; only `platform` can change a platform-scoped key, lift the fence, take the full bundle, run a restore or list a platform token; an instance upgraded from today has exactly one `platform` identity afterwards and it is the account that bootstrapped it; the OpenAPI document, both clients, `docs/security.md`'s role table and `docs/api-surface.md` say the same thing; `zoomies users` and the Users page can create the fourth role and say what it is for, in self-hosted words. Depends on ZF-202 (done). Size M, larger than written: the scope split and the schema rebuild are the work. Session: Claude Fable 5.1 at `high` for the scope split and the migration, Claude Opus 5 at `high` for the rest. Decisions: 27, 30, 31. ### ZF-208: limits at the public and agent edges **Classification: extension; S to M, the second gate.** Not started. Since it was written, one edge limit landed, the OIDC pending-state cache is capped at 1024 with a 429 (#317), and within-tier fairness landed with #345, so the create-budget item is the tier boundary alone. What the code does today: only `/agent/join` is rate-limited among the agent routes; heartbeat, results and report carry the one-megabyte body cap and nothing else, and a report is a bare array with no count cap; `PollTasks` counts polls in flight fleet-wide and sheds only polls that found nothing, from 256 held to fifteen seconds at 512, so one host's polls slow every host's; the log relay is exempt from the body limit by design; no `limits.*` key exists and nothing bounds hosts, pools, join tokens or event subscribers; the webhook body is five megabytes. And a fleet admin can point `oidc.issuer`, `capacity_demand.destination_url`, `github.api_base_url`, `agent.runner_download_url`, a backup remote's endpoint or a provider's endpoint at the process machine's metadata service or its LAN, because the only address check is loopback on providers. **Do, in one pull request per item:** 1. A per-host token bucket on heartbeat, report and results, reusing `auth.RateLimiter` keyed by host id and sized from `agent.heartbeat_interval` so a well-behaved agent never meets it; one in-flight task poll per host, a second answered at once with an empty batch; a cap on runners per report; a per-stream byte budget on the log relay that drops rather than blocks, as the relay already does. 2. Fleet-wide ceilings as configuration (`limits.hosts`, `limits.pools`, `limits.runners`, `limits.join_tokens`, `limits.event_subscribers`) platform-scoped under decision 31, each zero by default meaning unlimited, each refused at the handler with a message naming the ceiling, each a warning in the validator when set on a loopback bind, where it protects nothing. 3. The create budget shared fairly across pools of *different* priority before priority decides within a tier: a highest-priority pool may take the whole tick only when no lower tier has demand it has waited a full interval for. The scaling reason says when a pool was deferred by this. 4. The webhook body cap lowered from five megabytes to one; a `workflow_job` is tens of kilobytes. 5. An outbound address guard: one `config.CheckOutboundURL` refusing loopback, link-local and private-range destinations for the six settings above unless a platform-scoped `security.allow_private_egress` is set, called from the validator for the four keys and from the backup-remote and provider validators; a finding `egress.private_target` (error) with its row in `docs/problem-codes.md` and a paragraph in `docs/security.md`. On a single-team instance whose OIDC issuer is on the LAN, the operator sets the one key and the finding names it. **Accept when:** each limit has a test that reaches it; in the drill tier, one agent hammering heartbeats and polls leaves another host's task delivery within one poll interval, asserted rather than recorded; a private-range issuer is refused by name and admitted by the setting. Items 1, 3, 4 and 5 depend on nothing; item 2's scope depends on ZF-207. Size S to M. Session: Claude Sonnet 5 at `high`; Claude Opus 5 at `high` for the address guard, where a wrong list is a hole. Decisions: 27, 30, 31. ### ZF-209: a metering ledger the usage report can stand on **Classification: extension; M.** Not started. The seam is as it was: the usage report is computed at read time from `jobs`, `runners` and `usage_capacity_samples`, ZF-004 made it say where those rows begin, and the prune loop rolls nothing up before it deletes. Since it was written the report has been redrawn (#300) and `history_from` is on the JSON response but not on `/usage.csv`. On the primary target these are the figures the two parties reconcile, and today they are truncated at `retention.runners` (seven days) by a key the fleet can shorten. **Do:** 1. A `runner_sessions` table written once, when a runner's cleanup is confirmed (`cleaned_up_at` in its `0022` sense, host removal and GitHub's absence both seen) with runner, pool, host, installation, the job it ran if any, started, registered, finished, and the pool's cost rate at the time. Never updated; its own `retention.runner_sessions`, a year by default, platform-scoped with the other retention keys. 2. A `usage_daily` roll-up per pool, host and installation, produced by the prune loop *before* it deletes the rows it is computed from, so the report is complete for every day the roll-up covers however short the row retention is. Integer seconds and integer minor currency units keep sums exact. 3. `/usage` reads the roll-up for the days it covers and the rows for the rest, `history_from` becomes the roll-up's start rather than the rows', and `/usage.csv` gains the same field. `docs/metrics.md`'s "what happened last month is your scraper's problem" sentence says what is kept now. **Accept when:** a 90-day report taken with seven-day runner retention matches, to the second, one taken with retention off; a runner session is written exactly once across a controller restart mid-cleanup (the controller-restart drill); the roll-up is reproducible from the sessions table alone. Depends on ZF-105 and ZF-205 (done) and on ZF-207 for the scope of the retention keys. Size M. Session: Claude Opus 5 at `high`. Decisions: 27, 30. ### ZF-210: unattended provisioning and lifecycle Split as version 2.34 split it, without changing the ID: **210a** is bootstrap and readiness, **210b** is export and purge. The parent is complete only when both meet acceptance. #### ZF-210a: bootstrap and readiness **Classification: extension; S.** In progress, because its fourth item landed before the package existed: `zoomies init --answers FILE` implies `--non-interactive`, `--print-answers` writes the template, the file refuses rather than guesses at anything missing, and `mode: controller`, `github.skip` and `pool.skip` already describe a controller-only instance; the marketplace bootstrap depends on it. Two things it does not do: a containerised deployment skips the administrator by design and ends in the browser, and the first administrator the file creates writes no audit row. `bootstrap_required` is on `/api/v1/meta` and not on `/readyz`, so a provisioner waits on two endpoints. Every page that describes an unattended install still tells the reader to read the setup token out of the log. **Do:** 1. `ZOOMIES_BOOTSTRAP_ADMIN` with `ZOOMIES_BOOTSTRAP_PASSWORD_FILE` or `ZOOMIES_BOOTSTRAP_TOKEN_FILE`, read once at controller start beside `printSetupToken`: when the users table is empty they create the first identity as `platform` (ZF-207), audited as `auth.bootstrap` with actor `system`, and once any user exists they are ignored with a warning finding that names them. The token variant mints a platform API token instead of a password, so a provisioner can drive the instance's API with no browser at all. The setup-token flow stays for a person, and the answer file's `admin` keys now audit the row they create. 2. `bootstrap_required` on `/readyz` beside `fenced`, so one endpoint answers "can I use this yet". 3. A committed controller-only answers template (`agent.embedded: false`, `github.skip`, `pool.skip`, TLS from files, the external URL) beside the marketplace one, tested the way `internal/docs` tests that one; the `agent.*` settings section hidden when the instance reports `agent.none`; `docker-compose.yml`, `docs/configuration.md`, `docs/marketplace.md` and `docs/security.md` describe the environment variables as the unattended path and stop telling every reader to scrape the log. The self-hosted reason is on the page already: a compose file or a Terraform module has nobody watching. **Accept when:** a compose file brings up a controller, a platform identity and one joined agent with no human step and no log scraping, as a fixture test; the setup-token page still works for a person; the audit log shows who bootstrapped and how, on every path. Depends on ZF-207 for the role. Size S. Session: Claude Sonnet 5 at `high`. Decisions: 27, 30. #### ZF-210b: export and purge of one installation **Classification: extension; M.** Not started. `DELETE /installations/{id}` removes the installation, its pools and their runners, and leaves jobs, deliveries, scaling events and capacity samples behind by design; the configuration export moves settings, not history; the whole-database backup is sealed under a key the fleet does not hold (decision 32). **Do:** 1. `zoomies export --installation ID` writes everything about one installation (its pools, runners, jobs, deliveries, scaling events, the sessions from ZF-209 and the audit rows that name any of them) as one archive, and with `--passphrase-file` re-seals that installation's private key and webhook secret under the passphrase, using the argon2id-and-AES-GCM pattern `backup_remotes.passphrase_enc` already uses, so a fleet can take its App to another instance without the instance key. 2. `DELETE /installations/{id}?purge=true` removes the same set rather than only the rows a foreign key reaches. Both are audited; neither touches another installation's rows, and a test with two installations side by side proves it byte for byte. **Accept when:** export then purge of one installation leaves the other's rows and figures byte-identical; an exported archive restores onto a fresh instance under the passphrase and the installation verifies against GitHub. Depends on ZF-209 for the sessions in the archive and on ZF-207 (the export is a fleet action; the purge is admin with confirmation). Size M. Session: Claude Opus 5 at `high`. Decisions: 27, 32. ### ZF-221: runner resilience and efficient startup, the remainder **Classification: extension; S to M.** Everything version 2.40 listed as prepared is merged, and so is most of what it listed as remaining: the registration budget is `scheduler.registration_concurrency`; a runner's resource sample carries `sampled_at` and the host card says "measured … ago" with a `usage_fresh` flag and a gauge, so cached readings are no longer presented as current; ambiguous create and start responses are reconciled against labels and ownership before anything is retried (`container_conflict.go`); and the runner-and-sidecar share was re-cut (#336). Two things remain, one of them only if asked for. On the primary target the second matters more than anywhere: the platform supports a host it cannot reach, and has only what the agent reports. **Do:** 1. **Durable runtime incidents and visible recovery.** The agent's runtime cooldown (consecutive failures, the kind of the last one, the retry time) lives in the agent's memory and surfaces as a log line. Report it in the heartbeat; keep it on the host row beside `usage` and `throttle` as one JSON column (next free prefix); render it on the host view and the host card ("runtime recovering: third failure, retrying in 40 s") and as `host.runtime_recovering` in the drawer with the fix the agent itself would give; count it. A start that fails because an image will not pull names the registry host and the pool, as `host.image_pull_failed`, from the prewarm and start results the agent already sends; a fleet host whose egress blocks `ghcr.io` otherwise shows only runners that never register. 2. **A configurable active-create budget**, only if the operated instance's own figures show the one-at-a-time foreground start is what a host is waiting on. Not authorised on speculation. **Accept when:** a runtime failure on a host is visible on the host card and in the drawer within one heartbeat with its retry time, survives a controller restart, and clears on the next success; an image that cannot pull names the registry and the pool within one start attempt; nothing new is presented as current without its age. Depends on ZF-220 (done). Size S to M. Session: Claude Opus 5 at `high`. Decisions: none. ### ZF-401 to ZF-404: the fleet's own hosts The private-host flow is delivered (section 6) and the acceptance version 2 wrote for it (a private host joins, reconnects after a controller or agent restart, and can be drained and revoked from the UI) is met by merged, tested code: the two real-relay tests, cordon and remove on the host card, join-token revocation, and a host deletion that revokes the agent's credential because the token hash lives on the host row. What is left is small, and it is re-scoped for a fleet whose hosts join a controller it does not run. #### ZF-401: onboarding a private host, what is left **Classification: extension; S.** The relay region is resolved once, when the listener first starts, and sealed into the identity; if that relay is unreachable later the listener cannot start, and the only sign is a log line. Custom relays are not exposed, and the docs say the hosted relays are rate-limited and keep metadata. **Do:** a `tailcat.unavailable` warning raised by a gather section in `Problems()` from the listener's state, with the platform audience under ZF-207; re-run the relay expansion when the node fails to start, with the sealed region as first choice; and a sentence on the private-hosts page that a controller with a public agent endpoint needs a private connection only for hosts that cannot reach it, which is the self-hosted truth, and on the primary target the normal case. **Accept when:** a controller started with an unreachable sealed relay raises the problem within a minute and recovers without a restart once a relay answers, in a test against the local relay the suite already runs. Depends on ZF-207 for the audience. Size S. Session: Claude Sonnet 5 at `high`. #### ZF-403: revocation, what is left **Classification: extension; S, demand-gated.** Closed as implemented; one follow-on. Every private host of an instance holds the same tunnel address as a persistent capability, and deleting a host revokes its Zoomies credential but not that address, so an off-boarded machine can still reach the listener and then fail to authenticate. Rotating the identity invalidates every private host at once, which is why it is not automatic. **Do, when a fleet asks:** one audited platform action that rotates the identity, re-mints a join command for every private host and lists them, with the page saying what it will cost. Depends on ZF-207 (a platform action). Size S. Decisions: none. #### ZF-404: what the agent owns on a host, written down **Classification: extension; S; documentation and one default.** The code is non-invasive already (nothing in the tree upgrades a package, touches a firewall, reboots, or prunes containers, images or volumes) and the reconciliation found the one exception decision 25 names, the daemon-wide builder-cache prune. The promise is not written anywhere a fleet would read before running the enrolment command. **Do:** a "what the agent owns" section on `docs/security.md` and the private-hosts page (its service and user, `agent.work_dir`, the containers carrying its labels, its per-pool cache directory, and the builder-cache target with the shared-daemon advice of decision 25) linked from the Add-a-host page's command step; and the agent's own tests pinned to it where they can be, so that a future prune that widens fails a test that names the promise. **Accept when:** the pages say it, the Add-a-host page links it, and `internal/docs` keeps the section present. Depends on decision 25. Size S. Session: Claude Sonnet 5 at `high`. ### ZF-222: a status view for the audience that cannot sign in **Classification: extension; M. Decision 29, recommended adopted.** Every fleet fact needs `viewer`. The developer whose job has queued for twenty minutes has no account and GitHub tells them only "queued"; the controller knows whether it is capacity, labels, a lost permission or a webhook secret that stopped verifying, and none of it reaches them. On the primary target that developer is on the fleet's team, and the projection is built on the fleet half of ZF-207's split, so it cannot carry a platform finding. The cost is disclosure, so the projection is name-free and banded rather than trimmed: proving a body contains no names is a test, and keeping a view's prose from naming things is a promise renewed every time somebody edits it. **Do:** 1. `GET /api/v1/status`, a projection rather than a view: the one place in the API that is deliberately not a resource's `GET` shape. `state` (`healthy`, `degraded` or `blocked`, from the highest severity among the fleet's problems), `since`, `version`, and counts as bands (`none`, `few`, `many`, `backed_up`) rather than integers, with the median and p95 queue wait rounded to the minute. Its `reasons` carry `code`, `severity` and `since` and nothing else. A public sentence per code lives beside the operator's in `docs/problem-codes.md`, tested in both directions by `internal/docs` the way that page already is. 2. `status.mode: off | authenticated | public`, off by default, with `ZOOMIES_STATUS_MODE`, platform-scoped. `public` raises a `status.public` warning naming what becomes readable without an account, and an error when the bind is not loopback and TLS is off. `off` means all three routes answer 404. 3. `/status` as its own Vite entry (`web/status.html`) with its own, much smaller budget in `web/vite.config.ts`, polling `/api/v1/status` every thirty seconds and never opening `/api/v1/events`, because every frame on that bus is a resource view and carries names. 4. The three states map onto the existing `danger`, `pending` and `idle` tokens; `docs/ui-guidelines.md` records the mapping. 5. `GET /status.svg`, the fleet's state as a self-contained badge on the `docs/badge.svg` pattern, for a team's wiki or a repository README. 6. The paperwork an endpoint change carries: `api/openapi.yaml` and both generated clients, rows in `docs/api-surface.md` and `docs/configuration.md`, a dangerous-toggle section in `docs/security.md`, and the page in `docs/ui.md`. **Accept when:** a fixture fleet whose every pool, host, repository and runner is named something distinctive produces a `/api/v1/status` body and a rendered `/status` page containing none of those names, asserted by a test that searches each response for every fixture name; each of the four reasons a job waits produces a distinguishable public state; every problem code has a public sentence; `status.mode: public` on a public bind without TLS refuses to start and names which setting to change; the default leaves all three routes 404; the app shell's gzipped size is unchanged; Playwright covers the page signed out, on a phone and through the accessibility pass. Depends on ZF-207 and ZF-202. Size M. Session: Claude Opus 5 at `high`; the disclosure boundary is the work and the page is the easy half. Decisions: 29, 31. ### ZF-213: pool configuration as code **Classification: extension; M.** In progress, because half of it landed under another name: `GET /settings/export` and `POST /settings/import` (#297) round-trip every non-default instance setting as a versioned, secret-free document, plan each key as `change`, `unchanged`, `unset` or `refused` with the current and incoming values, apply as one change or none, dry-run, and skip; repeat apply is idempotent by construction. That is every property the package asked for, for the wrong object: the document carries no pools, and on the primary target the instance settings are the platform's while the pools are the one thing the fleet owns and would want as a file, to move between a self-hosted instance and an operated one, or between two of theirs. The reusable-workflow and matrix clause is already met by the migration planner's stated refusal and per-job overrides. **Do:** a `pools` export and import pair modelled exactly on `handlers_settings_transfer.go` (a version field, secret-free by construction since pools hold no secrets, installations referenced by target rather than by `ins_` id so a document moves between instances, a plan by pool name with `create`, `change`, `unchanged` and `refused`, `dry_run`, `skip`, one change or none) reusing `validatePoolInput` per pool and `HostCouldRun` for the stranding check, with the CLI verbs, the page's dialog and the rows in `docs/backup-and-restore.md` beside the settings half. **Accept when:** export then import onto a fresh instance with the same installation target reproduces every pool's platform, labels, limits and runner settings; repeat apply changes nothing; a conflicting edit produces a readable plan; absent fields have the semantics the settings import already documents; dry-run creates no resources. Depends on ZF-201 and the settings transfer (done). Size M. Session: Claude Opus 5 at `high`. Decisions: none. ### ZF-215: capacity fallback and scheduled readiness **Classification: extension; M; demand-gated.** Nothing of its own exists; what it was told to reconcile (`min_runners`, `idle_timeout`, priority, the per-pool timing overrides of `0037`, prewarm and its telemetry, elastic CPU) is all on `main`. `bestPool` picks one pool per job and a job whose pool is full waits there; no pool has a schedule. On the primary target both halves are cost levers on machines the fleet pays for: a schedule keeps `min_runners` warm only in the fleet's working hours, and fallback lets a heterogeneous fleet absorb a full pool. **Do, when a fleet asks:** a time-bounded `min_runners` with an explicit timezone; an opt-in per-pool `fallback_to` list consulted only when the claimed pool reports held or failing, never crossing installation, OS, architecture or trust policy, with the reason string saying so; idle cost shown beside the schedule. Provider fallback stays gated on a qualified provider. **Accept when:** scarce capacity follows the declared policy with a visible reason; no duplicate jobs or retry storm in the drill tier; schedule timezone and idle cost are explicit. Depends on ZF-208. Size M. Session: Claude Opus 5 at `high`. ### ZF-212: cache recipes **Classification: documentation; S; demand-gated.** The caches exist and are instrumented (the per-pool cache directory with its scope and size limit, the builder-cache target, image prewarm with its two series) and nothing publishes a recipe for using them from a workflow. The S3-compatible cache storage half is a trust question before it is a feature on the primary target, because it would route a fleet's cache traffic to storage the platform configures, and it is not authorised. **Do, when a fleet asks:** recipes for Go, npm, Maven and pip against `/opt/zoomies-cache` and for BuildKit `--cache-to`/`--cache-from type=local`, on the configuration page beside "The pool cache". Depends on nothing. Size S. Session: Claude Sonnet 5 at `high`. ### ZF-223: a report per installation **Classification: new; M; after ZF-209.** What ZF-303's evidence helper would have computed once for a readiness record is what a platform owes a fleet every month: per installation over a window, the counts the contract names (observed, eligible, created for, ran here, ran elsewhere, platform fault, cleanup pending and converged) and exact p50 and p95 of eligible to first create task, create to registered, and cleanup convergence, from the stored timestamps rather than bucketed histograms. Every timestamp and fault kind it needs is in the product; ZF-209's roll-up is what keeps the counts past retention. **Do:** `GET /installations/{id}/report?window=` and a section on the Usage page, with the definitions linked to `docs/metrics.md`; `/usage.csv` gains the counts. **Accept when:** the figures for a fixture fleet match a hand computation from its rows; a window older than the row retention still answers from the roll-up; nothing in the body names a repository the identity may not read. Depends on ZF-209. Size M. Session: Claude Opus 5 at `high`. Decisions: 6. ### ZF-224: auto-recovery on a lost runner **Classification: extension; S; delivered 22 September, alongside ZF-207.** A runner that dies under a job fails it in a way GitHub cannot tell from a test failure. Zoomies already knows better (`FleetFailed` is the split the Jobs page draws) and #406 gave an operator a button. This makes the choice a setting rather than a standing refusal, on the owner's instruction and recorded as [decision 0004](roadmap/decisions/0004-the-fleet-may-re-run-a-job-it-broke.md). **Done:** `scheduler.auto_rerun`, off by default, re-runs a job whose runner died under it, bounded by `scheduler.auto_rerun_limit` (1–5, default 1) counted from GitHub's own run attempt; the `scheduler.auto_rerun_on` warning; a timeline entry marked *via recovery*; and `zoomies_job_reruns_total{pool,trigger}`, which also labels the button's re-runs so the two are comparable. **Accepted because:** a table test covers off, on, a workflow's own failure, the bound reached and a higher bound; a repeated delivery buys no second re-run; the validator refuses a limit outside 1–5; the default configuration draws no new finding. Depends on nothing. Size S. Decisions: 0004. ### ZF-225: two-step sign-in for local accounts **Classification: extension; M; proposed and delivered 27 September.** A local account signs in with a password and nothing else, so a password reused on a site that leaked is the whole of an account's defence. Single sign-on accounts already get a second factor from their identity provider; the accounts the installer and the users page make had no way to have one. **Done:** optional TOTP (RFC 6238: SHA-1, six digits, thirty seconds, one step either side) that each account turns on from its account page; a QR code drawn by the controller with an in-repository encoder, the key as text, a confirming code, and ten single-use recovery codes shown once. The key is sealed with the instance key; recovery codes are stored hashed; the last accepted time step is recorded so a code is accepted once. Sign-in answers a right password with a five-minute pending sign-in bound to the browser, whose code attempts are charged to the existing sign-in limits. Turning it off and issuing new recovery codes take the password and a code. An administrator resets it for a lost phone from the users page or `zoomies users reset-two-step`, audited as `user.two_step_reset`. `security.require_two_step` (off by default) makes every password account enrol at its next sign-in, with the `two_step.required` info finding. Single sign-on accounts and API tokens are not asked, and the documentation says why. **Accepted because:** RFC 6238's vectors, the QR encoder against the standard's worked Reed-Solomon example and format tables, and a round trip of every supported version; a replayed code, a spent recovery code, an expired or skipped code step, and code failures reaching the sign-in limit are each refused in a test; the administrator's reset writes its audit row; the requirement forces enrolment and leaves single sign-on sessions working; the migration applies to a database with accounts and sessions in it; and a Playwright spec turns it on, signs in with a code and a recovery code, and turns it off. Depends on nothing. Size M. ### ZF-226: placement from job history **Classification: extension; M; proposed and delivered 3 October.** A runner is sized for the average job, so one heavy job on a small host was killed for memory and retried onto the same host. **Done:** each job's peak CPU and memory is recorded; placement and sizing use the heaviest job waiting in a pool, and a host too small for it is held off with the reason stated. `scheduler.history_sizing` is `off`, `shadow` or `on` and starts in `shadow`. The job page, `job_stats` and the MCP tools show peak usage, and a job killed for running out of memory raises `jobs.oom_killed`. `job_stats` can group by controller version. **Accepted because:** the scheduler stays pure and its decisions are table tests; shadow mode records what `on` would have done and changes nothing. Depends on ZF-220. Size M. ### ZF-227: AI Context **Classification: new; L; in progress from 29 September.** Let an assistant connected over MCP read a repository's source, within consent the repository's owner gave, so an assistant spends fewer tokens finding its way. **Done:** the foundations; the consent wizard and per-repository consent; setup pull requests with verification against the trusted branch; retrieval (`context_overview`, `context_read`, `context_search`, `context_pack`); repair, amend and remove; reinstall; repository-only, owners and OIDC uploads; token-savings and discoverability documentation. **In flight:** assistant-written notes behind a separate `context.publish` consent, GitHub Enterprise Server and a 503 naming the key host (#586); failure reasons that name the fix and a resync with the trusted branch (#591). Depends on ZF-228. Size L. ### ZF-228: connecting an assistant and owning your tokens **Classification: extension; M; proposed and delivered 27 to 30 September.** Pasting a token into an assistant was the only way in. **Done:** the controller is its own OAuth 2.1 authorisation server for `/mcp`, so an assistant is added by address and signs in through the browser; a client-metadata fetch refuses private ranges; rotating a secret asks for confirmation; two-step sign-in is honoured. Owner decisions taken: the single sign-on button text, hiding the password form while single sign-on works, and discovery retried in the background; `security.mcp_oauth` on a public address needs `server.external_url`; a client that registers again gets its registration back, at 100 an hour; a password change and "sign out other sessions" end the connections; every user manages their own tokens, capped at their role (`tokens.own`); spent tokens can be deleted. Depends on ZF-225. Size M. ### ZF-229: Kennel Club **Classification: new; L; in progress from 1 October.** How the repositories the fleet serves measure up against what affects CI and the fleet. Designed and staged in [roadmap/kennel-club.md](roadmap/kennel-club.md), which is the record for it; this entry is the roadmap's pointer. **Done:** Stage 1 (ZF-229a), the evaluator and its six observed checks, the store, the GitHub reader, the loop, problems, metrics, REST, MCP read tools and the UI (#661, #669, #670); per-repository tracking (#699, #701); the first of Stage 3 (ZF-229c) in #714, `ci.no_timeout`, `ci.no_concurrency`, `ci.action_not_pinned` and `token.permissions_unset` behind `kennel.workflow_checks`, as per-repository counts, and ten `setup.*` presence checks behind `kennel.repository_setup`; part one of the rest of ZF-229c as [agent-readiness.md](roadmap/agent-readiness.md) section 6 re-cuts it: the parser moved to `internal/kennel/workflow` with a fuzz target and a dogfooding test over this repository's own workflows, location evidence on every workflow finding (a blob SHA, a job index and a line, with the path shown only where it passes a grammar), waivers that lapse when the file changes, and the owed checks `exposure.target_checkout_pr_head`, `ci.workflow_unreadable`, `ci.pins_without_updater`, `ci.label_unserved` and `ci.secret_on_command_line` (#761); part two, which finishes ZF-229c: `capacity.matrix_exceeds_pool`, a prompt on every open finding with the page's Copy button, `zoomies kennel` with its five readers, and `zoomies kennel check [path]`, the same checks over a checkout with nothing sent anywhere, held by golden JSON (#767). Stage 4 (ZF-229d, repository settings), planned in [roadmap/plans/2026-10-09-zf-229d-settings.md](roadmap/plans/2026-10-09-zf-229d-settings.md), landed in seven pull requests (#779, #780, #782, #784, #787, #790, #792): four checks (`token.default_write`, `exposure.fork_approval_weak`, `exposure.private_fork_secrets` and `protection.required_check_never_reports`) behind one opt-in, `kennel.settings_checks`, that reads a repository's Actions settings and the status checks its default branch requires, judged against the jobs the fleet saw with the checks' names kept out of everything shown; the Administration-read permission asked for as a question when an organisation App is created; and a Protection tab that links each finding to the GitHub page where its setting is changed. **Open:** Stage 5 (ZF-229e, fix by pull request); the documentation page; a first look at Stage 4 against a real installation. Depends on ZF-227. Size L. ### ZF-230: a catalog of everything Zoomies can say is wrong **Classification: extension; M; proposed 8 October.** Problem codes and Kennel check codes are stable, but nothing machine-readable lists them with what to do and how to see that it worked, and an agent has to scrape two pages to learn them. [agent-readiness.md](roadmap/agent-readiness.md) section 3 is the design. **Do:** `docs/catalog.json`, generated by `make generate` from the problem-codes table and `kennel.Checks()`, with a JSON schema and a CI diff; `Fix`, `Verify` and `Docs` on the Kennel registry; a *verify* column for the operator-facing problem codes; `GET /api/v1/catalog`; `zoomies commands --output json` and the generated command reference; the provenance guard in `internal/docs` and its commit-range step; the *for an agent* section of the AI Context page. **Not:** a struct table for problem codes (the inline literals and the docs tests stay); a Markdown page per code. **Accepted because:** the catalog validates and CI refuses a stale one; an ID in a finding or an explanation that is not in it fails a test; the guard fails on a seeded term. Depends on nothing. Size M. ### ZF-231: why a job failed, with evidence **Classification: extension; M; proposed 8 October.** `GET /jobs/{id}/explanation` says what is happening and what to do, but not which class of failure it is, what the evidence was, or where in the catalog to read on; a timed-out job is not classified at all. [agent-readiness.md](roadmap/agent-readiness.md) section 4 is the design. **Do:** `class`, `confidence`, typed `evidence`, a bounded `log_excerpt`, `problem_code`/`check_code` and ordered `next_steps` on the existing payload; `zoomies why ` (and `jobs why`) taking an ID, a GitHub run or job URL or `--latest-failed`, with documented exit codes; the richer `get_job` over MCP with the excerpt in an untrusted block; the job drawer's **Why** section; demo seeds for every class. **Accepted because:** each seeded failure in `zoomies demo` returns its class at high confidence; a job the fleet never touched is `workflow-failure`, never `unknown`; a hostile log survives only as scrubbed text. Depends on ZF-230. Size M. ### ZF-233: skills for a coding agent **Classification: new; S; proposed 8 October; implemented 9 October.** `skills/zoomies/` (#768) and `skills/zoomies-kennel/`, with the generated command reference from ZF-230 pinned and tested, lint tests in `internal/docs` and `cmd/zoomies` that hold every skill to what an installer needs, every command it names to the binary, and the zoomies skill's three lists to every command there is, and install docs in the README and `docs/cli.md`. A third skill, `skills/zoomies-optimise/` (#1278, #1281), reads a slow job's evidence and says which of the four previewable changes fits, and starts nothing. On 25 October the published install command was run against this repository from an empty directory and home: it finds the three skills (and, unless they are named with `--skill`, the two for working on Zoomies itself), writes them to `.agents/skills/` and links them into `.claude/skills/`, and the files match the repository's byte for byte; a test holds both pages to that command. [agent-readiness.md](roadmap/agent-readiness.md) section 7 is the design. **Open:** gate row 15's second agent for the kennel skill: one agent's smoke test is recorded in its pull request; the owner runs the second. Depends on ZF-230, ZF-229c. Size S. ### ZF-234: the documentation for ZF-229 to ZF-233 **Classification: extension; S; proposed 8 October.** `docs/kennel-club.md` (owed by the Kennel Club record), the *why* section, the skills page, the FAQ entries, and the CLI, API, AI Context and Connect Claude updates. Foldable into each package's pull requests. **Done, 9 October:** most of section 8 landed with the packages it documents; the remainder, *Ask the fleet why* on the queued-job page, two FAQ entries, and every subcommand on the CLI page held there by a test, is in its own pull request. Gate row 16 is met by the strict site build and the two tests that hold the CLI page to the binary. Depends on ZF-230 to ZF-233. Size S. ### ZF-235: an assistant in the UI, on the operator's own model **Classification: new; XL; proposed 8 October; started 9 October.** A chat panel backed by a model the operator configures, reading the same payloads the UI reads, confirming every mutation, with deterministic Kennel fixes before any model-drafted patch and autonomy over those first. [agent-readiness.md](roadmap/agent-readiness.md) section 9 is the design, in ten slices (7a to 7j); the step-up authentication slice (7h) is platform work every governed setting needs, and the subscription sign-in gate (9.10) stays closed until the owner's written confirmation exists. Depends on ZF-230 to ZF-233, ZF-229e. Size XL. **Started, 9 October.** Slices 7a and 7b merged as #799: the provider interface and its contract tests, the adapters, the dialer with `assistant.local_only` and `assistant.allow_private_provider`, the `assistant_providers` table, the `/assistant/providers` routes and the Assistant settings page, with the demo seeding a built-in model. The pull requests up to #842 then shipped the widget, the read-only tool loop over the MCP server's tools behind a per-provider fleet switch that is off by default, redaction of the conversation and every tool result, personal providers for any signed-in person, the subscription kinds (Claude Code, Codex, Copilot, administrators only and never with tools), PR repairs from a `/eli fix` comment or an opt-in automatic policy, and the shared mention matcher that is ZF-239's first slice. A later pass added the repairs history to that loop as the two read tools it was missing (`list_elis_repairs`, `get_eli_repair`), which is how the assistant in the chat can say why a repair is stuck, while the request, the accept, the clear and the policy stay off the surface: a repair spends an attempt and pushes to a pull request, and that press is a person's at the repairs page; [the ZF-235 plan](roadmap/plans/2026-10-09-zf-235e-eli.md) has the record. Still open: per-person limits and data classes (the rest of 7c), mutate tools behind the confirmation card (7d), stored conversations, slash shortcuts and context chips (7e), deterministic Kennel fixes (7f), step-up (7h) and autonomy (7i). [roadmap/eli-review.md](roadmap/eli-review.md) reviews what was built against what competitors ship and says what to do next. ### ZF-236: the figures behind size advice **Classification: extension; S; proposed 8 October.** Label advice says *too small* or *too large* without showing the runs it rests on. [agent-readiness.md](roadmap/agent-readiness.md) section 5 is the design. **Do:** `observed` percentiles, sample size, window, `recommended_class`, `fits` and a `not_enough_data` state on the advice payload; `--window` and `--repo` on `jobs advice`; the figures on the `SizeAdvice` card; the constants named in `auto-pools.md`. Depends on ZF-226. Size S. ### ZF-239: a shared Eli App, as a relay for mentions **Classification: new; L; proposed 10 October; started 10 October.** Today every install creates its own GitHub App, so `/eli fix` is recognised from its text and the comments and commits come from whatever the operator named their App. This package adds an optional second kind of App, one Eli that many operators install, run as a relay that carries only `issue_comment` mentions and short-lived installation tokens. The operator's own App stays the default and stays the only App that registers runners and scales the fleet. [decision 0012](roadmap/decisions/0012-a-shared-eli-app-is-a-relay-for-mentions-and-nothing-else.md) is the design and the threat model. Five slices, in order: 1. **The shared matcher. Delivered, 10 October (#842).** `internal/prrepair/mention` decides what a mention is, imports the standard library only (a test enforces it), and is the one table of cases both halves will use. It also gained `/eli` and `/zoomies`, which notify nobody, beside the `@` forms. 2. **The controller's side, with no network. Next.** `Installation.Kind` (`own`, the default, or `shared`) with its migration; a token-source interface in `clientCache`, so a `shared` installation gets its tokens from a source instead of signing an App JWT with a sealed key; and an in-memory fake relay that implements it. Nothing above the token source changes: `RepairClient`, the worker, the policy and the budget see an ordinary client. Acceptance: an `own` installation behaves exactly as before, a `shared` one runs a full repair against the fake, and a test shows the controller holds no private key for it. No behaviour change for anyone. 3. **`zoomies relay`.** The webhook edge, bindings, the inbox the controller polls outbound, and the lease and token endpoints, with the App key behind a signing interface and a local-key fake. The edge is a pure package that takes a delivery and returns an envelope or a refusal, and may not import `internal/store`. 4. **Pairing,** in Settings and `zoomies init`, through GitHub's device flow. 5. **The hosted deployment,** its runbook, the retention statement and the App's public listing. **Not authorised: slices 3 to 5.** They need the owner to answer the four questions at the end of the decision record (who operates the relay and under what domain, whether `eli` is available as the App slug, the retention periods, and whether a refused mention is explained to the maintainer), and to rule on delivery rule 15: a relay the project operates is a hosted service, and rule 15 says no page names one. Either that rule is amended for this package, or the relay ships only as something an operator can run themselves. Slices 1 and 2 touch neither. Depends on ZF-235 for Eli's repair worker. Size L. ### ZF-240: the fleet as it moves, over MCP **Classification: extension; M; proposed, designed and implemented 12 October.** An agent connected over MCP, and Eli, could read what the fleet is and what it did over a period, but not what is happening as it happens: the event stream, the scheduler's decisions, the minute samples, a runner's timeline and the workflow runs were REST only, and the interesting questions cost more calls than Eli may spend. [fleet-analysis.md](roadmap/fleet-analysis.md) section 4 is the design. **Do:** `GET /events/recent` over the bus's ring, with a bounded `wait`; `recent_events`, `wait_for_change`, `scaling_decisions`, `fleet_samples`, `host_samples`, `runner_timeline`, `list_workflow_runs`, `list_webhook_deliveries` and `list_audit` for any viewer, and `support_bundle` for an administrator's agent; the first seven on Eli's list and the other three named as left off by the owner's choice, not by role; a live flag so Eli runs a wait again; the `what_changed` prompt and the `zoomies://fleet/now` resource; `zoomies events recent`; three recorded Eli scenarios. Push (resource subscriptions, a server-initiated stream) is deferred, and the design says why. **Accepted because:** the transport stays stateless; every tool is one documented route with the caller's own credential; workflow-written text is quoted only in the untrusted block of the event tools' text, and where the row tools and the structured copy carry it inline, they say it is untrusted. **Done:** `GET /events/recent`, the bus's ring as one JSON answer with a cursor and `complete`, `since=now` for a cursor at the present and nothing before it, and a `wait` held for at most 25 seconds that counts against `limits.event_subscribers` (a public view's visitor against `limits.visitor_event_subscribers`); each event goes only to a caller who could read it from its own route (#911, #922). The seven window tools, `recent_events`, `wait_for_change` (live), `scaling_decisions`, `fleet_samples`, `host_samples`, `runner_timeline` and `list_workflow_runs`, each answer held to 200 rows and 64 KiB, cut on whole frames or rows (a runner's timeline comes whole), the two event tools quoting names a workflow wrote only in the untrusted block; `wait_for_change` with no `since` answers a cursor at once, and with no `kinds` waits only for kinds that say the fleet changed; and three reads for whoever runs the fleet, `list_webhook_deliveries` and `list_audit` (viewer) and `support_bundle` (administrator, section by section). Eli is offered the seven, the window answers held to 12 KiB a block on whole rows and its waits to 10 seconds, with three recorded scenarios; the three reads are left off its list by the owner's choice: a delivery is GitHub's whole payload, who did what, by name, is not for a hosted model, and the support bundle is the whole instance in one document (#949), and its event tools never ask for audit or delivery events. The `what_changed` prompt, the `zoomies://fleet/now` resource and `zoomies events recent`, and *Watching the fleet move* in `docs/connect-claude.md`, in the pull request after it. The two event tools' `structuredContent` holds the events the text shows, with the text's cursor and the same 64 KiB bound (one event larger than that comes whole), and carries names outside the untrusted block, as the design asked; the page says so, so a client that hands it to a model treats them as data. `implemented` and not `done` until the second call of the two-call wait is seen to block against a real client. Depends on ZF-228, ZF-235. Size M. ### ZF-241: analyses the controller computes **Classification: new; M; proposed and designed 12 October, implemented 12 to 17 October.** Why a period was slow, whether a release made things worse, whether a host fails when it is busy and which host keeps failing to start runners are each answerable from what the fleet keeps, and each takes a model a dozen calls to assemble badly. [fleet-analysis.md](roadmap/fleet-analysis.md) section 5 is the design. **Do:** `internal/analysis`, pure, with a purity test; four findings, each a REST route, an MCP tool on Eli's list, a card in the UI where the question is already asked, and `zoomies analyse`: the queue-wait breakdown by cause, a comparison of two periods or releases that says when the sample is too small, host pressure against failures, and repeated runner-startup failures by host and pool; an Eli scenario per finding. **Accepted because:** one computed answer is rendered everywhere, as the job explanation is, so the UI, the CLI and Eli cannot disagree; the package is pure and its decisions are table tests. **Done:** `internal/analysis` extended, pure, its purity test renamed `TestTheAnalysisPackageIsPure`, with the four findings: the queue-wait breakdown, every second of a wait put down to one of eight causes; the comparison of two releases or two periods, which leaves cancelled and skipped jobs out of every figure and ranks nothing with fewer than 30 jobs on either side; host pressure against failures, with the line that separates them; and repeated runner-startup failures by host and pool, with what explains them (#964, #972, #975). The store's bounded reads, the controller's snapshots and a seeded cause for three of them in `zoomies demo` (#975). Four viewer routes, `GET /api/v1/analysis/queue-wait`, `/analysis/compare`, `/analysis/host-pressure` and `/analysis/startup-failures`, over the last 24 hours unless `since` and `until` say otherwise and never longer than `limits.job_stats_window`; a public view's visitor is refused before any read, host pressure names jobs only to a caller who can also read jobs, and a request the fleet's records refuse is answered with a code of its own and the sentence that says what to change (#975). The MCP tools `queue_wait_breakdown`, `compare_periods`, `host_pressure` and `startup_failures`, each answer cut on whole rows under the server's window block with a note naming the argument that shows more, the names a workflow or a host wrote quoted in the text only in the untrusted block, and inline in the structured copy under a schema that marks them untrusted; all four on Eli's list, with an Eli scenario each answered from one call; and `zoomies analyse` with its four subcommands (#983). A card where each question is already asked, rendering its route's answer: **Why jobs waited** on the Jobs page's Queued view, **What got worse and what got better** under the Usage page's "By release" table, **Whether its failures came under pressure** on a host's own page, and **What keeps failing to start** on the Runners page when its state filter includes failed runners; and *Asking why* in `docs/connect-claude.md` (#1130). [fleet-analysis.md](roadmap/fleet-analysis.md) section 5.7 records what was built against the plan's decisions. `implemented` and not `done` until the cards are seen against a real fleet's data: the Eli scenarios have played only under the scripted model, and no real MCP client has called the tools. Depends on ZF-240, ZF-231, ZF-226. Size M. ### ZF-242: explain a PR's observed required-check delay **Classification: new; M; started 12 October at the owner's request; implemented, closed by the owner on 25 October 2026.** Eli enhancement handoff track E1. Start with a pure, bounded calculation over an explicitly pinned PR head, target branch, applicable required-check identities and observed job attempts. This extends the analysis boundary ZF-241 establishes; it does not duplicate the recent-window work or infer a dependency graph from timestamps. **First slice:** `internal/analysis/merge_delay.go` and behavioural/purity tests. Opaque check identities preserve Kennel's required-name privacy boundary. Partial protection or observations, ambiguous producers/matrix mappings, missing checks and changed heads remain explicit. Queue and startup intervals are disjoint; unknown phases remain unknown. Observed check completion is not merge readiness. **Next slices:** authorised target-branch protection/PR reads, snapshot assembly, a read-only REST contract, MCP/CLI adapters and a contextual UI/Eli result. Keep these behind their own implementation plans rather than announcing a live feature from the pure core alone. Plan and baseline evidence: [E1 calculation core](roadmap/plans/2026-10-12-eli-merge-delay-core.md). The core needs no ZF-240 transport. Integration reuses ZF-241 and ZF-236 contracts where present. No publication, model call, job dispatch or new authority. **Closed 25 October 2026, at the owner's direction.** The core, evidence, snapshot, collector, API, CLI, MCP and Eli tool, and contextual UI with scripted scenarios are all merged (see the row in [roadmap/progress.md](roadmap/progress.md)). Never run, and not claimed: a live model evaluation of Eli's use of the result, and a real performance experiment against a production fleet. Both stay the owner's and reopen this package only if they find a defect. ### ZF-243: compare selected historical runner or workflow runs **Classification:** new. **Size:** M. **Started:** 12 October 2026, owner request to continue the Eli enhancement stack. Track E2 in the live handoff. **Why:** a period correlation does not establish matched workloads for a configuration experiment. Keep ZF-241's period/release finding separate. **Milestone scope, owner agreed 14 October 2026:** close E2 at descriptive historical comparison after dependency inspection reaches REST/UI, CLI and bounded MCP/Eli summaries. This milestone includes explicit capture, immutable replay, separate measurements, coverage/unknowns and runner/root-workflow/declaration provenance. Controlled experiment eligibility, transitive dependency resolution, inputs/test scope, configuration equivalence, cache/host attestation, fair complete cohorts, workflow dispatch, allocated cost attribution and live model evaluation remain deferred. Do not keep extending E2 automatically; next implement an E3 optimisation preview with honest inconclusive verification where evidence is incomplete. **Original broader scope (deferred beyond this milestone):** begin with a pure, bounded eligibility snapshot of operator-selected existing attempts. Retain the hypothesis, initiating user, repository, evidence identifier, revisions, exact intended delta, inputs, test scope, image/resource identity, cache/host observations, window and fixed rule version. Keep failed attempts; count cancelled and superseded attempts separately. Missing coverage, unequal workloads, unknown cache/host state and small samples stay inconclusive. Then compute successful latency, all-attempt consumption, reliability, allocation and estimated cost separately, with counts and dispersion. Controller assembly verifies repository access and complete selection before transport integration. **Acceptance:** reproducible retained selections, explicit unknowns, duplicate attempt refusal, failures preserved, faster latency with higher consumption shown as a trade-off, and no causal or verified-improvement claim from historical data. Dispatch and publication are later, separately authorised work. **Slices:** [eligibility plan](roadmap/plans/2026-10-12-eli-comparison-eligibility.md), [measurement plan](roadmap/plans/2026-10-12-eli-comparison-measurements.md), [bounded attempt collection](roadmap/plans/2026-10-13-eli-comparison-attempt-reader.md), [bounded GitHub attempt evidence](roadmap/plans/2026-10-13-eli-comparison-github-attempt.md), [admitted attempt joins](roadmap/plans/2026-10-13-eli-comparison-admitted-join.md), [observed snapshot assembly](roadmap/plans/2026-10-13-eli-comparison-snapshot.md), [retention and offline replay](roadmap/plans/2026-10-13-eli-comparison-retention.md), [capture/replay API](roadmap/plans/2026-10-13-eli-comparison-api.md), [CLI replay](roadmap/plans/2026-10-13-eli-comparison-cli.md), [MCP/Eli replay](roadmap/plans/2026-10-13-eli-comparison-mcp.md), [web capture and inspection](roadmap/plans/2026-10-13-eli-comparison-web.md), [CLI capture](roadmap/plans/2026-10-13-eli-comparison-cli-capture.md), [recorded grant provenance](roadmap/plans/2026-10-13-eli-comparison-grant-provenance.md), [runner observation foundation](roadmap/plans/2026-10-13-eli-runner-observation-foundation.md), [runner result integration](roadmap/plans/2026-10-13-eli-runner-observation-results.md), [private runner observation joins](roadmap/plans/2026-10-13-eli-comparison-runner-observations.md), [web runner evidence inspection](roadmap/plans/2026-10-13-eli-comparison-runner-evidence-web.md), [bounded MCP/Eli runner evidence](roadmap/plans/2026-10-13-eli-comparison-runner-evidence-mcp.md), [CLI runner evidence](roadmap/plans/2026-10-13-eli-comparison-runner-evidence-cli.md), [private trigger/workflow context](roadmap/plans/2026-10-13-eli-comparison-trigger-provenance.md), [push root-workflow source digests](roadmap/plans/2026-10-13-eli-comparison-workflow-source.md), [web workflow evidence](roadmap/plans/2026-10-13-eli-comparison-workflow-evidence-web.md), [bounded MCP/Eli workflow evidence](roadmap/plans/2026-10-13-eli-comparison-workflow-evidence-mcp.md), [CLI workflow evidence](roadmap/plans/2026-10-13-eli-comparison-workflow-evidence-cli.md), [private root dependency inventory](roadmap/plans/2026-10-13-eli-comparison-workflow-dependencies.md), [descriptive comparison milestone](roadmap/plans/2026-10-14-eli-e2-descriptive-comparison-milestone.md). The owner prioritised the web UI before CLI capture on 13 October 2026. These slices include bounded local retention and capture/replay routes. No new dependency, model call, dispatch or publication is introduced. Historical workload/ configuration/cache evidence and complete selection remain outstanding, so current observed reports stay inconclusive. ### ZF-244: preview a reviewed checkout optimisation **Classification: new; M; started 14 October at the owner's request to continue E3; implemented, closed by the owner on 25 October 2026.** **Why:** a healthy workflow can spend time fetching history it does not need. E2 descriptive measurements do not establish that a proposed change saves time. Keep optimisation proposals separate from failed-PR repair and require an exact reviewable edit before publication or experiment execution. **First slice:** a deterministic supplied-evidence checkout-depth preview. Reduce an explicit literal full-history fetch to one commit only for a pinned checkout step, with matching head/source/position timing evidence and explicitly reviewed repository/downstream history needs. Reuse the bounded workflow parser and repair preimage checks; preserve unrelated source bytes, comments, modes and line endings. Refuse unknown history needs, unsupported YAML, source overrides, stale evidence and disabled source/edit policy. Verification remains inconclusive. This pure foundation is not a runtime route or a complete E3 delivery. Next wire separately authorised bounded source/evidence admission, preview retention and web inspection, preserving the owner's web-first priority. Explicit approval and reconciled draft publication are now built; next is E2 verification of a published change. Keep experiment dispatch separate. No model or source call is introduced in the core. **Acceptance:** exact preview and currentness refusal for one supported change; subsequent scoped web preview, draft PR, linked explanation and honest E2 result. Passing CI alone cannot establish improvement. Publication never merges. **Closed 25 October 2026, at the owner's direction.** All four previews (checkout depth, setup cache, bounded readiness poll, Playwright sharding) are built end to end and merged: web page, private retention, bounded CLI, MCP and Eli replay, attributed approval and one reconciled draft pull request each, with writes web-only. Everything was verified against fakes and mocked routes. Never run, and not claimed: a run against one real repository with a real GitHub App, and an E2 comparison of a real published change, so no preview has a verified saving and verification stays inconclusive until someone captures one. Both stay the owner's. Depends on ZF-242 and ZF-243's descriptive milestone. Size M. [Preview foundation](roadmap/plans/2026-10-14-eli-e3-checkout-preview.md), [private retention/replay](roadmap/plans/2026-10-14-eli-e3-preview-retention.md), [admitted checkout evidence](roadmap/plans/2026-10-14-eli-e3-checkout-evidence.md). The preview foundation merged in #1021 and private retention in #1022. The policy, routes and web flow merged in #1171 (see [the 19 October plan](roadmap/plans/2026-10-19-eli-e3-checkout-web.md)), tested against fakes only. Attributed approval of a kept preview is in [the 23 October approval slice](roadmap/plans/2026-10-23-eli-e3-preview-approval.md): it names the preview, head, source digest and actor, is audited, expires with the preview and publishes nothing. Reconciled draft publication is in [the 23 October publication slice](roadmap/plans/2026-10-23-eli-e3-draft-publication.md): its own live opt-in, fresh head, branch, policy, source and approval checks, one draft pull request, non-force and never merged. The published preview links to E2's Comparisons page with the scope, workflow kind and a reviewable reference filled in, and can carry the captured comparison back as its verification ([the verification link](roadmap/plans/2026-10-23-eli-e3-verification-link.md)); the comparison itself stays a manual, inconclusive capture until suitable runs are chosen. The CLI, MCP and Eli read the same bounded preview ([the read-surfaces slice](roadmap/plans/2026-10-23-eli-e3-read-surfaces.md)): metadata and the one changed line, never the full file, with approval and publication staying on the web page. The wider catalogue starts with a pure supplied-evidence setup cache preview ([the cache foundation](roadmap/plans/2026-10-23-eli-e3-cache-preview-foundation.md)), the fleet's retained job history for repeated setup samples ([the cache history slice](roadmap/plans/2026-10-23-eli-e3-cache-history.md)), a bounded GitHub source read bound to one selected job ([the cache source slice](roadmap/plans/2026-10-23-eli-e3-cache-source.md)), and private retention plus inspect/capture/replay routes ([the cache retention and API slice](roadmap/plans/2026-10-23-eli-e3-cache-retention-api.md)) a Cache preview page ([the cache web slice](roadmap/plans/2026-10-23-eli-e3-cache-web.md)), bounded CLI/MCP/Eli replay of the kept preview ([the cache read slice](roadmap/plans/2026-10-23-eli-e3-cache-read.md)), attributed approval ([the cache approval slice](roadmap/plans/2026-10-23-eli-e3-cache-approval.md)) and one draft pull request for an approved preview ([the cache publication slice](roadmap/plans/2026-10-23-eli-e3-cache-publication.md)). Bounded readiness polling replaces one fixed sleep on an Ubuntu runner with a bounded poll of an operator-supplied condition, never a guessed one. It now has the pure preview and workflow edit ([the readiness foundation](roadmap/plans/2026-10-24-eli-e3-readiness-foundation.md)), bounded GitHub source binding and inspection ([the readiness source slice](roadmap/plans/2026-10-24-eli-e3-readiness-source.md)), the `/optimisations/readiness` page with its run picker and operator-supplied condition ([the readiness web slice](roadmap/plans/2026-10-24-eli-e3-readiness-web.md)), private retention and replay in migration 0126 ([the readiness retention slice](roadmap/plans/2026-10-24-eli-e3-readiness-retention.md)) bounded CLI, MCP and Eli replay ([the readiness read slice](roadmap/plans/2026-10-24-eli-e3-readiness-read.md)) attributed approval in migration 0127 ([the readiness approval slice](roadmap/plans/2026-10-24-eli-e3-readiness-approval.md)) and one reconciled draft pull request in migration 0128 ([the readiness publication slice](roadmap/plans/2026-10-24-eli-e3-readiness-publication.md)). Test sharding is built end to end, for Playwright only, as the owner accepted ([the contract](roadmap/plans/2026-10-24-eli-e3-test-sharding-contract.md)); the pure foundation is in [the foundation](roadmap/plans/2026-10-24-eli-e3-sharding-foundation.md), with bounded source binding and an inspect route in [the source slice](roadmap/plans/2026-10-24-eli-e3-sharding-source.md), and private retention with replay in migration 0129 ([the retention slice](roadmap/plans/2026-10-24-eli-e3-sharding-retention.md)) and the `/optimisations/sharding` page ([the web slice](roadmap/plans/2026-10-24-eli-e3-sharding-web.md)) and bounded CLI, MCP and Eli replay ([the read slice](roadmap/plans/2026-10-24-eli-e3-sharding-read.md)) and attributed approval in migration 0130 ([the approval slice](roadmap/plans/2026-10-24-eli-e3-sharding-approval.md)) and one reconciled draft pull request in migration 0131 ([the publication slice](roadmap/plans/2026-10-24-eli-e3-sharding-publication.md)). Still to do, and the owner's: a run against one real repository and a real E2 comparison of a published change. Writes from the CLI, MCP and Eli remain deliberately out of scope. [The handoff](eli-enhancement-roadmap-handoff.md#e3-what-is-built-and-what-is-not) has the detail. ### ZF-245: a cache service on every host A runner restores and saves the Actions cache (`actions/cache`, the `setup-*` actions' own caches) on its own host, with no change to any workflow. The agent runs an `actions/cache`-compatible service per cache identity and tells the runner where it is; a pool turns it on with `cache.service`, off by default. Every failure is a miss, and a pull request reads its base branch's entries and writes only its own. Slice 1, the protocol core in `internal/cacheservice`, is tested against GitHub's own `@actions/cache` client, slice 2, the node wrapper in the runner image, against the pinned runner release's own node, and slice 3, the `cache.service` pool setting, the host listener and the runner's environment. The runner overwrites `ACTIONS_RESULTS_URL` for every action, and that address is also where artifacts are found, so the redirect is made in the runner image: a wrapper around the bundled node loads a preload that rewrites only the cache's calls. Buildx's `type=gha` is not covered. The rest is gated on a whole job on a real runner. See [the design](roadmap/cache-service.md), which lists what is and is not proven. It was asked for on 8 November 2026 because a fleet should not rewrite its workflows to get a warm cache. Size M. The registry mirror is ZF-246. ## 9. Kept for the day somebody asks Nothing here is authorised by planning alone. Each starts when a fleet asks for it or the evidence arrives, and each keeps its ID and its acceptance from the version that wrote it. * **ZF-206, Windows**: a Windows host has joined and run a job (the owner, 11 October 2026). What remains is writing the evidence up against the checklist, which moves the support-matrix row; Windows on arm64 has digests and no build, and stays that way until asked. * **ZF-214c**, a second provider: chosen from demonstrated demand and repeatable test access, proven against `RunContractTests` and the same reconciler, bootstrap and pages with no controller-specific branch. The optional GARM experiment (two days at most) stays optional and non-gating. * **ZF-216**: the validation halves are withdrawn (section 7). What survives as product work, demand-gated: GPU admission that prevents oversubscription when a fleet's GPU host is shared by pools, and a disposable-VM backend as its own design with a clear isolation model. Neither before a fleet with the hardware. The isolation work is staged so each step is taken only if the one before it falls short, and the design sketch is candidate 4 in [candidates-from-hosted-services.md](roadmap/candidates-from-hosted-services.md): 1. *A sandboxed OCI runtime per pool* (size S): a pool `runtime` field (`runsc` for gVisor, `kata` for Kata Containers) passed through as the Engine API's `HostConfig.Runtime`. Same runner images, same caches and the same backend; a guest kernel per job without a new code path. The validator refuses a runtime the host's daemon does not list. 2. *Decision 28's spike*, below. 3. *A native `microvm` backend on Firecracker* (size L), only if the two above do not meet a fleet's need. It buys a guest kernel per job and `dind` without a privileged sidecar, and costs `/dev/kvm` on the host (rare on cloud VMs without nested virtualisation), a tap device per VM that the rootless default cannot create (a warning finding and a `security.md` row, never silent), a kernel and root filesystem built per image variant and architecture, no GPU passthrough, and caches that must move from bind mounts to virtio-fs or block devices. Its one advantage over Kata is snapshot-and-restore for pre-warmed runners, which matters only at scale. Docker stays the default in every case: most fleets run their own code on cloud VMs without KVM, and VM isolation is a pool's choice, not the product's. * **ZF-217**, the scale-set assessment: a time-boxed decision record with a disposable prototype, never a second scheduler. Not before a measured reason. * **ZF-404b**: deferred indefinitely under decision 25. Its controller-driven agent upgrades are narrowed by [decision 0011](roadmap/decisions/0011-an-operator-may-let-zoomies-update-itself.md): updating the agent's own binary, on a host that opted in, is ZF-232. * **ZF-218**: complete, the repin having landed with ZF-005; a second provider or an official marketplace submission is not planned. * **Decision 28's spike**: a container per job on Proxmox from the published runner image, one day, only after a fleet with Proxmox exists. ### ZF-237: move a complete instance between operators An operator can move a whole instance to another independently configured controller without giving it the source encryption key. A mandatory passphrase protects a portable snapshot; every stored secret is re-sealed under the destination key, and retained history, IDs and relationships survive. The source is drained and stopped before cutover. The destination stays fenced until its operator verifies agents, webhooks and rented-machine ownership. Destination process configuration and operator access replace the source's. Team passwords, API credentials and two-step secrets remain usable; sessions, unused enrolment capabilities and old operator access do not. Migration refuses an occupied destination, an unreadable secret, an unsupported schema or a damaged archive before changing the live database. Stream archives with explicit size limits and retain no passphrase. Ordinary backups keep their existing restore contract. Deliver one-click preparation with busy jobs left to finish, drain progress, a shared transfer engine, command-line export/import, REST export and staged import, documentation and failure tests. Accept when a complete round trip between different encryption keys preserves history and usable team credentials, source operator access cannot authenticate at the new controller, destination access works after restart, failures leave the source and live destination intact, and restored machine proofs remain unverified. The browser import uses bounded upload chunks and polls background verification so a reverse proxy need not accept the whole archive in one request or wait for its authentication and re-sealing. The single-request import remains available for direct API clients. See [the transfer design](roadmap/instance-transfer.md). The owner requested this work on 9 October 2026; it is the next implementation priority. ### ZF-238: a Windows host joins with one pasted command Choosing Windows in Hosts, Add a host gives an operator one PowerShell command that installs `zoomies.exe`, joins the controller on the `process` backend and starts the `zoomies-agent` service, for direct and private connections and for the release and `dev` channels, with the same checksum rule as `install.sh`. The API returns the Linux and Windows commands together (`commands.linux`, `commands.windows`, beside the existing `command`), `zoomies hosts join-token create --os windows` prints the same line, and the dialog states plainly what a process runner on a machine that keeps its state means. Re-running upgrades without enrolling again, `-Force` enrols again, and `-Uninstall` says what it leaves behind. Accept when the command parses as one literal argument per value for hostile addresses and tokens, a non-elevated or ARM64 window stops before anything is installed, no secret reaches the console or disk beyond the agent's own restricted files, and a hosted Windows runner installs, upgrades and removes the service against a real controller. This changes nothing about the support matrix: the Windows row moves only on the evidence listed in [the checklist](roadmap/support-and-measurement.md#windows-evidence-checklist), which a maintainer collects on a real Windows 11 machine. Size M. ## 10. Ordered delivery plan Consult [progress.md](roadmap/progress.md) before starting; an old description of a missing feature is not evidence it remains missing, and every package in section 8 was re-read on 19 September against the code. | Order | Work | Exit criterion | | --- | --- | --- | | next | ZF-237 complete instance transfer | The round-trip, ownership and refusal tests, with CLI and REST documentation | | later | ZF-245 a cache service on every host | A real runner restores from the host's service with no workflow change, and a pull request cannot plant an entry the default branch restores | | 0 | ZF-005 documentation corrections and the marketplace repin, done, 19 September | The three pages agree with the record; `release.env` pins the current full release | | 1 | ZF-207 two audiences: the role and its migration, the actions, the settings scope, the problems split, tokens, copy and audit | The per-role Playwright assertions hold; a single-team instance is unchanged; an upgraded instance has one platform identity | | 1b | ZF-224 auto-recovery on a lost runner, off by default | A job the fleet broke is re-run once on its own; a test that failed never is; the bound holds across a restart | | 2 | ZF-208 edge limits and the outbound address guard | Every limit has a test that reaches it; the hostile-agent drill holds | | 3 | ZF-210a environment bootstrap, readiness, the controller-only template | A compose file brings up a controller, a platform identity and a joined agent with no human step | | 4 | ZF-209 the usage ledger | The 90-day equality test; one session per runner across a restart | | 5 | ZF-221 durable runtime incidents and the image-pull signal | A runtime failure is on the card and in the drawer within one heartbeat and survives a restart | | 6 | ZF-401 the relay problem code; ZF-404 what the agent owns, written down | The listener's failure is a problem, not a log line; the promise is on the pages the join links | | 7 | ZF-222 the status view, if decision 29 is adopted | The fixture-name test; a default install still serves nothing new | | 8 | ZF-213 pools as a file | Export, plan, dry-run and apply for pools, modelled on the settings pair | | 9 | ZF-210b export with re-seal, and purge | Two installations side by side; an export restores elsewhere under its passphrase | | 10 | ZF-223 a report per installation | Matches a hand computation; answers past retention from the roll-up | | 10b | ZF-225 two-step sign-in for local accounts, delivered, 27 September | A password alone no longer signs in an account that has turned it on; a lost phone has an audited way back | | 11 | ZF-230 the catalog, the guard and the command generator | `catalog.json` validates and CI diffs it; the guard fails on a seeded term | | 12 | ZF-231 `why` | Every demo failure classed at high confidence; `unknown` says what was missing | | 13 | ZF-236 the figures behind size advice | The payload shows the runs it rests on; sparse data is a state, not an absence | | 14 | ZF-229c, the rest: location evidence, the owed checks, `zoomies kennel check` | A finding names its file and job; the offline check and the controller agree on this repository's own workflows | | 15 | ZF-233 the skills | The pinned reference matches the binary; a smoke test in two agents is recorded | | 16 | ZF-234 the documentation | `mkdocs build --strict` passes; every new route and command is on its page | | 17 | ZF-235 the assistant, slice by slice, after an owner decision to start | Each slice's acceptance in the design's section 9 | | 18 | ZF-239 slice 2, the controller's side of a shared App, with no network | An `own` installation is unchanged; a `shared` one completes a repair against the fake relay and holds no private key | | 19 | ZF-240 the fleet as it moves, over MCP, implemented, 12 October | One call answers what changed; a second with the cursor blocks until a seeded event; Eli's three scenarios hold and the three reads for whoever runs the fleet are left off its list | | 20 | ZF-241 the analyses, implemented, 17 October | Each seeded cause is named as dominant at high confidence; a small sample is said to be small; the card and the tool show one answer | | 21 | ZF-242 PR required-check delay, implemented, 25 October | Pinned heads and coverage survive every surface; missing/ambiguous checks stay visible; no merge-readiness claim | | 22 | ZF-243 selected historical comparison | Descriptive milestone merged; controlled-experiment proof deferred, incomplete evidence stays inconclusive | | 23 | ZF-244 reviewed optimisations (checkout, cache, readiness, sharding), implemented, 25 October | Pure supplied-evidence preview first, then scoped web inspection and separately approved draft publication; no verified savings without measurements | | - | ZF-239 slices 3 to 5, the relay itself | After the owner's answers and a ruling on delivery rule 15 | | - | ZF-215, ZF-212, ZF-403's rotation, and section 9 | When a fleet asks | **Keep one stream moving.** The primary target's packages are a chain (ZF-207 first, because ZF-208's ceilings, ZF-209's retention keys, ZF-210a's role, ZF-222's audience and ZF-401's problem all hang off it) and the self-hosted product keeps improving through them, because every one of them is a self-hosted feature. Critical correctness and security defects interrupt the chain. Real-use evidence from any fleet is recorded when it arrives and never waited for. ## 11. Owner inputs | Input | Needed for | Current treatment | | --- | --- | --- | | `V1.1.0`, a full release with no assets because the tag's capital letter defeated the workflow's `v*` guard | Whoever reads the releases page | Delete the release and its tag, or leave it. The guard now refuses a tag that does not begin with a lower-case `v` out loud rather than building nothing (ZF-005, 19 September); the release itself is the owner's | | Whether immutable releases are enabled in the repository settings | Decision 9's last residue | Check once; not roadmap work | | Decisions 29, 31 and 32 | ZF-222; the settings scope in ZF-207; the re-seal in ZF-210b | Written as if the recommendations were accepted; a different answer changes the package it names | | A fleet with a Windows host, a Proxmox cluster or a GPU | The section 9 rows | Recorded when it happens; never waited for | | The primary target's first fleet | The usability feedback ZF-303's second operator was for | Their onboarding is the session; record what they asked, not what was assumed | ## 12. Instruction for the next implementation session Read `CLAUDE.md`, `docs/architecture.md`, `docs/upgrading.md`, this roadmap and `roadmap/progress.md`. Start at the first unfinished slice in section 10. Inspect current code before interpreting anything in this document, and preserve completed work and package IDs. Deliver one reviewable behaviour per pull request, with acceptance evidence, the API, client and documentation updates rule 6 names, and a progress-row update in the same pull request. Preserve the single binary, SQLite and the pure scheduler. No live infrastructure is changed without task authority. Every package in section 8 ships as a self-hosted feature with a self-hosted reason, and nothing you write on the site, in the README or in the UI describes an instance operated on a team's behalf, a plan or a tier (rule 15). Keep implemented, done and withdrawn distinct, and report the exact remaining dependency. Do not invent live runs, elapsed observation, benchmark results or user feedback; do not wait for them either. ## 13. Change record * **8 November 2026: Version 3.14:** ZF-245 added: a cache service on every host, so a fleet gets a warm Actions cache without rewriting its workflows. The earlier proposal for it in [candidates-from-hosted-services.md](roadmap/candidates-from-hosted-services.md) used the number ZF-231, which was taken by then; it is ZF-245 now, and the registry mirror proposed beside it is ZF-246. Slice 1 is built; the design note lists what is not yet proven. * **17 October 2026: Version 3.13:** ZF-241 implemented: the four analyses in `internal/analysis`, each a viewer route under `/api/v1/analysis`, an MCP tool on Eli's list, a subcommand of `zoomies analyse` and a card where its question is already asked (the Jobs page's Queued view, Usage under "By release", a host's own page, and the Runners page when its state filter includes failed runners), documented as *Asking why* in `docs/connect-claude.md`. The entry and row 20 record it, and [fleet-analysis.md](roadmap/fleet-analysis.md) section 5.7 records what was built against the plan's decisions 3 to 8: the comparison card is on Usage, because the Overview has no version panel, and host pressure's on the host's page, which has no capacity map; a pool's ceiling and `ephemeral` flag are read as they are today, because the fleet keeps no history of pool settings; host pressure names jobs only to a caller who can read them; and the demo's comparison keeps its small sample, by the owner's decision. It stays `implemented` until the cards are seen against a real fleet's data. * **12 October 2026: Version 3.12:** ZF-240 implemented: `GET /events/recent` with `since=now` and a bounded wait, the seven window tools on Eli's list, the three reads for whoever runs the fleet left off it by the owner's choice, the `what_changed` prompt, the `zoomies://fleet/now` resource and `zoomies events recent`, documented in `docs/connect-claude.md`. The entry, row 19 and [fleet-analysis.md](roadmap/fleet-analysis.md) sections 2, 3 and 4.2 are corrected: the audit log is a viewer route, so the three are off Eli's list by choice, not by role. It stays `implemented` until the two-call wait is seen to block against a real client. * **12 October 2026: Version 3.11:** ZF-240 and ZF-241 added and designed in [fleet-analysis.md](roadmap/fleet-analysis.md): the recent past over MCP with a bounded wait, and four analyses the controller computes, each offered to an agent and to Eli alike. Push over MCP is deferred, with the reason recorded. Rows 19 and 20 in section 10. No code yet. * **11 October 2026: Version 3.10:** ZF-206, Windows: the owner tried the Windows agent on a real Windows host and it works. The text that said no Windows host had joined or run a job is corrected; the support-matrix row moves when the checklist's evidence is recorded. Separately, `zoomies upgrade` and `zoomies agent join` now recognise a WSL distro without systemd and offer the fix. * **10 October 2026: Version 3.9:** ZF-239 added: a shared Eli GitHub App as a relay for `/eli` and `@eli` mentions, from decision 0012. Slice 1, the shared mention matcher, is delivered (#842). Slice 2, the controller's side with no network, is authorised and listed as row 18 of section 10. Slices 3 to 5, the relay itself, are not authorised until the owner answers the decision's four questions and rules on delivery rule 15. * **10 October 2026: Version 3.8:** ZF-232 moved from section 8 to section 6: updating from the web UI is delivered, in eleven pull requests and the Part 6 pull request, which adds the drills, the narrative in `docs/upgrading.md` and the screenshots. Its record row is `done`. * **9 October 2026: Version 3.7:** ZF-237 added at the owner's request: complete instance transfer between independently configured controllers, with portable secrets, destination operator handover and recovery fencing. It is the next implementation priority. * **8 October 2026: Version 3.6:** the agent-readiness programme read in. ZF-229 (Kennel Club, whose record predates this entry) added to section 8 as the roadmap's pointer to its own record, with what #714 delivered and what remains; ZF-230, ZF-231 and ZF-233 to ZF-236 added from [roadmap/agent-readiness.md](roadmap/agent-readiness.md), which reviewed the owner's seven-phase plan against the code and re-cut it so that each phase extends what exists. Rows 11 to 17 added to section 10 (size advice is ZF-236, because main took ZF-232 the same day); ZF-235 is listed but not authorised to start until the owner says so. Header reconciled against `88c41f6`. * **8 October 2026: Version 3.5:** ZF-232 added to section 8, as proposed: updating Zoomies from the web UI, off by default. The owner chose a root-owned helper on each host over a service that replaces its own binary and over a documentation-only recipe. [Decision 0011](roadmap/decisions/0011-an-operator-may-let-zoomies-update-itself.md) records it, reads rule 12 as binding recovery logic and not an operator's own policy, and narrows decision 25's deferral of controller-driven agent upgrades to the one thing it allows: updating the agent's own binary, on a host that opted in. Rule 12, decision 25 and section 9 point to it. The header was still at 3.3, behind the 3.4 entry below, and now matches. Design only; no code. * **4 October 2026: Version 3.4:** the record caught up with the week. ZF-226 (placement from job history), ZF-227 (AI Context) and ZF-228 (connecting an assistant, and owning your tokens) added to section 8; the first two are delivered and in flight respectively, as `roadmap/progress.md` says. No earlier package changed. * **27 September 2026: Version 3.3:** ZF-225 proposed and delivered in one pull request: optional two-step sign-in (TOTP) for local password accounts, recovery codes, an audited administrator's reset, and `security.require_two_step`, off by default. Added to section 8 and to section 10 as row 10b. * **27 September 2026: Version 3.2:** ZF-216's disposable-VM backend in section 9 staged: a sandboxed OCI runtime per pool (gVisor or Kata) first, decision 28's spike second, and a native Firecracker `microvm` backend only if both fall short, with what each costs named. Still demand-gated; nothing in this entry implements runtime behaviour. * **19 September 2026, Version 3.1:** ZF-005 delivered, the three pages corrected, the marketplace package repinned to `v1.2.0`, the release workflow's tag guard made loud, and moved from section 8 to section 6, with section 10's row 0 marked done and section 11's `V1.1.0` row reduced to the release itself. Section 1 corrected: the Scorecard job runs on GitHub's Ubuntu runners again, because its publishing service refuses every other runner label and every run from the fleet had failed since 18 September. Nothing in this entry implements runtime behaviour. * **19 September 2026: Version 3.0:** the roadmap re-read against `main` at `9a80b31` and re-pointed. **The primary target** is now an instance operated on a team's behalf where the team connects its own runner hosts (section 2, decision 30), which is the shape decision 27's packages were written for; ZF-207 to ZF-210 move to the front and each is rewritten from where the code is now, ZF-207 grew, because migration `0031` made the process's own keys writable by any admin and the role `CHECK` in `0001_init.sql` needs a rebuild; ZF-208 gained an outbound address guard; ZF-210a is half done, because the answer file landed with the marketplace work; ZF-210b gained a passphrase re-seal (decision 32). **Withdrawn** (section 7, rule 17): Gate F, ZF-303, ZF-301c, ZF-219, ZF-211's measurement half, ZF-302's two reference-host drills, ZF-402, and every live-qualification clause, because the product proves itself by carrying this repository's CI on a Zoomies fleet since 18 September and three full releases in a week. **Read back into the record**: fifty-six pull requests since version 2.40, backups as a subsystem with offsite destinations and a staged restore (ZF-203, closed as delivered and larger than written), runner sizing, per-pool timings and elastic CPU (ZF-103, ZF-220), the start-up resilience work (ZF-221, mostly done), fault categories and rerun (ZF-202), prompt cancellation (ZF-105), the Tailcat recovery fixes (ZF-401), and the settings export and import that turned out to be half of ZF-213. **Added**: ZF-005, three documentation corrections and the marketplace repin; ZF-223, the report per installation that replaces ZF-303's helper; delivery rules 15 to 17; decisions 30 to 32; the `withdrawn` status. **Found for the owner**: `V1.1.0` is a full release with no assets. Nothing in this entry implements runtime behaviour, and nothing under `docs/` changes with it. * **15 September 2026: Version 2.40:** add ZF-222, a name-free fleet status projection for the audience that has no account and no way to tell a fleet at capacity from a pool that matches nothing, and decision 29, which is whether one administrative trust domain is also one readership. Sequenced behind ZF-207's problem split. Nothing is enabled by default and this entry implements no runtime behaviour. * **13 September 2026: Version 2.38:** add the owner's bounded pre-release allocation request as ZF-220. Prioritise measured host headroom, existing reservations and automatic pressure admission; retain separate scope for runtime incident diagnosis and remediation. * **13 September 2026: Version 2.37:** make repository-root `ROADMAP.md` the sole active source of truth for scope, ordering and authorisation of roadmap work. `IMPLEMENTATION_PLAN.md` and `roadmap/source/` are historical records; `roadmap/progress.md` records status and evidence only. * **13 September 2026: Version 2.36:** record the `v1.0.0` release in progress as the stable baseline for a VPS one-click-install track once published. Add ZF-218 for the tangible, provider-neutral deployment, HTTPS/bootstrap and partner-hand-off work, followed by ZF-219 for testing and one friendly-provider pilot readiness. Testing intentionally follows completed implementation; no official marketplace submission is implied. * **13 September 2026: Version 2.35:** confirm Proxmox as the first provider. Split ZF-214 into shared contract/recovery, complete Proxmox lifecycle and second-provider validation. Bring contract fixtures forward after bootstrap design, retain mutation/qualification gates, and time-box optional GARM reuse. * **13 September 2026: Version 2.34:** reconcile the delivery order with completed packages and a current competitor review. Add ZF-211 to ZF-217, prioritise ZF-207/208 and split ZF-210 by its actual dependencies. Keep qualification alongside product work. Scope the roadmap to self-hosted development and retain existing package acceptance criteria. * **12 September 2026: Version 2.33:** record six narrow correctness fixes as ZF-004 and add ZF-207 to ZF-210 for repeatable instance administration. * **9 September 2026: Version 2.32:** the dead-socket drill, in the half a tier with no daemon can do honestly: a second agent joins with its Docker socket missing, and the fleet has to say so. It does, and well; the host names the backend it cannot use and the socket it looked for, and the pool with work waiting raises an error quoting that sentence with a fix. The reason to write it anyway was the failure mode where saying nothing is worst: the jobs still queue and the pool still looks configured, so a fleet that quietly places nothing is indistinguishable from a fleet with nothing to do. What is left of ZF-302 is the two halves that need a machine this tier deliberately has not got (starting a daemon back up, and filling a filesystem) and both belong on the reference host the owner actions ask for. * **9 September 2026: Version 2.31:** the restore-and-rollback drill Gate F's sixth bullet asks for, in the two tiers its halves belong to, and both were weaker than they looked. The restore drill stopped at "the fence lifts and the controller answers", which proves a fleet *starts*; it now runs a job on the restored database, with the agent never told any of it happened. And the upgrade check now rolls back; the old binary started again on the copy the new build took before migrating. **Written the obvious way, that check passed without restoring anything at all.** The published release predates the ledger check that refuses a database written by a newer build, so it comes up on the migrated one and looks perfectly healthy: the silent start `docs/upgrading.md` warns about, and what somebody rolling back under pressure would read as success. The check asserts the schema went back now, and the documentation says plainly that rolling back *to* `0.2-beta` is the one case where nothing will stop you. Twice in two days a check has passed for a reason that was not the one it claimed; a directory that outlived its process, and now a binary that started on the wrong database. Both were caught by asking what would have to be true for the assertion to be worth making. * **9 September 2026: Version 2.30:** two more fault drills, and the second one found a defect in the stand-down ZF-105 built. A rate limit is the fault a real fleet is most likely to meet and the least visible from inside it (nothing has crashed, the fleet simply stops) so the product answers it with a per-installation hold and a gauge saying so. **Neither engaged.** GitHub refuses the *installation token refresh* first, ghinstallation mints that inside its transport, and the refusal reached `classify` unclassified: the poller kept calling every two seconds while the quota was gone, and `zoomies_github_paused` (the signal `docs/metrics.md` tells an operator to alert on) stayed at zero. It is fixed, and deliberately only when the refusal's own headers say the quota is actually gone: a 403 for a permission the App has not been granted comes back with quota to spare, and standing an installation down for fifteen minutes over that is a wait that fixes nothing. The other drill is the failing JIT endpoint, where the whole claim is that the fleet does not make a bad situation worse: the attempt is recorded rather than deleted, nothing is left on the host or on GitHub, and it recovers without anybody restarting anything. This is what a drill tier is for. Both faults have been covered by in-process tests for months; neither test could see that the stand-down was never reached, because in process the call that fails is the one the test makes. * **9 September 2026: Version 2.29:** the first two fault drills, and what they found. Both inject their fault while a job is actually running: killing the controller leaves the work running and the fleet finishes it after the restart, and killing the agent leaves the work running and the agent comes back as the same host rather than as a second one. **The finding is the agent's:** the exit code is written by the parent that reaped the process, so an agent restarted mid-job finds its adopted runner gone with nothing recorded and calls that a failure, racing the removal the finished job set off, so the same successful build is recorded `removed` on one run and `failed` on the next. That is a decision rather than a bug fix, and it is now a row in the drill record with the fleet's own accounting named as what is at stake. Two smaller things worth keeping: a drill that checked a directory was satisfied by a runner that had died, because the marker file outlives the process, and a drill that checked once was satisfied by a process that was already doomed; a death that follows its parent's arrives a beat later. * **9 September 2026: Version 2.28:** ZF-302 starts with the piece the plan says has to come first: keeping the drill rows. They were written to `roadmap/validation/drills.md` and reached one job summary and nowhere else, so the comparison the record exists for (recovered cleanly forty times and then did not) could not be made at all. The drill job now commits the rows it wrote, from the default branch only: a pull request's rows describe a commit that may never exist. Each row gained the run that wrote it, because a row worth keeping a month is one that can be taken back to the logs behind it. Nothing here is a fault drill yet; those are next, and they are worth writing only now that their rows will survive the run that produced them. * **9 September 2026: Version 2.27:** ZF-105's last open finding is closed, and with it the package. `TaskBatch.Backoff` had been on the wire since the protocol was written, published in the OpenAPI document and waited on by the agent, and no controller path ever set it; a load-shedding channel that existed everywhere except where the load is. The controller now counts the polls it is holding and asks the ones that found nothing to come back later, by an amount that rises with the excess. What the shape says: the pressure is the fleet's *size*, not its workload, because an idle host costs a held connection for the whole of a long poll; a batch with work in it is never delayed, because the long poll exists so a task reaches its host in the instant it is queued; and the wait is jittered, because a controller sheds every agent it is holding in the same instant, and an unjittered backoff would bring the fleet back together and re-form the queue it was spreading. * **9 September 2026: Version 2.26:** ZF-201 is done. Its last pull request gives the suite's GitHub fake a port, so the connect and verify pages are tested where they live rather than at every layer beneath them, and the suite gains its only fail-then-recover journey outside a wrong password. **That is what exposed the gap**: replacing an installation's private key had a route and no way in, so recovering from the commonest credential mistake there is (pasting the wrong `.pem`, a file GitHub hands over exactly once) meant disconnecting the installation, which takes its pools and their runner rows with it. A key is replaceable; a fleet should not have to be. Two of the harness defects were self-inflicted and worth recording as such: a fixture that published the fake's address *after* the controller answered `/healthz`, so a spec could read a port from the previous run; and a defensive `os.Stdout.Sync()` in the fake, which fails on a pipe and so killed the program the instant it had said where it was. A guard that turns a working program into a dead one is worse than no guard. * **9 September 2026: Version 2.25:** ZF-201's first pull request is done, and its three defects were all the same kind of thing: the product saying something that was not true on the journey a new operator takes. The bootstrap page claimed a length it cannot know, four steps, while the Overview's checklist draws five on exactly the install that reaches it. The installer's remedy for a spent join token had never fired, because a refused token on an anonymous route is a 422 and the transport mapped only 401. **And the third was larger than the package recorded**: `MissingRequirements` bundled the `workflow_job` subscription in with the permissions, so an App with every permission it needs but no subscription probed as a *broken credential* and was recorded unhealthy, while the fleet it describes works perfectly well on the fallback poller, which is a supported way to run. Every other entry in that list is something the fleet cannot work without; that one is something it works without. Writing the dialog's own test then found a fourth: the demo client's shortcut lived at one call site, so pressing Verify on the seeded installation (the one a new fleet has) answered "the stored private key is not a PEM-encoded RSA key". It is in the client cache now, where every path goes through it. * **9 September 2026: Version 2.24:** ZF-103's third pull request is done, and with it Phase 1: every package in it is `done`. The reserve had a column, a store statement and no way in (no route, no caller outside tests) so the documented floors were the only reserve any host has ever had. `PATCH /hosts` takes it now, **refused rather than clamped** when it would leave nothing to place on or is held back from a figure the host has never reported, and written by its own statement so a heartbeat can never touch it. The finding worth keeping is about provenance: what the Hosts page shows a host has promised away is *the scheduler's own sum*, recorded from the snapshot each pass decided on rather than recomputed for the page; a figure that disagreed with the one placement used would be worse than none, because it would be believed, and it reads as unknown until a pass has run rather than as zero. `pool.resources_unenforced` says the quiet part: a `process` pool's limits bind nothing, the reservation still holds the room, and the room is bookkeeping. `host.resources_unknown` is a note rather than a warning, because an agent too old to measure its machine is placed by slots exactly as every host was before it could. * **8 September 2026 (Version 2.23:** ZF-205 is done, and with it every unblocked package in Phase 2) ZF-201 waits on the owner's disposable organisation and ZF-206 on Gate F. **Two corrections to the package as written.** The poller's rate-limit pause is *not* fleet-wide and never was: it is keyed by installation, so the gauge carries the installation, and a fleet with two of them, one held, is a fleet half working that the described flag could not have said. And the time zone and freshness the package asked to be rendered from data already were; the window was the only prose, and it was worse than prose; a fetch of `/stats` defaulted to a day and a `stats` frame to an hour, so the Overview's completed counts and wait percentiles changed under the operator a second after every page load while the specification said an hour throughout. **Three findings.** A registry-wide label rule has to read the collectors' descriptors rather than a scrape: a vector with no observations reports nothing at all, so the version that gathered and inspected passed happily with a `repository` label added, and would have failed on the day something first incremented it. The load measurement earned its keep on its first run, finding that the audit log's action filter scanned the one table Zoomies deliberately never prunes. And the bulk prune's announcement, not its deletion, was the reconnect storm: past sixty-four rows it now sends the one frame that already means "fetch the resources again". * **8 September 2026: Version 2.22:** ZF-204 is done. Its last pull request is the upgrade check nothing else could stand in for: every other tier builds one binary and asks what it does, and upgrade day asks whether an installation is still an installation once the binary under it is replaced. `make test-upgrade` installs the last published release the way an operator does, swaps this build in over its state, and checks that the version moved, the operator's `zoomies.yaml` did not, the host row is the same row, and the pre-migration copy exists. **Writing it found that `curl … install.sh | sh` did not work at all**: GitHub's /releases/latest only knows about full releases, every Zoomies release so far is a prerelease, and the redirect the installer reads therefore landed on the release index with no tag in it. The documented one-line install has been broken for as long as there have been only prereleases, and nothing could have caught it; the installer's only CI coverage was a syntax check. **This also discharges an owner action**: the package asked for a fresh pre-release tag to upgrade from, and the last published release is a better fixture than a tag cut for the test, because it is what people actually have. **And a drill was written and thrown away**, which is the delivery rule working as intended: an agent-upgrade drill on the runtime tier passed with adoption removed, because the reaping it claimed to prevent sits behind a two-minute constant a separate process cannot move. The wiring is pinned in-process instead, where the clock can be, and the gap it exposed was real, since every existing test called `adoptExisting` directly and none of them noticed the call disappearing from the startup path. * **8 September 2026: Version 2.21:** the supply-chain work is done, and it found that three of its seven parts had already shipped: the plan's reconciliation predates the action pinning, the Dependabot configuration and the govulncheck workflow. The finding worth keeping from the rest is that **the pinning rule was a claim rather than a check**, CLAUDE.md has said CI enforces it for some time and nothing did. It is tested now, with the permissions rule and the release workflow's own guards. The image builds are written twice, once per runner vendor, rather than once with a swapped action: this workflow runs only on a tag, so CI cannot tell anybody it has been broken until the moment somebody needs a release, and the path that works today therefore stays byte for byte what it was. * **8 September 2026 (Version 2.20:** backup-before-migrate is done, and the interaction it uncovered is worth recording: `zoomies restore` opens the database it restored, so restoring a backup from an *older* release migrates it) and now keeps a copy of it as it was first. Restoring an old backup no longer consumes it. Both pages say so, which they did not before, because until this the migration on restore was invisible. * **8 September 2026: Version 2.19:** ZF-204's schema-safety tests are done, and writing them corrected the plan's picture twice. There are two releases now rather than one, so the upgrade fixture covers both points a real database sits at. And the fixture has to be a database built by applying the old migrations, not a current one with its ledger trimmed: trimming leaves the columns the later migrations add, so re-applying them fails on a duplicate and the thing being tested is an upgrade from a database that never existed. The first attempt did exactly that and the test caught it. * **8 September 2026: Version 2.18:** ZF-204's second pull request is done. The distinction it settled is what "skew" means: a *release* difference, not a commit difference. Two builds of one tag are the same release (worth knowing in a bug report, and not skew) and treating a rebuild as skew was what made the agent's log and the Hosts page disagree. The comparison behind both refuses to order what it cannot parse, because a wrong order would send an operator to upgrade the wrong side, and a host ahead of its controller is called out separately for the same reason: there the fix is the other machine. * **8 September 2026: Version 2.17:** ZF-204's first pull request is done, and it corrected two things in this plan. "An agent may lag by one minor release" is not a rule any code here can enforce, because nothing compares release numbers, what is enforced is that the protocol matches, and lag beyond that is a fact to show rather than a rule to apply. And the verifier's cordon finding has a sharper answer than "gate the poll loop": an agent refuses a *create* while cordoned or incompatible and serves every other task kind, because a cordoned host still has runners to drain and an agent that stopped polling would strand them. Pull request 3's first item is also already done: the ledger refusal landed in ZF-203, which needed it for a safe restore, and the emergency override that item asks for should not be built; the refusal names the release to run, and an override is a way to corrupt a database under pressure. * **8 September 2026: Version 2.16:** the fence is done, and ZF-203's code is complete; only the owner-run GitHub half of its acceptance remains. Two distinctions the work sharpened. Readiness fails while fenced and liveness does not, because the container image's health check is `/healthz`: a fenced controller must be taken out of rotation and must **not** be restarted, which would achieve nothing and lose the operator's session. And the fence stops the fleet *acting* without stopping it *deciding*; the plan is still computed and published, so an operator can tell "nothing to do" from "not allowed to", which is the difference a fenced fleet otherwise cannot show. * **8 September 2026: Version 2.15:** `zoomies restore` is done, and it fixed the fence's home. `recovery.fenced` is a row in the restored database's own `settings` table (the one the verifier noted shares a name with the settings API and none of its data) rather than a line in `zoomies.yaml`. The fence belongs to the data: a restored database is fenced wherever it is put, and a copy carried to a second machine arrives fenced too, which is the case the fence exists for. An unparseable value reads as fenced, because the fence is the safe side of its own question and a half-finished restore is exactly what writes one. * **8 September 2026: Version 2.14:** `zoomies backup` is done. Two shapes settled while writing it. A backup is one directory rather than a database file beside a manifest file: retention has something whole to delete, and `zoomies restore` will take one argument. And the summary states the key's presence or absence on every run, not only on the dangerous one; an operator who reads "backed up" and stops is exactly the person the sentence is for. * **8 September 2026: Version 2.13:** ZF-203's first pull request is done, and it sharpened one distinction the package's prose had left implicit: a *missing* encryption key over a sealed database is a startup refusal, and a *wrong* one cannot be, because a key is proven only by opening something. So the two guards live in different places (one in the command that would have generated a key, one as a problem code the drawer raises once the controller is running) and the restore documentation now says which failure looks like which. * **8 September 2026: Version 2.12:** the support bundle is done and **ZF-202 is finished**. Two decisions in it are worth recording. The action is admin rather than viewer, because the weakest role that covers a bundle is the strongest role inside it; it carries the settings section, and that has always been admin. And the byte cap sheds sections rather than refusing the request: explanations first because they are recomputable from the jobs beside them, then the scaling history, then jobs and runners; the fleet's own shape is what a bundle is for and never goes. A short bundle answers some questions and a 500 answers none. * **8 September 2026: Version 2.11:** the explanation is finished: the drawer and the CLI render it rather than reasoning for themselves, which is the defect this package named and the reason the endpoint exists. What the work clarified is where the boundary falls; a pool's live counts are facts and stay in the panel; only the reason moved. * **8 September 2026: Version 2.10:** `GET /jobs/{id}/explanation` is done. The shape it settled on is worth recording because the rest of the package will render it: one summary that is always set, a detail in the scheduler's own words where it has any, a fix that is absent when there is nothing to do, and `waiting` separated from `blocked`; a fleet that is merely busy clears itself and a pool nothing can place never will. * **8 September 2026: Version 2.9:** ZF-202's small half and its test fixture are done. The fixture is worth a line of its own because of what it changes about how this package is verified: the suite's shared fleet is deliberately healthy, so every page that explains a fault was unreachable from a test, and three pull requests in a row shipped without a pin for that reason. It found a defect on its first run; a held job is unmatched by construction, and the Jobs page hid every one of them by default. * **8 September 2026: Version 2.8:** the poller can be seen. Writing it found a stale instruction in this document: ZF-202 asked for `poller.paused` to be attributed to the whole poller "because the pause is fleet-wide until ZF-101 changes it", and ZF-101 changed it; the hold has been per installation since its second pull request. The entry names the installation, and the package's text is corrected rather than left to instruct the next session wrongly. * **8 September 2026: Version 2.7:** the two of ZF-202's small fixes that need no API shape are done, and one of them was a disclosure rather than a gap: `zoomies config print` has printed `capacity_demand.signing_secret` in full for as long as that feature has existed, because the blanking is a hand-written list. The replacement guard does not read the list. Nothing in the plan changed; the package's entry now says which half of its small fixes remains and why (an OpenAPI change and both generated clients). * **8 September 2026: Version 2.6:** ZF-103b is done, so Phase 1 is complete as code: every placement decision now costs a reservation, and the figures ZF-103a taught agents to report are read by something. Two choices the package did not name are worth keeping in front of the third pull request, because they decide what its numbers mean: the fallback share (a field a pool leaves unset costs one slot's worth of the host) is what makes the upgrade admit exactly what it admitted before, and free disk is charged only for the runners a pass adds, because the measurement already contains what the runners already there have written. The floors under a host's reserve are documented in section 6 and in `docs/hosts-and-pools.md`, and until the third pull request gives the reserve a route they are the only thing holding anything back. * **8 September 2026: Version 2.5:** ZF-206's first pull request is done, and it is the only part of that package this document authorises before decision 26 and Gate F. Nothing about the plan changed; what changed is that three places no longer imply Windows support. The package entry and section 10 say so, and the rest of ZF-206 is untouched. * **8 September 2026: Version 2.4:** **ZF-206, Windows runners**, added to Phase 2 with decision 26 choosing its shape, and sequenced after Gate F in section 10. The vocabulary for it already shipped (`naming.OSWindows` is a legal operating system and `docs/hosts-and-pools.md` used `os=windows` as a pool example) while nothing behind it did, which is the gap the package's first pull request closes on its own. * **8 September 2026: Version 2.3:** Assignment A is finished, and this document is reconciled against the code rather than against itself. Section 10 says where the assignment started and ended and which of its steps is still open; section 11 gains a **State** column, because a table of owner actions with no state is a table nobody updates; section 12 becomes one instruction per assignment, with Assignment A's marked spent so that a fresh session cannot re-issue a finished assignment, and Assignment B's written but explicitly not yet sent. Decision 15 and the Phase 1 preamble had ZF-103's two halves the wrong way round against how they shipped: 103a is the reporting half, 103b the admission half, and both precede Gate F. Delivery rule 14 writes down the discipline the whole assignment actually ran on -- a test is kept only once it has been seen to fail, and rule 7 names the two tests a migration must be added to. The ZF-002, ZF-101, ZF-103, ZF-205 and ZF-301b entries are corrected where they described work that has since landed differently. * **7 September 2026: Version 2.2:** the migration prefixes this document reserved are stale. `0010` and `0011` shipped from other work between the plan being written and ZF-101 starting, so ZF-101 took `0012`, and ZF-103's host resource reporting took `0017` when it landed later the same day. The schema rule itself is unchanged: the next file takes the next unused prefix, whatever that is by then, which is why the rule and not a number is what this document now names. * **6 September 2026: Version 2.1:** N02 is fixed and removed as a programme gate. Added the deliberately deferred **ZF-404b** host-stewardship and bounded-housekeeping slice; it is not authorised for implementation before Gate F. ## Approved logo refresh (10 October 2026) Apply the owner-selected running spaniel artwork to the app and documentation. Ship transparent black and white full logos and emblems as SVG and PNG, preserving the paw/swish at small sizes.