Files
archy/.planning/phases/09-botfights-platform-upgrade/09-06-PLAN.md
T

22 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
09-botfights-platform-upgrade 06 execute 3
09-05
apps/botfights/manifest.yml
app-catalog/catalog.json
scripts/image-versions.sh
releases/app-catalog.json
false
BOT-04
truths artifacts key_links
A fresh BotFights install on any node starts successfully because the manifest declares JWT_SECRET as a generated secret — without it the 1.2.0 image throws at import and crash-loops (D-04/BOT-04)
Each node gets its own JWT signing secret, materialised 0600 by the orchestrator, never hardcoded and never shared between nodes
Node instances point at the shared public arena by default via ARENA_UPSTREAM_URL in the manifest, with a documented way for an operator to unset it and run standalone (D-03/BOT-03)
Both catalog files and the image-version map name the same 1.2.0 image, so the drift checker is clean
The regenerated releases/app-catalog.json embeds the new manifest and is signed by the release-root key before it is published
path provides contains
apps/botfights/manifest.yml BotFights 1.2.0 manifest with generated JWT secret, secret_env injection and default-on arena federation botfights-jwt-secret
path provides contains
releases/app-catalog.json Regenerated, release-root-signed catalog carrying the embedded 1.2.0 manifest botfights
from to via pattern
apps/botfights/manifest.yml the orchestrator's secrets provider generated_secrets (kind hex32) materialises /var/lib/archipelago/secrets/botfights-jwt-secret and secret_env injects it as JWT_SECRET secret_env
from to via pattern
releases/app-catalog.json every node's container::app_catalog fetch the catalog manifest overlay wins over the on-disk manifest, so this file is what actually takes effect on nodes manifest
Deliver D-04/BOT-04 in the archy repo: bump the BotFights app manifest to 1.2.0, give it the generated `JWT_SECRET` it now cannot start without, turn on shared-arena federation by default, regenerate the signed catalog, have the release key sign it, and publish.

Decision IDs map to 09-CONTEXT.md Locked Decisions: D-01 = BOT-01 signer login, D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public arena (its default-on clause is implemented here), D-04 = BOT-04 registry/manifest + signed catalog updated and republished.

Purpose: the catalog manifest overlay is authoritative on nodes — editing a manifest on disk does nothing for a catalog-covered app. So this plan is the only way the fleet ever sees 1.2.0. It also carries a blocking correctness fix: server/src/middleware/jwt.ts throws at module import when JWT_SECRET is unset and NODE_ENV=production, and today's manifest sets NODE_ENV=production with no JWT_SECRET — publishing 1.2.0 without the generated secret would crash-loop every fresh install.

Repo: /home/archipelago/Projects/archy. Commit target: git push gitea-ai main (main is protected; the ai account is the push path), plus the vps2 mirror that serves the catalog.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md @.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md @.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md @.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md @.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md @apps/botfights/manifest.yml @apps/netbird-server/manifest.yml Task 1: Manifest 1.2.0 — generated JWT secret, default-on arena, both catalogs in lockstep Publishing this manifest switches every node's BotFights to the shared arena by default, and node-local fighter data stops being read (it is preserved on disk, never deleted). Mechanically revertible by republishing a catalog without `ARENA_UPSTREAM_URL`, but any bots users register on the public arena in the meantime stay there. The blocking human gate in Task 2 is where that trade is accepted or refused. apps/botfights/manifest.yml, app-catalog/catalog.json, scripts/image-versions.sh, releases/app-catalog.json - `apps/botfights/manifest.yml` — all 76 lines. Only `app.version`, `container.image`, `container.generated_secrets`, `container.secret_env` and `environment` change; the security, ports, volumes, health_check, interfaces and metadata blocks stay exactly as they are. - `apps/netbird-server/manifest.yml` lines 17-32 — the established `generated_secrets` shape (`- name: ` / `kind: `), with its comment explaining why netbird needs `base64`. - `apps/fedimint-clientd/manifest.yml` lines 24-27 and `apps/barkd/manifest.yml` lines 24-26 — the `secret_env` shape (`- key: ` / `secret_file: `). - `core/container/src/manifest.rs` lines 358-400 — `SecretGenKind` (`hex16`, `hex32`, `base64`, `bcrypt`) and `GeneratedSecret::target_files`. `hex32` is 32 random bytes as 64 lowercase hex chars, which is exactly what the app's own docs tell an operator to generate. - `app-catalog/catalog.json` lines 109-133 — the legacy hand-maintained `botfights` entry (`version`, `dockerImage`, `containerConfig.env`). This file is separate from `releases/app-catalog.json` and is what `scripts/check-app-catalog-drift.py` compares against. - `scripts/image-versions.sh` line 93 — `BOTFIGHTS_IMAGE`. - `scripts/generate-app-catalog.sh` — the `EMBED_MANIFESTS` loop that reads every `apps/*/manifest.yml` and embeds the whole document under each entry's `manifest` key; `releases/app-catalog.json` is a build output and is never hand-edited. - `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` — the verified public arena URL and the 1.2.0 image digest. Edit `apps/botfights/manifest.yml` (the current values are in the file — read it first): - `app.version`: set to `1.2.0`. - `container.image`: set the tag to `1.2.0`, same registry path. No occurrence of the previous tag may survive anywhere in this file. - Add under `container`, following the netbird/fedimint-clientd shapes: `generated_secrets` with one entry named `botfights-jwt-secret` of kind `hex32`, and `secret_env` with one entry mapping key `JWT_SECRET` to secret_file `botfights-jwt-secret`. Use `hex32`, not `base64` — the app uses the value directly as an HMAC key with no decode step, so copying netbird's kind verbatim would be wrong. Add a comment naming the concrete failure this prevents: the auth module throws at import when this variable is absent under `NODE_ENV=production`, so a fresh install without it never starts. - Extend `environment` (keeping `NODE_ENV=production`) with: `PORT=9100`, `ARENA_UPSTREAM_URL=`, and a comment recording that an operator may remove `ARENA_UPSTREAM_URL` to run a standalone, node-local arena. Do not add any wallet/payments variable and do not add `FIGHT_LOOP_ENABLED` — in proxy mode a node-local fight loop would write to a database nothing reads. - Leave every other block byte-identical.
Edit `app-catalog/catalog.json`'s `botfights` entry: `version` → `1.2.0`, `dockerImage` tag →
`1.2.0`. Leave its `containerConfig` block otherwise as-is — this legacy file is not what nodes
install from, but drift here trips the checker.

Edit `scripts/image-versions.sh`: `BOTFIGHTS_IMAGE` tag → `1.2.0`.

Regenerate the signed catalog input: `scripts/generate-app-catalog.sh` (EMBED_MANIFESTS defaults
on). Then confirm by reading `releases/app-catalog.json` that the `botfights` entry's `version`
is `1.2.0` and its embedded `manifest` carries the image tag, the generated secret, the
secret_env mapping and the arena URL.

Build the signer binary now so the human ceremony in Task 2 is not blocked waiting on a compile:
`scripts/sign-catalog.sh` refuses to compile anything itself and needs
`/tmp/archy-sign-bin/release/archipelago` to exist. Build the release binary and place it there
(background the build; if it hits `rust-lld: undefined hidden symbol`, that is incremental-cache
corruption — rebuild with `CARGO_INCREMENTAL=0`).

Finally, prepare the impact briefing the human gate needs, and put it in the SUMMARY: the number
of bots and fights currently held in each reachable node's local BotFights database (on
archi-dev-box, read `/var/lib/archipelago/botfights/botfights.db` with a read-only sqlite
connection — planning measured 115 bots and 102,440 fights), what happens to them under proxy
mode (preserved on disk, no longer displayed), and how an operator reverts a single node.
cd /home/archipelago/Projects/archy && python3 scripts/check-app-catalog-drift.py && cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests - `python3 scripts/check-app-catalog-drift.py` reports no drift for `botfights`. - `cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests` exits 0 (the new manifest deserializes and validates as an overlay). - `cd core && cargo test -p container manifest` exits 0. - `grep -q 'botfights-jwt-secret' apps/botfights/manifest.yml` and `grep -q 'kind: hex32' apps/botfights/manifest.yml` both succeed. - `grep -q 'JWT_SECRET' apps/botfights/manifest.yml` succeeds and no line in that file assigns it a literal value (`grep -Eq 'JWT_SECRET=' apps/botfights/manifest.yml` finds nothing). - `grep -c '1.2.0' apps/botfights/manifest.yml` is at least 2; `grep -c '1.1.0' apps/botfights/manifest.yml` is 0. - `grep -q 'ARENA_UPSTREAM_URL' apps/botfights/manifest.yml` succeeds and the value matches the arena URL recorded in `09-05-SUMMARY.md`. - `python3 -c "import json;d=json.load(open('releases/app-catalog.json'));b=d['apps']['botfights'];m=b['manifest']['app'];assert b['version']=='1.2.0';assert m['container']['image'].endswith(':1.2.0');assert any(s['name']=='botfights-jwt-secret' for s in m['container']['generated_secrets']);assert any(e['key']=='JWT_SECRET' for e in m['container']['secret_env']);assert any('ARENA_UPSTREAM_URL' in e for e in m['environment']);print('catalog ok')"` prints `catalog ok`. - `test -x /tmp/archy-sign-bin/release/archipelago` succeeds. - `git -C /home/archipelago/Projects/archy status --short` shows only the four intended files changed (another agent shares this tree — stage by explicit path). The manifest, both catalog files and the image map all describe BotFights 1.2.0 with a per-install JWT secret and default-on arena federation, and the regenerated catalog proves it. Task 2: Sign the regenerated catalog with the release master mnemonic (fleet-impact gate) `releases/app-catalog.json` has been regenerated with BotFights 1.2.0 embedded: a per-install generated `JWT_SECRET`, and `ARENA_UPSTREAM_URL` pointing at the public arena. The signer binary is built and waiting. Nothing has been pushed yet.
**What signing and publishing this changes, fleet-wide:**
- Every node that refreshes its catalog sees BotFights 1.2.0 and can update to it.
- Updated nodes become thin clients of the shared public arena: all nodes see all fighters, and
  fights cross nodes. That is the point of this phase (D-03).
- Each node's own BotFights database stops being read. Nothing is deleted — the file stays at
  `/var/lib/archipelago/botfights/` — but locally-registered fighters no longer appear in the
  UI. On archi-dev-box that is 115 bots and 102,440 fights (see the SUMMARY for the current
  count on every reachable node, and whether they were seeded into the arena in plan 09-04).
- Each node generates its own JWT secret on next install/update, so existing browser sessions on
  that node are signed out once.
- Reverting means regenerating and re-signing a catalog without `ARENA_UPSTREAM_URL`; bots that
  users register on the public arena in the meantime stay on the public arena.

A single node can opt out at any time by removing `ARENA_UPSTREAM_URL` from its container env.
Human-only: run the signing ceremony and enter the 24-word release master mnemonic at your own terminal. The mnemonic never passes through Claude, is never stored, and is never scripted around. This is also the approval gate for switching the fleet's BotFights to the shared arena. If you are happy to publish, run the ceremony:
```
bash /home/archipelago/Projects/archy/scripts/sign-catalog.sh
```

Paste your 24-word release master mnemonic, press Enter, then Ctrl-D. The script signs
`releases/app-catalog.json` in place and checks the signature was made by the expected
release-root key. Your mnemonic is read from the terminal only — never stored, never passed to
Claude. The script prints either a success line or a clear failure; if it fails, do not commit —
say so and Claude will investigate.

If you would rather not switch the fleet to the shared arena yet, say "hold federation" and
Claude will regenerate the catalog with `ARENA_UPSTREAM_URL` omitted (nodes stay standalone,
opt-in per node) before you sign anything.
The script prints ` SUCCESS — catalog signed by the correct release-root key` on success. Claude then re-checks independently in Task 3: `releases/app-catalog.json` carries a non-empty `signature` and `signed_by` equal to `did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur`, and the `botfights` entry is still 1.2.0 with its embedded manifest intact. Reply "signed" once the script reports success, or "hold federation", or paste the failure output Task 3: Verify the signature, commit both repos' work and publish the catalog The signing ceremony in Task 2 reported success and `releases/app-catalog.json` now carries a `signature` and the expected `signed_by` DID. releases/app-catalog.json, apps/botfights/manifest.yml, app-catalog/catalog.json, scripts/image-versions.sh - `scripts/sign-catalog.sh` lines 30-40 — the exact success condition it checks (`"signed_by": "did:key:z6Mkkid…"` present alongside a `signature` key); re-assert it independently rather than trusting the reply. - `core/archipelago/src/container/app_catalog.rs` lines 403-445 — how a node verifies the fetched catalog: an absent signature is accepted during the migration window, but a present signature that fails verification is a hard reject. A malformed publish would take BotFights (and every other app entry) off the fleet's catalog, so publishing a broken signature is worse than publishing none. - `core/archipelago/src/container/app_catalog.rs` line ~572 — the raw URL nodes fetch: `http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`. That is the vps2 Gitea, so the publish is not complete until `main` is on that remote. - `CLAUDE.md` "Commit & push every unit of work" — stage by explicit path (another agent shares this tree), never `git add -A`, and `main` is protected so pushes go through `gitea-ai`. Verify the signature independently of the script's own report: confirm `releases/app-catalog.json` contains a non-empty `signature` and a `signed_by` equal to the expected release-root DID, that the file is still valid JSON, and that the `botfights` entry is still 1.2.0 with its embedded manifest intact (signing must not have altered the payload).
Commit the four archy files in one focused commit, staged by explicit path, with a message
describing the BotFights 1.2.0 catalog publish and the generated-secret fix, ending with the
`Co-Authored-By: Claude …` trailer. Push with `git push gitea-ai main`.

Publish: ensure the same `main` lands on the vps2 Gitea that nodes fetch the catalog from, then
confirm from off-host that the published bytes are the signed ones —
`curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`
must return JSON whose `botfights` version is `1.2.0` and whose `signature` matches the local
file's. If a remote's token is stale (a known recurring issue with the vps2/local-origin
remotes), report it rather than improvising a workaround, and do not leave the catalog published
to some mirrors and not others without saying so explicitly in the SUMMARY.

Do not commit any secret: no registry token, no mnemonic, no JWT value. Verify the diff before
committing.
cd /home/archipelago/Projects/archy && python3 -c "import json;d=json.load(open('releases/app-catalog.json'));assert d.get('signature');assert d.get('signed_by')=='did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur';assert d['apps']['botfights']['version']=='1.2.0';print('signed ok')" && curl -fsS --max-time 20 http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json | python3 -c "import json,sys;d=json.load(sys.stdin);assert d['apps']['botfights']['version']=='1.2.0';assert d.get('signature');print('published ok')" - The local `releases/app-catalog.json` has a non-empty `signature` and the expected `signed_by` DID, and its `botfights` entry is 1.2.0 with the embedded manifest intact. - `curl` of the vps2 raw catalog URL returns the same signed content with `botfights` at 1.2.0. - `git -C /home/archipelago/Projects/archy log --oneline -1` shows the publish commit, and `git status --short` shows no leftover staged changes from this plan. - `git -C /home/archipelago/Projects/archy show --stat HEAD` lists exactly the four intended files. - `git -C /home/archipelago/Projects/archy show HEAD | grep -Eic '(mnemonic|BEGIN [A-Z ]*PRIVATE KEY)'` is 0. The signed 1.2.0 catalog is live at the URL nodes fetch, verified from off-host.

<threat_model>

Trust Boundaries

Boundary Description
release-root key (human, offline) → catalog The only authority that makes a catalog trustworthy to nodes
published catalog → every node's orchestrator A catalog entry decides which image a node runs and with what env
orchestrator secrets provider → container env JWT_SECRET is materialised and injected without ever passing through a manifest literal

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-09-28 Denial of service fresh 1.2.0 installs crash-looping on a missing JWT_SECRET critical mitigate generated_secrets (hex32) + secret_env in the manifest, asserted by both the catalog JSON check and the manifest-overlay test (Task 1)
T-09-29 Spoofing a tampering mirror serving an altered catalog high mitigate Release-root signature over the raw JSON; nodes hard-reject a present-but-invalid signature. The signature is verified locally and again from the published URL (Tasks 2-3)
T-09-30 Information disclosure a shared or hardcoded JWT secret across the fleet high mitigate Per-install generation by the orchestrator, written 0600 and owned by the rootless service user; no literal value anywhere in git
T-09-31 Elevation of privilege the mnemonic passing through the agent critical mitigate The ceremony is a human-only TTY step in scripts/sign-catalog.sh; the plan never scripts around it and the commit is grepped for key material
T-09-32 Tampering publishing a catalog whose signature does not match its payload high mitigate Independent post-ceremony verification plus a published-bytes check from off-host (Task 3)
T-09-33 Repudiation node-local fighter data becoming invisible without the operator knowing medium mitigate The blocking gate states the exact counts and the revert path; no data is deleted
</threat_model>
- `python3 scripts/check-app-catalog-drift.py` — clean. - `cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests && cargo test -p container manifest` — green. - The published catalog at the vps2 raw URL carries a valid release-root signature and BotFights 1.2.0. - Maps to `09-VALIDATION.md` row "BOT-04 | crash-loop | manifest declares JWT_SECRET via generated_secrets | unit (archy) | `cd core && cargo test -p container manifest`", upgrading it from "partial" with the added catalog-content assertion.

<success_criteria>

  • BotFights 1.2.0 is described identically by the manifest, both catalog files and the image map.
  • A fresh install cannot crash-loop for want of a JWT secret, and no two nodes share one.
  • Node instances default to the shared public arena, with a documented per-node opt-out.
  • The catalog is signed by the release-root key and published where nodes fetch it. </success_criteria>
Create `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` when done, recording the per-node local bot/fight counts that were surfaced at the gate, the decision taken there, and the published catalog URL. Stage by explicit path, commit, and `git push gitea-ai main`.