diff --git a/CLAUDE.md b/CLAUDE.md index 2d66c32..b136dcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Mutating commands accept `--dry-run`; use it to preview a `pr create`, `pr resub `plan/` holds one file per epic, and `plan/README.md` is the register. An epic's filename is its id, and that id is the Conventional Commits scope: -`feat(camo): ...` belongs to `plan/camo.md`. `scripts/check-commit-scope.sh` +`feat(camo): ...` belongs to `plan/complete/camo.md`. `scripts/check-commit-scope.sh` enforces it at commit-msg time against both the files and the register, so an epic that is only a row so far is still a valid scope. diff --git a/Cargo.toml b/Cargo.toml index 7425b7e..34e8954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower = { version = "0.5", features = ["util"] } # ATProto. 0.x and churning, so pinned exactly and confined to the atproto -# module (plan/sign-in.md). jose-jwk is the key type atrium-oauth uses but does not +# module (plan/complete/sign-in.md). jose-jwk is the key type atrium-oauth uses but does not # re-export; it must stay on the version atrium-oauth pins. # # atrium-oauth's default `default-client` feature means reqwest with diff --git a/docs/identity-and-sessions.md b/docs/identity-and-sessions.md index 02023aa..ad78bec 100644 --- a/docs/identity-and-sessions.md +++ b/docs/identity-and-sessions.md @@ -5,8 +5,8 @@ headquarters holds afterwards. > **Status: implemented on atrium-oauth 0.1.7 and working in production as of > 2026-08-07 — a real login on the camo scope, a real blob upload, and camo -> records written to a real repository.** See [plan/sign-in.md](../plan/sign-in.md) -> and [plan/camo.md](../plan/camo.md). +> records written to a real repository.** See [plan/sign-in.md](../plan/complete/sign-in.md) +> and [plan/camo.md](../plan/complete/camo.md). ## The shape of it diff --git a/docs/local-dev.md b/docs/local-dev.md index 22f4e22..96811fa 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -7,7 +7,7 @@ plus Node 24. Nothing else, and no AWS account: development uses the loopback OAuth client, so nothing has to be reachable from the internet. > **Status.** M2's sign-in flow works — the first real login completed in -> production on 2026-08-03 ([plan/sign-in.md](../plan/sign-in.md)). +> production on 2026-08-03 ([plan/sign-in.md](../plan/complete/sign-in.md)). ## The one rule diff --git a/plan/README.md b/plan/README.md index 0e68815..d0b52e2 100644 --- a/plan/README.md +++ b/plan/README.md @@ -22,10 +22,11 @@ Compose before anything runs on AWS. ## The id is the commit scope -Each epic's filename is its id, and that id never changes. `plan/camo.md` is -`camo`, so its commits read `feat(camo): …` and `fix(camo): …`. That is the +Each epic's filename is its id, and that id never changes. `plan/complete/camo.md` +is `camo`, so its commits read `feat(camo): …` and `fix(camo): …`. That is the whole reason the ids have no number prefix: an epic's order changes, and a -filename that has to be renamed is not an identifier. +filename that has to be renamed is not an identifier. Archiving moves the file +between directories and never renames it, for the same reason. `plan` is a valid scope too, for changes to this register. @@ -40,6 +41,8 @@ which says what genuinely cannot start first. `shipped` — the exit criterion is met. The file stays, because how something was built and what it cost to learn is worth more after it works than before. +A shipped epic with nothing open left moves to [complete/](complete/); one +with loose ends stays here until they close. `open` — being worked on or ready to be. @@ -50,14 +53,27 @@ adjacent code is open. Accessibility and copy are like this: they are never done, and pretending otherwise just produces an epic that is permanently 80% complete. -## Shipped +## Complete + +Nothing open, nothing left to decide. These live in [complete/](complete/) so +the list above stays the list of things somebody might work on. They are not +deleted — how something was built, and what it cost to learn, is worth more +after it works than before — and their ids stay valid commit scopes, because a +follow-up fix to a finished epic is still that epic's commit. + +| id | title | +|---|---| +| [sign-in](complete/sign-in.md) | A player signs in with their own ATProto account | +| [camo](complete/camo.md) | Player-owned camo, drawn here, stored in their PDS, painted in the match | +| [match-startup](complete/match-startup.md) | A slow match start can be attributed instead of guessed at | + +## Shipped, with loose ends + +The exit criterion is met and something is still open in the file. | id | title | |---|---| | [match-launch](match-launch.md) | A match runs on Fargate from a manifest we authored | -| [sign-in](sign-in.md) | A player signs in with their own ATProto account | -| [camo](camo.md) | Player-owned camo, drawn here, stored in their PDS, painted in the match | -| [match-startup](match-startup.md) | A slow match start can be attributed instead of guessed at | | [lobby](lobby.md) | Several humans get into one container | | [flare](flare.md) | In-match feedback reaches a public board | @@ -115,6 +131,14 @@ other people's repositories. `lexicons/PLAN.md` was that other way round, and its nine stages are where most of this register came from. Treat it as a source, not as an authority. +## Every epic ends in Done + +Open work above, finished work as `- [x]` items under a trailing `## Done`. +An epic with nothing under Done says so. + +That is what makes "is this finished" answerable by looking rather than by +reading: an epic with no `- [ ]` left is a candidate for [complete/](complete/). + ## Where TODO.md went This directory replaced it. TODO.md had become a flat list of everything diff --git a/plan/after-action.md b/plan/after-action.md index f7d002d..072ba9d 100644 --- a/plan/after-action.md +++ b/plan/after-action.md @@ -21,7 +21,6 @@ has an address and a way in. ## arena: open the structured report seam - - [ ] **Read reports from the game rather than from the client's text.** `MatchWatcher` appends `Client.phaseReport` verbatim, HTML and all, into a text file per phase — ten to twenty files per round. That string is the @@ -45,7 +44,6 @@ has an address and a way in. ## Schema - - [ ] **`blue.lance.afterAction`**, key `h(matchId)`, in the player's own repo. One per player per match, so it is single-repo and needs no consent negotiation beyond the scope. Carries the player's force, what happened @@ -65,7 +63,6 @@ has an address and a way in. ## Then - The second wave of [achievements](achievements.md), the ones that needed per-unit data. diff --git a/plan/appview.md b/plan/appview.md index ab61f07..36ae2d7 100644 --- a/plan/appview.md +++ b/plan/appview.md @@ -17,7 +17,6 @@ rather than a schema, and the first with an operational cost of its own. ## Ingest - - [ ] **Filter a stream to our collections.** Jetstream takes a `wantedCollections` list and supports whole-segment prefix wildcards, so `blue.lance.*` and `games.permadeath.*` are two filters rather than @@ -40,7 +39,6 @@ rather than a schema, and the first with an operational cost of its own. ## Backfill - - [ ] **Enumerate repos that already hold our collections.** `com.atproto.sync.listReposByCollection` answers exactly this and is served by relays rather than by PDSes. For a collection this niche the @@ -56,7 +54,6 @@ rather than a schema, and the first with an operational cost of its own. ## Operations - - [ ] **Somewhere to run it** (infra). A long-lived process with a cursor, so it needs durable state — the same conversation as [managed-store](managed-store.md), which is why that is a dependency. @@ -68,7 +65,6 @@ rather than a schema, and the first with an operational cost of its own. ## What it unlocks - - Matches and challenges written by clients that are not ours. This is the point of putting records in PDSes at all, and until this exists none of it is exercised. diff --git a/plan/arena-image.md b/plan/arena-image.md index f2bfe06..1c9ddc9 100644 --- a/plan/arena-image.md +++ b/plan/arena-image.md @@ -18,7 +18,6 @@ cited from a commit — it is not a second copy. ## The three strands - **Size, because the pull is on every match's critical path.** Fargate pulls the image cold for each task, roughly a gigabyte, adding ~16s to every start. `megamek/prune-data.sh` removes ~420MB of provably unreachable content — crew @@ -47,7 +46,6 @@ all be discovered by a build breaking rather than by a test. ## Also here - - [ ] **infra must set `ARENA_VERSION` on the api host.** `/api/version` reports the arena image tag matches launch from, and the site footer shows it. Infra pins that tag in the match task definition, so the same diff --git a/plan/asset-caching.md b/plan/asset-caching.md index 44997b3..cd2e84f 100644 --- a/plan/asset-caching.md +++ b/plan/asset-caching.md @@ -27,7 +27,6 @@ hit. The URL has to stop being per-match. ## Shape of the fix - Serve arena's static assets from a version-keyed, match-independent path — `/arena-assets/{arena_version}/…` — with `Cache-Control: public, max-age=31536000, immutable`. The version in the path is what makes the long @@ -37,7 +36,6 @@ and `/api/version` reports it. ## What to spike first, because it decides the design - - [ ] **Does Suramadu's bundle resolve its siblings relative to its script URL or to the page?** arena's `index.html` is ours, so pointing *it* at the versioned path is easy. If the bundle follows the script (webpack @@ -47,7 +45,6 @@ and `/api/version` reports it. ## Where the bytes would come from - - [ ] **Publish the assets at arena release time** to S3 behind the existing CDN, keyed by version. Cleanest, and it takes them off the match task entirely. @@ -57,7 +54,6 @@ and `/api/version` reports it. ## Why it is continuous - Nothing is blocked on it and nobody is waiting. It is worth doing the next time somebody is in arena's serving path anyway, and it gets more valuable as a player opens more matches — which is exactly what [daily-challenge](daily-challenge.md) diff --git a/plan/campaign.md b/plan/campaign.md index 8caf600..061f750 100644 --- a/plan/campaign.md +++ b/plan/campaign.md @@ -19,7 +19,6 @@ because it is the hardest thing to change once players own the records. ## arena - - [ ] **Extract crew data.** Nothing does today — the text renderer never touches a unit's crew, so pilot names, gunnery and piloting skills, hits and consciousness exist nowhere in any artifact. @@ -36,7 +35,6 @@ because it is the hardest thing to change once players own the records. ## Schemas - - [ ] **`blue.lance.pilot`**, key `tid`, in the player's repo. Callsign, skills, experience, injuries, record. - [ ] **`blue.lance.force`**, key `tid`. A roster of units and the pilots diff --git a/plan/camo.md b/plan/complete/camo.md similarity index 97% rename from plan/camo.md rename to plan/complete/camo.md index d2ad252..02edf14 100644 --- a/plan/camo.md +++ b/plan/complete/camo.md @@ -22,7 +22,6 @@ easier to debug on a collection nothing else references. ## Lexicon - **`blue.lance.camo`** — published, vendored at `lexicons/blue/lance/camo.json` with its CID pinned in `lexicons.json`, and `@atproto/lex` generates the TypeScript for it. @@ -35,12 +34,11 @@ and a unit's markings later without being rewritten. ## Carried elsewhere - The default camo is a nullable TEXT column on `account` in sqlite. It is a site setting rather than player content — which camo lance.blue reaches for, not the camo itself — so it is not in the player's repo and needs no lexicon. It is still durable server state, so it moves when the rest of the table does: -[managed-store](managed-store.md). +[managed-store](../managed-store.md). ## Done @@ -96,5 +94,5 @@ still durable server state, so it moves when the rest of the table does: `web/src/screens/challenge.ts`, and Duel and Scenario now open the lobby instead — where a bot seat gets no camo at all. The generator, the re-encode and the manifest slot all still work; nothing reaches them. - Restoring it is [lobby](lobby.md)'s deferred per-seat camo work, not a + Restoring it is [lobby](../lobby.md)'s deferred per-seat camo work, not a change to anything here. diff --git a/plan/match-startup.md b/plan/complete/match-startup.md similarity index 99% rename from plan/match-startup.md rename to plan/complete/match-startup.md index a63a87d..70730e5 100644 --- a/plan/match-startup.md +++ b/plan/complete/match-startup.md @@ -16,7 +16,6 @@ estimate. ## What was decided and left - Sub-100ms readiness wants a held connection — SSE, or a long poll that returns when the row flips — not a shorter interval. Not worth it against a start measured in seconds. Revisit if pre-warming lands and the start gets short diff --git a/plan/sign-in.md b/plan/complete/sign-in.md similarity index 93% rename from plan/sign-in.md rename to plan/complete/sign-in.md index 922a371..1b1ae6c 100644 --- a/plan/sign-in.md +++ b/plan/complete/sign-in.md @@ -19,10 +19,9 @@ already have, on whatever PDS they already use, and can take it away again. ## What this epic does not cover - The hygiene around a session — a revoked grant, rate limiting, garbage collection, and the missing test for the exchange itself — is -[session-security](session-security.md). Those were written down as +[session-security](../session-security.md). Those were written down as requirements while this was being built, deliberately, so that M2 was built against them rather than rediscovering them; none of them were answered. @@ -42,7 +41,7 @@ which was the first thing that needed to write to a player's repository. Tokens live server-side, not in the browser, because a match outlives the tab that started it: the control plane has to be able to write a result after the player has closed the page. - [docs/identity-and-sessions.md](../docs/identity-and-sessions.md) is the + [docs/identity-and-sessions.md](../../docs/identity-and-sessions.md) is the long form. - [x] **Key rotation.** `PRIVATE_KEY_JWK` accepts one JWK or an array (`private_keys` in `services/api/src/atproto/client.rs`), the whole diff --git a/plan/cost-model.md b/plan/cost-model.md index 97d971c..0bf11bf 100644 --- a/plan/cost-model.md +++ b/plan/cost-model.md @@ -22,7 +22,6 @@ holding it for a different reason. Nobody owns the sum. ## Measure - - [ ] **Duration from real matches.** Measure a few human 4v4s. Everything else multiplies by this number. - [ ] **The per-task numbers.** Container Insights is on at the `enhanced` @@ -32,7 +31,7 @@ holding it for a different reason. Nobody owns the sum. itself a cost decision, and it is infra's open question today. - [ ] **Fit the sizing coefficients.** `matches/sizing.rs` sizes each launch from its seats and units, anchored on production observations rather than - fitted to them ([match-startup](match-startup.md)). Once per-match stats + fitted to them ([match-startup](complete/match-startup.md)). Once per-match stats artifacts land in S3, revisit against real matches. A local sweep is not the tool — it took down the workstation. - [ ] **What the image pull costs.** ~16s of every match start, and @@ -45,7 +44,6 @@ holding it for a different reason. Nobody owns the sum. ## Then decide - - [ ] **What the concurrent cap should be.** The cap exists as an SSM parameter at 20 and is unenforced ([match-lifecycle](match-lifecycle.md)). Twenty is a number somebody picked; this epic is what makes it a number somebody @@ -60,7 +58,6 @@ holding it for a different reason. Nobody owns the sum. ## Why it is an epic rather than a spreadsheet - Because every input above lives in a different repo and moves independently, and because the two decisions at the end — the cap, and whether to spend a build on arm64 — have been deferred for want of a number rather than for want diff --git a/plan/daily-challenge.md b/plan/daily-challenge.md index d3a48a3..a83cc6d 100644 --- a/plan/daily-challenge.md +++ b/plan/daily-challenge.md @@ -24,7 +24,6 @@ having. ## What it needs first - **A comparable result.** Ranking two matches against each other needs a winner, teams, a starting unit count and timestamps — none of which arena's `result.json` carries today, and it is not written at all unless the match @@ -41,7 +40,6 @@ isn't one — [player-profile](player-profile.md). ## The day - - [ ] **Pick the day's scenario deterministically.** Same input, same answer, computed rather than stored, so no row has to exist before the day starts and anybody can check the pick was not chosen after seeing the @@ -61,7 +59,6 @@ isn't one — [player-profile](player-profile.md). ## The board - - [ ] **Rank one day's results.** Scoped to a day, over matches we hosted, from our own read model. This is deliberately *not* `leaderboard`: that epic is a rating anybody can recompute from pinned inputs, order-independent @@ -73,7 +70,6 @@ isn't one — [player-profile](player-profile.md). ## Streaks and history - - [ ] **Per-player daily history** — which days they played, and how they did. A read model over match records, not a new record type: the match records already say what happened, and a second durable copy would be a second @@ -88,7 +84,6 @@ isn't one — [player-profile](player-profile.md). ## Deliberately not here - **No lexicon, yet.** A daily result is a match record plus our own read model, and there is nothing a `blue.lance.dailyChallenge` record would carry that the match record does not. Minting a collection name before the feature has been @@ -102,7 +97,6 @@ If it happens it is its own epic. ## Related - [onboarding](onboarding.md) sits immediately before this on purpose. A daily challenge is a hook, and a hook that lands people in an interface they cannot use spends their attention once. diff --git a/plan/flare.md b/plan/flare.md index fa8cbe7..325492a 100644 --- a/plan/flare.md +++ b/plan/flare.md @@ -23,7 +23,6 @@ cooperation from userinput.app. ## Decisions already made - - The feature is called a flare. - Machine context — match id, scenario, round, arena image version — goes in the discussion `body` as a visible footer under the player's own words. Not @@ -34,7 +33,6 @@ cooperation from userinput.app. ## Lexicon - **`blue.lance.authPlayer`**, a `permission-set` — authored so the consent screen shows a title and a sentence instead of a raw `repo:` scope. Covers `blue.lance.camo` today; each new collection is added as its epic lands. @@ -53,7 +51,6 @@ stay separate scope strings. ## Still open - - [ ] **Rate-limit the endpoint.** Nothing stops a signed-in player filling the board. The records are in their own repo, so the cost is the board's listing rather than our storage. diff --git a/plan/forces.md b/plan/forces.md index 2ba654c..8eba4de 100644 --- a/plan/forces.md +++ b/plan/forces.md @@ -25,7 +25,6 @@ below is still called a force because that is MegaMek's own word. ## The interchange format - MegaMek's own `.mul` unit list. MegaMek reads them and MegaMekLab already exports them, so accepting a MUL upload on the match screen works before any force builder ships an export: stage it like a camo, presign it onto the slot, @@ -41,7 +40,6 @@ arena loads it into the lounge. ## MekBay - MekBay (mekbay.com, linked from megamek.org) is MegaMek's own web app: the full unit database searchable by era, faction, BV, equipment and quirks, force building with cloud sync, and printable record sheets. GPLv3, an Angular PWA @@ -67,7 +65,6 @@ Three routes, not exclusive: ## Lexicon - - [ ] **`blue.lance.force`**, key `tid`. A roster of units and the pilots assigned to them. `_lexicon.lance.blue` already exists, so there is no new DNS record. @@ -83,7 +80,6 @@ lands first writes the schema; the other one extends it. ## Also here - - [ ] **Match parameters beyond the units** — the map. Same shape of problem: the scenario file decides, and nothing lets a player say otherwise. diff --git a/plan/leaderboard.md b/plan/leaderboard.md index 143a28f..e3535bb 100644 --- a/plan/leaderboard.md +++ b/plan/leaderboard.md @@ -18,7 +18,6 @@ wait for any of this. ## Pick an order-independent algorithm - - [ ] **Rule out anything sequential.** Elo, TrueSkill and Glicko across rating periods all update incrementally, so two observers who process the same matches in different orders get different ratings. There is no global @@ -40,7 +39,6 @@ insertion point and would give a different answer depending on when it noticed. ## Schemas - - [ ] **`games.permadeath.rating.policy`**, key `tid`, in the rating service's repo. States the algorithm, its version and parameters, which sanctioner DIDs it honours, and what an unsanctioned match is worth. Sanctioned and @@ -64,7 +62,6 @@ insertion point and would give a different answer depending on when it noticed. ## Tooling - - [ ] **Ship a recompute tool** that takes an epoch record, fetches the pinned sanctions, runs the policy and prints the ratings. Without one, "reproducible" is a claim rather than a thing anybody does. @@ -77,7 +74,6 @@ insertion point and would give a different answer depending on when it noticed. ## Self-dealing - - [ ] **Decide the weight of a match nobody independent sanctioned.** A player can write whatever they like into their own repo, and two cooperating players can manufacture as many matches as they please. Unsanctioned diff --git a/plan/managed-store.md b/plan/managed-store.md index 60cebf0..0fd400f 100644 --- a/plan/managed-store.md +++ b/plan/managed-store.md @@ -22,7 +22,6 @@ is what stands between a player and signing in again. ## What moves - - [ ] **The swap itself.** DynamoDB fits the key-value shape, and the change is contained behind the atproto `Store` wrapper. sqlite stays as the no-AWS dev mode — the point is that one binary supports both, not that sqlite @@ -32,7 +31,7 @@ is what stands between a player and signing in again. - [ ] **The default camo column.** One nullable TEXT column on `account`, carrying which camo lance.blue reaches for. A key that no longer resolves already reads as "no default", so it moves whenever the rest of the table - does and needs no migration of its own. See [camo](camo.md). + does and needs no migration of its own. See [camo](complete/camo.md). - [ ] **Decide what the matches registry is.** It is described as ephemeral — OAuth sessions plus a live-match registry is the whole of what the api keeps — but [match-lifecycle](match-lifecycle.md) wants retention and @@ -42,7 +41,6 @@ is what stands between a player and signing in again. ## What this unblocks - An admin surface. `infra/scripts/matches/killall.sh` writes the sqlite directly, which is only possible because the store is a file on the box the operator can reach. An API that ends a match properly is easier to justify once diff --git a/plan/match-launch.md b/plan/match-launch.md index 00cf179..7b844df 100644 --- a/plan/match-launch.md +++ b/plan/match-launch.md @@ -16,8 +16,6 @@ Fargate, which also exercised arena's never-run upload path for the first time. ## Still open - - - [ ] **The minting happy path is untested here.** `POST /upload-urls/:id/:token/*name` is unit-tested up to the gates, but signing needs a real S3 client, so nothing covers the signature itself. @@ -34,7 +32,6 @@ them harder to find. ## Done - - [x] **M0 — skeleton.** The Cargo workspace and `headquarters-api`: config from env, `/healthz`, a Dockerfile, compose. Tracing discipline from day one — manifest URLs, tokens and authorization codes are never logged, diff --git a/plan/match-lifecycle.md b/plan/match-lifecycle.md index 71a6229..cc02d2f 100644 --- a/plan/match-lifecycle.md +++ b/plan/match-lifecycle.md @@ -25,7 +25,6 @@ outside those two. ## A match that dies quietly - - [ ] **Expire non-terminal rows by age.** `poll()`'s `ready` arm (`outlived`) covers a match that died after reaching `ready`, driven by the list for `ready` rows only — the one status that hands out a link, and the one @@ -59,7 +58,6 @@ outside those two. ## A match that ends badly - - [ ] **End of match dumps players into an empty lobby** (arena). After victory MegaMek returns the client to the lobby — no Princess, no observer — and the container lingers there before exiting. The linger exists so the @@ -79,7 +77,6 @@ outside those two. ## A human's view of an ended match - - [ ] **Only an operator script ends a match.** `infra/scripts/matches/killall.sh` stops tasks and writes the sqlite directly, because the API has no admin surface. Fine for one operator; revisit alongside @@ -93,7 +90,6 @@ outside those two. ## Not part of this - Whether a match produces a *durable record* is [match-records](match-records.md). This epic is only about the row, the task and the container agreeing on whether a match is still alive. diff --git a/plan/match-records.md b/plan/match-records.md index e87b628..634cc94 100644 --- a/plan/match-records.md +++ b/plan/match-records.md @@ -27,7 +27,6 @@ reason, not because it is the largest. ## Blocked on arena's result document - None of this is large, and all of it comes first. - [ ] **A winner field.** Today's `result.json` is `{round, players[{name, @@ -51,7 +50,6 @@ None of this is large, and all of it comes first. ## Three ideas this rests on - **A match is a claim until somebody sanctions it.** Every participant writes their own copy of the result into their own repo. A sanction is a separate record, written by a third party into that third party's repo, saying the @@ -71,7 +69,6 @@ the reference goes stale the first time the record is written again. ## Lexicons - - [ ] **`games.permadeath.service`**, key `literal:self`. A DID declaring what authority it claims: sanctioning matches, issuing achievements, publishing ratings. This is what makes a sanction meaningful to somebody @@ -107,7 +104,6 @@ the reference goes stale the first time the record is written again. ## headquarters - - [ ] **Add `repo:games.permadeath.match` to the requested scope**, and to `blue.lance.authPlayer` where it belongs. - [ ] **Write the match record N ways**, once per consenting participant. diff --git a/plan/onboarding.md b/plan/onboarding.md index 4d233a0..e0e3e66 100644 --- a/plan/onboarding.md +++ b/plan/onboarding.md @@ -22,7 +22,6 @@ other epic assumes a player who can already play. ## Why it sits where it does - [daily-challenge](daily-challenge.md) is a hook: one fight a day that anybody can try. A hook that lands people in an interface they cannot use is worse than no hook, because it spends the attention once. So this comes immediately @@ -31,7 +30,6 @@ epics. ## Open - - [ ] **Decide what the first five minutes are.** A tutorial scenario, a guided first match, a practice mode against Princess with the pressure off, or something that is not a match at all. This is the decision the @@ -54,7 +52,6 @@ epics. ## Related - Copy is [site-copy](site-copy.md)'s and stays a human's to write. This epic is the shape of the experience, not the words in it — but nothing here ships without words, so the two land together. diff --git a/plan/player-profile.md b/plan/player-profile.md index 268eaa9..ee071f2 100644 --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -25,7 +25,6 @@ page exists to show something that does not exist. ## Open - - [ ] **A profile screen** that Your account can point at, rather than Home. - [ ] **Real Wins and Kills**, from match records. Kills need per-unit attribution, which is `after-action`'s work, so the two tiles land at @@ -39,7 +38,6 @@ page exists to show something that does not exist. ## The Matches screen - The screen that Your matches leads to is a first pass, and it is this epic's other half. @@ -63,7 +61,6 @@ problem. ## Related - Every page now asks `/api/session` once, the blog's included, because the account control is mounted by the layout. The theme control is no longer in that menu — it was, on the grounds that the menu was the only place on the site diff --git a/plan/proxy-split.md b/plan/proxy-split.md index ddf0ec5..345a988 100644 --- a/plan/proxy-split.md +++ b/plan/proxy-split.md @@ -19,7 +19,6 @@ playing. ## What it needs - - [ ] **A second binary sharing a library crate.** The workspace already commits to this shape: `services/api` is `headquarters-api`, and the proxy is `headquarters-proxy` with a shared library crate joining them. @@ -36,7 +35,6 @@ playing. ## Related - The proxy is the auth boundary for a match: it hands each caller their own `/match/{id}//` and refuses any identity that is not the session's. Moving it is therefore also a security-sensitive change, and `security-review` should diff --git a/plan/publishing.md b/plan/publishing.md index a8d07d3..70829f5 100644 --- a/plan/publishing.md +++ b/plan/publishing.md @@ -18,7 +18,6 @@ carries its `site.standard.document` record. ## Open - - [ ] **`web/scripts/publish.sh` has never run.** The publication record and the first document record were made with the sequoia CLI directly, so the script exists and is tested but has never met the real thing. Its live diff --git a/plan/render-performance.md b/plan/render-performance.md index d682ea0..8537e3d 100644 --- a/plan/render-performance.md +++ b/plan/render-performance.md @@ -20,7 +20,6 @@ should not become one. ## Why it is an epic - Rendering was sluggish and the shipped fixes roughly halved median downpress-to-pixels, with the p90 tail falling from ~470ms to ~330ms. The proxy was ruled out early: the api host stayed under 5% CPU during a match. @@ -35,7 +34,6 @@ in other epics. ## The state of it, as of 2026-08-07 - The first real profile exists. async-profiler 4.5 ships in the image and loads into both JVMs when `ARENA_PROFILE=1`, dumping collapsed stacks that `exit/finalize.sh` uploads under a `diagnostics` key. @@ -52,7 +50,6 @@ items, and `arena.sh up --spectate` exists for exactly that. ## Where the work is - Everything open lives in arena: the frame tearing discriminator, the 1008px tiled board background, DirectDraw's re-encode on every draw, capping the client canvas resolution, the game-summary images, server-side text diff --git a/plan/scenarios.md b/plan/scenarios.md index 2c6e336..5f20257 100644 --- a/plan/scenarios.md +++ b/plan/scenarios.md @@ -29,7 +29,6 @@ This is a stopgap and has always been one. ## What replaces it - - [ ] **Generate the catalog from the image.** The scenario files are in the arena image's `data/scenarios/`, and everything the catalog holds is in them. A build step that reads the image is the honest version of what a @@ -48,7 +47,6 @@ This is a stopgap and has always been one. ## Why it is worth doing before daily-challenge - [daily-challenge](daily-challenge.md) picks one scenario a day and gives it to everybody. A catalog that is correct because somebody remembered to re-check it is a weak base for that: a wrong slot list is a launch failure for every player @@ -56,7 +54,6 @@ that day rather than for one. ## Related - Match parameters beyond the scenario — the map, and a slot's units — are [forces](forces.md). The picker landed with the challenge screen and a slot's units are still whatever the scenario file says. diff --git a/plan/security-review.md b/plan/security-review.md index b2dfcb1..f1165e8 100644 --- a/plan/security-review.md +++ b/plan/security-review.md @@ -23,7 +23,6 @@ whole purpose is to bring in people nobody has met. ## The boundaries - - [ ] **The proxy is the auth boundary.** It hands each caller their own `/match/{id}//` and refuses any identity that is not the session's, by string comparison, since the identity is in the path. Everything @@ -52,7 +51,6 @@ whole purpose is to bring in people nobody has met. ## What this is not - It is not a substitute for the specific items already tracked elsewhere — [session-security](session-security.md)'s revoked grants and rate limits, and `user-safety`'s reporting and blocking. Those are known gaps with known fixes. diff --git a/plan/session-security.md b/plan/session-security.md index d4d501d..efb26a3 100644 --- a/plan/session-security.md +++ b/plan/session-security.md @@ -13,7 +13,7 @@ exitCriterion: > # session-security These were written down as requirements before M2 was built, so that -[sign-in](sign-in.md) was built against them rather than rediscovering them. +[sign-in](complete/sign-in.md) was built against them rather than rediscovering them. None of them were answered. Two related items from elsewhere in the old TODO join them. @@ -40,15 +40,14 @@ player's PDS are two different facts, and only the second one is authoritative. ## Already answered, in sign-in - Key rotation and handle re-verification were part of the same list and are -done. Both are described in [sign-in](sign-in.md), and neither has been +done. Both are described in [sign-in](complete/sign-in.md), and neither has been exercised against the real event it exists for. ## Done Nothing in this epic's own list. Two items from the same original -requirements set were answered in [sign-in](sign-in.md) and are recorded +requirements set were answered in [sign-in](complete/sign-in.md) and are recorded there: - [x] **Key rotation**, as a keyset in `PRIVATE_KEY_JWK` with every public half diff --git a/plan/site-copy.md b/plan/site-copy.md index 9a3c70b..7244137 100644 --- a/plan/site-copy.md +++ b/plan/site-copy.md @@ -22,25 +22,27 @@ filler in it, and the filler leaves when somebody writes the real thing. ## What is placeholder today - -The hero headline and lead, the three facts, the FAQ, the About page apart from -Credits — its short description, the blurb on each card, and the whole -Development goals page — the Hangar placeholder screen, the footer disclaimer -in both the component and `chrome.ts`, the error lines, the `` -description and image alt in `Base.astro`, and the tagline in the social card. - -Two of them cost something while they sit there: - -- **The footer no longer says the project is non-commercial and unaffiliated.** - That is a claim worth making, and it is currently filler. -- **The unfurl and the search snippet are filler.** Anyone who shares a link - shares the filler with it. The card tagline is a string in - `web/scripts/social-card.mjs`, and a pre-commit hook re-renders - `web/public/social-card.png` when it changes. +- [ ] **The hero headline and lead**, and the three facts under them. +- [ ] **The FAQ**, in `web/src/content/faq.json`. +- [ ] **The About page**, apart from Credits — its short description, the blurb + on each card, and the whole Development goals page. +- [ ] **The Hangar placeholder screen.** +- [ ] **The footer disclaimer**, in both the component and `chrome.ts`. This + one costs something while it sits there: the footer no longer says the + project is non-commercial and unaffiliated, which is a claim worth + making. +- [ ] **The error lines.** +- [ ] **The `` description and image alt** in `Base.astro`, and the + tagline in the social card. These cost something too — the unfurl and + the search snippet are filler, so anyone who shares a link shares the + filler with it. The card tagline is a string in + `web/scripts/social-card.mjs`, and a pre-commit hook re-renders + `web/public/social-card.png` when it changes. +- [ ] **Re-check the hero's host list** whenever the sign-in copy gets a look. + See Related below. ## What is not on the list - **The sign-in form.** It is one form, in `web/src/signin-form.ts`, and what it says about the grant is already true — that is a factual claim about what lance.blue asks for, not brand copy. @@ -57,7 +59,6 @@ way they would edit any other prose. ## Related - The hero's host list is copy that goes stale on its own — `HOSTS` in `web/src/host-cycle.ts` rotates through account hosts, checked against the community PDS list on `discourse.atmosphere.community` and the hosts named on diff --git a/plan/user-safety.md b/plan/user-safety.md index cf09ae4..9eca058 100644 --- a/plan/user-safety.md +++ b/plan/user-safety.md @@ -26,7 +26,6 @@ another player while using the system exactly as intended. ## Refusing contact - - [ ] **Block.** A player should be able to say a given DID may not challenge them, appear in their lineup, or reach them at all. This is the piece [invitations](invitations.md) most needs, and the two should land close @@ -42,7 +41,6 @@ another player while using the system exactly as intended. ## Reporting - - [ ] **Report a player**, as distinct from reporting a bug. [flare](flare.md) posts to a public board under the reporter's own handle, which is exactly wrong for reporting a person: it is public, it is attributable, and it @@ -57,7 +55,6 @@ another player while using the system exactly as intended. ## Content - - [ ] **Camo is an image channel between strangers.** Re-encoding makes the bytes safe to decode; it does nothing about what the picture is of. A camo served into a match is shown to every other player in it. @@ -71,7 +68,6 @@ another player while using the system exactly as intended. ## Why it is here rather than later - Nothing above is urgent while the people playing are people who know each other. All of it becomes urgent the moment [onboarding](onboarding.md) and [daily-challenge](daily-challenge.md) do their job, which is why it sits diff --git a/plan/web-testing.md b/plan/web-testing.md index 50409d7..66a652f 100644 --- a/plan/web-testing.md +++ b/plan/web-testing.md @@ -21,7 +21,6 @@ is not. ## How far the fake goes - `dom.test.mjs` is the first test that needed a DOM and got it from a twenty-line fake `document` rather than a runner. That works because `el()` touches one method. @@ -36,7 +35,6 @@ fake stops paying. ## What the absence costs - `signin-form.test.mjs` is the bill. The thing worth asserting is what the accessibility tree says about the rendered form, and with no DOM it asserts the source shapes that produce it instead. Every one of those is a regex on a file, @@ -48,7 +46,6 @@ with every one added. ## Open - - [ ] **Choose a runner.** The decision is not made and should be written down when it is, along with what it costs the build. - [ ] **`router.ts`'s route predicate**, which is the reason to have one. diff --git a/scripts/check-commit-scope.sh b/scripts/check-commit-scope.sh index 958a96a..cf21565 100755 --- a/scripts/check-commit-scope.sh +++ b/scripts/check-commit-scope.sh @@ -7,8 +7,9 @@ # naming an epic nobody has written down. # # Valid ids come from two places, because most epics are registered before -# they have a file: the filenames in plan/, and the ids in plan/README.md's -# register tables. Either is enough. +# they have a file: the filenames in plan/ (and plan/complete/, where finished +# epics are archived), and the ids in plan/README.md's register tables. Either +# is enough. # # It is not a rule that every commit belongs to an epic. A commit with no # scope passes, and so does anything in NON_EPIC_SCOPES — build work, @@ -50,8 +51,11 @@ scopes="${BASH_REMATCH[1]}" known="$NON_EPIC_SCOPES $LEGACY_SCOPES" -# Epics with a file. -for f in "$plan_dir"/*.md; do +# Epics with a file, including the completed ones under plan/complete/. An +# epic that is finished keeps its id: a follow-up fix to it is still that +# epic's commit, and refusing the scope would only push the change under a +# vaguer one. +for f in "$plan_dir"/*.md "$plan_dir"/complete/*.md; do [ -e "$f" ] || continue id="$(basename "$f" .md)" [ "$id" = "README" ] && continue diff --git a/services/api/src/atproto/client.rs b/services/api/src/atproto/client.rs index 5424b2e..09e3b09 100644 --- a/services/api/src/atproto/client.rs +++ b/services/api/src/atproto/client.rs @@ -113,7 +113,7 @@ pub fn handle_resolver(http: &HttpClient) -> HandleResolver { } /// `PRIVATE_KEY_JWK` accepts one JWK object or an array, so a rotation can -/// publish a new key in the JWKS before the old one stops signing (plan/sign-in.md). +/// publish a new key in the JWKS before the old one stops signing (plan/complete/sign-in.md). /// Every key must be an EC private key with a `kid` — atrium's Keyset rejects /// anything else at construction. fn private_keys(jwk: &str) -> Result, String> { diff --git a/services/api/src/atproto/mod.rs b/services/api/src/atproto/mod.rs index 0c7091c..9273651 100644 --- a/services/api/src/atproto/mod.rs +++ b/services/api/src/atproto/mod.rs @@ -1,7 +1,7 @@ //! The one wrapper around atrium. Everything else in this crate talks to //! ATProto through the `Atproto` struct below; only this module and its //! submodules import `atrium_*` or `jose_jwk` — the crates are 0.x and churn, -//! and this is the wall the churn stops at (plan/sign-in.md). +//! and this is the wall the churn stops at (plan/complete/sign-in.md). mod client; mod store; @@ -1380,7 +1380,7 @@ mod tests { /// `PRIVATE_KEY_JWK` also accepts a JSON array — the shape a rotation /// publishes so a new key can start signing before the old one is - /// withdrawn (plan/sign-in.md). Both keys must be accepted and both must be + /// withdrawn (plan/complete/sign-in.md). Both keys must be accepted and both must be /// served, or a rotation silently drops one signer. #[tokio::test] async fn confidential_array_of_keys_are_all_accepted() {