{
  "config": {
    "argv": [
      "/home/user/actions-runner-overdeck-debian1/_work/_tool/node/22.23.1/x64/bin/node",
      "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/node_modules/@playwright/test/cli.js",
      "test",
      "--reporter=list,json"
    ],
    "configFile": "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/playwright.config.ts",
    "rootDir": "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/apps/web/tests",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/alexcodeplace/overdeck/commit/9f23d4b61ea9af2e6ac32232ee9f4ad13067a907",
        "commitHash": "9f23d4b61ea9af2e6ac32232ee9f4ad13067a907",
        "prHref": "https://github.com/alexcodeplace/overdeck/pull/13",
        "prTitle": "k3s: qualification-first control-plane recovery baseline",
        "prBaseHash": "d3a8030304c182208fdacd4da4cfe76abdfe713f",
        "buildHref": "https://github.com/alexcodeplace/overdeck/actions/runs/31488133161"
      },
      "gitCommit": {
        "shortHash": "9f23d4b",
        "hash": "9f23d4b61ea9af2e6ac32232ee9f4ad13067a907",
        "subject": "Merge 4e6b0bba3dbb42fa514a5d8400e8890424a1d82d into d3a8030304c182208fdacd4da4cfe76abdfe713f",
        "body": "Merge 4e6b0bba3dbb42fa514a5d8400e8890424a1d82d into d3a8030304c182208fdacd4da4cfe76abdfe713f\n",
        "author": {
          "name": "alexcodeplace",
          "email": "git@alex.org.il",
          "time": 1786448791000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1786448791000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/plans/2026-08-10-k3s-migration-execution.md b/docs/plans/2026-08-10-k3s-migration-execution.md\nindex ff51c28..94c1aac 100644\n--- a/docs/plans/2026-08-10-k3s-migration-execution.md\n+++ b/docs/plans/2026-08-10-k3s-migration-execution.md\n@@ -127,27 +127,52 @@ bash apply-overdeck-k3s-phase0.sh /home/user/Projects/overdeck\n \n **Rollback:** remove the created worktree/branch if desired; no cluster rollback is needed.\n \n-**Current receipt:** package prepared in this work session; owner execution pending.\n+**Current receipt:** owner execution passed all required Phase 0 repository gates, produced commit `ec69e612bb666a459ba8fff31656616988236e4d`, opened PR #12, and merged to `main` in merge commit `9990b8cb9b5ebf40acba7c22a2b9763ca4134480`. One legacy integration gate was explicitly optional and recorded as failed; it did not block the Phase 0 audit landing.\n \n-### Phase 1 — canonical control-plane baseline, backup and recovery\n+### Phase 1 — qualification-first control-plane baseline, backup and recovery\n \n-**Input:** Phase 0 receipt.\n+**Input:** Phase 0 receipt plus the three safe Phase 1 failure receipts.\n \n **Assistant work:**\n \n-- generate canonical `/etc/rancher/k3s/config.yaml` and supporting config fragments from observed state;\n-- pin the approved K3s version and install artifact digest/source;\n-- define API endpoint/TLS SAN/Tailscale routing contract;\n-- implement server preflight, drift report and idempotent converge;\n-- implement SQLite/embedded-datastore plus server-token backup, encryption/permissions, off-host copy and retention;\n-- implement isolated restore verification and a no-change dry run;\n-- add upgrade/rollback scaffolding without performing an upgrade.\n-\n-**Owner action:** run one phase script and approve the backup destination/root action.\n-\n-**Gate:** server remains healthy; current config is reproducible; backup and token are paired; restore verification succeeds; API reachability from each eligible node is recorded.\n-\n-**Rollback:** restore prior config and service unit atomically; do not change K3s version in this phase.\n+- rederive Phase 1 around the live `debian3` topology rather than patching v3;\n+- separate the outer `/usr/local/bin/k3s` launcher from the running extracted executable;\n+- require active service, outer-launcher `/readyz`, and non-empty Ready Node inventory;\n+- model the live `agent-token -> token` layout without archiving a symlink;\n+- separate launcher/runtime version-lock fields and tie the release URL only to the launcher digest;\n+- aggregate complete qualification failures before any K3s configuration mutation;\n+- create, encrypt, verify, and materialize a pre-change recovery archive before publication;\n+- publish the canonical drop-in, contract, and version lock transactionally;\n+- verify the converged server, workstation, and every reachable registry host;\n+- create and verify a post-change recovery archive before finalization;\n+- install the recurring backup timer transactionally;\n+- authorize Git publication only in the final successful live receipt;\n+- make the package launcher commit/push/PR only after that authorization.\n+\n+**Owner action:** run one replacement phase script. No separate manual plan/apply/Git sequence is required.\n+\n+**Gate:**\n+\n+- all repository, failure-injection, fake-cluster, Phase 0, fleet, and focused Factory gates pass;\n+- complete live qualification passes;\n+- pre-change encrypted restore proof predates configuration publication;\n+- K3s remains healthy with unchanged version, launcher, runtime, and datastore type;\n+- post-change encrypted restore proof succeeds;\n+- recurring timer is installed and active;\n+- receipt returns `status=success` and `git_publication_allowed=true`;\n+- only then is the exact candidate committed and optionally pushed as a draft PR.\n+\n+**Rollback:** before server finalization, restore prior managed files/absence and timer state, restart K3s only when needed, and prove readiness. Qualification failures require no K3s rollback and never create a Git commit.\n+\n+**Implementation status:** Phase 1 v1–v3 are superseded and must not be merged. Their owner runs stopped safely before persistent cluster mutation. The replacement implementation is qualification-first and includes a sanitized replay fixture from the third receipt, 55 Python unit/failure tests, and a full fake-cluster transaction covering pre-change backup → converge → post-change backup → timer → finalization. Live replacement execution is pending.\n+\n+**Durable references:**\n+\n+- design: `docs/specs/2026-08-10-k3s-control-plane-baseline-design.md`;\n+- rederivation report: `docs/reports/2026-08-11-k3s-phase1-rederivation.md`;\n+- backup/restore runbook: `docs/runbooks/k3s-control-plane-backup-restore.md`;\n+- execution instructions: `docs/runbooks/k3s-phase1-agent-instructions.md`;\n+- entrypoint: `tools/k3s/phase1-control-plane.sh`.\n \n ### Phase 2 — one-command enrollment engine, dry-run first\n \n@@ -283,7 +308,11 @@ bash tools/k3s/enroll-node.sh debian4\n - Read-only Phase 0 collector and its offline test added.\n - Detailed audit recorded at `docs/reports/2026-08-10-k3s-migration-audit.md`.\n - Phase 0 changed-files package/application script generation in progress.\n+- Phase 0 commit `ec69e612bb666a459ba8fff31656616988236e4d` merged through PR #12 at `9990b8cb9b5ebf40acba7c22a2b9763ca4134480`.\n+- Phase 1 v1–v3 stopped safely before persistent cluster mutation and are superseded by the qualification-first replacement.\n+- The replacement replays the live launcher/runtime and agent-token topology, proves a pre-change backup before configuration publication, and permits Git publication only after full live success.\n+- Phase 1 has no product UI, primitive, design-system, or Astryx delta.\n \n ## Next executable action\n \n-Run the Phase 0 application script on the trusted workstation, then return its generated `overdeck-k3s-phase0-result-*.tar.gz`. Use that current receipt to generate Phase 1 without guessing cluster state.\n+Run `apply-overdeck-k3s-phase1-qualified.sh /home/user/Projects/overdeck` on the trusted workstation and return the generated `overdeck-k3s-phase1-result-*.tar.gz`. Keep any draft pull request unmerged until that receipt is reviewed.\ndiff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md\nindex db0d235..39182fc 100644\n--- a/docs/plans/INDEX.md\n+++ b/docs/plans/INDEX.md\n@@ -27,7 +27,7 @@ Read this file at session start, after compaction, and before claiming work. Eve\n | FIRE | DONE | [Cluster degraded — oversized mirror trips the fleet](2026-08-11-cluster-push-oversize-trip.md) | 15.3 GB non-git `Press.zone` mirror times out the 120 s push budget and marks every host unhealthy | Landed `b86ee4380` + deployed; doctor green on ssh/parity/rsync for all three hosts; debian2 back in both spill orders |\n | FIRE | IDLE | [Emergency Offload](2026-08-10-emergency-offload.md) | #170 restore remote execution and laptop usability | Whole-agent remote seat completed on Debian2, exit 0; customer payload and spill pending |\n | FIRE | BLOCKED | [Factory on k3s](2026-08-10-factory-k3s.md) | #96/#175 immutable k3s execution vertical slice | Repair dispatch `bl1io3br2` started nothing: Debian3 seat capacity 3/3; preserved WIP unchanged |\n-| FIRE | ACTIVE | [K3s migration and node enrollment](2026-08-10-k3s-migration-execution.md) | Reconcile scheduler/Factory tracks; phased cluster migration; one-command post-Tailscale enrollment | Phase 0 audit, rollback repair and read-only receipt package prepared; owner execution pending |\n+| FIRE | ACTIVE | [K3s migration and node enrollment](2026-08-10-k3s-migration-execution.md) | Reconcile scheduler/Factory tracks; phased cluster migration; one-command post-Tailscale enrollment | Phase 0 merged at `9990b8cb`; Phase 1 v1–v3 safely stopped and superseded; qualification-first replacement prepared with live-topology replay and pre-change recovery gate |\n | 2 | BLOCKED | [Seat Image Node Drift](2026-08-11-seat-image-node-drift.md) | debian1 seat image node v22≠v24; land-queue gate red fleet-wide | Handed to k3s/node-image lane; direct-land escape in use |\n | 1 | ACTIVE | [Flow-First Operating Model](2026-08-10-flow-first-operating-model.md) | Permanent delivery workflow adjustment | Owner goal active: main session MUST continuously execute; background waits route immediately to another lane, never stop |\n | 2 | ACTIVE | [Factory Reliability](2026-08-10-factory-reliability.md) | #1/#151/#171/#181/#182/#183 transport, supervision, timeout fixture, disk admission, UI kill/copy | #1 landed/deployed at 747fe53f; #151 passed 10 focused runs and 144-test Factory suite |\ndiff --git a/docs/reports/2026-08-11-k3s-phase1-rederivation.md b/docs/reports/2026-08-11-k3s-phase1-rederivation.md\nnew file mode 100644\nindex 0000000..b0a0cb4\n--- /dev/null\n+++ b/docs/reports/2026-08-11-k3s-phase1-rederivation.md\n@@ -0,0 +1,214 @@\n+# K3s Phase 1 rederivation after three safe live stops\n+\n+Date: 2026-08-11\n+Audience: owner, migration agents, reviewers\n+Status: replacement implementation complete; qualification-first live execution pending\n+\n+## Outcome\n+\n+Phase 1 was rederived rather than patched a fourth time. The replacement treats the three owner runs as live compatibility evidence and changes the transaction boundary accordingly:\n+\n+1. qualify the complete apply path;\n+2. create, encrypt, verify, and materialize a pre-change recovery archive;\n+3. only then publish control-plane configuration;\n+4. verify the converged control plane and create a second recovery archive;\n+5. install the recurring backup timer;\n+6. only after the live transaction succeeds may the package launcher commit, push, or open a pull request.\n+\n+The previous Phase 1 candidates remain unmerged and are not inputs to this implementation.\n+\n+## Evidence from the three runs\n+\n+### Run 1 — shared plan index conflict\n+\n+The first package was generated from an older `main` and attempted to patch `docs/plans/INDEX.md` textually. Newer coordination changes on `main` caused a conflict before tests or live actions.\n+\n+Correction retained in the replacement:\n+\n+- stable files use a three-way Git patch;\n+- the K3s row in `docs/plans/INDEX.md` is updated semantically;\n+- unrelated rows, receipts, status changes, formatting, and line endings are preserved;\n+- a missing, duplicate, malformed, symlinked, or non-UTF-8 target fails closed.\n+\n+### Run 2 — privileged `PATH` assumption\n+\n+The second package passed repository gates but the remote root helper searched for `k3s` only through the non-interactive sudo environment. `/usr/local/bin` was not visible there.\n+\n+Correction retained in the replacement:\n+\n+- child commands use a deterministic root-controlled executable path;\n+- the outer K3s launcher is discovered from structured systemd `ExecStart`, canonical installer paths, and the approved path;\n+- every privileged executable and its directory chain are verified;\n+- no missing executable is repaired by downloading, reinstalling, or upgrading K3s.\n+\n+### Run 3 — launcher/runtime conflation\n+\n+The third package found both:\n+\n+- outer command launcher: `/usr/local/bin/k3s`;\n+- running executable: `/var/lib/rancher/k3s/data/<digest>/bin/k3s`.\n+\n+It selected the running executable and called:\n+\n+```text\n+<runtime-app>/k3s kubectl get --raw=/readyz\n+```\n+\n+That applet rejected nested `kubectl` dispatch. The resulting message claimed K3s was not ready even though `k3s.service` was active.\n+\n+Correction in the replacement:\n+\n+- launcher and runtime are separate typed inventory objects;\n+- only the outer launcher is used for `kubectl`, `etcd-snapshot`, and `--version`;\n+- the running executable is evidence only;\n+- readiness failures distinguish invalid launcher dispatch, API reachability, authentication, and service state;\n+- server inspection retries readiness for a bounded 30-second window instead of a single five-second attempt.\n+\n+## Additional defect found before another live run\n+\n+The latest receipt reported:\n+\n+```text\n+/var/lib/rancher/k3s/server/agent-token\n+  -> /var/lib/rancher/k3s/server/token\n+```\n+\n+The prior backup implementation rejected every symlink and would therefore have failed after the readiness defect was fixed.\n+\n+The replacement classifies agent-token layout explicitly:\n+\n+- absent;\n+- independent root-owned mode-restricted regular file;\n+- symlink resolving exactly to the canonical server token.\n+\n+A supported symlink is not archived as a TAR link. The manifest records a constrained restoration descriptor, and the offline restore proof reconstructs only `agent-token -> token` inside the isolated restore tree. Broken, cyclic, escaping, or unrelated symlinks fail closed.\n+\n+## Version-lock correction\n+\n+The old lock associated one digest with both the downloaded K3s release and the extracted runtime executable. Those are not guaranteed to be the same artifact.\n+\n+The replacement schema records:\n+\n+```json\n+{\n+  \"version\": \"v1.36.3+k3s1\",\n+  \"launcher\": {\n+    \"invocation_path\": \"/usr/local/bin/k3s\",\n+    \"resolved_path\": \"/usr/local/bin/k3s\",\n+    \"sha256\": \"...\",\n+    \"release_url\": \"...\"\n+  },\n+  \"runtime\": {\n+    \"present\": true,\n+    \"resolved_path\": \"/var/lib/rancher/k3s/data/.../bin/k3s\",\n+    \"sha256\": \"...\"\n+  }\n+}\n+```\n+\n+Only the launcher digest is tied to the release URL and future upgrade candidate. A legacy ambiguous lock is rejected rather than silently migrated.\n+\n+## Qualification boundary\n+\n+The replacement qualification matrix runs these checks before control-plane configuration can change:\n+\n+1. `age` tools and private identity are available with safe ownership and permissions.\n+2. The recurring user-systemd backup installation is preflighted in a temporary directory.\n+3. The server is reached through the registered recovery door.\n+4. The ephemeral root helper is checksum verified.\n+5. The outer launcher and running executable are inventoried separately.\n+6. `k3s.service`, `/readyz`, and non-empty Ready Node inventory pass.\n+7. The datastore is classified as SQLite or embedded etcd.\n+8. Server-token and agent-token layouts are supported.\n+9. The desired endpoint, SAN set, configuration, contract, and version lock are planned without publication.\n+10. Workstation kubeconfig `/readyz` passes.\n+11. Every registry-reachable host proves the same API CA and hostname/IP-verified TLS.\n+12. A pre-change datastore/config/token archive is created on the server.\n+13. The TAR is streamed directly into `age` off-host with plaintext size and SHA-256 comparison.\n+14. The encrypted archive is decrypted temporarily, verified file by file, and materialized into an isolated restore tree.\n+15. Remote plaintext backup staging is removed.\n+\n+Independent checks continue after a failure so the receipt reports all known failures and blocked dependencies instead of stopping at the first defect.\n+\n+Qualification may create the workstation age identity and a verified encrypted pre-change backup. It does not publish K3s configuration, restart K3s, install the recurring timer, or publish Git changes.\n+\n+## Apply boundary\n+\n+Only a passing qualification can enter apply:\n+\n+1. publish the three managed control-plane files transactionally;\n+2. restart K3s only if the deterministic YAML drop-in changed;\n+3. prove server readiness, Ready Node inventory, launcher/runtime digests, and serving-certificate SANs;\n+4. prove workstation and peer API/CA/TLS access again;\n+5. create and verify a post-change encrypted recovery archive;\n+6. install and verify the recurring backup timer;\n+7. finalize the server transaction and remove rollback authority;\n+8. retain both verified encrypted backups and their metadata.\n+\n+A failure before finalization restores the prior managed files or prior absence, restarts K3s when required, verifies readiness, rolls back timer files, retains recovery evidence, and prevents Git publication.\n+\n+## Git publication boundary\n+\n+The package launcher now applies repository files and runs tests in an isolated worktree but does not commit before live success.\n+\n+The exact order is:\n+\n+```text\n+package verification\n+  -> repository patch\n+  -> deterministic tests\n+  -> local sudo qualification\n+  -> live qualification + pre-change backup\n+  -> live apply + post-change backup\n+  -> inspect phase1-result.json\n+  -> stage declared paths\n+  -> commit\n+  -> push candidate branch\n+  -> draft pull request\n+```\n+\n+The orchestrator must return both:\n+\n+```json\n+{\n+  \"status\": \"success\",\n+  \"git_publication_allowed\": true\n+}\n+```\n+\n+before the launcher stages or commits any repository path. Qualification-only mode never commits or pushes.\n+\n+## Test rederivation\n+\n+The replacement test suite includes:\n+\n+- 55 Python unit and failure-injection tests;\n+- a sanitized fixture derived from the real `debian3` receipt;\n+- distinct outer-launcher and extracted-runtime behavior;\n+- the runtime-app nested-`kubectl` failure that stopped run 3;\n+- agent-token absent, regular, safe symlink, unrelated symlink, broken symlink, and unsafe permissions;\n+- non-empty Node inventory and explicit probe-failure classification;\n+- pre-change backup without not-yet-published canonical files;\n+- post-change backup requiring a complete canonical set;\n+- separate launcher/runtime version-lock fields;\n+- safe TAR extraction and exact manifest file-set validation;\n+- SQLite integrity verification;\n+- isolated restore materialization and agent-token symlink reconstruction;\n+- a full fake-cluster transaction proving:\n+  - qualification and pre-change backup precede convergence;\n+  - post-change backup follows convergence;\n+  - timer installation follows post-change proof;\n+  - finalization is last;\n+  - Git publication is authorized only on success.\n+\n+The fake-cluster fixture now models K3s v1.36.3+k3s1, `/usr/local/bin/k3s`, an extracted runtime path, SQLite, three Ready Nodes, and `agent-token -> token`.\n+\n+## Safety status of prior candidates\n+\n+The three reported owner runs stopped before persistent K3s mutation. Their isolated worktrees and candidate commits are evidence only. They must not be merged or used as the base for the replacement.\n+\n+The replacement package is a complete delta from the merged Phase 0 base and can be applied to a newer `origin/main` through three-way patching plus the semantic plan-index update.\n+\n+## UI boundary\n+\n+This phase changes no product UI, no design-system code, and no UI primitive. Astryx is not required. The owner rule against creating a primitive without permission is unaffected.\ndiff --git a/docs/runbooks/k3s-control-plane-backup-restore.md b/docs/runbooks/k3s-control-plane-backup-restore.md\nnew file mode 100644\nindex 0000000..f086a6f\n--- /dev/null\n+++ b/docs/runbooks/k3s-control-plane-backup-restore.md\n@@ -0,0 +1,303 @@\n+# K3s control-plane backup and restore runbook\n+\n+Audience: owner and trusted recovery agents\n+Applies to: Phase 1 qualification-first tooling\n+Safety rule: verification and materialization are allowed; live restore activation is always a separate owner-approved incident operation\n+\n+## 1. Artifacts created by a successful Phase 1 run\n+\n+Default workstation paths:\n+\n+```text\n+~/.config/overdeck/k3s-backup.agekey\n+~/.config/overdeck/k3s-backup.json\n+~/.local/lib/overdeck/k3s/\n+~/.local/state/overdeck/k3s-backups/debian3/\n+~/.local/state/overdeck/k3s-phase1-debian3.lock\n+~/.config/systemd/user/overdeck-k3s-backup.service\n+~/.config/systemd/user/overdeck-k3s-backup.timer\n+```\n+\n+A successful apply retains at least two verified pairs:\n+\n+```text\n+overdeck-k3s-debian3-prechange-<stamp>.tar.age\n+overdeck-k3s-debian3-prechange-<stamp>.json\n+overdeck-k3s-debian3-postchange-<stamp>.tar.age\n+overdeck-k3s-debian3-postchange-<stamp>.json\n+```\n+\n+The `.json` file is non-secret verification metadata. The `.tar.age` file contains encrypted datastore, token, configuration, and service recovery material.\n+\n+## 2. Critical custody rule\n+\n+The recovery archive and age private key must not have their only durable copies on the same workstation.\n+\n+After Phase 1 succeeds:\n+\n+1. copy the encrypted `.tar.age` files to an owner-controlled off-workstation location;\n+2. copy the age private key to a separate secure secret store or offline medium;\n+3. preserve mode `0600` when stored on a filesystem;\n+4. never upload the private key in a phase receipt;\n+5. record the external locations in the owner’s private operations inventory, not in Git.\n+\n+The encrypted backup is useful without secrecy only if the private key remains protected. The private key alone is not a backup.\n+\n+## 3. Inspect recurring backup status\n+\n+```bash\n+systemctl --user status overdeck-k3s-backup.timer --no-pager\n+systemctl --user list-timers overdeck-k3s-backup.timer --all\n+systemctl --user status overdeck-k3s-backup.service --no-pager\n+```\n+\n+Read recent logs:\n+\n+```bash\n+journalctl --user -u overdeck-k3s-backup.service -n 200 --no-pager\n+```\n+\n+List verified pairs:\n+\n+```bash\n+find \"$HOME/.local/state/overdeck/k3s-backups/debian3\" \\\n+  -maxdepth 1 -type f \\\n+  \\( -name '*.tar.age' -o -name '*.json' \\) \\\n+  -printf '%TY-%Tm-%Td %TH:%TM:%TS %m %s %p\\n' | sort\n+```\n+\n+Do not delete unpaired or failure-qualified archives automatically. They may be the only evidence from an interrupted transaction.\n+\n+## 4. Run an additional backup manually\n+\n+Use the installed runtime after Phase 1 succeeds:\n+\n+```bash\n+bash \"$HOME/.local/lib/overdeck/k3s/phase1-control-plane.sh\" \\\n+  --config \"$HOME/.config/overdeck/k3s-backup.json\" \\\n+  --mode backup \\\n+  --non-interactive\n+```\n+\n+This operation:\n+\n+- uploads a fresh ephemeral helper;\n+- verifies K3s health and identity;\n+- creates a `scheduled` archive;\n+- streams and encrypts it off-host;\n+- materializes an isolated restore proof;\n+- checks workstation and peer API access;\n+- removes remote plaintext staging;\n+- prunes only old verified archive/metadata pairs.\n+\n+## 5. Verify an encrypted archive without live changes\n+\n+Choose an empty private directory:\n+\n+```bash\n+BACKUP=\"$HOME/.local/state/overdeck/k3s-backups/debian3/<archive>.tar.age\"\n+RESTORE_ROOT=\"$HOME/k3s-restore-proof-$(date -u +%Y%m%dT%H%M%SZ)\"\n+\n+bash tools/k3s/restore-control-plane.sh \\\n+  --encrypted \"$BACKUP\" \\\n+  --identity \"$HOME/.config/overdeck/k3s-backup.agekey\" \\\n+  --restore-root \"$RESTORE_ROOT\"\n+```\n+\n+The wrapper decrypts to a private temporary TAR, validates it, and materializes the restore tree. It does not stop K3s or copy anything into `/etc` or `/var/lib`.\n+\n+Expected top-level output:\n+\n+```text\n+$RESTORE_ROOT/\n+  OVERDECK_RESTORE_PLAN.json\n+  etc/rancher/k3s/\n+  var/lib/rancher/k3s/server/token\n+  var/lib/rancher/k3s/server/agent-token\n+  var/lib/rancher/k3s/server/db/...\n+  etc/systemd/system/...\n+```\n+\n+On the current `debian3` layout, `agent-token` is expected to be reconstructed as the relative link:\n+\n+```text\n+agent-token -> token\n+```\n+\n+The encrypted TAR itself never contains that symlink.\n+\n+## 6. Interpret verification metadata\n+\n+A verified metadata file includes:\n+\n+- `status: verified`;\n+- purpose (`prechange`, `postchange`, or `scheduled`);\n+- encrypted file size and SHA-256;\n+- server identity;\n+- K3s version plus separate launcher/runtime facts;\n+- datastore type and integrity result;\n+- agent-token restoration classification;\n+- canonical configuration presence/consistency;\n+- `secret_values_recorded: false`.\n+\n+A pre-change backup may report:\n+\n+```json\n+{\n+  \"canonical_config\": {\n+    \"present\": false,\n+    \"purpose\": \"prechange\",\n+    \"reason\": \"prechange-baseline\"\n+  }\n+}\n+```\n+\n+That is expected when the archive predates Phase 1 publication.\n+\n+Post-change and scheduled backups must report `canonical_config.present: true`.\n+\n+## 7. Qualification-only execution\n+\n+The package launcher supports a qualification-only mode. It is useful after a topology or workstation change:\n+\n+```bash\n+./apply-overdeck-k3s-phase1-qualified.sh \\\n+  /home/user/Projects/overdeck \\\n+  --qualification-only\n+```\n+\n+It may create an age identity and verified encrypted pre-change backup, but it must not:\n+\n+- publish K3s configuration;\n+- restart K3s;\n+- install the timer;\n+- stage or commit repository files;\n+- push or open a pull request.\n+\n+Return the generated receipt for review.\n+\n+## 8. Unfinished configuration transaction\n+\n+A normal rerun refuses to mutate when the server reports an unfinished transaction.\n+\n+Find the transaction ID in:\n+\n+```text\n+<phase-result>/live/remote-inspect-error.json\n+<phase-result>/live/server-inspect.json\n+<phase-result>/live/phase1-result.json\n+```\n+\n+Do not delete `/var/lib/overdeck/k3s-phase1/transactions` manually.\n+\n+### Roll back\n+\n+Use when the prior apply did not complete or its evidence is incomplete:\n+\n+```bash\n+bash tools/k3s/phase1-control-plane.sh \\\n+  --mode recover \\\n+  --repo-root /home/user/Projects/overdeck \\\n+  --server debian3 \\\n+  --transaction <transaction-id> \\\n+  --recovery-action rollback\n+```\n+\n+A successful rollback restores prior managed files/absence and requires K3s readiness.\n+\n+### Finalize\n+\n+Use only when the prior live result proves convergence, both recovery archives, timer installation, and peer checks succeeded, but the final response was lost:\n+\n+```bash\n+bash tools/k3s/phase1-control-plane.sh \\\n+  --mode recover \\\n+  --repo-root /home/user/Projects/overdeck \\\n+  --server debian3 \\\n+  --transaction <transaction-id> \\\n+  --recovery-action finalize\n+```\n+\n+Finalization discards rollback authority. Never use it merely to make a blocked rerun proceed.\n+\n+## 9. Live restore activation — incident procedure\n+\n+The Phase 1 tools deliberately stop before live activation. A real restore is destructive and must be adapted to the incident, current K3s version, surviving control-plane state, and datastore type.\n+\n+Before activation:\n+\n+1. preserve the failed live filesystem and logs;\n+2. verify the selected encrypted archive again;\n+3. compare server machine identity and intended target;\n+4. confirm exact K3s version and outer launcher digest;\n+5. confirm console or equivalent recovery access;\n+6. stop workload publication and prevent concurrent operators;\n+7. prepare rollback media for the current failed state;\n+8. choose SQLite filesystem restoration or embedded-etcd snapshot restoration explicitly.\n+\n+### SQLite outline\n+\n+With K3s stopped and after preserving the current DB/token/config:\n+\n+- restore the isolated `/etc/rancher/k3s` tree;\n+- restore server/agent token layout;\n+- replace the K3s DB tree from the verified restore root;\n+- apply root ownership and restrictive modes;\n+- start K3s;\n+- prove `/readyz`, Nodes, CA, serving SANs, workloads, and peer access.\n+\n+### Embedded-etcd outline\n+\n+With a verified snapshot and matching token/version:\n+\n+- follow the current K3s cluster-reset snapshot-restore procedure;\n+- use the snapshot copied into the isolated restore tree;\n+- restore the matching server token and configuration;\n+- verify cluster identity before admitting agents/workloads.\n+\n+Do not derive a live command blindly from this outline. The incident agent must inspect the installed K3s version’s official recovery procedure and produce an owner-reviewed command sequence.\n+\n+## 10. Backup failure handling\n+\n+If an encrypted archive has a failure suffix such as:\n+\n+```text\n+.unverified-phase-failed.tar.age\n+.verified-phase-failed.tar.age\n+```\n+\n+retain it with its metadata and receipt. Do not treat it as the newest valid recovery point unless `verify-backup.py` succeeds independently.\n+\n+The server-side plaintext archive should be absent after cleanup. A retained remote path under `/var/lib/overdeck/k3s-phase1/backups` indicates an interrupted cleanup. Remove it only through a reviewed helper transaction or after confirming its transaction ID and corresponding off-host archive.\n+\n+## 11. Upgrade boundary\n+\n+`upgrade-control-plane.sh` is plan-only. It rejects apply requests. A future K3s upgrade must:\n+\n+- use an explicit version;\n+- verify the outer launcher candidate SHA-256;\n+- retain pre-upgrade backup and rollback capability;\n+- preserve datastore/token compatibility;\n+- update launcher and runtime lock fields only after post-upgrade proof.\n+\n+Phase 1 never upgrades K3s to repair discovery, readiness, or backup failures.\n+\n+## 12. Security notes\n+\n+Never include in tickets, chat, or Git:\n+\n+- age private-key contents;\n+- server or agent token contents;\n+- kubeconfig contents;\n+- decrypted datastore files;\n+- raw `k3s.service` environment values;\n+- unredacted secret-bearing process arguments.\n+\n+Safe evidence includes:\n+\n+- SHA-256 of encrypted archives;\n+- public age recipient;\n+- public CA hash;\n+- executable digests;\n+- file metadata without content hashes for token files;\n+- transaction IDs and redacted structured receipts.\ndiff --git a/docs/runbooks/k3s-phase1-agent-instructions.md b/docs/runbooks/k3s-phase1-agent-instructions.md\nnew file mode 100644\nindex 0000000..348273a\n--- /dev/null\n+++ b/docs/runbooks/k3s-phase1-agent-instructions.md\n@@ -0,0 +1,242 @@\n+# Agent instructions — execute K3s Phase 1 qualification-first package\n+\n+Audience: the trusted agent/operator running the downloaded package\n+Goal: one command performs package verification, repository tests, live qualification, transactional apply, and candidate Git publication\n+\n+## Required downloads\n+\n+Place these files in the same directory:\n+\n+```text\n+overdeck-k3s-phase1-qualified.zip\n+apply-overdeck-k3s-phase1-qualified.sh\n+```\n+\n+Use the accompanying checksum file when provided.\n+\n+## Normal command\n+\n+Run as the normal owner of the repository, not as root:\n+\n+```bash\n+chmod +x ./apply-overdeck-k3s-phase1-qualified.sh\n+\n+./apply-overdeck-k3s-phase1-qualified.sh \\\n+  /home/user/Projects/overdeck\n+```\n+\n+The launcher may request sudo once for local `age` installation or enabling user lingering. Do not wrap the launcher itself in `sudo`.\n+\n+## Operator contract\n+\n+Do not:\n+\n+- edit package contents;\n+- use an older Phase 1 launcher with this ZIP;\n+- apply v1, v2, or v3 candidates;\n+- delete prior failed worktrees before the replacement receipt is reviewed;\n+- hand-merge or push the candidate to `main`;\n+- run root helper commands manually;\n+- expose the age key, K3s token, kubeconfig, or decrypted backup.\n+\n+The launcher verifies that its own bytes match the embedded launcher.\n+\n+## What the launcher does\n+\n+### Repository preparation\n+\n+1. Verify ZIP paths, file types, checksums, declared changed paths, patch contract, and launcher coupling.\n+2. Fetch current `origin/main` without modifying the shared checkout.\n+3. Create a fresh isolated worktree and branch.\n+4. Apply stable paths with three-way Git patching.\n+5. Merge the Phase 1 migration-plan section and K3s plan-index receipt semantically while preserving newer unrelated coordination content.\n+6. Fail if any undeclared path changes.\n+\n+### Required gates\n+\n+The launcher runs locally against the exact candidate worktree:\n+\n+- Bash syntax;\n+- Python compilation;\n+- semantic migration-plan and plan-index tests;\n+- Phase 1 unit/failure-injection suite;\n+- full fake-cluster qualification/apply transaction;\n+- Phase 0 collector and presentation regressions;\n+- execution-locality proof;\n+- legacy K3s unit gate;\n+- fleet core and CLI tests;\n+- Git whitespace/path checks;\n+- focused Factory Kubernetes regression when pytest is available.\n+\n+The legacy live K3s integration gate remains optional because it exercises the older SSH/host-path experiment rather than the Phase 1 control-plane transaction. Its result is recorded.\n+\n+### Live qualification and apply\n+\n+After repository gates, the launcher obtains local sudo authorization when required and invokes one `--mode apply` run.\n+\n+The orchestrator itself performs complete qualification and creates a verified pre-change encrypted backup before publishing configuration. It then converges, re-verifies, creates a post-change backup, installs the recurring timer, and finalizes.\n+\n+### Git publication\n+\n+The launcher does not stage or commit before live success. It reads:\n+\n+```text\n+<live-receipt>/phase1-result.json\n+```\n+\n+and requires:\n+\n+```json\n+{\n+  \"status\": \"success\",\n+  \"git_publication_allowed\": true\n+}\n+```\n+\n+Only then does it:\n+\n+1. stage exactly declared paths;\n+2. commit the candidate;\n+3. push the candidate branch when enabled;\n+4. open a draft PR when `gh` is authenticated.\n+\n+It never pushes directly to `main` or merges the PR.\n+\n+## Qualification-only mode\n+\n+To gather a complete compatibility receipt and verified pre-change backup without configuration or Git publication:\n+\n+```bash\n+./apply-overdeck-k3s-phase1-qualified.sh \\\n+  /home/user/Projects/overdeck \\\n+  --qualification-only\n+```\n+\n+Expected outcome:\n+\n+- repository gates pass;\n+- live qualification passes or reports all known failures;\n+- a verified encrypted pre-change backup may be retained;\n+- no managed K3s file changes;\n+- no K3s restart;\n+- no timer installation;\n+- no Git stage/commit/push/PR.\n+\n+Normal execution is preferred now because the replacement’s apply mode already has the same qualification boundary.\n+\n+## Supported options\n+\n+```text\n+--package PATH\n+--server NAME\n+--kubeconfig PATH\n+--backup-dir PATH\n+--age-identity PATH\n+--retention COUNT\n+--ssh-door tailscale_ip|tailscale_ssh|lan\n+--result-root PATH\n+--qualification-only\n+--no-push\n+--no-pr\n+--no-enable-linger\n+--gate-timeout SECONDS\n+--live-timeout SECONDS\n+--skip-live              testing only; repository validation, no production claim\n+```\n+\n+Do not use `--skip-live` for the migration. It cannot establish Phase 1 completion.\n+\n+## Expected live surfaces\n+\n+On `debian3`, apply may create or update:\n+\n+```text\n+/etc/rancher/k3s/config.yaml.d/90-overdeck-control-plane.yaml\n+/etc/rancher/k3s/overdeck/control-plane.json\n+/etc/rancher/k3s/overdeck/version-lock.json\n+/var/lib/overdeck/k3s-phase1/transactions/\n+/var/lib/overdeck/k3s-phase1/backups/     # temporary plaintext staging\n+```\n+\n+On the workstation:\n+\n+```text\n+~/.config/overdeck/k3s-backup.agekey\n+~/.config/overdeck/k3s-backup.json\n+~/.local/lib/overdeck/k3s/\n+~/.local/state/overdeck/k3s-backups/debian3/\n+~/.local/state/overdeck/k3s-phase1-debian3.lock\n+~/.config/systemd/user/overdeck-k3s-backup.service\n+~/.config/systemd/user/overdeck-k3s-backup.timer\n+```\n+\n+K3s restarts only when the managed YAML differs.\n+\n+## What is forbidden in this phase\n+\n+The launcher and helpers must not:\n+\n+- replace or upgrade the K3s binary;\n+- change the datastore type;\n+- run cluster reset;\n+- activate a restore;\n+- rewrite the existing installer unit/arguments;\n+- change server node IP or flannel interface;\n+- reboot hosts;\n+- enroll a new node;\n+- deploy Kubernetes workload policy;\n+- modify Tailscale ACLs;\n+- push or merge `main`.\n+\n+## Success output\n+\n+The launcher prints:\n+\n+```text\n+Worktree: ...\n+Branch: ...\n+Commit: ...\n+Live: success\n+Push: pushed|skipped\n+PR: created|skipped...\n+Return this archive for review: /.../overdeck-k3s-phase1-result-<stamp>.tar.gz\n+```\n+\n+The result archive contains redacted logs, package metadata, gate results, live receipts, the committed patch when applicable, and Git publication status. It does not contain encrypted backups or private keys.\n+\n+Upload the result archive exactly as printed. Keep the draft PR unmerged.\n+\n+## Failure output\n+\n+The launcher still creates a result archive and leaves the isolated worktree and transaction evidence intact.\n+\n+On qualification failure:\n+\n+- no K3s configuration is published;\n+- no Git commit/push occurs;\n+- a verified pre-change backup may remain;\n+- `qualification.json` lists all failed and blocked checks.\n+\n+On apply failure:\n+\n+- timer rollback is attempted;\n+- server rollback is attempted when configuration was published;\n+- Git publication is forbidden;\n+- pre-change backup remains retained;\n+- any interrupted post-change archive is marked as failure evidence.\n+\n+Do not debug manually. Upload the result archive.\n+\n+## Post-success owner action\n+\n+After receipt review but before considering Phase 1 operationally complete:\n+\n+1. securely copy encrypted backups off the workstation;\n+2. securely escrow the age private key separately;\n+3. merge only the reviewed exact candidate commit;\n+4. retain the pre-change archive through at least the Phase 2 enrollment canary;\n+5. confirm the recurring timer produces a later verified scheduled backup.\n+\n+## UI boundary\n+\n+This phase contains no product UI delta, no new UI primitive, and no Astryx dependency.\ndiff --git a/docs/specs/2026-08-10-k3s-control-plane-baseline-design.md b/docs/specs/2026-08-10-k3s-control-plane-baseline-design.md\nnew file mode 100644\nindex 0000000..0a87c88\n--- /dev/null\n+++ b/docs/specs/2026-08-10-k3s-control-plane-baseline-design.md\n@@ -0,0 +1,501 @@\n+# K3s Phase 1 — qualification-first control-plane baseline, backup, and recovery\n+\n+Audience: owner, migration agents, and reviewers\n+Status: rederived implementation prepared; live execution pending\n+Supersedes: Phase 1 v1, v2, and v3 candidates\n+\n+## 1. Decision summary\n+\n+Phase 1 establishes a reproducible recovery baseline around the existing K3s server without changing the K3s version, datastore type, server node IP, current flannel interface, workload scheduling, or existing installer arguments.\n+\n+The phase owns these durable surfaces:\n+\n+1. `/etc/rancher/k3s/config.yaml.d/90-overdeck-control-plane.yaml`\n+2. `/etc/rancher/k3s/overdeck/control-plane.json`\n+3. `/etc/rancher/k3s/overdeck/version-lock.json`\n+4. encrypted pre-change and post-change backups on the trusted workstation\n+5. a hardened recurring workstation-side backup service and timer\n+\n+The critical change after three safe live stops is ordering. A complete qualification and verified pre-change restore proof are mandatory before the first managed K3s file can be published. Repository commit and push are later still: the external package launcher may publish Git only after the live receipt explicitly authorizes it.\n+\n+No Overdeck product UI changes in Phase 1. No primitive or Astryx component is added.\n+\n+## 2. Goals\n+\n+- Identify the real outer K3s command launcher independently of the running extracted executable.\n+- Pin the current K3s version and separate launcher/runtime digests without upgrading either.\n+- Establish the canonical API endpoint, TLS SAN set, server identity, and future-agent network contract.\n+- Preserve existing installer arguments and non-owned configuration while drift-locking their non-secret shape.\n+- Qualify `/readyz`, Ready Node inventory, kubeconfig, peer API CA, and serving-certificate identity.\n+- Support SQLite and embedded-etcd recovery archives.\n+- Pair every datastore backup with the original server token and a safe agent-token restoration contract.\n+- Encrypt backups off-host before retention.\n+- Verify every archive and materialize a complete isolated restore tree without modifying the live server.\n+- Create a verified pre-change backup before configuration publication and a verified post-change backup before finalization.\n+- Install an idempotent, locked, hardened recurring backup timer.\n+- Roll back server and workstation-managed state automatically before the commit point.\n+- Produce a redacted, machine-readable receipt that can gate Git publication and Phase 2.\n+\n+## 3. Non-goals\n+\n+- No K3s version change or binary replacement.\n+- No datastore conversion.\n+- No cluster reset or live restore.\n+- No server reboot.\n+- No node enrollment.\n+- No workload migration.\n+- No namespace, RBAC, quota, network-policy, or Pod Security rollout.\n+- No removal or translation of unknown installer arguments.\n+- No Tailscale policy change.\n+- No direct push or merge to `main`.\n+- No automatic external escrow of the age private key.\n+\n+## 4. Trust model\n+\n+### 4.1 Trusted workstation\n+\n+The workstation controls the transaction. It holds:\n+\n+- the repository and host registry;\n+- SSH recovery-door configuration;\n+- the kubeconfig used to derive and prove the API endpoint;\n+- the age private key;\n+- encrypted off-server recovery archives;\n+- recurring backup units and configuration;\n+- qualification, transaction, and package receipts.\n+\n+The workstation does not persist a decrypted TAR. Decryption and restore materialization occur under a private temporary directory and are removed after verification.\n+\n+### 4.2 Existing K3s server\n+\n+The server executes a checksum-verified, stdlib-only Python helper through non-interactive sudo. The helper is uploaded to a random `0700` file under `/tmp` for each invocation and removed at exit. Qualification does not install a persistent privileged helper.\n+\n+The helper may:\n+\n+- inspect non-secret service, executable, network, datastore, and file metadata;\n+- create root-only backup staging under `/var/lib/overdeck/k3s-phase1/backups`;\n+- create rollback transactions under `/var/lib/overdeck/k3s-phase1/transactions`;\n+- publish exactly three managed files;\n+- restart only `k3s.service`, and only when the managed YAML changes;\n+- remove only phase-owned staging and rollback material.\n+\n+It never prints token values, token hashes, kubeconfig contents, datastore contents, raw environment values, or unredacted secret-bearing arguments.\n+\n+### 4.3 Other registry-reachable hosts\n+\n+Every host declared `reachable` in `buildbox-hosts.json` runs a read-only proof through its registered SSH door:\n+\n+1. fetch `<canonical-endpoint>/cacerts`;\n+2. compare the CA bundle SHA-256 with the server helper observation;\n+3. use that CA to perform hostname/IP-verified TLS against the API endpoint.\n+\n+A reachable host is not silently skipped. Hosts explicitly declared unreachable are outside the Phase 1 peer gate.\n+\n+## 5. K3s executable roles\n+\n+K3s installations may expose two related but non-interchangeable executables:\n+\n+### 5.1 Outer command launcher\n+\n+Typical path: `/usr/local/bin/k3s`.\n+\n+Used for:\n+\n+- `k3s --version`;\n+- `k3s kubectl ...`;\n+- `k3s etcd-snapshot ...`;\n+- release-artifact digest lock and future upgrade candidate verification.\n+\n+Discovery order:\n+\n+1. structured systemd `ExecStart` executable path;\n+2. canonical K3s installer paths;\n+3. a deterministic approved root executable path.\n+\n+The running process executable is never a launcher candidate.\n+\n+### 5.2 Running executable evidence\n+\n+Typical path: `/var/lib/rancher/k3s/data/<digest>/bin/k3s`.\n+\n+Used only to record:\n+\n+- current running executable path;\n+- SHA-256;\n+- trusted ownership/mode/directory-chain status;\n+- continuity across Phase 1 convergence.\n+\n+It is not called with nested `kubectl` or `etcd-snapshot` commands.\n+\n+### 5.3 Privileged executable validation\n+\n+When the helper runs as root, an executable is accepted only when:\n+\n+- its invocation path is absolute and named `k3s`;\n+- the resolved target is a regular executable file;\n+- the target is root-owned;\n+- the target is not group/world writable;\n+- both the invocation and resolved directory chains are root-owned and not group/world writable.\n+\n+A discovery failure returns sanitized service state and attempted paths. It does not trigger an installation or upgrade.\n+\n+## 6. Qualification transaction\n+\n+Qualification is a complete compatibility gate, not a shallow dry run.\n+\n+### 6.1 Independent checks\n+\n+The orchestrator records each check as `passed`, `failed`, or `blocked`. Independent checks continue after a failure so one owner run produces the full known compatibility matrix.\n+\n+Checks:\n+\n+1. **Age identity and tools**\n+   - install `age` only when explicitly allowed by the package launcher;\n+   - create or load a regular non-symlink identity owned by the current user with mode `0600`;\n+   - derive the public recipient without recording the private value.\n+\n+2. **Recurring backup preflight**\n+   - validate every source file;\n+   - verify user-systemd availability;\n+   - render service/timer units in a temporary directory;\n+   - run `systemd-analyze --user verify` when available;\n+   - inspect linger and non-interactive sudo requirements;\n+   - do not install any unit.\n+\n+3. **Server inspection**\n+   - verify registered SSH identity;\n+   - upload/checksum the ephemeral helper;\n+   - discover launcher and runtime separately;\n+   - require active K3s service;\n+   - retry outer-launcher `/readyz` for a bounded 30 seconds;\n+   - require a successful, non-empty Node query with at least one Ready Node;\n+   - classify datastore;\n+   - classify server and agent token layouts;\n+   - record public CA hash and serving certificate facts;\n+   - reject unfinished control-plane transactions.\n+\n+4. **Control-plane plan**\n+   - derive endpoint from kubeconfig, remapping only a loopback host to the verified server Tailscale address while preserving the port;\n+   - derive deterministic SANs;\n+   - generate desired managed YAML, contract, and version lock;\n+   - compare contents without publication;\n+   - reject external configuration drift against an existing canonical contract.\n+\n+5. **Workstation API proof**\n+   - run kubeconfig `/readyz` and require `ok`.\n+\n+6. **Registry-host API/CA/TLS proof**\n+   - run the peer proof from every reachable host and require all to match.\n+\n+7. **Pre-change backup and restore proof**\n+   - create a datastore/config/token archive on the server;\n+   - stream it into `age` on the workstation;\n+   - compare plaintext size and SHA-256 with server metadata;\n+   - decrypt under a private temporary directory;\n+   - verify the TAR, manifest, datastore, token contract, and file set;\n+   - materialize an isolated restore root;\n+   - remove remote plaintext staging;\n+   - retain the verified encrypted archive and metadata.\n+\n+### 6.2 Qualification mutation boundary\n+\n+Qualification may create:\n+\n+- the workstation age identity;\n+- local receipt files;\n+- a verified encrypted pre-change backup;\n+- temporary remote helper and backup staging, both removed before completion.\n+\n+Qualification does not:\n+\n+- publish K3s configuration;\n+- restart K3s;\n+- install the recurring timer;\n+- create a Git commit;\n+- push a branch;\n+- open a pull request.\n+\n+## 7. Configuration ownership\n+\n+### 7.1 Managed YAML drop-in\n+\n+`90-overdeck-control-plane.yaml` contains only:\n+\n+- `write-kubeconfig-mode: \"0600\"`;\n+- deterministic `tls-san+` values.\n+\n+The drop-in is mode `0600`, root-owned, and atomically replaced. K3s restarts only when its bytes change.\n+\n+### 7.2 Control-plane contract\n+\n+`control-plane.json` records:\n+\n+- hostname, machine ID, Tailscale IPv4/DNS, and `tailscale0`;\n+- canonical API endpoint and SAN list;\n+- public CA SHA-256;\n+- future-agent rule: Tailscale IPv4 plus `tailscale0`;\n+- explicit decision not to rewrite current server node-IP/flannel settings;\n+- a non-secret external configuration baseline;\n+- qualification requirements and observed agent-token layout.\n+\n+The baseline includes hashes and metadata for non-secret configuration, redacted process arguments, environment key names, and metadata-only records for secret-bearing environment files. Exact files exist only in the encrypted backup.\n+\n+### 7.3 Version lock schema 2\n+\n+`version-lock.json` separates:\n+\n+- `launcher`: invocation path, resolved path, SHA-256, and release URL;\n+- `runtime`: presence, resolved path, SHA-256, and discovery source;\n+- K3s version;\n+- install-script reference;\n+- explicit version-and-launcher-digest upgrade policy.\n+\n+A schema-1 lock that conflates launcher and runtime is rejected. Installed version/launcher drift is never silently adopted.\n+\n+## 8. Token backup model\n+\n+### 8.1 Server token\n+\n+The canonical server token must be a root-owned, mode-restricted regular file. Its value and content hash never enter receipts or manifests.\n+\n+### 8.2 Agent token\n+\n+Supported forms:\n+\n+- absent;\n+- root-owned, mode-restricted regular file;\n+- symlink resolving exactly to the canonical server token.\n+\n+A safe symlink is represented in the manifest as:\n+\n+```json\n+{\n+  \"classification\": \"symlink-to-server-token\",\n+  \"restore\": \"symlink-to-token\",\n+  \"link_target\": \"token\",\n+  \"payload_path\": null\n+}\n+```\n+\n+The TAR contains no symlink. The verifier reconstructs only the relative link `agent-token -> token` inside the isolated restore root.\n+\n+## 9. Backup and restore contract\n+\n+### 9.1 SQLite\n+\n+The helper uses Python’s online SQLite backup API against `state.db`, excludes live WAL/SHM files, and requires `PRAGMA integrity_check = ok` on the copied database.\n+\n+### 9.2 Embedded etcd\n+\n+The helper invokes a uniquely named compressed snapshot through the outer launcher, requires the snapshot to appear in `etcd-snapshot ls`, copies it into the phase payload, and removes only the uniquely created source snapshot after TAR staging.\n+\n+### 9.3 Manifest\n+\n+Schema 2 records:\n+\n+- purpose: `prechange`, `postchange`, or `scheduled`;\n+- server identity;\n+- K3s version;\n+- launcher and runtime facts separately;\n+- public CA hash;\n+- datastore metadata;\n+- server-token path;\n+- agent-token restoration descriptor;\n+- systemd source paths;\n+- every payload file’s path, size, SHA-256, mode, and source uid/gid.\n+\n+It records no secret values or token content hashes.\n+\n+### 9.4 Canonical file rules\n+\n+- A `prechange` archive may contain none of the three Phase 1 canonical files.\n+- If any canonical file is present, all three must be present and consistent.\n+- `postchange` and `scheduled` archives require the complete canonical set.\n+\n+### 9.5 Archive validation\n+\n+The verifier rejects:\n+\n+- absolute or traversal paths;\n+- symlinks and hard links;\n+- devices and FIFOs;\n+- duplicate members;\n+- unlisted payload files;\n+- missing manifest files;\n+- size, SHA-256, or mode mismatches;\n+- unsafe token permissions/contracts;\n+- unsupported datastore types;\n+- incomplete canonical configuration;\n+- version-lock/manifest identity mismatch.\n+\n+### 9.6 Isolated materialization\n+\n+The restore root must be empty, non-live, and non-symlinked. Materialization reconstructs:\n+\n+- `/etc/rancher/k3s` configuration;\n+- server and agent token layout;\n+- SQLite DB tree or etcd snapshot path;\n+- captured systemd files;\n+- `OVERDECK_RESTORE_PLAN.json`.\n+\n+It does not stop K3s, replace live files, apply ownership, run cluster reset, or activate a restore.\n+\n+## 10. Server configuration transaction\n+\n+Changed convergence uses states:\n+\n+```text\n+prepared -> files-published -> converged -> finalizing -> finalized\n+```\n+\n+Rollback states:\n+\n+```text\n+prepared/files-published/converged -> rolled-back\n+```\n+\n+A failed rollback becomes `rollback-failed` and blocks later mutation.\n+\n+### 10.1 Prepare\n+\n+- refuse another unfinished transaction;\n+- record each managed path as absent, regular file, or symlink;\n+- copy prior files without following links;\n+- atomically publish transaction metadata and rollback material.\n+\n+### 10.2 Publish and prove\n+\n+- atomically publish only changed managed files;\n+- set root ownership and mode `0600`;\n+- restart K3s only when YAML changed;\n+- require outer-launcher `/readyz`;\n+- require successful Ready Node inventory;\n+- require unchanged launcher and running-runtime digests;\n+- require every managed SAN in the serving certificate.\n+\n+### 10.3 Rollback\n+\n+Before finalization:\n+\n+- restore exact prior bytes/type/mode/ownership or prior absence;\n+- restart K3s when required;\n+- require readiness;\n+- retain transaction evidence.\n+\n+### 10.4 Finalization\n+\n+- mark `finalizing`;\n+- remove rollback material;\n+- mark `finalized`;\n+- make finalization idempotent;\n+- prohibit rollback after rollback authority may have been removed.\n+\n+## 11. Apply sequence\n+\n+A passing qualification returns endpoint, SANs, recipient, plan, server observation, peer results, and verified pre-change artifact.\n+\n+Apply then executes:\n+\n+1. transactional managed configuration convergence;\n+2. fresh server inspect with readiness and Nodes;\n+3. workstation `/readyz`;\n+4. peer API/CA/TLS proof;\n+5. verified encrypted post-change backup and isolated restore proof;\n+6. recurring backup timer installation;\n+7. server transaction finalization;\n+8. backup retention pruning;\n+9. success receipt with `git_publication_allowed: true`.\n+\n+A failure returns `git_publication_allowed: false` and attempts timer/config rollback.\n+\n+## 12. Recurring backup timer\n+\n+The user-systemd installation copies a self-contained runtime under `~/.local/lib/overdeck/k3s`, including a snapshot of the host registry. The service runs `--mode backup` with:\n+\n+- an exclusive server-specific lock;\n+- private config, state, and backup paths;\n+- `UMask=0077`;\n+- `NoNewPrivileges=true`;\n+- `PrivateTmp=true`;\n+- `ProtectSystem=strict`;\n+- explicit writable paths;\n+- bounded runtime and randomized timer delay.\n+\n+Timer publication is transactional. Previous files, symlinks, enabled state, active state, and phase-enabled linger are restored on pre-finalization failure.\n+\n+Retention deletes only verified archive/metadata pairs beyond the configured count. Unpaired or malformed evidence is retained for review.\n+\n+## 13. Git publication contract\n+\n+The external package launcher:\n+\n+1. verifies ZIP checksums and launcher coupling;\n+2. creates an isolated worktree from current `origin/main`;\n+3. applies stable paths with a three-way patch;\n+4. semantically updates the K3s plan-index row;\n+5. runs all required repository and fake-cluster gates locally;\n+6. obtains local sudo authorization when required;\n+7. invokes one live `--mode apply` transaction;\n+8. requires `status=success` and `git_publication_allowed=true`;\n+9. stages exactly declared paths;\n+10. commits and optionally pushes a candidate branch and opens a draft PR.\n+\n+No repository commit exists before live success. Qualification-only mode never stages, commits, pushes, or opens a PR.\n+\n+## 14. Failure and recovery policy\n+\n+### Before configuration publication\n+\n+- no K3s rollback is needed;\n+- verified pre-change backup remains retained;\n+- temporary remote helper/staging is removed when reachable;\n+- no Git publication occurs.\n+\n+### After configuration publication but before finalization\n+\n+- restore previous managed paths;\n+- restart K3s if needed;\n+- verify readiness;\n+- restore timer files/state;\n+- retain pre-change backup and failure receipt;\n+- do not commit/push.\n+\n+### Ambiguous or incomplete transaction\n+\n+The next run refuses mutation and reports the transaction ID. The owner uses the explicit recovery mode:\n+\n+```bash\n+bash tools/k3s/phase1-control-plane.sh \\\n+  --mode recover \\\n+  --server debian3 \\\n+  --transaction <id> \\\n+  --recovery-action rollback\n+```\n+\n+`finalize` is used only when the prior success evidence is complete and rollback material should be intentionally discarded.\n+\n+## 15. Acceptance criteria\n+\n+Phase 1 is complete only when one live receipt proves:\n+\n+- full qualification passed;\n+- outer launcher and runtime are separate and trusted;\n+- K3s service, `/readyz`, and Ready Node inventory passed;\n+- endpoint, CA, and TLS identity passed from workstation and every reachable registry host;\n+- pre-change encrypted backup passed offline restore materialization before convergence;\n+- post-change encrypted backup passed after convergence;\n+- server/agent token restoration contracts passed without exposing values;\n+- canonical files are deterministic and drift-locked;\n+- recurring timer is installed and active;\n+- no rollback is pending;\n+- `git_publication_allowed=true`;\n+- candidate Git commit contains only declared paths;\n+- branch/PR remains unmerged until receipt review.\n+\n+## 16. References\n+\n+- Rederivation report: `docs/reports/2026-08-11-k3s-phase1-rederivation.md`\n+- Backup/restore runbook: `docs/runbooks/k3s-control-plane-backup-restore.md`\n+- Agent instructions: `docs/runbooks/k3s-phase1-agent-instructions.md`\n+- Migration plan: `docs/plans/2026-08-10-k3s-migration-execution.md`\n+- Entrypoint: `tools/k3s/phase1-control-plane.sh`\ndiff --git a/tools/ci/constants.mjs b/tools/ci/constants.mjs\nindex 3c0e6fe..db7d5f0 100644\n--- a/tools/ci/constants.mjs\n+++ b/tools/ci/constants.mjs\n@@ -97,6 +97,7 @@ export const EXCLUDED_WORKSTATION_SUITES = [\n   { path: 'modules/workstation/claude/tests/agent-seat-slice.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/agent-session-cap.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/agent-session-mux-routing.test.sh', reason: 'shell-integration' },\n+  { path: 'modules/workstation/claude/tests/agent-session-reap.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/agent-unsafe.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/bg-gate-installed-smoke.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/bg-gate-matcher.test.mjs', reason: 'node-unit' },\n@@ -119,6 +120,8 @@ export const EXCLUDED_WORKSTATION_SUITES = [\n   { path: 'modules/workstation/claude/tests/gate0-remote-dispatch.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/git-guard-forkbomb.dangerlab.sh', reason: 'dangerlab' },\n   { path: 'modules/workstation/claude/tests/git-guard-shim.test.sh', reason: 'shell-integration' },\n+  { path: 'modules/workstation/claude/tests/gpt-orchestrator-skill.test.py', reason: 'python-unit' },\n+  { path: 'modules/workstation/claude/tests/gpt-workflow-skill.test.py', reason: 'python-unit' },\n   { path: 'modules/workstation/claude/tests/human-session-dotname.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/human-session.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/k3s-remote-build-integration.sh', reason: 'k3s-integration' },\n@@ -169,6 +172,7 @@ export const EXCLUDED_WORKSTATION_SUITES = [\n   { path: 'modules/workstation/claude/tests/tmpjail-shim-routing.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/trello-token.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/whole-gate-dispatch.test.mjs', reason: 'node-unit' },\n+  { path: 'modules/workstation/claude/tests/wip-triage-skill.test.py', reason: 'python-unit' },\n   { path: 'modules/workstation/claude/tests/worktree-lock-gate.test.sh', reason: 'shell-integration' },\n ];\n \ndiff --git a/tools/ci/gate-inventory.json b/tools/ci/gate-inventory.json\nindex a8d285d..9c377e2 100644\n--- a/tools/ci/gate-inventory.json\n+++ b/tools/ci/gate-inventory.json\n@@ -1,6 +1,6 @@\n {\n   \"schemaVersion\": \"1\",\n-  \"generatedAt\": \"2026-08-11T03:48:11.372Z\",\n+  \"generatedAt\": \"2026-08-11T11:45:45.822Z\",\n   \"workspacePackages\": [\n     {\n       \"id\": \"workspace:apps/web\",\n@@ -87,7 +87,7 @@\n         \"id\": \"workstation:agent-session-passthrough\",\n         \"path\": \"modules/workstation/claude/tests/agent-session-passthrough.test.sh\",\n         \"runner\": \"bash\",\n-        \"sourceDigest\": \"ab85a16b9641f55fedc4b5a5c5af3e632616b01ce2902dd7cbb43e357dd6ab8a\"\n+        \"sourceDigest\": \"8e052d8f9d42d1f7c25c54d8a2c7eb5fd99cf7b5c88eb8e149851a55ea715977\"\n       },\n       {\n         \"id\": \"workstation:human-session-fallback\",\n@@ -157,6 +157,11 @@\n         \"reason\": \"shell-integration\",\n         \"sourceDigest\": \"5b45cad4c2a79176b754816dc8abb381055c94acac35139b0461d18ba6ac2f77\"\n       },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/agent-session-reap.test.mjs\",\n+        \"reason\": \"node-unit\",\n+        \"sourceDigest\": \"d5841a5a23cdad4c66ec4394d85a645dbf7ee68cd0bc44dc1acb26ee2ac46c45\"\n+      },\n       {\n         \"path\": \"modules/workstation/claude/tests/agent-unsafe.test.sh\",\n         \"reason\": \"shell-integration\",\n@@ -267,6 +272,16 @@\n         \"reason\": \"shell-integration\",\n         \"sourceDigest\": \"980e624ac1868c18445a73e641a58bdc9114e4f0e21f63a92c43aa6f6a09a20e\"\n       },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/gpt-orchestrator-skill.test.py\",\n+        \"reason\": \"python-unit\",\n+        \"sourceDigest\": \"0521b2ed88a38a49eb00e072d738ffa7ce763a43800459d741628fd8d54c94cc\"\n+      },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/gpt-workflow-skill.test.py\",\n+        \"reason\": \"python-unit\",\n+        \"sourceDigest\": \"64a8460ce9b6e23afec795516a775f329e4653e5c4cf55b164849b7f3c6e4de4\"\n+      },\n       {\n         \"path\": \"modules/workstation/claude/tests/human-session-dotname.test.sh\",\n         \"reason\": \"shell-integration\",\n@@ -305,7 +320,7 @@\n       {\n         \"path\": \"modules/workstation/claude/tests/local-gate.test.mjs\",\n         \"reason\": \"node-unit\",\n-        \"sourceDigest\": \"d636c74b8199f967cda23e4d5f8d1a17fc322bfe3d976aa520129ffbbbac7aa2\"\n+        \"sourceDigest\": \"b42623332f39f3e63aba18a05dd4fcb4fcd2440903e525a1ac1114fe0c85d915\"\n       },\n       {\n         \"path\": \"modules/workstation/claude/tests/main-checkout-guard.test.sh\",\n@@ -390,7 +405,7 @@\n       {\n         \"path\": \"modules/workstation/claude/tests/remote-build.test.mjs\",\n         \"reason\": \"node-unit\",\n-        \"sourceDigest\": \"5e03f11beb7844be803c74d142ccc9abecc5e04fccb6016e887503dca844087f\"\n+        \"sourceDigest\": \"cd918b748fca1d61fe542287d3f3625b8d7cfdb529f9224ec4b34f60d2e3ab95\"\n       },\n       {\n         \"path\": \"modules/workstation/claude/tests/remote-job-env.test.mjs\",\n@@ -517,6 +532,11 @@\n         \"reason\": \"node-unit\",\n         \"sourceDigest\": \"bd03a613e9e39fab5d9f04649b8e2806bc05db2279e927cf686125632939b2de\"\n       },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/wip-triage-skill.test.py\",\n+        \"reason\": \"python-unit\",\n+        \"sourceDigest\": \"14af31b6d899ab600b3f84db7b9deaa7673855eb19f60ede4fbe7a57b8a190f0\"\n+      },\n       {\n         \"path\": \"modules/workstation/claude/tests/worktree-lock-gate.test.sh\",\n         \"reason\": \"shell-integration\",\ndiff --git a/tools/k3s/README.md b/tools/k3s/README.md\nnew file mode 100644\nindex 0000000..5e178a4\n--- /dev/null\n+++ b/tools/k3s/README.md\n@@ -0,0 +1,61 @@\n+# Overdeck K3s migration tooling\n+\n+This directory contains phase-scoped, receipt-driven K3s migration tools. Normal repository users run the phase package launcher; root-side helpers are implementation details and must not be invoked manually except through a documented recovery command.\n+\n+## Phase 0\n+\n+`phase0-discover.sh` performs read-only repository, host, and cluster inventory.\n+\n+## Phase 1 — qualification-first control-plane baseline\n+\n+Primary files:\n+\n+- `phase1-control-plane.sh` — local-only workstation entrypoint.\n+- `phase1-control-plane.py` — qualification, apply, backup, and recovery orchestrator.\n+- `remote/phase1-server.py` — ephemeral checksum-verified root helper for server inspection, transactional configuration, and backup staging.\n+- `verify-backup.py` — decrypted TAR validation and non-live isolated restore materialization.\n+- `restore-control-plane.sh` — explicit decrypt-and-materialize wrapper; never activates a live restore.\n+- `upgrade-plan.py` and `upgrade-control-plane.sh` — digest-locked, plan-only upgrade scaffold.\n+- `systemd/` — hardened recurring encrypted backup user service and timer.\n+- `lib/phase1_common.py` — shared stdlib-only safety, archive, SSH, locking, and retention helpers.\n+- `test/fixtures/debian3-20260811-server-state.json` — sanitized live topology fixture from the third owner run.\n+- `test/` — unit, failure-injection, systemd, backup/restore, and full fake-cluster transaction tests.\n+\n+### Transaction order\n+\n+`--mode apply` always executes:\n+\n+```text\n+complete qualification\n+  -> verified encrypted pre-change backup\n+  -> transactional managed configuration\n+  -> post-converge health/identity proof\n+  -> verified encrypted post-change backup\n+  -> recurring timer installation\n+  -> transaction finalization\n+```\n+\n+It cannot publish Git changes. The external phase package launcher reads the final receipt and performs Git publication only when `status=success` and `git_publication_allowed=true`.\n+\n+### Modes\n+\n+- `--mode qualify` or `--mode plan`: run complete qualification and create a verified pre-change backup, but do not publish K3s configuration or install the timer.\n+- `--mode apply`: qualification plus the transactional apply sequence above.\n+- `--mode backup`: scheduled encrypted backup, restore proof, API checks, and retention.\n+- `--mode recover`: explicit rollback or finalization of a named unfinished server transaction.\n+\n+### Important contracts\n+\n+- `/usr/local/bin/k3s`-style outer launcher and `/var/lib/rancher/k3s/data/.../bin/k3s` runtime are separate roles.\n+- The agent token may be absent, regular, or a symlink resolving exactly to the server token.\n+- Archive TARs never contain symlinks, devices, FIFOs, or unlisted payload files.\n+- A pre-change backup may legitimately predate the three Phase 1 canonical files.\n+- A post-change or scheduled backup must contain the complete canonical file set.\n+- K3s upgrades, datastore conversion, cluster reset, and live restore are out of scope.\n+\n+References:\n+\n+- `docs/specs/2026-08-10-k3s-control-plane-baseline-design.md`\n+- `docs/reports/2026-08-11-k3s-phase1-rederivation.md`\n+- `docs/runbooks/k3s-control-plane-backup-restore.md`\n+- `docs/runbooks/k3s-phase1-agent-instructions.md`\ndiff --git a/tools/k3s/lib/phase1_common.py b/tools/k3s/lib/phase1_common.py\nnew file mode 100755\nindex 0000000..28a7c1c\n--- /dev/null\n+++ b/tools/k3s/lib/phase1_common.py\n@@ -0,0 +1,527 @@\n+#!/usr/bin/env python3\n+\"\"\"Shared helpers for the Overdeck K3s Phase 1 tooling.\n+\n+The module is deliberately Python-stdlib-only so it can run on the workstation\n+and on clean Debian-family buildboxes without provisioning a virtualenv.\n+\"\"\"\n+from __future__ import annotations\n+\n+import hashlib\n+import json\n+import os\n+import re\n+import shutil\n+import stat\n+import subprocess\n+import tarfile\n+import tempfile\n+from contextlib import contextmanager\n+from dataclasses import dataclass\n+from datetime import datetime, timezone\n+from pathlib import Path, PurePosixPath\n+from typing import Any, BinaryIO, Iterable, Iterator, Mapping, Sequence\n+\n+try:\n+    import fcntl\n+except ImportError:  # pragma: no cover - Overdeck's supported workstations are Unix\n+    fcntl = None  # type: ignore[assignment]\n+\n+K3S_VERSION_RE = re.compile(r\"\\bk3s version (?P<version>v[^\\s]+)\")\n+SAFE_NAME_RE = re.compile(r\"[^A-Za-z0-9._-]+\")\n+\n+\n+class Phase1Error(RuntimeError):\n+    \"\"\"Raised for a user-actionable Phase 1 failure.\"\"\"\n+\n+\n+@dataclass(frozen=True)\n+class HostAccess:\n+    name: str\n+    host: str\n+    port: int\n+    user: str\n+    identity_file: str | None\n+    door: str\n+    magic_dns: str | None\n+    state: str\n+\n+    @property\n+    def target(self) -> str:\n+        return f\"{self.user}@{self.host}\"\n+\n+\n+def utc_now() -> str:\n+    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(\"+00:00\", \"Z\")\n+\n+\n+def utc_stamp() -> str:\n+    return datetime.now(timezone.utc).strftime(\"%Y%m%dT%H%M%SZ\")\n+\n+\n+def safe_name(value: str) -> str:\n+    cleaned = SAFE_NAME_RE.sub(\"-\", value).strip(\"-.\")\n+    if not cleaned:\n+        raise Phase1Error(f\"value cannot be converted to a safe name: {value!r}\")\n+    return cleaned\n+\n+\n+def sha256_file(path: Path, *, chunk_size: int = 1024 * 1024) -> str:\n+    digest = hashlib.sha256()\n+    with path.open(\"rb\") as handle:\n+        while True:\n+            chunk = handle.read(chunk_size)\n+            if not chunk:\n+                break\n+            digest.update(chunk)\n+    return digest.hexdigest()\n+\n+\n+def sha256_bytes(payload: bytes) -> str:\n+    return hashlib.sha256(payload).hexdigest()\n+\n+\n+def parse_k3s_version(text: str) -> str:\n+    match = K3S_VERSION_RE.search(text)\n+    if not match:\n+        raise Phase1Error(f\"cannot parse K3s version from output: {text.strip()!r}\")\n+    return match.group(\"version\")\n+\n+\n+def read_json(path: Path) -> Any:\n+    try:\n+        with path.open(encoding=\"utf-8\") as handle:\n+            return json.load(handle)\n+    except FileNotFoundError as exc:\n+        raise Phase1Error(f\"required JSON file does not exist: {path}\") from exc\n+    except json.JSONDecodeError as exc:\n+        raise Phase1Error(f\"invalid JSON in {path}: {exc}\") from exc\n+\n+\n+def atomic_write_json(path: Path, value: Any, *, mode: int = 0o600) -> None:\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    payload = json.dumps(value, indent=2, sort_keys=True) + \"\\n\"\n+    fd, tmp_name = tempfile.mkstemp(prefix=f\".{path.name}.\", dir=path.parent)\n+    tmp = Path(tmp_name)\n+    try:\n+        os.fchmod(fd, mode)\n+        with os.fdopen(fd, \"w\", encoding=\"utf-8\") as handle:\n+            handle.write(payload)\n+            handle.flush()\n+            os.fsync(handle.fileno())\n+        os.replace(tmp, path)\n+        dir_fd = os.open(path.parent, os.O_DIRECTORY)\n+        try:\n+            os.fsync(dir_fd)\n+        finally:\n+            os.close(dir_fd)\n+    finally:\n+        try:\n+            tmp.unlink()\n+        except FileNotFoundError:\n+            pass\n+\n+\n+@contextmanager\n+def exclusive_lock(path: Path) -> Iterator[BinaryIO]:\n+    \"\"\"Hold a non-blocking process lock for the duration of a Phase 1 run.\n+\n+    The workstation is the trusted controller.  A local lock prevents a manual\n+    invocation and the recurring systemd timer from mutating/backuping the same\n+    server concurrently.  The server helper independently rejects overlapping\n+    control-plane transactions from any controller.\n+    \"\"\"\n+\n+    if fcntl is None:  # pragma: no cover - defensive portability guard\n+        raise Phase1Error(\"Phase 1 requires Unix advisory file locking\")\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    handle = path.open(\"a+b\")\n+    os.chmod(path, 0o600)\n+    try:\n+        try:\n+            fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)\n+        except BlockingIOError as exc:\n+            raise Phase1Error(f\"another Phase 1 operation holds the lock: {path}\") from exc\n+        handle.seek(0)\n+        handle.truncate()\n+        handle.write(f\"pid={os.getpid()} started_utc={utc_now()}\\n\".encode())\n+        handle.flush()\n+        os.fsync(handle.fileno())\n+        yield handle\n+    finally:\n+        try:\n+            fcntl.flock(handle.fileno(), fcntl.LOCK_UN)\n+        finally:\n+            handle.close()\n+\n+\n+def normalize_path(path: str | Path) -> Path:\n+    return Path(os.path.expandvars(os.path.expanduser(str(path)))).resolve()\n+\n+\n+def absolute_path_without_symlink_resolution(path: str | Path) -> Path:\n+    \"\"\"Expand a path without hiding whether its final component is a symlink.\n+\n+    ``Path.resolve`` is appropriate for repository and inventory locations, but\n+    it defeats explicit symlink checks for private keys and restore targets.  A\n+    lexical absolute path preserves the final path component for those checks.\n+    \"\"\"\n+\n+    expanded = os.path.expandvars(os.path.expanduser(str(path)))\n+    return Path(os.path.abspath(expanded))\n+\n+\n+def load_host_registry(path: Path) -> dict[str, Any]:\n+    document = read_json(path)\n+    if not isinstance(document, dict) or document.get(\"schema_version\") != 1:\n+        raise Phase1Error(f\"unsupported host registry schema in {path}\")\n+    hosts = document.get(\"hosts\")\n+    if not isinstance(hosts, list):\n+        raise Phase1Error(f\"host registry has no hosts array: {path}\")\n+    return document\n+\n+\n+def resolve_host_access(\n+    registry: Mapping[str, Any],\n+    name: str,\n+    *,\n+    preferred_door: str = \"tailscale_ip\",\n+    require_reachable: bool = True,\n+) -> HostAccess:\n+    candidates = [item for item in registry.get(\"hosts\", []) if isinstance(item, dict) and item.get(\"name\") == name]\n+    if len(candidates) != 1:\n+        raise Phase1Error(f\"expected exactly one host named {name!r}; found {len(candidates)}\")\n+    entry = candidates[0]\n+    state = str(entry.get(\"state\") or \"unknown\")\n+    if require_reachable and state != \"reachable\":\n+        raise Phase1Error(f\"host {name} is declared {state!r}; Phase 1 requires registry state 'reachable'\")\n+    access = entry.get(\"access\")\n+    if not isinstance(access, dict):\n+        raise Phase1Error(f\"host {name} has no access declaration\")\n+    order = [preferred_door, \"tailscale_ip\", \"tailscale_ssh\", \"lan\"]\n+    seen: set[str] = set()\n+    door_name = \"\"\n+    door: Mapping[str, Any] | None = None\n+    for candidate in order:\n+        if candidate in seen:\n+            continue\n+        seen.add(candidate)\n+        raw = access.get(candidate)\n+        if isinstance(raw, dict) and raw.get(\"host\") and raw.get(\"user\"):\n+            door_name = candidate\n+            door = raw\n+            break\n+    if door is None:\n+        raise Phase1Error(f\"host {name} has no usable access door\")\n+    try:\n+        port = int(door.get(\"port\") or 22)\n+    except (TypeError, ValueError) as exc:\n+        raise Phase1Error(f\"host {name} has an invalid SSH port\") from exc\n+    if not (1 <= port <= 65535):\n+        raise Phase1Error(f\"host {name} has an out-of-range SSH port: {port}\")\n+    identity = door.get(\"identity_file\")\n+    if identity is not None:\n+        identity = str(normalize_path(str(identity)))\n+    magic_dns_raw = access.get(\"tailscale_ssh\")\n+    magic_dns = str(magic_dns_raw.get(\"host\")) if isinstance(magic_dns_raw, dict) and magic_dns_raw.get(\"host\") else None\n+    return HostAccess(\n+        name=name,\n+        host=str(door[\"host\"]),\n+        port=port,\n+        user=str(door[\"user\"]),\n+        identity_file=identity,\n+        door=door_name,\n+        magic_dns=magic_dns,\n+        state=state,\n+    )\n+\n+\n+def eligible_reachable_hosts(registry: Mapping[str, Any]) -> list[str]:\n+    names: list[str] = []\n+    for item in registry.get(\"hosts\", []):\n+        if not isinstance(item, dict):\n+            continue\n+        name = item.get(\"name\")\n+        if item.get(\"state\") == \"reachable\" and isinstance(name, str) and name:\n+            names.append(name)\n+    return sorted(set(names))\n+\n+\n+def build_ssh_command(access: HostAccess, *, timeout: int = 12) -> list[str]:\n+    if timeout < 1:\n+        raise Phase1Error(\"SSH timeout must be positive\")\n+    command = [\n+        \"ssh\",\n+        \"-F\",\n+        \"/dev/null\",\n+        \"-p\",\n+        str(access.port),\n+        \"-o\",\n+        \"BatchMode=yes\",\n+        \"-o\",\n+        f\"ConnectTimeout={timeout}\",\n+        \"-o\",\n+        \"ServerAliveInterval=15\",\n+        \"-o\",\n+        \"ServerAliveCountMax=2\",\n+        \"-o\",\n+        \"StrictHostKeyChecking=yes\",\n+    ]\n+    if access.identity_file:\n+        command.extend([\"-o\", \"IdentitiesOnly=yes\", \"-i\", access.identity_file])\n+    command.append(access.target)\n+    return command\n+\n+\n+def run_command(\n+    argv: Sequence[str],\n+    *,\n+    input_bytes: bytes | None = None,\n+    input_text: str | None = None,\n+    check: bool = True,\n+    timeout: int | None = None,\n+    env: Mapping[str, str] | None = None,\n+    cwd: Path | None = None,\n+) -> subprocess.CompletedProcess[Any]:\n+    if input_bytes is not None and input_text is not None:\n+        raise ValueError(\"provide only one of input_bytes or input_text\")\n+    text = input_bytes is None\n+    input_value: bytes | str | None = input_text if input_text is not None else input_bytes\n+    completed = subprocess.run(\n+        list(argv),\n+        input=input_value,\n+        stdout=subprocess.PIPE,\n+        stderr=subprocess.PIPE,\n+        text=text,\n+        timeout=timeout,\n+        env=dict(env) if env is not None else None,\n+        cwd=str(cwd) if cwd else None,\n+        check=False,\n+    )\n+    if check and completed.returncode != 0:\n+        stdout = completed.stdout.decode(errors=\"replace\") if isinstance(completed.stdout, bytes) else completed.stdout\n+        stderr = completed.stderr.decode(errors=\"replace\") if isinstance(completed.stderr, bytes) else completed.stderr\n+        rendered = \" \".join(str(part) for part in argv)\n+        raise Phase1Error(\n+            f\"command failed with exit {completed.returncode}: {rendered}\\n\"\n+            f\"stdout:\\n{stdout[-4000:]}\\n\"\n+            f\"stderr:\\n{stderr[-4000:]}\"\n+        )\n+    return completed\n+\n+\n+def require_commands(commands: Iterable[str]) -> None:\n+    missing = [command for command in commands if shutil.which(command) is None]\n+    if missing:\n+        raise Phase1Error(f\"missing required command(s): {', '.join(missing)}\")\n+\n+\n+def validate_tar_member_name(name: str) -> None:\n+    pure = PurePosixPath(name)\n+    if not name or name in {\".\", \"./\"} or pure.is_absolute() or \"..\" in pure.parts:\n+        raise Phase1Error(f\"archive contains unsafe path: {name!r}\")\n+\n+\n+def _extract_regular_member(handle: tarfile.TarFile, member: tarfile.TarInfo, target: Path) -> None:\n+    source = handle.extractfile(member)\n+    if source is None:\n+        raise Phase1Error(f\"archive member has no readable payload: {member.name!r}\")\n+    target.parent.mkdir(parents=True, exist_ok=True)\n+    if target.exists() or target.is_symlink():\n+        raise Phase1Error(f\"archive contains duplicate member path: {member.name!r}\")\n+    fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)\n+    try:\n+        with os.fdopen(fd, \"wb\") as destination:\n+            shutil.copyfileobj(source, destination, length=1024 * 1024)\n+            destination.flush()\n+            os.fsync(destination.fileno())\n+    finally:\n+        source.close()\n+    # Strip set-id/sticky bits.  Backup payload files are data, never executables.\n+    os.chmod(target, int(member.mode) & 0o0777)\n+\n+\n+def safe_extract_tar(archive: Path, destination: Path) -> None:\n+    \"\"\"Extract only ordinary files/directories without trusting tarfile defaults.\n+\n+    This intentionally avoids ``TarFile.extractall`` so the verifier behaves\n+    consistently on all supported Python versions and never follows archive\n+    links, applies ownership, or creates device nodes.\n+    \"\"\"\n+\n+    destination.mkdir(parents=True, exist_ok=True)\n+    root = destination.resolve()\n+    with tarfile.open(archive, \"r:*\") as handle:\n+        members = handle.getmembers()\n+        seen: set[str] = set()\n+        for member in members:\n+            validate_tar_member_name(member.name)\n+            normalized = PurePosixPath(member.name).as_posix().rstrip(\"/\")\n+            if normalized in seen:\n+                raise Phase1Error(f\"archive contains duplicate member path: {member.name!r}\")\n+            seen.add(normalized)\n+            if member.issym() or member.islnk() or member.isdev() or member.isfifo():\n+                raise Phase1Error(f\"archive contains unsupported special member: {member.name!r}\")\n+            target = (root / member.name).resolve()\n+            if target != root and root not in target.parents:\n+                raise Phase1Error(f\"archive member escapes extraction root: {member.name!r}\")\n+            if not member.isdir() and not member.isfile():\n+                raise Phase1Error(f\"archive contains unsupported member type: {member.name!r}\")\n+        directory_modes: list[tuple[Path, int]] = []\n+        for member in members:\n+            target = root / member.name\n+            if member.isdir():\n+                target.mkdir(parents=True, exist_ok=True)\n+                os.chmod(target, 0o700)\n+                directory_modes.append((target, int(member.mode) & 0o0777))\n+            else:\n+                _extract_regular_member(handle, member, target)\n+        for target, mode in sorted(directory_modes, key=lambda item: len(item[0].parts), reverse=True):\n+            os.chmod(target, mode)\n+\n+\n+def verify_manifest(extracted_root: Path, manifest_path: Path) -> dict[str, Any]:\n+    manifest = read_json(manifest_path)\n+    if not isinstance(manifest, dict) or manifest.get(\"schema_version\") not in {1, 2}:\n+        raise Phase1Error(\"unsupported backup manifest schema\")\n+    schema = int(manifest[\"schema_version\"])\n+    files = manifest.get(\"files\")\n+    if not isinstance(files, list) or not files:\n+        raise Phase1Error(\"backup manifest has no files\")\n+    seen: set[str] = set()\n+    for item in files:\n+        if not isinstance(item, dict):\n+            raise Phase1Error(\"backup manifest contains an invalid file entry\")\n+        rel = item.get(\"path\")\n+        expected_sha = item.get(\"sha256\")\n+        expected_size = item.get(\"size\")\n+        if not isinstance(rel, str) or not rel or not isinstance(expected_sha, str) or not isinstance(expected_size, int):\n+            raise Phase1Error(\"backup manifest file entry is incomplete\")\n+        validate_tar_member_name(rel)\n+        if rel in seen:\n+            raise Phase1Error(f\"backup manifest contains duplicate path: {rel}\")\n+        seen.add(rel)\n+        path = (extracted_root / rel).resolve()\n+        if extracted_root.resolve() not in path.parents:\n+            raise Phase1Error(f\"manifest path escapes extraction root: {rel}\")\n+        if not path.is_file() or path.is_symlink():\n+            raise Phase1Error(f\"manifest file is missing or unsafe: {rel}\")\n+        actual_size = path.stat().st_size\n+        if actual_size != expected_size:\n+            raise Phase1Error(f\"size mismatch for {rel}: expected {expected_size}, got {actual_size}\")\n+        expected_mode = item.get(\"mode\")\n+        if expected_mode is not None:\n+            if not isinstance(expected_mode, str) or not re.fullmatch(r\"[0-7]{4}\", expected_mode):\n+                raise Phase1Error(f\"backup manifest has an invalid mode for {rel}\")\n+            actual_mode = f\"{stat.S_IMODE(path.stat().st_mode):04o}\"\n+            if actual_mode != expected_mode:\n+                raise Phase1Error(\n+                    f\"mode mismatch for {rel}: expected {expected_mode}, got {actual_mode}\"\n+                )\n+        actual_sha = sha256_file(path)\n+        if actual_sha != expected_sha:\n+            raise Phase1Error(f\"SHA-256 mismatch for {rel}\")\n+    token_rel = manifest.get(\"server_token_path\")\n+    if not isinstance(token_rel, str) or token_rel not in seen:\n+        raise Phase1Error(\"backup manifest does not pair a server token with the datastore\")\n+    datastore = manifest.get(\"datastore\")\n+    if not isinstance(datastore, dict) or datastore.get(\"type\") not in {\"sqlite\", \"etcd\"}:\n+        raise Phase1Error(\"backup manifest datastore type is unsupported\")\n+    if schema == 2:\n+        purpose = manifest.get(\"purpose\")\n+        if purpose not in {\"prechange\", \"postchange\", \"scheduled\"}:\n+            raise Phase1Error(\"backup manifest has an unsupported purpose\")\n+        agent = manifest.get(\"agent_token\")\n+        if not isinstance(agent, dict):\n+            raise Phase1Error(\"backup manifest is missing the agent-token restoration descriptor\")\n+        classification = agent.get(\"classification\")\n+        payload_path = agent.get(\"payload_path\")\n+        if classification == \"regular\":\n+            if payload_path != \"payload/server/agent-token\" or payload_path not in seen:\n+                raise Phase1Error(\"regular agent token is not paired with its payload file\")\n+        elif classification == \"symlink-to-server-token\":\n+            if agent.get(\"restore\") != \"symlink-to-token\" or agent.get(\"link_target\") != \"token\":\n+                raise Phase1Error(\"agent-token symlink restoration descriptor is unsafe\")\n+            if payload_path is not None or \"payload/server/agent-token\" in seen:\n+                raise Phase1Error(\"agent-token symlink must not be archived as a file or link\")\n+        elif classification == \"absent\":\n+            if payload_path is not None or \"payload/server/agent-token\" in seen:\n+                raise Phase1Error(\"absent agent token unexpectedly has payload material\")\n+        else:\n+            raise Phase1Error(\"backup manifest has an unsupported agent-token classification\")\n+    actual_files = {\n+        path.relative_to(extracted_root).as_posix()\n+        for path in extracted_root.rglob(\"*\")\n+        if path.is_file() and not path.is_symlink() and path != manifest_path\n+    }\n+    if actual_files != seen:\n+        missing = sorted(seen - actual_files)\n+        unexpected = sorted(actual_files - seen)\n+        raise Phase1Error(\n+            \"backup archive and manifest file sets differ: \"\n+            f\"missing={missing[:10]} unexpected={unexpected[:10]}\"\n+        )\n+    return manifest\n+\n+\n+def prune_backup_pairs(backup_dir: Path, *, prefix: str, retain: int) -> list[str]:\n+    if retain < 1:\n+        raise Phase1Error(\"backup retention count must be at least one\")\n+    backup_dir.mkdir(parents=True, exist_ok=True)\n+    candidates: list[Path] = []\n+    for path in backup_dir.glob(f\"{prefix}-*.tar.age\"):\n+        if not path.is_file():\n+            continue\n+        metadata = path.with_suffix(\"\").with_suffix(\".json\")\n+        try:\n+            document = read_json(metadata)\n+        except Phase1Error:\n+            # Unpaired or malformed archives are retained for operator review;\n+            # automated retention never destroys unverified evidence.\n+            continue\n+        if not isinstance(document, dict) or document.get(\"status\") != \"verified\":\n+            continue\n+        expected_sha = document.get(\"encrypted_sha256\")\n+        if not isinstance(expected_sha, str) or expected_sha != sha256_file(path):\n+            continue\n+        candidates.append(path)\n+    candidates = sorted(\n+        candidates,\n+        key=lambda path: (path.stat().st_mtime_ns, path.name),\n+        reverse=True,\n+    )\n+    removed: list[str] = []\n+    for archive in candidates[retain:]:\n+        metadata = archive.with_suffix(\"\").with_suffix(\".json\")\n+        archive.unlink(missing_ok=True)\n+        metadata.unlink(missing_ok=True)\n+        removed.append(archive.name)\n+    return removed\n+\n+\n+def mode_string(mode: int) -> str:\n+    return stat.filemode(mode)\n+\n+\n+def redact_mapping(value: Any, *, sensitive_keys: set[str] | None = None) -> Any:\n+    sensitive = sensitive_keys or {\n+        \"token\",\n+        \"server_token\",\n+        \"agent_token\",\n+        \"password\",\n+        \"secret\",\n+        \"credential\",\n+        \"private_key\",\n+        \"identity\",\n+    }\n+    if isinstance(value, dict):\n+        redacted: dict[str, Any] = {}\n+        for key, child in value.items():\n+            normalized = str(key).lower().replace(\"-\", \"_\")\n+            if any(term in normalized for term in sensitive):\n+                redacted[key] = \"<redacted>\"\n+            else:\n+                redacted[key] = redact_mapping(child, sensitive_keys=sensitive)\n+        return redacted\n+    if isinstance(value, list):\n+        return [redact_mapping(item, sensitive_keys=sensitive) for item in value]\n+    return value\ndiff --git a/tools/k3s/phase1-control-plane.py b/tools/k3s/phase1-control-plane.py\nnew file mode 100755\nindex 0000000..59aa941\n--- /dev/null\n+++ b/tools/k3s/phase1-control-plane.py\n@@ -0,0 +1,1455 @@\n+#!/usr/bin/env python3\n+\"\"\"Workstation orchestrator for Overdeck K3s Phase 1.\n+\n+The command converges a non-secret control-plane configuration contract, proves\n+K3s health, creates an encrypted off-host datastore+token backup, verifies that\n+backup offline, proves API/CA reachability from every registry-reachable node,\n+and installs a recurring workstation backup timer.\n+\"\"\"\n+from __future__ import annotations\n+\n+import argparse\n+import hashlib\n+import ipaddress\n+import json\n+import os\n+import pwd\n+import secrets\n+import shlex\n+import shutil\n+import signal\n+import subprocess\n+import sys\n+import tempfile\n+import threading\n+import time\n+from pathlib import Path\n+from typing import Any, Mapping, Sequence\n+from urllib.parse import urlparse\n+\n+SCRIPT = Path(__file__).resolve()\n+K3S_DIR = SCRIPT.parent\n+LIB_DIR = K3S_DIR / \"lib\"\n+sys.path.insert(0, str(LIB_DIR))\n+from phase1_common import (  # noqa: E402\n+    HostAccess,\n+    Phase1Error,\n+    absolute_path_without_symlink_resolution,\n+    atomic_write_json,\n+    build_ssh_command,\n+    eligible_reachable_hosts,\n+    exclusive_lock,\n+    load_host_registry,\n+    normalize_path,\n+    prune_backup_pairs,\n+    read_json,\n+    require_commands,\n+    resolve_host_access,\n+    run_command,\n+    safe_name,\n+    sha256_file,\n+    utc_now,\n+    utc_stamp,\n+)\n+\n+REMOTE_INSTALL_PATH = \"/usr/local/libexec/overdeck/k3s-phase1-server.py\"\n+REMOTE_HELPER = K3S_DIR / \"remote/phase1-server.py\"\n+VERIFY_BACKUP = K3S_DIR / \"verify-backup.py\"\n+SYSTEMD_SERVICE = K3S_DIR / \"systemd/overdeck-k3s-backup.service\"\n+SYSTEMD_TIMER = K3S_DIR / \"systemd/overdeck-k3s-backup.timer\"\n+\n+\n+class Logger:\n+    def __init__(self, log_path: Path) -> None:\n+        log_path.parent.mkdir(parents=True, exist_ok=True)\n+        self.log_path = log_path\n+\n+    def __call__(self, message: str) -> None:\n+        line = f\"[phase1 {utc_now()}] {message}\"\n+        print(line, file=sys.stderr, flush=True)\n+        with self.log_path.open(\"a\", encoding=\"utf-8\") as handle:\n+            handle.write(line + \"\\n\")\n+\n+\n+class Orchestrator:\n+    def __init__(self, args: argparse.Namespace) -> None:\n+        self.args = args\n+        self.repo_root = normalize_path(args.repo_root)\n+        self.registry_path = normalize_path(args.host_registry or self.repo_root / \"modules/workstation/claude/buildbox-hosts.json\")\n+        self.kubeconfig = normalize_path(args.kubeconfig)\n+        self.backup_dir = normalize_path(args.backup_dir)\n+        self.age_identity = absolute_path_without_symlink_resolution(args.age_identity)\n+        self.lock_file = normalize_path(\n+            args.lock_file\n+            or Path.home() / \".local/state/overdeck\" / f\"k3s-phase1-{safe_name(args.server)}.lock\"\n+        )\n+        self.receipt_dir = normalize_path(args.receipt_dir or self.default_receipt_dir())\n+        self.receipt_dir.mkdir(parents=True, exist_ok=False)\n+        os.chmod(self.receipt_dir, 0o700)\n+        (self.receipt_dir / \"logs\").mkdir(mode=0o700)\n+        self.log = Logger(self.receipt_dir / \"logs/phase1.log\")\n+        self.registry = load_host_registry(self.registry_path)\n+        self.server_access = resolve_host_access(\n+            self.registry,\n+            args.server,\n+            preferred_door=args.ssh_door,\n+            require_reachable=True,\n+        )\n+        self.ssh = build_ssh_command(self.server_access, timeout=args.ssh_timeout)\n+        self.remote_installed = False\n+        self.remote_helper_path = REMOTE_INSTALL_PATH\n+        self.remote_temp_path: str | None = None\n+        self.qualification: dict[str, Any] = {\n+            \"schema_version\": 2,\n+            \"status\": \"running\",\n+            \"started_utc\": utc_now(),\n+            \"checks\": [],\n+        }\n+        self.backup_artifacts: dict[str, dict[str, Any]] = {}\n+        self.current_backup_purpose: str | None = None\n+        self.converge_tx: str | None = None\n+        self.converge_changed = False\n+        self.backup_tx: str | None = None\n+        self.remote_archive: str | None = None\n+        self.local_backup: Path | None = None\n+        self.backup_verified = False\n+        self.timer_rollback: dict[str, Any] | None = None\n+        self.linger_enabled_by_phase = False\n+        self.plan_doc: dict[str, Any] | None = None\n+        self.result: dict[str, Any] = {\n+            \"schema_version\": 2,\n+            \"mode\": args.mode,\n+            \"status\": \"running\",\n+            \"started_utc\": utc_now(),\n+            \"server\": args.server,\n+            \"repo_root\": str(self.repo_root),\n+            \"kubeconfig\": str(self.kubeconfig),\n+            \"backup_dir\": str(self.backup_dir),\n+            \"receipt_dir\": str(self.receipt_dir),\n+            \"lock_file\": str(self.lock_file),\n+            \"steps\": [],\n+        }\n+\n+    def default_receipt_dir(self) -> Path:\n+        if self.args.mode == \"backup\":\n+            base = self.backup_dir / \"receipts\"\n+            prefix = \"scheduled\"\n+        else:\n+            base = Path.cwd()\n+            prefix = \"live\"\n+        return base / f\"overdeck-k3s-phase1-{prefix}-{utc_stamp()}-{os.getpid()}\"\n+\n+    def add_step(self, name: str, status: str, **detail: Any) -> None:\n+        item = {\"name\": name, \"status\": status, \"at_utc\": utc_now()}\n+        item.update(detail)\n+        self.result[\"steps\"].append(item)\n+        atomic_write_json(self.receipt_dir / \"phase1-result.json\", self.result)\n+\n+    def save_json(self, relative: str, value: Any) -> None:\n+        atomic_write_json(self.receipt_dir / relative, value)\n+\n+    def remote_call(self, *arguments: str, timeout: int = 300) -> dict[str, Any]:\n+        remote_command = shlex.join([\"sudo\", \"-n\", \"/usr/bin/python3\", self.remote_helper_path, *arguments])\n+        command = self.ssh + [remote_command]\n+        completed = run_command(command, check=False, timeout=timeout)\n+        stdout = completed.stdout if isinstance(completed.stdout, str) else completed.stdout.decode(errors=\"replace\")\n+        stderr = completed.stderr if isinstance(completed.stderr, str) else completed.stderr.decode(errors=\"replace\")\n+        try:\n+            payload = json.loads(stdout)\n+        except json.JSONDecodeError as exc:\n+            raise Phase1Error(\n+                f\"remote helper returned invalid JSON (exit {completed.returncode})\\n\"\n+                f\"stdout:\\n{stdout[-3000:]}\\nstderr:\\n{stderr[-3000:]}\"\n+            ) from exc\n+        if completed.returncode != 0 or payload.get(\"status\") == \"error\":\n+            command_name = safe_name(arguments[0]) if arguments else \"unknown\"\n+            # The root helper emits only explicitly sanitized diagnostics. Persist\n+            # that structured payload so a live preflight failure can be repaired\n+            # without asking the operator to run ad-hoc secret-bearing commands.\n+            self.save_json(f\"remote-{command_name}-error.json\", payload)\n+            raise Phase1Error(str(payload.get(\"error\") or stderr or f\"remote helper exited {completed.returncode}\"))\n+        return payload\n+\n+    def install_remote_helper(self) -> None:\n+        \"\"\"Upload a checksum-verified ephemeral helper for this invocation.\n+\n+        Qualification must not persist a privileged helper on the server.  The\n+        recurring workstation service repeats this same upload on every backup,\n+        so no successful Phase 1 path depends on stale server-side code.\n+        \"\"\"\n+        if not REMOTE_HELPER.is_file() or REMOTE_HELPER.is_symlink():\n+            raise Phase1Error(f\"remote helper is missing or unsafe: {REMOTE_HELPER}\")\n+        require_commands([\"ssh\", \"python3\"])\n+        remote_tmp = f\"/tmp/overdeck-k3s-phase1-{os.getpid()}-{secrets.token_hex(4)}.py\"\n+        upload = self.ssh + [\"umask 077; cat > \" + shlex.quote(remote_tmp)]\n+        run_command(upload, input_bytes=REMOTE_HELPER.read_bytes(), timeout=60)\n+        run_command(self.ssh + [shlex.join([\"chmod\", \"0700\", remote_tmp])], timeout=30)\n+        expected = sha256_file(REMOTE_HELPER)\n+        actual = run_command(\n+            self.ssh + [shlex.join([\"sha256sum\", remote_tmp])], timeout=30\n+        ).stdout.split()[0]\n+        if actual != expected:\n+            raise Phase1Error(f\"remote helper SHA-256 mismatch: expected {expected}, got {actual}\")\n+        self.remote_helper_path = remote_tmp\n+        self.remote_temp_path = remote_tmp\n+        self.remote_installed = True\n+        self.add_step(\n+            \"install-remote-helper\",\n+            \"passed\",\n+            sha256=expected,\n+            path=remote_tmp,\n+            persistence=\"ephemeral\",\n+        )\n+\n+    def cleanup_ephemeral_helper(self) -> None:\n+        if not self.remote_temp_path:\n+            return\n+        completed = run_command(\n+            self.ssh + [shlex.join([\"rm\", \"-f\", self.remote_temp_path])],\n+            check=False,\n+            timeout=30,\n+        )\n+        if completed.returncode != 0:\n+            self.result[\"ephemeral_helper_cleanup_error\"] = completed.stderr[-2000:]\n+        self.remote_temp_path = None\n+\n+    def endpoint_from_kubeconfig(self, fallback_ip: str) -> str:\n+        if not self.kubeconfig.is_file():\n+            raise Phase1Error(f\"K3s kubeconfig is missing: {self.kubeconfig}\")\n+        require_commands([\"kubectl\"])\n+        completed = run_command(\n+            [\"kubectl\", \"--kubeconfig\", str(self.kubeconfig), \"config\", \"view\", \"--minify\", \"-o\", \"json\"],\n+            timeout=30,\n+        )\n+        try:\n+            document = json.loads(completed.stdout)\n+            server = document[\"clusters\"][0][\"cluster\"][\"server\"]\n+        except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:\n+            raise Phase1Error(\"cannot determine API endpoint from kubeconfig\") from exc\n+        parsed = urlparse(str(server))\n+        if parsed.scheme != \"https\" or not parsed.hostname:\n+            raise Phase1Error(f\"unsupported Kubernetes API endpoint: {server!r}\")\n+        if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {\"\", \"/\"}:\n+            raise Phase1Error(\"Kubernetes API endpoint must not contain credentials, path, query, or fragment\")\n+        try:\n+            port = parsed.port or 443\n+        except ValueError as exc:\n+            raise Phase1Error(f\"Kubernetes API endpoint has an invalid port: {server!r}\") from exc\n+        if not (1 <= port <= 65535):\n+            raise Phase1Error(f\"Kubernetes API endpoint port is out of range: {port}\")\n+        host = parsed.hostname\n+        if host in {\"127.0.0.1\", \"localhost\", \"::1\"}:\n+            # Preserve the kubeconfig's explicit API port.  Only the loopback\n+            # host is remapped to the server's verified Tailscale identity.\n+            host = fallback_ip\n+        try:\n+            host = str(ipaddress.ip_address(host))\n+        except ValueError:\n+            labels = host.rstrip(\".\").split(\".\")\n+            if len(host) > 253 or any(\n+                not label\n+                or len(label) > 63\n+                or not label[0].isalnum()\n+                or not label[-1].isalnum()\n+                or any(not (char.isalnum() or char == \"-\") for char in label)\n+                for label in labels\n+            ):\n+                raise Phase1Error(f\"Kubernetes API endpoint hostname is invalid: {host!r}\")\n+            host = host.rstrip(\".\").lower()\n+        rendered_host = f\"[{host}]\" if \":\" in host else host\n+        return f\"https://{rendered_host}:{port}\"\n+\n+    @staticmethod\n+    def san_values(endpoint: str, inspect: Mapping[str, Any], access: HostAccess) -> list[str]:\n+        parsed = urlparse(endpoint)\n+        candidates = [\n+            parsed.hostname,\n+            inspect.get(\"tailscale\", {}).get(\"ipv4\"),\n+            inspect.get(\"tailscale\", {}).get(\"dns_name\"),\n+            inspect.get(\"hostname\"),\n+            access.host,\n+            access.magic_dns,\n+        ]\n+        values: list[str] = []\n+        seen: set[str] = set()\n+        for raw in candidates:\n+            if not isinstance(raw, str):\n+                continue\n+            value = raw.strip().rstrip(\".\")\n+            if not value or value in seen:\n+                continue\n+            seen.add(value)\n+            values.append(value)\n+        return values\n+\n+    def inspect_server(self) -> dict[str, Any]:\n+        inspect = self.remote_call(\"inspect\", timeout=150)\n+        self.save_json(\"server-inspect.json\", inspect)\n+        registry_ip = self.server_access.host if self.server_access.door == \"tailscale_ip\" else None\n+        observed_ip = inspect.get(\"tailscale\", {}).get(\"ipv4\")\n+        if registry_ip and observed_ip != registry_ip:\n+            raise Phase1Error(\n+                f\"server Tailscale identity drift: registry={registry_ip!r} observed={observed_ip!r}\"\n+            )\n+        if not isinstance(observed_ip, str) or not observed_ip:\n+            raise Phase1Error(\"server did not report exactly one Tailscale IPv4 address\")\n+        readiness = inspect.get(\"service\", {}).get(\"ready\") or {}\n+        if inspect.get(\"service\", {}).get(\"state\") != \"active\" or readiness.get(\"ready\") is not True:\n+            raise Phase1Error(\n+                \"server K3s readiness failed: \"\n+                f\"classification={readiness.get('classification')} detail={readiness.get('detail')}\"\n+            )\n+        node_probe = inspect.get(\"node_probe\") or {}\n+        if node_probe.get(\"ok\") is not True or int(node_probe.get(\"ready_count\") or 0) < 1:\n+            raise Phase1Error(\n+                \"server Node inventory qualification failed: \"\n+                f\"classification={node_probe.get('classification')} detail={nod",
      "actualWorkers": 1
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/test-results",
        "repeatEach": 1,
        "retries": 2,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/alexcodeplace/overdeck/commit/9f23d4b61ea9af2e6ac32232ee9f4ad13067a907",
            "commitHash": "9f23d4b61ea9af2e6ac32232ee9f4ad13067a907",
            "prHref": "https://github.com/alexcodeplace/overdeck/pull/13",
            "prTitle": "k3s: qualification-first control-plane recovery baseline",
            "prBaseHash": "d3a8030304c182208fdacd4da4cfe76abdfe713f",
            "buildHref": "https://github.com/alexcodeplace/overdeck/actions/runs/31488133161"
          },
          "gitCommit": {
            "shortHash": "9f23d4b",
            "hash": "9f23d4b61ea9af2e6ac32232ee9f4ad13067a907",
            "subject": "Merge 4e6b0bba3dbb42fa514a5d8400e8890424a1d82d into d3a8030304c182208fdacd4da4cfe76abdfe713f",
            "body": "Merge 4e6b0bba3dbb42fa514a5d8400e8890424a1d82d into d3a8030304c182208fdacd4da4cfe76abdfe713f\n",
            "author": {
              "name": "alexcodeplace",
              "email": "git@alex.org.il",
              "time": 1786448791000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1786448791000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/plans/2026-08-10-k3s-migration-execution.md b/docs/plans/2026-08-10-k3s-migration-execution.md\nindex ff51c28..94c1aac 100644\n--- a/docs/plans/2026-08-10-k3s-migration-execution.md\n+++ b/docs/plans/2026-08-10-k3s-migration-execution.md\n@@ -127,27 +127,52 @@ bash apply-overdeck-k3s-phase0.sh /home/user/Projects/overdeck\n \n **Rollback:** remove the created worktree/branch if desired; no cluster rollback is needed.\n \n-**Current receipt:** package prepared in this work session; owner execution pending.\n+**Current receipt:** owner execution passed all required Phase 0 repository gates, produced commit `ec69e612bb666a459ba8fff31656616988236e4d`, opened PR #12, and merged to `main` in merge commit `9990b8cb9b5ebf40acba7c22a2b9763ca4134480`. One legacy integration gate was explicitly optional and recorded as failed; it did not block the Phase 0 audit landing.\n \n-### Phase 1 — canonical control-plane baseline, backup and recovery\n+### Phase 1 — qualification-first control-plane baseline, backup and recovery\n \n-**Input:** Phase 0 receipt.\n+**Input:** Phase 0 receipt plus the three safe Phase 1 failure receipts.\n \n **Assistant work:**\n \n-- generate canonical `/etc/rancher/k3s/config.yaml` and supporting config fragments from observed state;\n-- pin the approved K3s version and install artifact digest/source;\n-- define API endpoint/TLS SAN/Tailscale routing contract;\n-- implement server preflight, drift report and idempotent converge;\n-- implement SQLite/embedded-datastore plus server-token backup, encryption/permissions, off-host copy and retention;\n-- implement isolated restore verification and a no-change dry run;\n-- add upgrade/rollback scaffolding without performing an upgrade.\n-\n-**Owner action:** run one phase script and approve the backup destination/root action.\n-\n-**Gate:** server remains healthy; current config is reproducible; backup and token are paired; restore verification succeeds; API reachability from each eligible node is recorded.\n-\n-**Rollback:** restore prior config and service unit atomically; do not change K3s version in this phase.\n+- rederive Phase 1 around the live `debian3` topology rather than patching v3;\n+- separate the outer `/usr/local/bin/k3s` launcher from the running extracted executable;\n+- require active service, outer-launcher `/readyz`, and non-empty Ready Node inventory;\n+- model the live `agent-token -> token` layout without archiving a symlink;\n+- separate launcher/runtime version-lock fields and tie the release URL only to the launcher digest;\n+- aggregate complete qualification failures before any K3s configuration mutation;\n+- create, encrypt, verify, and materialize a pre-change recovery archive before publication;\n+- publish the canonical drop-in, contract, and version lock transactionally;\n+- verify the converged server, workstation, and every reachable registry host;\n+- create and verify a post-change recovery archive before finalization;\n+- install the recurring backup timer transactionally;\n+- authorize Git publication only in the final successful live receipt;\n+- make the package launcher commit/push/PR only after that authorization.\n+\n+**Owner action:** run one replacement phase script. No separate manual plan/apply/Git sequence is required.\n+\n+**Gate:**\n+\n+- all repository, failure-injection, fake-cluster, Phase 0, fleet, and focused Factory gates pass;\n+- complete live qualification passes;\n+- pre-change encrypted restore proof predates configuration publication;\n+- K3s remains healthy with unchanged version, launcher, runtime, and datastore type;\n+- post-change encrypted restore proof succeeds;\n+- recurring timer is installed and active;\n+- receipt returns `status=success` and `git_publication_allowed=true`;\n+- only then is the exact candidate committed and optionally pushed as a draft PR.\n+\n+**Rollback:** before server finalization, restore prior managed files/absence and timer state, restart K3s only when needed, and prove readiness. Qualification failures require no K3s rollback and never create a Git commit.\n+\n+**Implementation status:** Phase 1 v1–v3 are superseded and must not be merged. Their owner runs stopped safely before persistent cluster mutation. The replacement implementation is qualification-first and includes a sanitized replay fixture from the third receipt, 55 Python unit/failure tests, and a full fake-cluster transaction covering pre-change backup → converge → post-change backup → timer → finalization. Live replacement execution is pending.\n+\n+**Durable references:**\n+\n+- design: `docs/specs/2026-08-10-k3s-control-plane-baseline-design.md`;\n+- rederivation report: `docs/reports/2026-08-11-k3s-phase1-rederivation.md`;\n+- backup/restore runbook: `docs/runbooks/k3s-control-plane-backup-restore.md`;\n+- execution instructions: `docs/runbooks/k3s-phase1-agent-instructions.md`;\n+- entrypoint: `tools/k3s/phase1-control-plane.sh`.\n \n ### Phase 2 — one-command enrollment engine, dry-run first\n \n@@ -283,7 +308,11 @@ bash tools/k3s/enroll-node.sh debian4\n - Read-only Phase 0 collector and its offline test added.\n - Detailed audit recorded at `docs/reports/2026-08-10-k3s-migration-audit.md`.\n - Phase 0 changed-files package/application script generation in progress.\n+- Phase 0 commit `ec69e612bb666a459ba8fff31656616988236e4d` merged through PR #12 at `9990b8cb9b5ebf40acba7c22a2b9763ca4134480`.\n+- Phase 1 v1–v3 stopped safely before persistent cluster mutation and are superseded by the qualification-first replacement.\n+- The replacement replays the live launcher/runtime and agent-token topology, proves a pre-change backup before configuration publication, and permits Git publication only after full live success.\n+- Phase 1 has no product UI, primitive, design-system, or Astryx delta.\n \n ## Next executable action\n \n-Run the Phase 0 application script on the trusted workstation, then return its generated `overdeck-k3s-phase0-result-*.tar.gz`. Use that current receipt to generate Phase 1 without guessing cluster state.\n+Run `apply-overdeck-k3s-phase1-qualified.sh /home/user/Projects/overdeck` on the trusted workstation and return the generated `overdeck-k3s-phase1-result-*.tar.gz`. Keep any draft pull request unmerged until that receipt is reviewed.\ndiff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md\nindex db0d235..39182fc 100644\n--- a/docs/plans/INDEX.md\n+++ b/docs/plans/INDEX.md\n@@ -27,7 +27,7 @@ Read this file at session start, after compaction, and before claiming work. Eve\n | FIRE | DONE | [Cluster degraded — oversized mirror trips the fleet](2026-08-11-cluster-push-oversize-trip.md) | 15.3 GB non-git `Press.zone` mirror times out the 120 s push budget and marks every host unhealthy | Landed `b86ee4380` + deployed; doctor green on ssh/parity/rsync for all three hosts; debian2 back in both spill orders |\n | FIRE | IDLE | [Emergency Offload](2026-08-10-emergency-offload.md) | #170 restore remote execution and laptop usability | Whole-agent remote seat completed on Debian2, exit 0; customer payload and spill pending |\n | FIRE | BLOCKED | [Factory on k3s](2026-08-10-factory-k3s.md) | #96/#175 immutable k3s execution vertical slice | Repair dispatch `bl1io3br2` started nothing: Debian3 seat capacity 3/3; preserved WIP unchanged |\n-| FIRE | ACTIVE | [K3s migration and node enrollment](2026-08-10-k3s-migration-execution.md) | Reconcile scheduler/Factory tracks; phased cluster migration; one-command post-Tailscale enrollment | Phase 0 audit, rollback repair and read-only receipt package prepared; owner execution pending |\n+| FIRE | ACTIVE | [K3s migration and node enrollment](2026-08-10-k3s-migration-execution.md) | Reconcile scheduler/Factory tracks; phased cluster migration; one-command post-Tailscale enrollment | Phase 0 merged at `9990b8cb`; Phase 1 v1–v3 safely stopped and superseded; qualification-first replacement prepared with live-topology replay and pre-change recovery gate |\n | 2 | BLOCKED | [Seat Image Node Drift](2026-08-11-seat-image-node-drift.md) | debian1 seat image node v22≠v24; land-queue gate red fleet-wide | Handed to k3s/node-image lane; direct-land escape in use |\n | 1 | ACTIVE | [Flow-First Operating Model](2026-08-10-flow-first-operating-model.md) | Permanent delivery workflow adjustment | Owner goal active: main session MUST continuously execute; background waits route immediately to another lane, never stop |\n | 2 | ACTIVE | [Factory Reliability](2026-08-10-factory-reliability.md) | #1/#151/#171/#181/#182/#183 transport, supervision, timeout fixture, disk admission, UI kill/copy | #1 landed/deployed at 747fe53f; #151 passed 10 focused runs and 144-test Factory suite |\ndiff --git a/docs/reports/2026-08-11-k3s-phase1-rederivation.md b/docs/reports/2026-08-11-k3s-phase1-rederivation.md\nnew file mode 100644\nindex 0000000..b0a0cb4\n--- /dev/null\n+++ b/docs/reports/2026-08-11-k3s-phase1-rederivation.md\n@@ -0,0 +1,214 @@\n+# K3s Phase 1 rederivation after three safe live stops\n+\n+Date: 2026-08-11\n+Audience: owner, migration agents, reviewers\n+Status: replacement implementation complete; qualification-first live execution pending\n+\n+## Outcome\n+\n+Phase 1 was rederived rather than patched a fourth time. The replacement treats the three owner runs as live compatibility evidence and changes the transaction boundary accordingly:\n+\n+1. qualify the complete apply path;\n+2. create, encrypt, verify, and materialize a pre-change recovery archive;\n+3. only then publish control-plane configuration;\n+4. verify the converged control plane and create a second recovery archive;\n+5. install the recurring backup timer;\n+6. only after the live transaction succeeds may the package launcher commit, push, or open a pull request.\n+\n+The previous Phase 1 candidates remain unmerged and are not inputs to this implementation.\n+\n+## Evidence from the three runs\n+\n+### Run 1 — shared plan index conflict\n+\n+The first package was generated from an older `main` and attempted to patch `docs/plans/INDEX.md` textually. Newer coordination changes on `main` caused a conflict before tests or live actions.\n+\n+Correction retained in the replacement:\n+\n+- stable files use a three-way Git patch;\n+- the K3s row in `docs/plans/INDEX.md` is updated semantically;\n+- unrelated rows, receipts, status changes, formatting, and line endings are preserved;\n+- a missing, duplicate, malformed, symlinked, or non-UTF-8 target fails closed.\n+\n+### Run 2 — privileged `PATH` assumption\n+\n+The second package passed repository gates but the remote root helper searched for `k3s` only through the non-interactive sudo environment. `/usr/local/bin` was not visible there.\n+\n+Correction retained in the replacement:\n+\n+- child commands use a deterministic root-controlled executable path;\n+- the outer K3s launcher is discovered from structured systemd `ExecStart`, canonical installer paths, and the approved path;\n+- every privileged executable and its directory chain are verified;\n+- no missing executable is repaired by downloading, reinstalling, or upgrading K3s.\n+\n+### Run 3 — launcher/runtime conflation\n+\n+The third package found both:\n+\n+- outer command launcher: `/usr/local/bin/k3s`;\n+- running executable: `/var/lib/rancher/k3s/data/<digest>/bin/k3s`.\n+\n+It selected the running executable and called:\n+\n+```text\n+<runtime-app>/k3s kubectl get --raw=/readyz\n+```\n+\n+That applet rejected nested `kubectl` dispatch. The resulting message claimed K3s was not ready even though `k3s.service` was active.\n+\n+Correction in the replacement:\n+\n+- launcher and runtime are separate typed inventory objects;\n+- only the outer launcher is used for `kubectl`, `etcd-snapshot`, and `--version`;\n+- the running executable is evidence only;\n+- readiness failures distinguish invalid launcher dispatch, API reachability, authentication, and service state;\n+- server inspection retries readiness for a bounded 30-second window instead of a single five-second attempt.\n+\n+## Additional defect found before another live run\n+\n+The latest receipt reported:\n+\n+```text\n+/var/lib/rancher/k3s/server/agent-token\n+  -> /var/lib/rancher/k3s/server/token\n+```\n+\n+The prior backup implementation rejected every symlink and would therefore have failed after the readiness defect was fixed.\n+\n+The replacement classifies agent-token layout explicitly:\n+\n+- absent;\n+- independent root-owned mode-restricted regular file;\n+- symlink resolving exactly to the canonical server token.\n+\n+A supported symlink is not archived as a TAR link. The manifest records a constrained restoration descriptor, and the offline restore proof reconstructs only `agent-token -> token` inside the isolated restore tree. Broken, cyclic, escaping, or unrelated symlinks fail closed.\n+\n+## Version-lock correction\n+\n+The old lock associated one digest with both the downloaded K3s release and the extracted runtime executable. Those are not guaranteed to be the same artifact.\n+\n+The replacement schema records:\n+\n+```json\n+{\n+  \"version\": \"v1.36.3+k3s1\",\n+  \"launcher\": {\n+    \"invocation_path\": \"/usr/local/bin/k3s\",\n+    \"resolved_path\": \"/usr/local/bin/k3s\",\n+    \"sha256\": \"...\",\n+    \"release_url\": \"...\"\n+  },\n+  \"runtime\": {\n+    \"present\": true,\n+    \"resolved_path\": \"/var/lib/rancher/k3s/data/.../bin/k3s\",\n+    \"sha256\": \"...\"\n+  }\n+}\n+```\n+\n+Only the launcher digest is tied to the release URL and future upgrade candidate. A legacy ambiguous lock is rejected rather than silently migrated.\n+\n+## Qualification boundary\n+\n+The replacement qualification matrix runs these checks before control-plane configuration can change:\n+\n+1. `age` tools and private identity are available with safe ownership and permissions.\n+2. The recurring user-systemd backup installation is preflighted in a temporary directory.\n+3. The server is reached through the registered recovery door.\n+4. The ephemeral root helper is checksum verified.\n+5. The outer launcher and running executable are inventoried separately.\n+6. `k3s.service`, `/readyz`, and non-empty Ready Node inventory pass.\n+7. The datastore is classified as SQLite or embedded etcd.\n+8. Server-token and agent-token layouts are supported.\n+9. The desired endpoint, SAN set, configuration, contract, and version lock are planned without publication.\n+10. Workstation kubeconfig `/readyz` passes.\n+11. Every registry-reachable host proves the same API CA and hostname/IP-verified TLS.\n+12. A pre-change datastore/config/token archive is created on the server.\n+13. The TAR is streamed directly into `age` off-host with plaintext size and SHA-256 comparison.\n+14. The encrypted archive is decrypted temporarily, verified file by file, and materialized into an isolated restore tree.\n+15. Remote plaintext backup staging is removed.\n+\n+Independent checks continue after a failure so the receipt reports all known failures and blocked dependencies instead of stopping at the first defect.\n+\n+Qualification may create the workstation age identity and a verified encrypted pre-change backup. It does not publish K3s configuration, restart K3s, install the recurring timer, or publish Git changes.\n+\n+## Apply boundary\n+\n+Only a passing qualification can enter apply:\n+\n+1. publish the three managed control-plane files transactionally;\n+2. restart K3s only if the deterministic YAML drop-in changed;\n+3. prove server readiness, Ready Node inventory, launcher/runtime digests, and serving-certificate SANs;\n+4. prove workstation and peer API/CA/TLS access again;\n+5. create and verify a post-change encrypted recovery archive;\n+6. install and verify the recurring backup timer;\n+7. finalize the server transaction and remove rollback authority;\n+8. retain both verified encrypted backups and their metadata.\n+\n+A failure before finalization restores the prior managed files or prior absence, restarts K3s when required, verifies readiness, rolls back timer files, retains recovery evidence, and prevents Git publication.\n+\n+## Git publication boundary\n+\n+The package launcher now applies repository files and runs tests in an isolated worktree but does not commit before live success.\n+\n+The exact order is:\n+\n+```text\n+package verification\n+  -> repository patch\n+  -> deterministic tests\n+  -> local sudo qualification\n+  -> live qualification + pre-change backup\n+  -> live apply + post-change backup\n+  -> inspect phase1-result.json\n+  -> stage declared paths\n+  -> commit\n+  -> push candidate branch\n+  -> draft pull request\n+```\n+\n+The orchestrator must return both:\n+\n+```json\n+{\n+  \"status\": \"success\",\n+  \"git_publication_allowed\": true\n+}\n+```\n+\n+before the launcher stages or commits any repository path. Qualification-only mode never commits or pushes.\n+\n+## Test rederivation\n+\n+The replacement test suite includes:\n+\n+- 55 Python unit and failure-injection tests;\n+- a sanitized fixture derived from the real `debian3` receipt;\n+- distinct outer-launcher and extracted-runtime behavior;\n+- the runtime-app nested-`kubectl` failure that stopped run 3;\n+- agent-token absent, regular, safe symlink, unrelated symlink, broken symlink, and unsafe permissions;\n+- non-empty Node inventory and explicit probe-failure classification;\n+- pre-change backup without not-yet-published canonical files;\n+- post-change backup requiring a complete canonical set;\n+- separate launcher/runtime version-lock fields;\n+- safe TAR extraction and exact manifest file-set validation;\n+- SQLite integrity verification;\n+- isolated restore materialization and agent-token symlink reconstruction;\n+- a full fake-cluster transaction proving:\n+  - qualification and pre-change backup precede convergence;\n+  - post-change backup follows convergence;\n+  - timer installation follows post-change proof;\n+  - finalization is last;\n+  - Git publication is authorized only on success.\n+\n+The fake-cluster fixture now models K3s v1.36.3+k3s1, `/usr/local/bin/k3s`, an extracted runtime path, SQLite, three Ready Nodes, and `agent-token -> token`.\n+\n+## Safety status of prior candidates\n+\n+The three reported owner runs stopped before persistent K3s mutation. Their isolated worktrees and candidate commits are evidence only. They must not be merged or used as the base for the replacement.\n+\n+The replacement package is a complete delta from the merged Phase 0 base and can be applied to a newer `origin/main` through three-way patching plus the semantic plan-index update.\n+\n+## UI boundary\n+\n+This phase changes no product UI, no design-system code, and no UI primitive. Astryx is not required. The owner rule against creating a primitive without permission is unaffected.\ndiff --git a/docs/runbooks/k3s-control-plane-backup-restore.md b/docs/runbooks/k3s-control-plane-backup-restore.md\nnew file mode 100644\nindex 0000000..f086a6f\n--- /dev/null\n+++ b/docs/runbooks/k3s-control-plane-backup-restore.md\n@@ -0,0 +1,303 @@\n+# K3s control-plane backup and restore runbook\n+\n+Audience: owner and trusted recovery agents\n+Applies to: Phase 1 qualification-first tooling\n+Safety rule: verification and materialization are allowed; live restore activation is always a separate owner-approved incident operation\n+\n+## 1. Artifacts created by a successful Phase 1 run\n+\n+Default workstation paths:\n+\n+```text\n+~/.config/overdeck/k3s-backup.agekey\n+~/.config/overdeck/k3s-backup.json\n+~/.local/lib/overdeck/k3s/\n+~/.local/state/overdeck/k3s-backups/debian3/\n+~/.local/state/overdeck/k3s-phase1-debian3.lock\n+~/.config/systemd/user/overdeck-k3s-backup.service\n+~/.config/systemd/user/overdeck-k3s-backup.timer\n+```\n+\n+A successful apply retains at least two verified pairs:\n+\n+```text\n+overdeck-k3s-debian3-prechange-<stamp>.tar.age\n+overdeck-k3s-debian3-prechange-<stamp>.json\n+overdeck-k3s-debian3-postchange-<stamp>.tar.age\n+overdeck-k3s-debian3-postchange-<stamp>.json\n+```\n+\n+The `.json` file is non-secret verification metadata. The `.tar.age` file contains encrypted datastore, token, configuration, and service recovery material.\n+\n+## 2. Critical custody rule\n+\n+The recovery archive and age private key must not have their only durable copies on the same workstation.\n+\n+After Phase 1 succeeds:\n+\n+1. copy the encrypted `.tar.age` files to an owner-controlled off-workstation location;\n+2. copy the age private key to a separate secure secret store or offline medium;\n+3. preserve mode `0600` when stored on a filesystem;\n+4. never upload the private key in a phase receipt;\n+5. record the external locations in the owner’s private operations inventory, not in Git.\n+\n+The encrypted backup is useful without secrecy only if the private key remains protected. The private key alone is not a backup.\n+\n+## 3. Inspect recurring backup status\n+\n+```bash\n+systemctl --user status overdeck-k3s-backup.timer --no-pager\n+systemctl --user list-timers overdeck-k3s-backup.timer --all\n+systemctl --user status overdeck-k3s-backup.service --no-pager\n+```\n+\n+Read recent logs:\n+\n+```bash\n+journalctl --user -u overdeck-k3s-backup.service -n 200 --no-pager\n+```\n+\n+List verified pairs:\n+\n+```bash\n+find \"$HOME/.local/state/overdeck/k3s-backups/debian3\" \\\n+  -maxdepth 1 -type f \\\n+  \\( -name '*.tar.age' -o -name '*.json' \\) \\\n+  -printf '%TY-%Tm-%Td %TH:%TM:%TS %m %s %p\\n' | sort\n+```\n+\n+Do not delete unpaired or failure-qualified archives automatically. They may be the only evidence from an interrupted transaction.\n+\n+## 4. Run an additional backup manually\n+\n+Use the installed runtime after Phase 1 succeeds:\n+\n+```bash\n+bash \"$HOME/.local/lib/overdeck/k3s/phase1-control-plane.sh\" \\\n+  --config \"$HOME/.config/overdeck/k3s-backup.json\" \\\n+  --mode backup \\\n+  --non-interactive\n+```\n+\n+This operation:\n+\n+- uploads a fresh ephemeral helper;\n+- verifies K3s health and identity;\n+- creates a `scheduled` archive;\n+- streams and encrypts it off-host;\n+- materializes an isolated restore proof;\n+- checks workstation and peer API access;\n+- removes remote plaintext staging;\n+- prunes only old verified archive/metadata pairs.\n+\n+## 5. Verify an encrypted archive without live changes\n+\n+Choose an empty private directory:\n+\n+```bash\n+BACKUP=\"$HOME/.local/state/overdeck/k3s-backups/debian3/<archive>.tar.age\"\n+RESTORE_ROOT=\"$HOME/k3s-restore-proof-$(date -u +%Y%m%dT%H%M%SZ)\"\n+\n+bash tools/k3s/restore-control-plane.sh \\\n+  --encrypted \"$BACKUP\" \\\n+  --identity \"$HOME/.config/overdeck/k3s-backup.agekey\" \\\n+  --restore-root \"$RESTORE_ROOT\"\n+```\n+\n+The wrapper decrypts to a private temporary TAR, validates it, and materializes the restore tree. It does not stop K3s or copy anything into `/etc` or `/var/lib`.\n+\n+Expected top-level output:\n+\n+```text\n+$RESTORE_ROOT/\n+  OVERDECK_RESTORE_PLAN.json\n+  etc/rancher/k3s/\n+  var/lib/rancher/k3s/server/token\n+  var/lib/rancher/k3s/server/agent-token\n+  var/lib/rancher/k3s/server/db/...\n+  etc/systemd/system/...\n+```\n+\n+On the current `debian3` layout, `agent-token` is expected to be reconstructed as the relative link:\n+\n+```text\n+agent-token -> token\n+```\n+\n+The encrypted TAR itself never contains that symlink.\n+\n+## 6. Interpret verification metadata\n+\n+A verified metadata file includes:\n+\n+- `status: verified`;\n+- purpose (`prechange`, `postchange`, or `scheduled`);\n+- encrypted file size and SHA-256;\n+- server identity;\n+- K3s version plus separate launcher/runtime facts;\n+- datastore type and integrity result;\n+- agent-token restoration classification;\n+- canonical configuration presence/consistency;\n+- `secret_values_recorded: false`.\n+\n+A pre-change backup may report:\n+\n+```json\n+{\n+  \"canonical_config\": {\n+    \"present\": false,\n+    \"purpose\": \"prechange\",\n+    \"reason\": \"prechange-baseline\"\n+  }\n+}\n+```\n+\n+That is expected when the archive predates Phase 1 publication.\n+\n+Post-change and scheduled backups must report `canonical_config.present: true`.\n+\n+## 7. Qualification-only execution\n+\n+The package launcher supports a qualification-only mode. It is useful after a topology or workstation change:\n+\n+```bash\n+./apply-overdeck-k3s-phase1-qualified.sh \\\n+  /home/user/Projects/overdeck \\\n+  --qualification-only\n+```\n+\n+It may create an age identity and verified encrypted pre-change backup, but it must not:\n+\n+- publish K3s configuration;\n+- restart K3s;\n+- install the timer;\n+- stage or commit repository files;\n+- push or open a pull request.\n+\n+Return the generated receipt for review.\n+\n+## 8. Unfinished configuration transaction\n+\n+A normal rerun refuses to mutate when the server reports an unfinished transaction.\n+\n+Find the transaction ID in:\n+\n+```text\n+<phase-result>/live/remote-inspect-error.json\n+<phase-result>/live/server-inspect.json\n+<phase-result>/live/phase1-result.json\n+```\n+\n+Do not delete `/var/lib/overdeck/k3s-phase1/transactions` manually.\n+\n+### Roll back\n+\n+Use when the prior apply did not complete or its evidence is incomplete:\n+\n+```bash\n+bash tools/k3s/phase1-control-plane.sh \\\n+  --mode recover \\\n+  --repo-root /home/user/Projects/overdeck \\\n+  --server debian3 \\\n+  --transaction <transaction-id> \\\n+  --recovery-action rollback\n+```\n+\n+A successful rollback restores prior managed files/absence and requires K3s readiness.\n+\n+### Finalize\n+\n+Use only when the prior live result proves convergence, both recovery archives, timer installation, and peer checks succeeded, but the final response was lost:\n+\n+```bash\n+bash tools/k3s/phase1-control-plane.sh \\\n+  --mode recover \\\n+  --repo-root /home/user/Projects/overdeck \\\n+  --server debian3 \\\n+  --transaction <transaction-id> \\\n+  --recovery-action finalize\n+```\n+\n+Finalization discards rollback authority. Never use it merely to make a blocked rerun proceed.\n+\n+## 9. Live restore activation — incident procedure\n+\n+The Phase 1 tools deliberately stop before live activation. A real restore is destructive and must be adapted to the incident, current K3s version, surviving control-plane state, and datastore type.\n+\n+Before activation:\n+\n+1. preserve the failed live filesystem and logs;\n+2. verify the selected encrypted archive again;\n+3. compare server machine identity and intended target;\n+4. confirm exact K3s version and outer launcher digest;\n+5. confirm console or equivalent recovery access;\n+6. stop workload publication and prevent concurrent operators;\n+7. prepare rollback media for the current failed state;\n+8. choose SQLite filesystem restoration or embedded-etcd snapshot restoration explicitly.\n+\n+### SQLite outline\n+\n+With K3s stopped and after preserving the current DB/token/config:\n+\n+- restore the isolated `/etc/rancher/k3s` tree;\n+- restore server/agent token layout;\n+- replace the K3s DB tree from the verified restore root;\n+- apply root ownership and restrictive modes;\n+- start K3s;\n+- prove `/readyz`, Nodes, CA, serving SANs, workloads, and peer access.\n+\n+### Embedded-etcd outline\n+\n+With a verified snapshot and matching token/version:\n+\n+- follow the current K3s cluster-reset snapshot-restore procedure;\n+- use the snapshot copied into the isolated restore tree;\n+- restore the matching server token and configuration;\n+- verify cluster identity before admitting agents/workloads.\n+\n+Do not derive a live command blindly from this outline. The incident agent must inspect the installed K3s version’s official recovery procedure and produce an owner-reviewed command sequence.\n+\n+## 10. Backup failure handling\n+\n+If an encrypted archive has a failure suffix such as:\n+\n+```text\n+.unverified-phase-failed.tar.age\n+.verified-phase-failed.tar.age\n+```\n+\n+retain it with its metadata and receipt. Do not treat it as the newest valid recovery point unless `verify-backup.py` succeeds independently.\n+\n+The server-side plaintext archive should be absent after cleanup. A retained remote path under `/var/lib/overdeck/k3s-phase1/backups` indicates an interrupted cleanup. Remove it only through a reviewed helper transaction or after confirming its transaction ID and corresponding off-host archive.\n+\n+## 11. Upgrade boundary\n+\n+`upgrade-control-plane.sh` is plan-only. It rejects apply requests. A future K3s upgrade must:\n+\n+- use an explicit version;\n+- verify the outer launcher candidate SHA-256;\n+- retain pre-upgrade backup and rollback capability;\n+- preserve datastore/token compatibility;\n+- update launcher and runtime lock fields only after post-upgrade proof.\n+\n+Phase 1 never upgrades K3s to repair discovery, readiness, or backup failures.\n+\n+## 12. Security notes\n+\n+Never include in tickets, chat, or Git:\n+\n+- age private-key contents;\n+- server or agent token contents;\n+- kubeconfig contents;\n+- decrypted datastore files;\n+- raw `k3s.service` environment values;\n+- unredacted secret-bearing process arguments.\n+\n+Safe evidence includes:\n+\n+- SHA-256 of encrypted archives;\n+- public age recipient;\n+- public CA hash;\n+- executable digests;\n+- file metadata without content hashes for token files;\n+- transaction IDs and redacted structured receipts.\ndiff --git a/docs/runbooks/k3s-phase1-agent-instructions.md b/docs/runbooks/k3s-phase1-agent-instructions.md\nnew file mode 100644\nindex 0000000..348273a\n--- /dev/null\n+++ b/docs/runbooks/k3s-phase1-agent-instructions.md\n@@ -0,0 +1,242 @@\n+# Agent instructions — execute K3s Phase 1 qualification-first package\n+\n+Audience: the trusted agent/operator running the downloaded package\n+Goal: one command performs package verification, repository tests, live qualification, transactional apply, and candidate Git publication\n+\n+## Required downloads\n+\n+Place these files in the same directory:\n+\n+```text\n+overdeck-k3s-phase1-qualified.zip\n+apply-overdeck-k3s-phase1-qualified.sh\n+```\n+\n+Use the accompanying checksum file when provided.\n+\n+## Normal command\n+\n+Run as the normal owner of the repository, not as root:\n+\n+```bash\n+chmod +x ./apply-overdeck-k3s-phase1-qualified.sh\n+\n+./apply-overdeck-k3s-phase1-qualified.sh \\\n+  /home/user/Projects/overdeck\n+```\n+\n+The launcher may request sudo once for local `age` installation or enabling user lingering. Do not wrap the launcher itself in `sudo`.\n+\n+## Operator contract\n+\n+Do not:\n+\n+- edit package contents;\n+- use an older Phase 1 launcher with this ZIP;\n+- apply v1, v2, or v3 candidates;\n+- delete prior failed worktrees before the replacement receipt is reviewed;\n+- hand-merge or push the candidate to `main`;\n+- run root helper commands manually;\n+- expose the age key, K3s token, kubeconfig, or decrypted backup.\n+\n+The launcher verifies that its own bytes match the embedded launcher.\n+\n+## What the launcher does\n+\n+### Repository preparation\n+\n+1. Verify ZIP paths, file types, checksums, declared changed paths, patch contract, and launcher coupling.\n+2. Fetch current `origin/main` without modifying the shared checkout.\n+3. Create a fresh isolated worktree and branch.\n+4. Apply stable paths with three-way Git patching.\n+5. Merge the Phase 1 migration-plan section and K3s plan-index receipt semantically while preserving newer unrelated coordination content.\n+6. Fail if any undeclared path changes.\n+\n+### Required gates\n+\n+The launcher runs locally against the exact candidate worktree:\n+\n+- Bash syntax;\n+- Python compilation;\n+- semantic migration-plan and plan-index tests;\n+- Phase 1 unit/failure-injection suite;\n+- full fake-cluster qualification/apply transaction;\n+- Phase 0 collector and presentation regressions;\n+- execution-locality proof;\n+- legacy K3s unit gate;\n+- fleet core and CLI tests;\n+- Git whitespace/path checks;\n+- focused Factory Kubernetes regression when pytest is available.\n+\n+The legacy live K3s integration gate remains optional because it exercises the older SSH/host-path experiment rather than the Phase 1 control-plane transaction. Its result is recorded.\n+\n+### Live qualification and apply\n+\n+After repository gates, the launcher obtains local sudo authorization when required and invokes one `--mode apply` run.\n+\n+The orchestrator itself performs complete qualification and creates a verified pre-change encrypted backup before publishing configuration. It then converges, re-verifies, creates a post-change backup, installs the recurring timer, and finalizes.\n+\n+### Git publication\n+\n+The launcher does not stage or commit before live success. It reads:\n+\n+```text\n+<live-receipt>/phase1-result.json\n+```\n+\n+and requires:\n+\n+```json\n+{\n+  \"status\": \"success\",\n+  \"git_publication_allowed\": true\n+}\n+```\n+\n+Only then does it:\n+\n+1. stage exactly declared paths;\n+2. commit the candidate;\n+3. push the candidate branch when enabled;\n+4. open a draft PR when `gh` is authenticated.\n+\n+It never pushes directly to `main` or merges the PR.\n+\n+## Qualification-only mode\n+\n+To gather a complete compatibility receipt and verified pre-change backup without configuration or Git publication:\n+\n+```bash\n+./apply-overdeck-k3s-phase1-qualified.sh \\\n+  /home/user/Projects/overdeck \\\n+  --qualification-only\n+```\n+\n+Expected outcome:\n+\n+- repository gates pass;\n+- live qualification passes or reports all known failures;\n+- a verified encrypted pre-change backup may be retained;\n+- no managed K3s file changes;\n+- no K3s restart;\n+- no timer installation;\n+- no Git stage/commit/push/PR.\n+\n+Normal execution is preferred now because the replacement’s apply mode already has the same qualification boundary.\n+\n+## Supported options\n+\n+```text\n+--package PATH\n+--server NAME\n+--kubeconfig PATH\n+--backup-dir PATH\n+--age-identity PATH\n+--retention COUNT\n+--ssh-door tailscale_ip|tailscale_ssh|lan\n+--result-root PATH\n+--qualification-only\n+--no-push\n+--no-pr\n+--no-enable-linger\n+--gate-timeout SECONDS\n+--live-timeout SECONDS\n+--skip-live              testing only; repository validation, no production claim\n+```\n+\n+Do not use `--skip-live` for the migration. It cannot establish Phase 1 completion.\n+\n+## Expected live surfaces\n+\n+On `debian3`, apply may create or update:\n+\n+```text\n+/etc/rancher/k3s/config.yaml.d/90-overdeck-control-plane.yaml\n+/etc/rancher/k3s/overdeck/control-plane.json\n+/etc/rancher/k3s/overdeck/version-lock.json\n+/var/lib/overdeck/k3s-phase1/transactions/\n+/var/lib/overdeck/k3s-phase1/backups/     # temporary plaintext staging\n+```\n+\n+On the workstation:\n+\n+```text\n+~/.config/overdeck/k3s-backup.agekey\n+~/.config/overdeck/k3s-backup.json\n+~/.local/lib/overdeck/k3s/\n+~/.local/state/overdeck/k3s-backups/debian3/\n+~/.local/state/overdeck/k3s-phase1-debian3.lock\n+~/.config/systemd/user/overdeck-k3s-backup.service\n+~/.config/systemd/user/overdeck-k3s-backup.timer\n+```\n+\n+K3s restarts only when the managed YAML differs.\n+\n+## What is forbidden in this phase\n+\n+The launcher and helpers must not:\n+\n+- replace or upgrade the K3s binary;\n+- change the datastore type;\n+- run cluster reset;\n+- activate a restore;\n+- rewrite the existing installer unit/arguments;\n+- change server node IP or flannel interface;\n+- reboot hosts;\n+- enroll a new node;\n+- deploy Kubernetes workload policy;\n+- modify Tailscale ACLs;\n+- push or merge `main`.\n+\n+## Success output\n+\n+The launcher prints:\n+\n+```text\n+Worktree: ...\n+Branch: ...\n+Commit: ...\n+Live: success\n+Push: pushed|skipped\n+PR: created|skipped...\n+Return this archive for review: /.../overdeck-k3s-phase1-result-<stamp>.tar.gz\n+```\n+\n+The result archive contains redacted logs, package metadata, gate results, live receipts, the committed patch when applicable, and Git publication status. It does not contain encrypted backups or private keys.\n+\n+Upload the result archive exactly as printed. Keep the draft PR unmerged.\n+\n+## Failure output\n+\n+The launcher still creates a result archive and leaves the isolated worktree and transaction evidence intact.\n+\n+On qualification failure:\n+\n+- no K3s configuration is published;\n+- no Git commit/push occurs;\n+- a verified pre-change backup may remain;\n+- `qualification.json` lists all failed and blocked checks.\n+\n+On apply failure:\n+\n+- timer rollback is attempted;\n+- server rollback is attempted when configuration was published;\n+- Git publication is forbidden;\n+- pre-change backup remains retained;\n+- any interrupted post-change archive is marked as failure evidence.\n+\n+Do not debug manually. Upload the result archive.\n+\n+## Post-success owner action\n+\n+After receipt review but before considering Phase 1 operationally complete:\n+\n+1. securely copy encrypted backups off the workstation;\n+2. securely escrow the age private key separately;\n+3. merge only the reviewed exact candidate commit;\n+4. retain the pre-change archive through at least the Phase 2 enrollment canary;\n+5. confirm the recurring timer produces a later verified scheduled backup.\n+\n+## UI boundary\n+\n+This phase contains no product UI delta, no new UI primitive, and no Astryx dependency.\ndiff --git a/docs/specs/2026-08-10-k3s-control-plane-baseline-design.md b/docs/specs/2026-08-10-k3s-control-plane-baseline-design.md\nnew file mode 100644\nindex 0000000..0a87c88\n--- /dev/null\n+++ b/docs/specs/2026-08-10-k3s-control-plane-baseline-design.md\n@@ -0,0 +1,501 @@\n+# K3s Phase 1 — qualification-first control-plane baseline, backup, and recovery\n+\n+Audience: owner, migration agents, and reviewers\n+Status: rederived implementation prepared; live execution pending\n+Supersedes: Phase 1 v1, v2, and v3 candidates\n+\n+## 1. Decision summary\n+\n+Phase 1 establishes a reproducible recovery baseline around the existing K3s server without changing the K3s version, datastore type, server node IP, current flannel interface, workload scheduling, or existing installer arguments.\n+\n+The phase owns these durable surfaces:\n+\n+1. `/etc/rancher/k3s/config.yaml.d/90-overdeck-control-plane.yaml`\n+2. `/etc/rancher/k3s/overdeck/control-plane.json`\n+3. `/etc/rancher/k3s/overdeck/version-lock.json`\n+4. encrypted pre-change and post-change backups on the trusted workstation\n+5. a hardened recurring workstation-side backup service and timer\n+\n+The critical change after three safe live stops is ordering. A complete qualification and verified pre-change restore proof are mandatory before the first managed K3s file can be published. Repository commit and push are later still: the external package launcher may publish Git only after the live receipt explicitly authorizes it.\n+\n+No Overdeck product UI changes in Phase 1. No primitive or Astryx component is added.\n+\n+## 2. Goals\n+\n+- Identify the real outer K3s command launcher independently of the running extracted executable.\n+- Pin the current K3s version and separate launcher/runtime digests without upgrading either.\n+- Establish the canonical API endpoint, TLS SAN set, server identity, and future-agent network contract.\n+- Preserve existing installer arguments and non-owned configuration while drift-locking their non-secret shape.\n+- Qualify `/readyz`, Ready Node inventory, kubeconfig, peer API CA, and serving-certificate identity.\n+- Support SQLite and embedded-etcd recovery archives.\n+- Pair every datastore backup with the original server token and a safe agent-token restoration contract.\n+- Encrypt backups off-host before retention.\n+- Verify every archive and materialize a complete isolated restore tree without modifying the live server.\n+- Create a verified pre-change backup before configuration publication and a verified post-change backup before finalization.\n+- Install an idempotent, locked, hardened recurring backup timer.\n+- Roll back server and workstation-managed state automatically before the commit point.\n+- Produce a redacted, machine-readable receipt that can gate Git publication and Phase 2.\n+\n+## 3. Non-goals\n+\n+- No K3s version change or binary replacement.\n+- No datastore conversion.\n+- No cluster reset or live restore.\n+- No server reboot.\n+- No node enrollment.\n+- No workload migration.\n+- No namespace, RBAC, quota, network-policy, or Pod Security rollout.\n+- No removal or translation of unknown installer arguments.\n+- No Tailscale policy change.\n+- No direct push or merge to `main`.\n+- No automatic external escrow of the age private key.\n+\n+## 4. Trust model\n+\n+### 4.1 Trusted workstation\n+\n+The workstation controls the transaction. It holds:\n+\n+- the repository and host registry;\n+- SSH recovery-door configuration;\n+- the kubeconfig used to derive and prove the API endpoint;\n+- the age private key;\n+- encrypted off-server recovery archives;\n+- recurring backup units and configuration;\n+- qualification, transaction, and package receipts.\n+\n+The workstation does not persist a decrypted TAR. Decryption and restore materialization occur under a private temporary directory and are removed after verification.\n+\n+### 4.2 Existing K3s server\n+\n+The server executes a checksum-verified, stdlib-only Python helper through non-interactive sudo. The helper is uploaded to a random `0700` file under `/tmp` for each invocation and removed at exit. Qualification does not install a persistent privileged helper.\n+\n+The helper may:\n+\n+- inspect non-secret service, executable, network, datastore, and file metadata;\n+- create root-only backup staging under `/var/lib/overdeck/k3s-phase1/backups`;\n+- create rollback transactions under `/var/lib/overdeck/k3s-phase1/transactions`;\n+- publish exactly three managed files;\n+- restart only `k3s.service`, and only when the managed YAML changes;\n+- remove only phase-owned staging and rollback material.\n+\n+It never prints token values, token hashes, kubeconfig contents, datastore contents, raw environment values, or unredacted secret-bearing arguments.\n+\n+### 4.3 Other registry-reachable hosts\n+\n+Every host declared `reachable` in `buildbox-hosts.json` runs a read-only proof through its registered SSH door:\n+\n+1. fetch `<canonical-endpoint>/cacerts`;\n+2. compare the CA bundle SHA-256 with the server helper observation;\n+3. use that CA to perform hostname/IP-verified TLS against the API endpoint.\n+\n+A reachable host is not silently skipped. Hosts explicitly declared unreachable are outside the Phase 1 peer gate.\n+\n+## 5. K3s executable roles\n+\n+K3s installations may expose two related but non-interchangeable executables:\n+\n+### 5.1 Outer command launcher\n+\n+Typical path: `/usr/local/bin/k3s`.\n+\n+Used for:\n+\n+- `k3s --version`;\n+- `k3s kubectl ...`;\n+- `k3s etcd-snapshot ...`;\n+- release-artifact digest lock and future upgrade candidate verification.\n+\n+Discovery order:\n+\n+1. structured systemd `ExecStart` executable path;\n+2. canonical K3s installer paths;\n+3. a deterministic approved root executable path.\n+\n+The running process executable is never a launcher candidate.\n+\n+### 5.2 Running executable evidence\n+\n+Typical path: `/var/lib/rancher/k3s/data/<digest>/bin/k3s`.\n+\n+Used only to record:\n+\n+- current running executable path;\n+- SHA-256;\n+- trusted ownership/mode/directory-chain status;\n+- continuity across Phase 1 convergence.\n+\n+It is not called with nested `kubectl` or `etcd-snapshot` commands.\n+\n+### 5.3 Privileged executable validation\n+\n+When the helper runs as root, an executable is accepted only when:\n+\n+- its invocation path is absolute and named `k3s`;\n+- the resolved target is a regular executable file;\n+- the target is root-owned;\n+- the target is not group/world writable;\n+- both the invocation and resolved directory chains are root-owned and not group/world writable.\n+\n+A discovery failure returns sanitized service state and attempted paths. It does not trigger an installation or upgrade.\n+\n+## 6. Qualification transaction\n+\n+Qualification is a complete compatibility gate, not a shallow dry run.\n+\n+### 6.1 Independent checks\n+\n+The orchestrator records each check as `passed`, `failed`, or `blocked`. Independent checks continue after a failure so one owner run produces the full known compatibility matrix.\n+\n+Checks:\n+\n+1. **Age identity and tools**\n+   - install `age` only when explicitly allowed by the package launcher;\n+   - create or load a regular non-symlink identity owned by the current user with mode `0600`;\n+   - derive the public recipient without recording the private value.\n+\n+2. **Recurring backup preflight**\n+   - validate every source file;\n+   - verify user-systemd availability;\n+   - render service/timer units in a temporary directory;\n+   - run `systemd-analyze --user verify` when available;\n+   - inspect linger and non-interactive sudo requirements;\n+   - do not install any unit.\n+\n+3. **Server inspection**\n+   - verify registered SSH identity;\n+   - upload/checksum the ephemeral helper;\n+   - discover launcher and runtime separately;\n+   - require active K3s service;\n+   - retry outer-launcher `/readyz` for a bounded 30 seconds;\n+   - require a successful, non-empty Node query with at least one Ready Node;\n+   - classify datastore;\n+   - classify server and agent token layouts;\n+   - record public CA hash and serving certificate facts;\n+   - reject unfinished control-plane transactions.\n+\n+4. **Control-plane plan**\n+   - derive endpoint from kubeconfig, remapping only a loopback host to the verified server Tailscale address while preserving the port;\n+   - derive deterministic SANs;\n+   - generate desired managed YAML, contract, and version lock;\n+   - compare contents without publication;\n+   - reject external configuration drift against an existing canonical contract.\n+\n+5. **Workstation API proof**\n+   - run kubeconfig `/readyz` and require `ok`.\n+\n+6. **Registry-host API/CA/TLS proof**\n+   - run the peer proof from every reachable host and require all to match.\n+\n+7. **Pre-change backup and restore proof**\n+   - create a datastore/config/token archive on the server;\n+   - stream it into `age` on the workstation;\n+   - compare plaintext size and SHA-256 with server metadata;\n+   - decrypt under a private temporary directory;\n+   - verify the TAR, manifest, datastore, token contract, and file set;\n+   - materialize an isolated restore root;\n+   - remove remote plaintext staging;\n+   - retain the verified encrypted archive and metadata.\n+\n+### 6.2 Qualification mutation boundary\n+\n+Qualification may create:\n+\n+- the workstation age identity;\n+- local receipt files;\n+- a verified encrypted pre-change backup;\n+- temporary remote helper and backup staging, both removed before completion.\n+\n+Qualification does not:\n+\n+- publish K3s configuration;\n+- restart K3s;\n+- install the recurring timer;\n+- create a Git commit;\n+- push a branch;\n+- open a pull request.\n+\n+## 7. Configuration ownership\n+\n+### 7.1 Managed YAML drop-in\n+\n+`90-overdeck-control-plane.yaml` contains only:\n+\n+- `write-kubeconfig-mode: \"0600\"`;\n+- deterministic `tls-san+` values.\n+\n+The drop-in is mode `0600`, root-owned, and atomically replaced. K3s restarts only when its bytes change.\n+\n+### 7.2 Control-plane contract\n+\n+`control-plane.json` records:\n+\n+- hostname, machine ID, Tailscale IPv4/DNS, and `tailscale0`;\n+- canonical API endpoint and SAN list;\n+- public CA SHA-256;\n+- future-agent rule: Tailscale IPv4 plus `tailscale0`;\n+- explicit decision not to rewrite current server node-IP/flannel settings;\n+- a non-secret external configuration baseline;\n+- qualification requirements and observed agent-token layout.\n+\n+The baseline includes hashes and metadata for non-secret configuration, redacted process arguments, environment key names, and metadata-only records for secret-bearing environment files. Exact files exist only in the encrypted backup.\n+\n+### 7.3 Version lock schema 2\n+\n+`version-lock.json` separates:\n+\n+- `launcher`: invocation path, resolved path, SHA-256, and release URL;\n+- `runtime`: presence, resolved path, SHA-256, and discovery source;\n+- K3s version;\n+- install-script reference;\n+- explicit version-and-launcher-digest upgrade policy.\n+\n+A schema-1 lock that conflates launcher and runtime is rejected. Installed version/launcher drift is never silently adopted.\n+\n+## 8. Token backup model\n+\n+### 8.1 Server token\n+\n+The canonical server token must be a root-owned, mode-restricted regular file. Its value and content hash never enter receipts or manifests.\n+\n+### 8.2 Agent token\n+\n+Supported forms:\n+\n+- absent;\n+- root-owned, mode-restricted regular file;\n+- symlink resolving exactly to the canonical server token.\n+\n+A safe symlink is represented in the manifest as:\n+\n+```json\n+{\n+  \"classification\": \"symlink-to-server-token\",\n+  \"restore\": \"symlink-to-token\",\n+  \"link_target\": \"token\",\n+  \"payload_path\": null\n+}\n+```\n+\n+The TAR contains no symlink. The verifier reconstructs only the relative link `agent-token -> token` inside the isolated restore root.\n+\n+## 9. Backup and restore contract\n+\n+### 9.1 SQLite\n+\n+The helper uses Python’s online SQLite backup API against `state.db`, excludes live WAL/SHM files, and requires `PRAGMA integrity_check = ok` on the copied database.\n+\n+### 9.2 Embedded etcd\n+\n+The helper invokes a uniquely named compressed snapshot through the outer launcher, requires the snapshot to appear in `etcd-snapshot ls`, copies it into the phase payload, and removes only the uniquely created source snapshot after TAR staging.\n+\n+### 9.3 Manifest\n+\n+Schema 2 records:\n+\n+- purpose: `prechange`, `postchange`, or `scheduled`;\n+- server identity;\n+- K3s version;\n+- launcher and runtime facts separately;\n+- public CA hash;\n+- datastore metadata;\n+- server-token path;\n+- agent-token restoration descriptor;\n+- systemd source paths;\n+- every payload file’s path, size, SHA-256, mode, and source uid/gid.\n+\n+It records no secret values or token content hashes.\n+\n+### 9.4 Canonical file rules\n+\n+- A `prechange` archive may contain none of the three Phase 1 canonical files.\n+- If any canonical file is present, all three must be present and consistent.\n+- `postchange` and `scheduled` archives require the complete canonical set.\n+\n+### 9.5 Archive validation\n+\n+The verifier rejects:\n+\n+- absolute or traversal paths;\n+- symlinks and hard links;\n+- devices and FIFOs;\n+- duplicate members;\n+- unlisted payload files;\n+- missing manifest files;\n+- size, SHA-256, or mode mismatches;\n+- unsafe token permissions/contracts;\n+- unsupported datastore types;\n+- incomplete canonical configuration;\n+- version-lock/manifest identity mismatch.\n+\n+### 9.6 Isolated materialization\n+\n+The restore root must be empty, non-live, and non-symlinked. Materialization reconstructs:\n+\n+- `/etc/rancher/k3s` configuration;\n+- server and agent token layout;\n+- SQLite DB tree or etcd snapshot path;\n+- captured systemd files;\n+- `OVERDECK_RESTORE_PLAN.json`.\n+\n+It does not stop K3s, replace live files, apply ownership, run cluster reset, or activate a restore.\n+\n+## 10. Server configuration transaction\n+\n+Changed convergence uses states:\n+\n+```text\n+prepared -> files-published -> converged -> finalizing -> finalized\n+```\n+\n+Rollback states:\n+\n+```text\n+prepared/files-published/converged -> rolled-back\n+```\n+\n+A failed rollback becomes `rollback-failed` and blocks later mutation.\n+\n+### 10.1 Prepare\n+\n+- refuse another unfinished transaction;\n+- record each managed path as absent, regular file, or symlink;\n+- copy prior files without following links;\n+- atomically publish transaction metadata and rollback material.\n+\n+### 10.2 Publish and prove\n+\n+- atomically publish only changed managed files;\n+- set root ownership and mode `0600`;\n+- restart K3s only when YAML changed;\n+- require outer-launcher `/readyz`;\n+- require successful Ready Node inventory;\n+- require unchanged launcher and running-runtime digests;\n+- require every managed SAN in the serving certificate.\n+\n+### 10.3 Rollback\n+\n+Before finalization:\n+\n+- restore exact prior bytes/type/mode/ownership or prior absence;\n+- restart K3s when required;\n+- require readiness;\n+- retain transaction evidence.\n+\n+### 10.4 Finalization\n+\n+- mark `finalizing`;\n+- remove rollback material;\n+- mark `finalized`;\n+- make finalization idempotent;\n+- prohibit rollback after rollback authority may have been removed.\n+\n+## 11. Apply sequence\n+\n+A passing qualification returns endpoint, SANs, recipient, plan, server observation, peer results, and verified pre-change artifact.\n+\n+Apply then executes:\n+\n+1. transactional managed configuration convergence;\n+2. fresh server inspect with readiness and Nodes;\n+3. workstation `/readyz`;\n+4. peer API/CA/TLS proof;\n+5. verified encrypted post-change backup and isolated restore proof;\n+6. recurring backup timer installation;\n+7. server transaction finalization;\n+8. backup retention pruning;\n+9. success receipt with `git_publication_allowed: true`.\n+\n+A failure returns `git_publication_allowed: false` and attempts timer/config rollback.\n+\n+## 12. Recurring backup timer\n+\n+The user-systemd installation copies a self-contained runtime under `~/.local/lib/overdeck/k3s`, including a snapshot of the host registry. The service runs `--mode backup` with:\n+\n+- an exclusive server-specific lock;\n+- private config, state, and backup paths;\n+- `UMask=0077`;\n+- `NoNewPrivileges=true`;\n+- `PrivateTmp=true`;\n+- `ProtectSystem=strict`;\n+- explicit writable paths;\n+- bounded runtime and randomized timer delay.\n+\n+Timer publication is transactional. Previous files, symlinks, enabled state, active state, and phase-enabled linger are restored on pre-finalization failure.\n+\n+Retention deletes only verified archive/metadata pairs beyond the configured count. Unpaired or malformed evidence is retained for review.\n+\n+## 13. Git publication contract\n+\n+The external package launcher:\n+\n+1. verifies ZIP checksums and launcher coupling;\n+2. creates an isolated worktree from current `origin/main`;\n+3. applies stable paths with a three-way patch;\n+4. semantically updates the K3s plan-index row;\n+5. runs all required repository and fake-cluster gates locally;\n+6. obtains local sudo authorization when required;\n+7. invokes one live `--mode apply` transaction;\n+8. requires `status=success` and `git_publication_allowed=true`;\n+9. stages exactly declared paths;\n+10. commits and optionally pushes a candidate branch and opens a draft PR.\n+\n+No repository commit exists before live success. Qualification-only mode never stages, commits, pushes, or opens a PR.\n+\n+## 14. Failure and recovery policy\n+\n+### Before configuration publication\n+\n+- no K3s rollback is needed;\n+- verified pre-change backup remains retained;\n+- temporary remote helper/staging is removed when reachable;\n+- no Git publication occurs.\n+\n+### After configuration publication but before finalization\n+\n+- restore previous managed paths;\n+- restart K3s if needed;\n+- verify readiness;\n+- restore timer files/state;\n+- retain pre-change backup and failure receipt;\n+- do not commit/push.\n+\n+### Ambiguous or incomplete transaction\n+\n+The next run refuses mutation and reports the transaction ID. The owner uses the explicit recovery mode:\n+\n+```bash\n+bash tools/k3s/phase1-control-plane.sh \\\n+  --mode recover \\\n+  --server debian3 \\\n+  --transaction <id> \\\n+  --recovery-action rollback\n+```\n+\n+`finalize` is used only when the prior success evidence is complete and rollback material should be intentionally discarded.\n+\n+## 15. Acceptance criteria\n+\n+Phase 1 is complete only when one live receipt proves:\n+\n+- full qualification passed;\n+- outer launcher and runtime are separate and trusted;\n+- K3s service, `/readyz`, and Ready Node inventory passed;\n+- endpoint, CA, and TLS identity passed from workstation and every reachable registry host;\n+- pre-change encrypted backup passed offline restore materialization before convergence;\n+- post-change encrypted backup passed after convergence;\n+- server/agent token restoration contracts passed without exposing values;\n+- canonical files are deterministic and drift-locked;\n+- recurring timer is installed and active;\n+- no rollback is pending;\n+- `git_publication_allowed=true`;\n+- candidate Git commit contains only declared paths;\n+- branch/PR remains unmerged until receipt review.\n+\n+## 16. References\n+\n+- Rederivation report: `docs/reports/2026-08-11-k3s-phase1-rederivation.md`\n+- Backup/restore runbook: `docs/runbooks/k3s-control-plane-backup-restore.md`\n+- Agent instructions: `docs/runbooks/k3s-phase1-agent-instructions.md`\n+- Migration plan: `docs/plans/2026-08-10-k3s-migration-execution.md`\n+- Entrypoint: `tools/k3s/phase1-control-plane.sh`\ndiff --git a/tools/ci/constants.mjs b/tools/ci/constants.mjs\nindex 3c0e6fe..db7d5f0 100644\n--- a/tools/ci/constants.mjs\n+++ b/tools/ci/constants.mjs\n@@ -97,6 +97,7 @@ export const EXCLUDED_WORKSTATION_SUITES = [\n   { path: 'modules/workstation/claude/tests/agent-seat-slice.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/agent-session-cap.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/agent-session-mux-routing.test.sh', reason: 'shell-integration' },\n+  { path: 'modules/workstation/claude/tests/agent-session-reap.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/agent-unsafe.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/bg-gate-installed-smoke.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/bg-gate-matcher.test.mjs', reason: 'node-unit' },\n@@ -119,6 +120,8 @@ export const EXCLUDED_WORKSTATION_SUITES = [\n   { path: 'modules/workstation/claude/tests/gate0-remote-dispatch.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/git-guard-forkbomb.dangerlab.sh', reason: 'dangerlab' },\n   { path: 'modules/workstation/claude/tests/git-guard-shim.test.sh', reason: 'shell-integration' },\n+  { path: 'modules/workstation/claude/tests/gpt-orchestrator-skill.test.py', reason: 'python-unit' },\n+  { path: 'modules/workstation/claude/tests/gpt-workflow-skill.test.py', reason: 'python-unit' },\n   { path: 'modules/workstation/claude/tests/human-session-dotname.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/human-session.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/k3s-remote-build-integration.sh', reason: 'k3s-integration' },\n@@ -169,6 +172,7 @@ export const EXCLUDED_WORKSTATION_SUITES = [\n   { path: 'modules/workstation/claude/tests/tmpjail-shim-routing.test.sh', reason: 'shell-integration' },\n   { path: 'modules/workstation/claude/tests/trello-token.test.mjs', reason: 'node-unit' },\n   { path: 'modules/workstation/claude/tests/whole-gate-dispatch.test.mjs', reason: 'node-unit' },\n+  { path: 'modules/workstation/claude/tests/wip-triage-skill.test.py', reason: 'python-unit' },\n   { path: 'modules/workstation/claude/tests/worktree-lock-gate.test.sh', reason: 'shell-integration' },\n ];\n \ndiff --git a/tools/ci/gate-inventory.json b/tools/ci/gate-inventory.json\nindex a8d285d..9c377e2 100644\n--- a/tools/ci/gate-inventory.json\n+++ b/tools/ci/gate-inventory.json\n@@ -1,6 +1,6 @@\n {\n   \"schemaVersion\": \"1\",\n-  \"generatedAt\": \"2026-08-11T03:48:11.372Z\",\n+  \"generatedAt\": \"2026-08-11T11:45:45.822Z\",\n   \"workspacePackages\": [\n     {\n       \"id\": \"workspace:apps/web\",\n@@ -87,7 +87,7 @@\n         \"id\": \"workstation:agent-session-passthrough\",\n         \"path\": \"modules/workstation/claude/tests/agent-session-passthrough.test.sh\",\n         \"runner\": \"bash\",\n-        \"sourceDigest\": \"ab85a16b9641f55fedc4b5a5c5af3e632616b01ce2902dd7cbb43e357dd6ab8a\"\n+        \"sourceDigest\": \"8e052d8f9d42d1f7c25c54d8a2c7eb5fd99cf7b5c88eb8e149851a55ea715977\"\n       },\n       {\n         \"id\": \"workstation:human-session-fallback\",\n@@ -157,6 +157,11 @@\n         \"reason\": \"shell-integration\",\n         \"sourceDigest\": \"5b45cad4c2a79176b754816dc8abb381055c94acac35139b0461d18ba6ac2f77\"\n       },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/agent-session-reap.test.mjs\",\n+        \"reason\": \"node-unit\",\n+        \"sourceDigest\": \"d5841a5a23cdad4c66ec4394d85a645dbf7ee68cd0bc44dc1acb26ee2ac46c45\"\n+      },\n       {\n         \"path\": \"modules/workstation/claude/tests/agent-unsafe.test.sh\",\n         \"reason\": \"shell-integration\",\n@@ -267,6 +272,16 @@\n         \"reason\": \"shell-integration\",\n         \"sourceDigest\": \"980e624ac1868c18445a73e641a58bdc9114e4f0e21f63a92c43aa6f6a09a20e\"\n       },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/gpt-orchestrator-skill.test.py\",\n+        \"reason\": \"python-unit\",\n+        \"sourceDigest\": \"0521b2ed88a38a49eb00e072d738ffa7ce763a43800459d741628fd8d54c94cc\"\n+      },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/gpt-workflow-skill.test.py\",\n+        \"reason\": \"python-unit\",\n+        \"sourceDigest\": \"64a8460ce9b6e23afec795516a775f329e4653e5c4cf55b164849b7f3c6e4de4\"\n+      },\n       {\n         \"path\": \"modules/workstation/claude/tests/human-session-dotname.test.sh\",\n         \"reason\": \"shell-integration\",\n@@ -305,7 +320,7 @@\n       {\n         \"path\": \"modules/workstation/claude/tests/local-gate.test.mjs\",\n         \"reason\": \"node-unit\",\n-        \"sourceDigest\": \"d636c74b8199f967cda23e4d5f8d1a17fc322bfe3d976aa520129ffbbbac7aa2\"\n+        \"sourceDigest\": \"b42623332f39f3e63aba18a05dd4fcb4fcd2440903e525a1ac1114fe0c85d915\"\n       },\n       {\n         \"path\": \"modules/workstation/claude/tests/main-checkout-guard.test.sh\",\n@@ -390,7 +405,7 @@\n       {\n         \"path\": \"modules/workstation/claude/tests/remote-build.test.mjs\",\n         \"reason\": \"node-unit\",\n-        \"sourceDigest\": \"5e03f11beb7844be803c74d142ccc9abecc5e04fccb6016e887503dca844087f\"\n+        \"sourceDigest\": \"cd918b748fca1d61fe542287d3f3625b8d7cfdb529f9224ec4b34f60d2e3ab95\"\n       },\n       {\n         \"path\": \"modules/workstation/claude/tests/remote-job-env.test.mjs\",\n@@ -517,6 +532,11 @@\n         \"reason\": \"node-unit\",\n         \"sourceDigest\": \"bd03a613e9e39fab5d9f04649b8e2806bc05db2279e927cf686125632939b2de\"\n       },\n+      {\n+        \"path\": \"modules/workstation/claude/tests/wip-triage-skill.test.py\",\n+        \"reason\": \"python-unit\",\n+        \"sourceDigest\": \"14af31b6d899ab600b3f84db7b9deaa7673855eb19f60ede4fbe7a57b8a190f0\"\n+      },\n       {\n         \"path\": \"modules/workstation/claude/tests/worktree-lock-gate.test.sh\",\n         \"reason\": \"shell-integration\",\ndiff --git a/tools/k3s/README.md b/tools/k3s/README.md\nnew file mode 100644\nindex 0000000..5e178a4\n--- /dev/null\n+++ b/tools/k3s/README.md\n@@ -0,0 +1,61 @@\n+# Overdeck K3s migration tooling\n+\n+This directory contains phase-scoped, receipt-driven K3s migration tools. Normal repository users run the phase package launcher; root-side helpers are implementation details and must not be invoked manually except through a documented recovery command.\n+\n+## Phase 0\n+\n+`phase0-discover.sh` performs read-only repository, host, and cluster inventory.\n+\n+## Phase 1 — qualification-first control-plane baseline\n+\n+Primary files:\n+\n+- `phase1-control-plane.sh` — local-only workstation entrypoint.\n+- `phase1-control-plane.py` — qualification, apply, backup, and recovery orchestrator.\n+- `remote/phase1-server.py` — ephemeral checksum-verified root helper for server inspection, transactional configuration, and backup staging.\n+- `verify-backup.py` — decrypted TAR validation and non-live isolated restore materialization.\n+- `restore-control-plane.sh` — explicit decrypt-and-materialize wrapper; never activates a live restore.\n+- `upgrade-plan.py` and `upgrade-control-plane.sh` — digest-locked, plan-only upgrade scaffold.\n+- `systemd/` — hardened recurring encrypted backup user service and timer.\n+- `lib/phase1_common.py` — shared stdlib-only safety, archive, SSH, locking, and retention helpers.\n+- `test/fixtures/debian3-20260811-server-state.json` — sanitized live topology fixture from the third owner run.\n+- `test/` — unit, failure-injection, systemd, backup/restore, and full fake-cluster transaction tests.\n+\n+### Transaction order\n+\n+`--mode apply` always executes:\n+\n+```text\n+complete qualification\n+  -> verified encrypted pre-change backup\n+  -> transactional managed configuration\n+  -> post-converge health/identity proof\n+  -> verified encrypted post-change backup\n+  -> recurring timer installation\n+  -> transaction finalization\n+```\n+\n+It cannot publish Git changes. The external phase package launcher reads the final receipt and performs Git publication only when `status=success` and `git_publication_allowed=true`.\n+\n+### Modes\n+\n+- `--mode qualify` or `--mode plan`: run complete qualification and create a verified pre-change backup, but do not publish K3s configuration or install the timer.\n+- `--mode apply`: qualification plus the transactional apply sequence above.\n+- `--mode backup`: scheduled encrypted backup, restore proof, API checks, and retention.\n+- `--mode recover`: explicit rollback or finalization of a named unfinished server transaction.\n+\n+### Important contracts\n+\n+- `/usr/local/bin/k3s`-style outer launcher and `/var/lib/rancher/k3s/data/.../bin/k3s` runtime are separate roles.\n+- The agent token may be absent, regular, or a symlink resolving exactly to the server token.\n+- Archive TARs never contain symlinks, devices, FIFOs, or unlisted payload files.\n+- A pre-change backup may legitimately predate the three Phase 1 canonical files.\n+- A post-change or scheduled backup must contain the complete canonical file set.\n+- K3s upgrades, datastore conversion, cluster reset, and live restore are out of scope.\n+\n+References:\n+\n+- `docs/specs/2026-08-10-k3s-control-plane-baseline-design.md`\n+- `docs/reports/2026-08-11-k3s-phase1-rederivation.md`\n+- `docs/runbooks/k3s-control-plane-backup-restore.md`\n+- `docs/runbooks/k3s-phase1-agent-instructions.md`\ndiff --git a/tools/k3s/lib/phase1_common.py b/tools/k3s/lib/phase1_common.py\nnew file mode 100755\nindex 0000000..28a7c1c\n--- /dev/null\n+++ b/tools/k3s/lib/phase1_common.py\n@@ -0,0 +1,527 @@\n+#!/usr/bin/env python3\n+\"\"\"Shared helpers for the Overdeck K3s Phase 1 tooling.\n+\n+The module is deliberately Python-stdlib-only so it can run on the workstation\n+and on clean Debian-family buildboxes without provisioning a virtualenv.\n+\"\"\"\n+from __future__ import annotations\n+\n+import hashlib\n+import json\n+import os\n+import re\n+import shutil\n+import stat\n+import subprocess\n+import tarfile\n+import tempfile\n+from contextlib import contextmanager\n+from dataclasses import dataclass\n+from datetime import datetime, timezone\n+from pathlib import Path, PurePosixPath\n+from typing import Any, BinaryIO, Iterable, Iterator, Mapping, Sequence\n+\n+try:\n+    import fcntl\n+except ImportError:  # pragma: no cover - Overdeck's supported workstations are Unix\n+    fcntl = None  # type: ignore[assignment]\n+\n+K3S_VERSION_RE = re.compile(r\"\\bk3s version (?P<version>v[^\\s]+)\")\n+SAFE_NAME_RE = re.compile(r\"[^A-Za-z0-9._-]+\")\n+\n+\n+class Phase1Error(RuntimeError):\n+    \"\"\"Raised for a user-actionable Phase 1 failure.\"\"\"\n+\n+\n+@dataclass(frozen=True)\n+class HostAccess:\n+    name: str\n+    host: str\n+    port: int\n+    user: str\n+    identity_file: str | None\n+    door: str\n+    magic_dns: str | None\n+    state: str\n+\n+    @property\n+    def target(self) -> str:\n+        return f\"{self.user}@{self.host}\"\n+\n+\n+def utc_now() -> str:\n+    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(\"+00:00\", \"Z\")\n+\n+\n+def utc_stamp() -> str:\n+    return datetime.now(timezone.utc).strftime(\"%Y%m%dT%H%M%SZ\")\n+\n+\n+def safe_name(value: str) -> str:\n+    cleaned = SAFE_NAME_RE.sub(\"-\", value).strip(\"-.\")\n+    if not cleaned:\n+        raise Phase1Error(f\"value cannot be converted to a safe name: {value!r}\")\n+    return cleaned\n+\n+\n+def sha256_file(path: Path, *, chunk_size: int = 1024 * 1024) -> str:\n+    digest = hashlib.sha256()\n+    with path.open(\"rb\") as handle:\n+        while True:\n+            chunk = handle.read(chunk_size)\n+            if not chunk:\n+                break\n+            digest.update(chunk)\n+    return digest.hexdigest()\n+\n+\n+def sha256_bytes(payload: bytes) -> str:\n+    return hashlib.sha256(payload).hexdigest()\n+\n+\n+def parse_k3s_version(text: str) -> str:\n+    match = K3S_VERSION_RE.search(text)\n+    if not match:\n+        raise Phase1Error(f\"cannot parse K3s version from output: {text.strip()!r}\")\n+    return match.group(\"version\")\n+\n+\n+def read_json(path: Path) -> Any:\n+    try:\n+        with path.open(encoding=\"utf-8\") as handle:\n+            return json.load(handle)\n+    except FileNotFoundError as exc:\n+        raise Phase1Error(f\"required JSON file does not exist: {path}\") from exc\n+    except json.JSONDecodeError as exc:\n+        raise Phase1Error(f\"invalid JSON in {path}: {exc}\") from exc\n+\n+\n+def atomic_write_json(path: Path, value: Any, *, mode: int = 0o600) -> None:\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    payload = json.dumps(value, indent=2, sort_keys=True) + \"\\n\"\n+    fd, tmp_name = tempfile.mkstemp(prefix=f\".{path.name}.\", dir=path.parent)\n+    tmp = Path(tmp_name)\n+    try:\n+        os.fchmod(fd, mode)\n+        with os.fdopen(fd, \"w\", encoding=\"utf-8\") as handle:\n+            handle.write(payload)\n+            handle.flush()\n+            os.fsync(handle.fileno())\n+        os.replace(tmp, path)\n+        dir_fd = os.open(path.parent, os.O_DIRECTORY)\n+        try:\n+            os.fsync(dir_fd)\n+        finally:\n+            os.close(dir_fd)\n+    finally:\n+        try:\n+            tmp.unlink()\n+        except FileNotFoundError:\n+            pass\n+\n+\n+@contextmanager\n+def exclusive_lock(path: Path) -> Iterator[BinaryIO]:\n+    \"\"\"Hold a non-blocking process lock for the duration of a Phase 1 run.\n+\n+    The workstation is the trusted controller.  A local lock prevents a manual\n+    invocation and the recurring systemd timer from mutating/backuping the same\n+    server concurrently.  The server helper independently rejects overlapping\n+    control-plane transactions from any controller.\n+    \"\"\"\n+\n+    if fcntl is None:  # pragma: no cover - defensive portability guard\n+        raise Phase1Error(\"Phase 1 requires Unix advisory file locking\")\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    handle = path.open(\"a+b\")\n+    os.chmod(path, 0o600)\n+    try:\n+        try:\n+            fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)\n+        except BlockingIOError as exc:\n+            raise Phase1Error(f\"another Phase 1 operation holds the lock: {path}\") from exc\n+        handle.seek(0)\n+        handle.truncate()\n+        handle.write(f\"pid={os.getpid()} started_utc={utc_now()}\\n\".encode())\n+        handle.flush()\n+        os.fsync(handle.fileno())\n+        yield handle\n+    finally:\n+        try:\n+            fcntl.flock(handle.fileno(), fcntl.LOCK_UN)\n+        finally:\n+            handle.close()\n+\n+\n+def normalize_path(path: str | Path) -> Path:\n+    return Path(os.path.expandvars(os.path.expanduser(str(path)))).resolve()\n+\n+\n+def absolute_path_without_symlink_resolution(path: str | Path) -> Path:\n+    \"\"\"Expand a path without hiding whether its final component is a symlink.\n+\n+    ``Path.resolve`` is appropriate for repository and inventory locations, but\n+    it defeats explicit symlink checks for private keys and restore targets.  A\n+    lexical absolute path preserves the final path component for those checks.\n+    \"\"\"\n+\n+    expanded = os.path.expandvars(os.path.expanduser(str(path)))\n+    return Path(os.path.abspath(expanded))\n+\n+\n+def load_host_registry(path: Path) -> dict[str, Any]:\n+    document = read_json(path)\n+    if not isinstance(document, dict) or document.get(\"schema_version\") != 1:\n+        raise Phase1Error(f\"unsupported host registry schema in {path}\")\n+    hosts = document.get(\"hosts\")\n+    if not isinstance(hosts, list):\n+        raise Phase1Error(f\"host registry has no hosts array: {path}\")\n+    return document\n+\n+\n+def resolve_host_access(\n+    registry: Mapping[str, Any],\n+    name: str,\n+    *,\n+    preferred_door: str = \"tailscale_ip\",\n+    require_reachable: bool = True,\n+) -> HostAccess:\n+    candidates = [item for item in registry.get(\"hosts\", []) if isinstance(item, dict) and item.get(\"name\") == name]\n+    if len(candidates) != 1:\n+        raise Phase1Error(f\"expected exactly one host named {name!r}; found {len(candidates)}\")\n+    entry = candidates[0]\n+    state = str(entry.get(\"state\") or \"unknown\")\n+    if require_reachable and state != \"reachable\":\n+        raise Phase1Error(f\"host {name} is declared {state!r}; Phase 1 requires registry state 'reachable'\")\n+    access = entry.get(\"access\")\n+    if not isinstance(access, dict):\n+        raise Phase1Error(f\"host {name} has no access declaration\")\n+    order = [preferred_door, \"tailscale_ip\", \"tailscale_ssh\", \"lan\"]\n+    seen: set[str] = set()\n+    door_name = \"\"\n+    door: Mapping[str, Any] | None = None\n+    for candidate in order:\n+        if candidate in seen:\n+            continue\n+        seen.add(candidate)\n+        raw = access.get(candidate)\n+        if isinstance(raw, dict) and raw.get(\"host\") and raw.get(\"user\"):\n+            door_name = candidate\n+            door = raw\n+            break\n+    if door is None:\n+        raise Phase1Error(f\"host {name} has no usable access door\")\n+    try:\n+        port = int(door.get(\"port\") or 22)\n+    except (TypeError, ValueError) as exc:\n+        raise Phase1Error(f\"host {name} has an invalid SSH port\") from exc\n+    if not (1 <= port <= 65535):\n+        raise Phase1Error(f\"host {name} has an out-of-range SSH port: {port}\")\n+    identity = door.get(\"identity_file\")\n+    if identity is not None:\n+        identity = str(normalize_path(str(identity)))\n+    magic_dns_raw = access.get(\"tailscale_ssh\")\n+    magic_dns = str(magic_dns_raw.get(\"host\")) if isinstance(magic_dns_raw, dict) and magic_dns_raw.get(\"host\") else None\n+    return HostAccess(\n+        name=name,\n+        host=str(door[\"host\"]),\n+        port=port,\n+        user=str(door[\"user\"]),\n+        identity_file=identity,\n+        door=door_name,\n+        magic_dns=magic_dns,\n+        state=state,\n+    )\n+\n+\n+def eligible_reachable_hosts(registry: Mapping[str, Any]) -> list[str]:\n+    names: list[str] = []\n+    for item in registry.get(\"hosts\", []):\n+        if not isinstance(item, dict):\n+            continue\n+        name = item.get(\"name\")\n+        if item.get(\"state\") == \"reachable\" and isinstance(name, str) and name:\n+            names.append(name)\n+    return sorted(set(names))\n+\n+\n+def build_ssh_command(access: HostAccess, *, timeout: int = 12) -> list[str]:\n+    if timeout < 1:\n+        raise Phase1Error(\"SSH timeout must be positive\")\n+    command = [\n+        \"ssh\",\n+        \"-F\",\n+        \"/dev/null\",\n+        \"-p\",\n+        str(access.port),\n+        \"-o\",\n+        \"BatchMode=yes\",\n+        \"-o\",\n+        f\"ConnectTimeout={timeout}\",\n+        \"-o\",\n+        \"ServerAliveInterval=15\",\n+        \"-o\",\n+        \"ServerAliveCountMax=2\",\n+        \"-o\",\n+        \"StrictHostKeyChecking=yes\",\n+    ]\n+    if access.identity_file:\n+        command.extend([\"-o\", \"IdentitiesOnly=yes\", \"-i\", access.identity_file])\n+    command.append(access.target)\n+    return command\n+\n+\n+def run_command(\n+    argv: Sequence[str],\n+    *,\n+    input_bytes: bytes | None = None,\n+    input_text: str | None = None,\n+    check: bool = True,\n+    timeout: int | None = None,\n+    env: Mapping[str, str] | None = None,\n+    cwd: Path | None = None,\n+) -> subprocess.CompletedProcess[Any]:\n+    if input_bytes is not None and input_text is not None:\n+        raise ValueError(\"provide only one of input_bytes or input_text\")\n+    text = input_bytes is None\n+    input_value: bytes | str | None = input_text if input_text is not None else input_bytes\n+    completed = subprocess.run(\n+        list(argv),\n+        input=input_value,\n+        stdout=subprocess.PIPE,\n+        stderr=subprocess.PIPE,\n+        text=text,\n+        timeout=timeout,\n+        env=dict(env) if env is not None else None,\n+        cwd=str(cwd) if cwd else None,\n+        check=False,\n+    )\n+    if check and completed.returncode != 0:\n+        stdout = completed.stdout.decode(errors=\"replace\") if isinstance(completed.stdout, bytes) else completed.stdout\n+        stderr = completed.stderr.decode(errors=\"replace\") if isinstance(completed.stderr, bytes) else completed.stderr\n+        rendered = \" \".join(str(part) for part in argv)\n+        raise Phase1Error(\n+            f\"command failed with exit {completed.returncode}: {rendered}\\n\"\n+            f\"stdout:\\n{stdout[-4000:]}\\n\"\n+            f\"stderr:\\n{stderr[-4000:]}\"\n+        )\n+    return completed\n+\n+\n+def require_commands(commands: Iterable[str]) -> None:\n+    missing = [command for command in commands if shutil.which(command) is None]\n+    if missing:\n+        raise Phase1Error(f\"missing required command(s): {', '.join(missing)}\")\n+\n+\n+def validate_tar_member_name(name: str) -> None:\n+    pure = PurePosixPath(name)\n+    if not name or name in {\".\", \"./\"} or pure.is_absolute() or \"..\" in pure.parts:\n+        raise Phase1Error(f\"archive contains unsafe path: {name!r}\")\n+\n+\n+def _extract_regular_member(handle: tarfile.TarFile, member: tarfile.TarInfo, target: Path) -> None:\n+    source = handle.extractfile(member)\n+    if source is None:\n+        raise Phase1Error(f\"archive member has no readable payload: {member.name!r}\")\n+    target.parent.mkdir(parents=True, exist_ok=True)\n+    if target.exists() or target.is_symlink():\n+        raise Phase1Error(f\"archive contains duplicate member path: {member.name!r}\")\n+    fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)\n+    try:\n+        with os.fdopen(fd, \"wb\") as destination:\n+            shutil.copyfileobj(source, destination, length=1024 * 1024)\n+            destination.flush()\n+            os.fsync(destination.fileno())\n+    finally:\n+        source.close()\n+    # Strip set-id/sticky bits.  Backup payload files are data, never executables.\n+    os.chmod(target, int(member.mode) & 0o0777)\n+\n+\n+def safe_extract_tar(archive: Path, destination: Path) -> None:\n+    \"\"\"Extract only ordinary files/directories without trusting tarfile defaults.\n+\n+    This intentionally avoids ``TarFile.extractall`` so the verifier behaves\n+    consistently on all supported Python versions and never follows archive\n+    links, applies ownership, or creates device nodes.\n+    \"\"\"\n+\n+    destination.mkdir(parents=True, exist_ok=True)\n+    root = destination.resolve()\n+    with tarfile.open(archive, \"r:*\") as handle:\n+        members = handle.getmembers()\n+        seen: set[str] = set()\n+        for member in members:\n+            validate_tar_member_name(member.name)\n+            normalized = PurePosixPath(member.name).as_posix().rstrip(\"/\")\n+            if normalized in seen:\n+                raise Phase1Error(f\"archive contains duplicate member path: {member.name!r}\")\n+            seen.add(normalized)\n+            if member.issym() or member.islnk() or member.isdev() or member.isfifo():\n+                raise Phase1Error(f\"archive contains unsupported special member: {member.name!r}\")\n+            target = (root / member.name).resolve()\n+            if target != root and root not in target.parents:\n+                raise Phase1Error(f\"archive member escapes extraction root: {member.name!r}\")\n+            if not member.isdir() and not member.isfile():\n+                raise Phase1Error(f\"archive contains unsupported member type: {member.name!r}\")\n+        directory_modes: list[tuple[Path, int]] = []\n+        for member in members:\n+            target = root / member.name\n+            if member.isdir():\n+                target.mkdir(parents=True, exist_ok=True)\n+                os.chmod(target, 0o700)\n+                directory_modes.append((target, int(member.mode) & 0o0777))\n+            else:\n+                _extract_regular_member(handle, member, target)\n+        for target, mode in sorted(directory_modes, key=lambda item: len(item[0].parts), reverse=True):\n+            os.chmod(target, mode)\n+\n+\n+def verify_manifest(extracted_root: Path, manifest_path: Path) -> dict[str, Any]:\n+    manifest = read_json(manifest_path)\n+    if not isinstance(manifest, dict) or manifest.get(\"schema_version\") not in {1, 2}:\n+        raise Phase1Error(\"unsupported backup manifest schema\")\n+    schema = int(manifest[\"schema_version\"])\n+    files = manifest.get(\"files\")\n+    if not isinstance(files, list) or not files:\n+        raise Phase1Error(\"backup manifest has no files\")\n+    seen: set[str] = set()\n+    for item in files:\n+        if not isinstance(item, dict):\n+            raise Phase1Error(\"backup manifest contains an invalid file entry\")\n+        rel = item.get(\"path\")\n+        expected_sha = item.get(\"sha256\")\n+        expected_size = item.get(\"size\")\n+        if not isinstance(rel, str) or not rel or not isinstance(expected_sha, str) or not isinstance(expected_size, int):\n+            raise Phase1Error(\"backup manifest file entry is incomplete\")\n+        validate_tar_member_name(rel)\n+        if rel in seen:\n+            raise Phase1Error(f\"backup manifest contains duplicate path: {rel}\")\n+        seen.add(rel)\n+        path = (extracted_root / rel).resolve()\n+        if extracted_root.resolve() not in path.parents:\n+            raise Phase1Error(f\"manifest path escapes extraction root: {rel}\")\n+        if not path.is_file() or path.is_symlink():\n+            raise Phase1Error(f\"manifest file is missing or unsafe: {rel}\")\n+        actual_size = path.stat().st_size\n+        if actual_size != expected_size:\n+            raise Phase1Error(f\"size mismatch for {rel}: expected {expected_size}, got {actual_size}\")\n+        expected_mode = item.get(\"mode\")\n+        if expected_mode is not None:\n+            if not isinstance(expected_mode, str) or not re.fullmatch(r\"[0-7]{4}\", expected_mode):\n+                raise Phase1Error(f\"backup manifest has an invalid mode for {rel}\")\n+            actual_mode = f\"{stat.S_IMODE(path.stat().st_mode):04o}\"\n+            if actual_mode != expected_mode:\n+                raise Phase1Error(\n+                    f\"mode mismatch for {rel}: expected {expected_mode}, got {actual_mode}\"\n+                )\n+        actual_sha = sha256_file(path)\n+        if actual_sha != expected_sha:\n+            raise Phase1Error(f\"SHA-256 mismatch for {rel}\")\n+    token_rel = manifest.get(\"server_token_path\")\n+    if not isinstance(token_rel, str) or token_rel not in seen:\n+        raise Phase1Error(\"backup manifest does not pair a server token with the datastore\")\n+    datastore = manifest.get(\"datastore\")\n+    if not isinstance(datastore, dict) or datastore.get(\"type\") not in {\"sqlite\", \"etcd\"}:\n+        raise Phase1Error(\"backup manifest datastore type is unsupported\")\n+    if schema == 2:\n+        purpose = manifest.get(\"purpose\")\n+        if purpose not in {\"prechange\", \"postchange\", \"scheduled\"}:\n+            raise Phase1Error(\"backup manifest has an unsupported purpose\")\n+        agent = manifest.get(\"agent_token\")\n+        if not isinstance(agent, dict):\n+            raise Phase1Error(\"backup manifest is missing the agent-token restoration descriptor\")\n+        classification = agent.get(\"classification\")\n+        payload_path = agent.get(\"payload_path\")\n+        if classification == \"regular\":\n+            if payload_path != \"payload/server/agent-token\" or payload_path not in seen:\n+                raise Phase1Error(\"regular agent token is not paired with its payload file\")\n+        elif classification == \"symlink-to-server-token\":\n+            if agent.get(\"restore\") != \"symlink-to-token\" or agent.get(\"link_target\") != \"token\":\n+                raise Phase1Error(\"agent-token symlink restoration descriptor is unsafe\")\n+            if payload_path is not None or \"payload/server/agent-token\" in seen:\n+                raise Phase1Error(\"agent-token symlink must not be archived as a file or link\")\n+        elif classification == \"absent\":\n+            if payload_path is not None or \"payload/server/agent-token\" in seen:\n+                raise Phase1Error(\"absent agent token unexpectedly has payload material\")\n+        else:\n+            raise Phase1Error(\"backup manifest has an unsupported agent-token classification\")\n+    actual_files = {\n+        path.relative_to(extracted_root).as_posix()\n+        for path in extracted_root.rglob(\"*\")\n+        if path.is_file() and not path.is_symlink() and path != manifest_path\n+    }\n+    if actual_files != seen:\n+        missing = sorted(seen - actual_files)\n+        unexpected = sorted(actual_files - seen)\n+        raise Phase1Error(\n+            \"backup archive and manifest file sets differ: \"\n+            f\"missing={missing[:10]} unexpected={unexpected[:10]}\"\n+        )\n+    return manifest\n+\n+\n+def prune_backup_pairs(backup_dir: Path, *, prefix: str, retain: int) -> list[str]:\n+    if retain < 1:\n+        raise Phase1Error(\"backup retention count must be at least one\")\n+    backup_dir.mkdir(parents=True, exist_ok=True)\n+    candidates: list[Path] = []\n+    for path in backup_dir.glob(f\"{prefix}-*.tar.age\"):\n+        if not path.is_file():\n+            continue\n+        metadata = path.with_suffix(\"\").with_suffix(\".json\")\n+        try:\n+            document = read_json(metadata)\n+        except Phase1Error:\n+            # Unpaired or malformed archives are retained for operator review;\n+            # automated retention never destroys unverified evidence.\n+            continue\n+        if not isinstance(document, dict) or document.get(\"status\") != \"verified\":\n+            continue\n+        expected_sha = document.get(\"encrypted_sha256\")\n+        if not isinstance(expected_sha, str) or expected_sha != sha256_file(path):\n+            continue\n+        candidates.append(path)\n+    candidates = sorted(\n+        candidates,\n+        key=lambda path: (path.stat().st_mtime_ns, path.name),\n+        reverse=True,\n+    )\n+    removed: list[str] = []\n+    for archive in candidates[retain:]:\n+        metadata = archive.with_suffix(\"\").with_suffix(\".json\")\n+        archive.unlink(missing_ok=True)\n+        metadata.unlink(missing_ok=True)\n+        removed.append(archive.name)\n+    return removed\n+\n+\n+def mode_string(mode: int) -> str:\n+    return stat.filemode(mode)\n+\n+\n+def redact_mapping(value: Any, *, sensitive_keys: set[str] | None = None) -> Any:\n+    sensitive = sensitive_keys or {\n+        \"token\",\n+        \"server_token\",\n+        \"agent_token\",\n+        \"password\",\n+        \"secret\",\n+        \"credential\",\n+        \"private_key\",\n+        \"identity\",\n+    }\n+    if isinstance(value, dict):\n+        redacted: dict[str, Any] = {}\n+        for key, child in value.items():\n+            normalized = str(key).lower().replace(\"-\", \"_\")\n+            if any(term in normalized for term in sensitive):\n+                redacted[key] = \"<redacted>\"\n+            else:\n+                redacted[key] = redact_mapping(child, sensitive_keys=sensitive)\n+        return redacted\n+    if isinstance(value, list):\n+        return [redact_mapping(item, sensitive_keys=sensitive) for item in value]\n+    return value\ndiff --git a/tools/k3s/phase1-control-plane.py b/tools/k3s/phase1-control-plane.py\nnew file mode 100755\nindex 0000000..59aa941\n--- /dev/null\n+++ b/tools/k3s/phase1-control-plane.py\n@@ -0,0 +1,1455 @@\n+#!/usr/bin/env python3\n+\"\"\"Workstation orchestrator for Overdeck K3s Phase 1.\n+\n+The command converges a non-secret control-plane configuration contract, proves\n+K3s health, creates an encrypted off-host datastore+token backup, verifies that\n+backup offline, proves API/CA reachability from every registry-reachable node,\n+and installs a recurring workstation backup timer.\n+\"\"\"\n+from __future__ import annotations\n+\n+import argparse\n+import hashlib\n+import ipaddress\n+import json\n+import os\n+import pwd\n+import secrets\n+import shlex\n+import shutil\n+import signal\n+import subprocess\n+import sys\n+import tempfile\n+import threading\n+import time\n+from pathlib import Path\n+from typing import Any, Mapping, Sequence\n+from urllib.parse import urlparse\n+\n+SCRIPT = Path(__file__).resolve()\n+K3S_DIR = SCRIPT.parent\n+LIB_DIR = K3S_DIR / \"lib\"\n+sys.path.insert(0, str(LIB_DIR))\n+from phase1_common import (  # noqa: E402\n+    HostAccess,\n+    Phase1Error,\n+    absolute_path_without_symlink_resolution,\n+    atomic_write_json,\n+    build_ssh_command,\n+    eligible_reachable_hosts,\n+    exclusive_lock,\n+    load_host_registry,\n+    normalize_path,\n+    prune_backup_pairs,\n+    read_json,\n+    require_commands,\n+    resolve_host_access,\n+    run_command,\n+    safe_name,\n+    sha256_file,\n+    utc_now,\n+    utc_stamp,\n+)\n+\n+REMOTE_INSTALL_PATH = \"/usr/local/libexec/overdeck/k3s-phase1-server.py\"\n+REMOTE_HELPER = K3S_DIR / \"remote/phase1-server.py\"\n+VERIFY_BACKUP = K3S_DIR / \"verify-backup.py\"\n+SYSTEMD_SERVICE = K3S_DIR / \"systemd/overdeck-k3s-backup.service\"\n+SYSTEMD_TIMER = K3S_DIR / \"systemd/overdeck-k3s-backup.timer\"\n+\n+\n+class Logger:\n+    def __init__(self, log_path: Path) -> None:\n+        log_path.parent.mkdir(parents=True, exist_ok=True)\n+        self.log_path = log_path\n+\n+    def __call__(self, message: str) -> None:\n+        line = f\"[phase1 {utc_now()}] {message}\"\n+        print(line, file=sys.stderr, flush=True)\n+        with self.log_path.open(\"a\", encoding=\"utf-8\") as handle:\n+            handle.write(line + \"\\n\")\n+\n+\n+class Orchestrator:\n+    def __init__(self, args: argparse.Namespace) -> None:\n+        self.args = args\n+        self.repo_root = normalize_path(args.repo_root)\n+        self.registry_path = normalize_path(args.host_registry or self.repo_root / \"modules/workstation/claude/buildbox-hosts.json\")\n+        self.kubeconfig = normalize_path(args.kubeconfig)\n+        self.backup_dir = normalize_path(args.backup_dir)\n+        self.age_identity = absolute_path_without_symlink_resolution(args.age_identity)\n+        self.lock_file = normalize_path(\n+            args.lock_file\n+            or Path.home() / \".local/state/overdeck\" / f\"k3s-phase1-{safe_name(args.server)}.lock\"\n+        )\n+        self.receipt_dir = normalize_path(args.receipt_dir or self.default_receipt_dir())\n+        self.receipt_dir.mkdir(parents=True, exist_ok=False)\n+        os.chmod(self.receipt_dir, 0o700)\n+        (self.receipt_dir / \"logs\").mkdir(mode=0o700)\n+        self.log = Logger(self.receipt_dir / \"logs/phase1.log\")\n+        self.registry = load_host_registry(self.registry_path)\n+        self.server_access = resolve_host_access(\n+            self.registry,\n+            args.server,\n+            preferred_door=args.ssh_door,\n+            require_reachable=True,\n+        )\n+        self.ssh = build_ssh_command(self.server_access, timeout=args.ssh_timeout)\n+        self.remote_installed = False\n+        self.remote_helper_path = REMOTE_INSTALL_PATH\n+        self.remote_temp_path: str | None = None\n+        self.qualification: dict[str, Any] = {\n+            \"schema_version\": 2,\n+            \"status\": \"running\",\n+            \"started_utc\": utc_now(),\n+            \"checks\": [],\n+        }\n+        self.backup_artifacts: dict[str, dict[str, Any]] = {}\n+        self.current_backup_purpose: str | None = None\n+        self.converge_tx: str | None = None\n+        self.converge_changed = False\n+        self.backup_tx: str | None = None\n+        self.remote_archive: str | None = None\n+        self.local_backup: Path | None = None\n+        self.backup_verified = False\n+        self.timer_rollback: dict[str, Any] | None = None\n+        self.linger_enabled_by_phase = False\n+        self.plan_doc: dict[str, Any] | None = None\n+        self.result: dict[str, Any] = {\n+            \"schema_version\": 2,\n+            \"mode\": args.mode,\n+            \"status\": \"running\",\n+            \"started_utc\": utc_now(),\n+            \"server\": args.server,\n+            \"repo_root\": str(self.repo_root),\n+            \"kubeconfig\": str(self.kubeconfig),\n+            \"backup_dir\": str(self.backup_dir),\n+            \"receipt_dir\": str(self.receipt_dir),\n+            \"lock_file\": str(self.lock_file),\n+            \"steps\": [],\n+        }\n+\n+    def default_receipt_dir(self) -> Path:\n+        if self.args.mode == \"backup\":\n+            base = self.backup_dir / \"receipts\"\n+            prefix = \"scheduled\"\n+        else:\n+            base = Path.cwd()\n+            prefix = \"live\"\n+        return base / f\"overdeck-k3s-phase1-{prefix}-{utc_stamp()}-{os.getpid()}\"\n+\n+    def add_step(self, name: str, status: str, **detail: Any) -> None:\n+        item = {\"name\": name, \"status\": status, \"at_utc\": utc_now()}\n+        item.update(detail)\n+        self.result[\"steps\"].append(item)\n+        atomic_write_json(self.receipt_dir / \"phase1-result.json\", self.result)\n+\n+    def save_json(self, relative: str, value: Any) -> None:\n+        atomic_write_json(self.receipt_dir / relative, value)\n+\n+    def remote_call(self, *arguments: str, timeout: int = 300) -> dict[str, Any]:\n+        remote_command = shlex.join([\"sudo\", \"-n\", \"/usr/bin/python3\", self.remote_helper_path, *arguments])\n+        command = self.ssh + [remote_command]\n+        completed = run_command(command, check=False, timeout=timeout)\n+        stdout = completed.stdout if isinstance(completed.stdout, str) else completed.stdout.decode(errors=\"replace\")\n+        stderr = completed.stderr if isinstance(completed.stderr, str) else completed.stderr.decode(errors=\"replace\")\n+        try:\n+            payload = json.loads(stdout)\n+        except json.JSONDecodeError as exc:\n+            raise Phase1Error(\n+                f\"remote helper returned invalid JSON (exit {completed.returncode})\\n\"\n+                f\"stdout:\\n{stdout[-3000:]}\\nstderr:\\n{stderr[-3000:]}\"\n+            ) from exc\n+        if completed.returncode != 0 or payload.get(\"status\") == \"error\":\n+            command_name = safe_name(arguments[0]) if arguments else \"unknown\"\n+            # The root helper emits only explicitly sanitized diagnostics. Persist\n+            # that structured payload so a live preflight failure can be repaired\n+            # without asking the operator to run ad-hoc secret-bearing commands.\n+            self.save_json(f\"remote-{command_name}-error.json\", payload)\n+            raise Phase1Error(str(payload.get(\"error\") or stderr or f\"remote helper exited {completed.returncode}\"))\n+        return payload\n+\n+    def install_remote_helper(self) -> None:\n+        \"\"\"Upload a checksum-verified ephemeral helper for this invocation.\n+\n+        Qualification must not persist a privileged helper on the server.  The\n+        recurring workstation service repeats this same upload on every backup,\n+        so no successful Phase 1 path depends on stale server-side code.\n+        \"\"\"\n+        if not REMOTE_HELPER.is_file() or REMOTE_HELPER.is_symlink():\n+            raise Phase1Error(f\"remote helper is missing or unsafe: {REMOTE_HELPER}\")\n+        require_commands([\"ssh\", \"python3\"])\n+        remote_tmp = f\"/tmp/overdeck-k3s-phase1-{os.getpid()}-{secrets.token_hex(4)}.py\"\n+        upload = self.ssh + [\"umask 077; cat > \" + shlex.quote(remote_tmp)]\n+        run_command(upload, input_bytes=REMOTE_HELPER.read_bytes(), timeout=60)\n+        run_command(self.ssh + [shlex.join([\"chmod\", \"0700\", remote_tmp])], timeout=30)\n+        expected = sha256_file(REMOTE_HELPER)\n+        actual = run_command(\n+            self.ssh + [shlex.join([\"sha256sum\", remote_tmp])], timeout=30\n+        ).stdout.split()[0]\n+        if actual != expected:\n+            raise Phase1Error(f\"remote helper SHA-256 mismatch: expected {expected}, got {actual}\")\n+        self.remote_helper_path = remote_tmp\n+        self.remote_temp_path = remote_tmp\n+        self.remote_installed = True\n+        self.add_step(\n+            \"install-remote-helper\",\n+            \"passed\",\n+            sha256=expected,\n+            path=remote_tmp,\n+            persistence=\"ephemeral\",\n+        )\n+\n+    def cleanup_ephemeral_helper(self) -> None:\n+        if not self.remote_temp_path:\n+            return\n+        completed = run_command(\n+            self.ssh + [shlex.join([\"rm\", \"-f\", self.remote_temp_path])],\n+            check=False,\n+            timeout=30,\n+        )\n+        if completed.returncode != 0:\n+            self.result[\"ephemeral_helper_cleanup_error\"] = completed.stderr[-2000:]\n+        self.remote_temp_path = None\n+\n+    def endpoint_from_kubeconfig(self, fallback_ip: str) -> str:\n+        if not self.kubeconfig.is_file():\n+            raise Phase1Error(f\"K3s kubeconfig is missing: {self.kubeconfig}\")\n+        require_commands([\"kubectl\"])\n+        completed = run_command(\n+            [\"kubectl\", \"--kubeconfig\", str(self.kubeconfig), \"config\", \"view\", \"--minify\", \"-o\", \"json\"],\n+            timeout=30,\n+        )\n+        try:\n+            document = json.loads(completed.stdout)\n+            server = document[\"clusters\"][0][\"cluster\"][\"server\"]\n+        except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:\n+            raise Phase1Error(\"cannot determine API endpoint from kubeconfig\") from exc\n+        parsed = urlparse(str(server))\n+        if parsed.scheme != \"https\" or not parsed.hostname:\n+            raise Phase1Error(f\"unsupported Kubernetes API endpoint: {server!r}\")\n+        if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {\"\", \"/\"}:\n+            raise Phase1Error(\"Kubernetes API endpoint must not contain credentials, path, query, or fragment\")\n+        try:\n+            port = parsed.port or 443\n+        except ValueError as exc:\n+            raise Phase1Error(f\"Kubernetes API endpoint has an invalid port: {server!r}\") from exc\n+        if not (1 <= port <= 65535):\n+            raise Phase1Error(f\"Kubernetes API endpoint port is out of range: {port}\")\n+        host = parsed.hostname\n+        if host in {\"127.0.0.1\", \"localhost\", \"::1\"}:\n+            # Preserve the kubeconfig's explicit API port.  Only the loopback\n+            # host is remapped to the server's verified Tailscale identity.\n+            host = fallback_ip\n+        try:\n+            host = str(ipaddress.ip_address(host))\n+        except ValueError:\n+            labels = host.rstrip(\".\").split(\".\")\n+            if len(host) > 253 or any(\n+                not label\n+                or len(label) > 63\n+                or not label[0].isalnum()\n+                or not label[-1].isalnum()\n+                or any(not (char.isalnum() or char == \"-\") for char in label)\n+                for label in labels\n+            ):\n+                raise Phase1Error(f\"Kubernetes API endpoint hostname is invalid: {host!r}\")\n+            host = host.rstrip(\".\").lower()\n+        rendered_host = f\"[{host}]\" if \":\" in host else host\n+        return f\"https://{rendered_host}:{port}\"\n+\n+    @staticmethod\n+    def san_values(endpoint: str, inspect: Mapping[str, Any], access: HostAccess) -> list[str]:\n+        parsed = urlparse(endpoint)\n+        candidates = [\n+            parsed.hostname,\n+            inspect.get(\"tailscale\", {}).get(\"ipv4\"),\n+            inspect.get(\"tailscale\", {}).get(\"dns_name\"),\n+            inspect.get(\"hostname\"),\n+            access.host,\n+            access.magic_dns,\n+        ]\n+        values: list[str] = []\n+        seen: set[str] = set()\n+        for raw in candidates:\n+            if not isinstance(raw, str):\n+                continue\n+            value = raw.strip().rstrip(\".\")\n+            if not value or value in seen:\n+                continue\n+            seen.add(value)\n+            values.append(value)\n+        return values\n+\n+    def inspect_server(self) -> dict[str, Any]:\n+        inspect = self.remote_call(\"inspect\", timeout=150)\n+        self.save_json(\"server-inspect.json\", inspect)\n+        registry_ip = self.server_access.host if self.server_access.door == \"tailscale_ip\" else None\n+        observed_ip = inspect.get(\"tailscale\", {}).get(\"ipv4\")\n+        if registry_ip and observed_ip != registry_ip:\n+            raise Phase1Error(\n+                f\"server Tailscale identity drift: registry={registry_ip!r} observed={observed_ip!r}\"\n+            )\n+        if not isinstance(observed_ip, str) or not observed_ip:\n+            raise Phase1Error(\"server did not report exactly one Tailscale IPv4 address\")\n+        readiness = inspect.get(\"service\", {}).get(\"ready\") or {}\n+        if inspect.get(\"service\", {}).get(\"state\") != \"active\" or readiness.get(\"ready\") is not True:\n+            raise Phase1Error(\n+                \"server K3s readiness failed: \"\n+                f\"classification={readiness.get('classification')} detail={readiness.get('detail')}\"\n+            )\n+        node_probe = inspect.get(\"node_probe\") or {}\n+        if node_probe.get(\"ok\") is not True or int(node_probe.get(\"ready_count\") or 0) < 1:\n+            raise Phase1Error(\n+                \"server Node inventory qualification failed: \"\n+                f\"classification={node_probe.get('classification')} detail={nod",
          "actualWorkers": 1
        },
        "id": "chromium",
        "name": "chromium",
        "testDir": "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/apps/web/tests",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "list"
      ],
      [
        "json"
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.61.1",
    "workers": 1,
    "webServer": {
      "command": "pnpm --filter @overdeck/web exec astro dev --force",
      "url": "http://localhost:4321",
      "reuseExistingServer": false,
      "timeout": 120000,
      "env": {
        "COLLECTOR_URL": "http://127.0.0.1:14980",
        "COLLECTOR_TOKEN": "overdeck-dev"
      }
    }
  },
  "suites": [
    {
      "title": "actions.spec.ts",
      "file": "actions.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "allowlisted action POST without Origin header returns 403 (CSRF default-deny)",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 45,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:11.065Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "54a77c18d7b95f68bfce-f7578a1ca979bbba6576",
          "file": "actions.spec.ts",
          "line": 17,
          "column": 1
        },
        {
          "title": "non-allowlisted action verb returns 404 through the proxy",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 14,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:11.169Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "54a77c18d7b95f68bfce-d0d4137eee3efb01dc7e",
          "file": "actions.spec.ts",
          "line": 24,
          "column": 1
        },
        {
          "title": "reap with pid absent from orphan candidates returns 400 through the proxy",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 21,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:11.195Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "54a77c18d7b95f68bfce-7cae6dd33a1bd0b27b77",
          "file": "actions.spec.ts",
          "line": 32,
          "column": 1
        },
        {
          "title": "reap confirm dialog posts to the mock executor and shows a success toast",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1563,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:11.227Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "54a77c18d7b95f68bfce-f2c70bc79bd1fc9831c4",
          "file": "actions.spec.ts",
          "line": 40,
          "column": 1
        }
      ]
    },
    {
      "title": "bots-live.spec.ts",
      "file": "bots-live.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [],
      "suites": [
        {
          "title": "bots page (live stack)",
          "file": "bots-live.spec.ts",
          "line": 11,
          "column": 6,
          "specs": [
            {
              "title": "shows named fleet with observability columns, not opaque bot ids",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [
                    {
                      "type": "skip",
                      "description": "requires an explicit live deployment URL",
                      "location": {
                        "file": "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/apps/web/tests/bots-live.spec.ts",
                        "line": 12,
                        "column": 8
                      }
                    }
                  ],
                  "expectedStatus": "skipped",
                  "projectId": "chromium",
                  "projectName": "chromium",
                  "results": [
                    {
                      "workerIndex": -1,
                      "parallelIndex": -1,
                      "status": "skipped",
                      "duration": 0,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-08-11T11:51:13.051Z",
                      "annotations": [
                        {
                          "type": "skip",
                          "description": "requires an explicit live deployment URL",
                          "location": {
                            "file": "/home/user/actions-runner-overdeck-debian1/_work/overdeck/overdeck/apps/web/tests/bots-live.spec.ts",
                            "line": 12,
                            "column": 8
                          }
                        }
                      ],
                      "attachments": []
                    }
                  ],
                  "status": "skipped"
                }
              ],
              "id": "eabf515c8eb481fe1719-ffe26e9f33f6af903f22",
              "file": "bots-live.spec.ts",
              "line": 15,
              "column": 3
            }
          ]
        }
      ]
    },
    {
      "title": "bots.spec.ts",
      "file": "bots.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "/bots renders fleet summary and full observability table",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 426,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:13.056Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "186337d26b5ef0ab544c-4957a0cc27275b2ca9ee",
          "file": "bots.spec.ts",
          "line": 74,
          "column": 1
        },
        {
          "title": "overview Bots card shows bot names, not opaque ids",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 352,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:13.502Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "186337d26b5ef0ab544c-525172f2504180c59265",
          "file": "bots.spec.ts",
          "line": 94,
          "column": 1
        }
      ]
    },
    {
      "title": "ci-build.spec.ts",
      "file": "ci-build.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "renders CI & Build in dark theme with incident fixture fleet and panels",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 475,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:13.884Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-05b1da52001b832f65fd",
          "file": "ci-build.spec.ts",
          "line": 47,
          "column": 1
        },
        {
          "title": "renders correctly in light theme",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 400,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:14.381Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-9471fd008bb3f0511deb",
          "file": "ci-build.spec.ts",
          "line": 68,
          "column": 1
        },
        {
          "title": "clicking a machine card opens detail modal with telemetry sections",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 429,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:14.790Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-59e28664ae9fa4bdcbe4",
          "file": "ci-build.spec.ts",
          "line": 80,
          "column": 1
        },
        {
          "title": "every action button carries a valid X1 offload verb",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 378,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:15.227Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-99587ada7fc68c6705c4",
          "file": "ci-build.spec.ts",
          "line": 92,
          "column": 1
        },
        {
          "title": "normal-temp fixture does not show critical temperature banner in modal",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4367,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:15.616Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-c44a6a2b05b6f903bd06",
          "file": "ci-build.spec.ts",
          "line": 105,
          "column": 1
        },
        {
          "title": "crit-temp fixture shows critical temperature banner in modal",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4362,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:19.992Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-4b8f9622b748eaf96df1",
          "file": "ci-build.spec.ts",
          "line": 125,
          "column": 1
        },
        {
          "title": "CI-only fixture renders CI content and labels every unavailable offload KPI",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 375,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:24.363Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-a1ac0a60ed7c6be6c5bc",
          "file": "ci-build.spec.ts",
          "line": 145,
          "column": 1
        },
        {
          "title": "Logs opens read-only viewer; Drain confirm appears above machine modal",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 589,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:24.747Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6b6c088eeed60781ac83-565900b40d2975dee131",
          "file": "ci-build.spec.ts",
          "line": 169,
          "column": 1
        }
      ]
    },
    {
      "title": "ci-trains.spec.ts",
      "file": "ci-trains.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "renders all-scope trains, cross-repo PRs, runners, and amplification with honest gaps",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 400,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:25.359Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-041493fe6c03377fb24a",
          "file": "ci-trains.spec.ts",
          "line": 51,
          "column": 1
        },
        {
          "title": "repo scope filters every train dashboard model and hides the Repo column",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 407,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:25.776Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-dab65afe062d947ba95e",
          "file": "ci-trains.spec.ts",
          "line": 87,
          "column": 1
        },
        {
          "title": "train mutations post exact verbs and string args; success waits for HTTP and ToastProvider stays valid",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 839,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:26.192Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-f84bcafde2f05716e871",
          "file": "ci-trains.spec.ts",
          "line": 102,
          "column": 1
        },
        {
          "title": "Open performs validated client navigation with no action POST",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 408,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:27.039Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-2a02522744b5277ad339",
          "file": "ci-trains.spec.ts",
          "line": 132,
          "column": 1
        },
        {
          "title": "stale train mutation surfaces exact parsed server error without optimistic success",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4427,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:27.456Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-7038047c5dac5fca989a",
          "file": "ci-trains.spec.ts",
          "line": 144,
          "column": 1
        },
        {
          "title": "cached trains preserve each independent completeness gap and expose no actions",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4984,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:31.892Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-e1a8a1cbbdc7a425b90e",
          "file": "ci-trains.spec.ts",
          "line": 162,
          "column": 1
        },
        {
          "title": "simultaneous incompleteness reaches every named component without false totals",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4465,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:36.885Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-68799ccb7f1b2b31d711",
          "file": "ci-trains.spec.ts",
          "line": 181,
          "column": 1
        },
        {
          "title": "missing selected repo after SSE refresh resets scope to All",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4420,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:41.359Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-640f1a52738ad2763b63",
          "file": "ci-trains.spec.ts",
          "line": 199,
          "column": 1
        },
        {
          "title": "renders train dashboard in light theme",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 4376,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:45.787Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ff06dbb8977fa82b5f56-d9c5f26b3e833f33e79f",
          "file": "ci-trains.spec.ts",
          "line": 215,
          "column": 1
        }
      ]
    },
    {
      "title": "decisions.spec.ts",
      "file": "decisions.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "expands and collapses a decision row in place",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 487,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:50.185Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ca2913af3b2fa1993ca8-b49c1cec5bd475c3189f",
          "file": "decisions.spec.ts",
          "line": 112,
          "column": 1
        },
        {
          "title": "option answer posts the exact payload to the collector mock",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 386,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:50.688Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ca2913af3b2fa1993ca8-e77cd01d8ba031b0b0f9",
          "file": "decisions.spec.ts",
          "line": 129,
          "column": 1
        },
        {
          "title": "free-text answer posts the typed choice payload",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 397,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:51.084Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ca2913af3b2fa1993ca8-c9606ade536c9a64c341",
          "file": "decisions.spec.ts",
          "line": 144,
          "column": 1
        },
        {
          "title": "failure path shows an error and preserves the typed answer",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 422,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:51.489Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "ca2913af3b2fa1993ca8-e0e813c35a7327a57bbc",
          "file": "decisions.spec.ts",
          "line": 159,
          "column": 1
        }
      ]
    },
    {
      "title": "hooks-controls.spec.ts",
      "file": "hooks-controls.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "hooks control reflects fixture policy and round-trips pessimistically",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 448,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:51.935Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3096f0d0016190f6588e-c6f1de063dabc958dd2e",
          "file": "hooks-controls.spec.ts",
          "line": 9,
          "column": 1
        },
        {
          "title": "hooks control stays checked while a delayed mutation is pending",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 775,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:52.400Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3096f0d0016190f6588e-80f34db6e74800399f5c",
          "file": "hooks-controls.spec.ts",
          "line": 17,
          "column": 1
        },
        {
          "title": "hooks control retains confirmed state and announces a failed mutation",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 414,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:53.184Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3096f0d0016190f6588e-4e5660e2666f1d3fe39a",
          "file": "hooks-controls.spec.ts",
          "line": 28,
          "column": 1
        },
        {
          "title": "hooks control surfaces issue repair and clears it after repair",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 416,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:53.609Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3096f0d0016190f6588e-1ef6fc13f1db76d509db",
          "file": "hooks-controls.spec.ts",
          "line": 38,
          "column": 1
        },
        {
          "title": "hooks control hides repair for nonrepairable issues",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 339,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:54.035Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3096f0d0016190f6588e-1204f98dabd4ff8f02f4",
          "file": "hooks-controls.spec.ts",
          "line": 48,
          "column": 1
        }
      ]
    },
    {
      "title": "inbox.spec.ts",
      "file": "inbox.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "renders the full triage list sorted by severity then recency",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 380,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:54.401Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-1f37f1d8f3e17dc7b3e5",
          "file": "inbox.spec.ts",
          "line": 32,
          "column": 1
        },
        {
          "title": "filter rail shows kind counts and filters the list",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 450,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:54.798Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-b71d2d6ecd804c2f1a53",
          "file": "inbox.spec.ts",
          "line": 56,
          "column": 1
        },
        {
          "title": "snooze persists before expiry without deleting collector item",
          "ok": true,
          "tags": [
            "UJ-004",
            "H1",
            "forbidden-side-effect",
            "browser-sensitive"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 465,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:55.257Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-82a3e29ccafcfc7568ae",
          "file": "inbox.spec.ts",
          "line": 78,
          "column": 1
        },
        {
          "title": "expired snooze restores item and removes durable entry",
          "ok": true,
          "tags": [
            "UJ-004",
            "A1",
            "expiry-boundary",
            "browser-sensitive"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 561,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:55.732Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-8a494be8d59ad0086910",
          "file": "inbox.spec.ts",
          "line": 114,
          "column": 1
        },
        {
          "title": "project color tags honor settings overrides and auto-assigned wheel colors",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 361,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:56.303Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-2c4688f8b0c3dac836c9",
          "file": "inbox.spec.ts",
          "line": 157,
          "column": 1
        },
        {
          "title": "open and train actions are enabled; unknown actions stay disabled",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 358,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:56.673Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-bd5fc21e9aa461fd7869",
          "file": "inbox.spec.ts",
          "line": 176,
          "column": 1
        },
        {
          "title": "valid Open navigates client-side and never posts an open action",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 401,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:57.042Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-dc5d1e29e570f7073a02",
          "file": "inbox.spec.ts",
          "line": 197,
          "column": 1
        },
        {
          "title": "invalid Open stays on-page, shows danger feedback, and sends no POST",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 433,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:57.452Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-bd58bb6d2746a79eb74b",
          "file": "inbox.spec.ts",
          "line": 208,
          "column": 1
        },
        {
          "title": "train rerun posts exact string args and shows success only after HTTP success",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 619,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:57.894Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-84c1f681b4da4082c82b",
          "file": "inbox.spec.ts",
          "line": 221,
          "column": 1
        },
        {
          "title": "train action rejection shows parsed server error and no success toast",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 434,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:58.524Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "c9086cbe786fc8171a5a-fdbb190ee19ef35872fa",
          "file": "inbox.spec.ts",
          "line": 237,
          "column": 1
        }
      ]
    },
    {
      "title": "map.spec.ts",
      "file": "map.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "renders fixture map nodes and inspector panel",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 393,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:58.985Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "2d8799038d2916f69765-8f7732f7808e9d2e08b3",
          "file": "map.spec.ts",
          "line": 26,
          "column": 1
        },
        {
          "title": "selecting a node updates the inspector",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 385,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:59.398Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "2d8799038d2916f69765-2c01a76e2c9781f728c4",
          "file": "map.spec.ts",
          "line": 42,
          "column": 1
        },
        {
          "title": "aggregates queue only when every repo queue is complete and non-null",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 819,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:51:59.792Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "2d8799038d2916f69765-d6bde9e9d18e39016d78",
          "file": "map.spec.ts",
          "line": 48,
          "column": 1
        }
      ]
    },
    {
      "title": "overview.spec.ts",
      "file": "overview.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "renders every mockup-v2 Overview widget group with fixture values, no console errors",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 441,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:00.634Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6e2f4ad74dab265ac116-508195b1e3b46db73a31",
          "file": "overview.spec.ts",
          "line": 29,
          "column": 1
        },
        {
          "title": "renders correctly in light theme with no console errors",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 394,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:01.092Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6e2f4ad74dab265ac116-cae4e486d3fe3a58d437",
          "file": "overview.spec.ts",
          "line": 82,
          "column": 1
        },
        {
          "title": "an item-resolved SSE delta removes the item from Needs you now",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 352,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:01.495Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6e2f4ad74dab265ac116-ad2fa7e01549230a4b46",
          "file": "overview.spec.ts",
          "line": 102,
          "column": 1
        },
        {
          "title": "renders queue completeness independently from run history",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 802,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:01.855Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6e2f4ad74dab265ac116-177360bff7a725cdebd0",
          "file": "overview.spec.ts",
          "line": 112,
          "column": 1
        },
        {
          "title": "keeps overview content inside mobile, tablet, laptop, and desktop viewports",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1033,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:02.669Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "6e2f4ad74dab265ac116-44bd1b437bbd42f4a27c",
          "file": "overview.spec.ts",
          "line": 144,
          "column": 1
        }
      ]
    },
    {
      "title": "pages.spec.ts",
      "file": "pages.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "/plans renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 384,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:03.722Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-7c368165a48dc02672a3",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/factory renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 591,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:04.123Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-3ca8a33b72fe9cdb3f92",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/agents renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 375,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:04.722Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-71104bdc4e5f03b7247d",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/ci renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 347,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:05.106Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-b5865500b2ddec04d358",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/bots renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 344,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:05.461Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-3fbd11cc44d363f10d22",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/limits renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 379,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:05.813Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-c83f12efaf6b115031a3",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/gates renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 390,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:06.203Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-028a69c209ca2edab625",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/projects renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 379,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:06.601Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-e47407779793a9f24033",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "/settings renders non-empty fixture content",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 408,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:06.989Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-b29a7469bf3dbbfa450c",
          "file": "pages.spec.ts",
          "line": 48,
          "column": 3
        },
        {
          "title": "plan rows link to the componentized run detail page",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 764,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:07.407Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-cef620f7347de0a07f32",
          "file": "pages.spec.ts",
          "line": 79,
          "column": 1
        },
        {
          "title": "clicking a plan row opens the run detail page",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 692,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:08.180Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-b8ffe7d6ef71a3fbae86",
          "file": "pages.spec.ts",
          "line": 89,
          "column": 1
        },
        {
          "title": "settings color override round-trips through POST /config/projects",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 581,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:08.882Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9bb6bb2ed574bebb3c58-c7d11e6a5e292c6f3371",
          "file": "pages.spec.ts",
          "line": 99,
          "column": 1
        }
      ]
    },
    {
      "title": "plans-observability.spec.ts",
      "file": "plans-observability.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "shows one row per plan with its attempt count, not one row per checkout",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 395,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:09.485Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "a01358ea540bd4185999-5b0774398718165edf04",
          "file": "plans-observability.spec.ts",
          "line": 59,
          "column": 1
        },
        {
          "title": "marks the deck stale when its payload stops advancing",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 342,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:09.896Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "a01358ea540bd4185999-d0adf42e4337546fc9f6",
          "file": "plans-observability.spec.ts",
          "line": 71,
          "column": 1
        }
      ]
    },
    {
      "title": "sessions.spec.ts",
      "file": "sessions.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "lists every project, with the cli, launcher and state of each session",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 602,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:10.260Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "316fd374235e8aec2ba4-6038dba4271d90bf5e2c",
          "file": "sessions.spec.ts",
          "line": 25,
          "column": 1
        },
        {
          "title": "attaches to a tmux-hosted session in the browser and sends a keystroke",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 508,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:10.886Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "316fd374235e8aec2ba4-fc5328026844a29eab85",
          "file": "sessions.spec.ts",
          "line": 43,
          "column": 1
        },
        {
          "title": "keeps an attached terminal open and polling while navigating, and lets the reader move it",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1498,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:11.404Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "316fd374235e8aec2ba4-562c6d2907f8d3f8d73b",
          "file": "sessions.spec.ts",
          "line": 57,
          "column": 1
        },
        {
          "title": "offers an account-aware resume command for a finished claude session",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 536,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:12.910Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "316fd374235e8aec2ba4-6e90ff2dce93b7139e66",
          "file": "sessions.spec.ts",
          "line": 90,
          "column": 1
        },
        {
          "title": "states why an unreachable session cannot be reconnected instead of showing a dead button",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 393,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:13.455Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "316fd374235e8aec2ba4-23ae0543b1100c216b88",
          "file": "sessions.spec.ts",
          "line": 100,
          "column": 1
        },
        {
          "title": "renders the TerminalView gallery entry in the design system",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 627,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:13.856Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "316fd374235e8aec2ba4-915e68658d0db3fe7183",
          "file": "sessions.spec.ts",
          "line": 109,
          "column": 1
        }
      ]
    },
    {
      "title": "shell.spec.ts",
      "file": "shell.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "renders the dashboard shell with the exact sidebar structure",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 380,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:17.863Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3420664513cb0bae123e-9cb9ffbd081a13e1cbc3",
          "file": "shell.spec.ts",
          "line": 33,
          "column": 1
        },
        {
          "title": "defaults to dark theme with no console errors",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 137,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:18.259Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3420664513cb0bae123e-8e8398e7ae0fc7de4102",
          "file": "shell.spec.ts",
          "line": 45,
          "column": 1
        },
        {
          "title": "theme preference persists across a fresh shell load",
          "ok": true,
          "tags": [
            "UJ-003",
            "H1",
            "persistence",
            "browser-sensitive"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 802,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:18.405Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3420664513cb0bae123e-4e8afed991c085ca416b",
          "file": "shell.spec.ts",
          "line": 57,
          "column": 1
        },
        {
          "title": "invalid stored theme falls back to dark without overwriting storage",
          "ok": true,
          "tags": [
            "UJ-003",
            "A1",
            "invalid-preference",
            "browser-sensitive"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 260,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:19.216Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "3420664513cb0bae123e-70050dc68927d340f491",
          "file": "shell.spec.ts",
          "line": 98,
          "column": 1
        }
      ]
    },
    {
      "title": "ui-matrix.spec.ts",
      "file": "ui-matrix.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "/ renders overview needs-you-now card without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 341,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:19.509Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-da8c1805de70d796255d",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/inbox renders inbox heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 348,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:19.865Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-d1caf28aeba8fbfebf76",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/plans renders plans heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 346,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:20.221Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-0d61c1d3f5daa5da341c",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/agents renders agents heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 349,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:20.577Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-2017a80cc5a3af28d93a",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/ci renders CI runners panel without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 349,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:20.936Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-656915ce5759323fc08d",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/bots renders bots heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 337,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:21.294Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-026fcc13b42c7191d7df",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/limits renders limits heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 347,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:21.640Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-2ddd95e0492ab1c2369d",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/gates renders gates heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 344,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:21.997Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-e87d1c86c01c6da16f60",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/projects renders projects repository without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 362,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:22.351Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-59515c3479d77fb7bad8",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/projects/multideal renders project detail criterion without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 393,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:22.722Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-8307015f229d5ea8c462",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/settings renders settings heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 339,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:23.125Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-1b18ed2ccfb449c3d324",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/decisions renders decisions heading without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 339,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:23.472Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-22426444831f20cce4a9",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/map renders map canvas without runtime failures",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 343,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:23.819Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-0d2de1147c72adf0ca17",
          "file": "ui-matrix.spec.ts",
          "line": 60,
          "column": 3
        },
        {
          "title": "/settings project color input is editable",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 337,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:24.172Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-afa11ce8d923b908b2aa",
          "file": "ui-matrix.spec.ts",
          "line": 79,
          "column": 3
        },
        {
          "title": "/settings save project colors is enabled",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 341,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:24.517Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-4a26d3aff804d61b7cf0",
          "file": "ui-matrix.spec.ts",
          "line": 79,
          "column": 3
        },
        {
          "title": "/inbox non-gateway action is enabled",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 345,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:24.867Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-b9de069c7c09b2ae1251",
          "file": "ui-matrix.spec.ts",
          "line": 79,
          "column": 3
        },
        {
          "title": "/inbox reap action is enabled",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 348,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:25.220Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-1854425aec2cf5913415",
          "file": "ui-matrix.spec.ts",
          "line": 79,
          "column": 3
        },
        {
          "title": "/inbox destructive confirmation controls are enabled before submission",
          "ok": true,
          "tags": [],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium",
              "projectName": "chromium",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 394,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-08-11T11:52:25.577Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "9509544c671cbfb44ae5-10f396436fb1520b7f62",
          "file": "ui-matrix.spec.ts",
          "line": 85,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-08-11T11:51:02.646Z",
    "duration": 83444.142,
    "expected": 92,
    "skipped": 1,
    "unexpected": 0,
    "flaky": 0
  }
}