Skip to content

v0.81.0 — the roadmap becomes a machine

v0.80.0 fixed nine inconsistencies between the roadmap and the specs it points at, by hand, by reading fourteen projects against the changelog and the tags. Five of the nine are exactly what a staleness query catches automatically. This release builds the machine, and then moves 133 documents into it.

The other half of the ask is older and simpler: the people now involved in development do not use VSCode and do not read the repo. A folder per feature is a wall of folders to them. So the planning ledger stays in the repo — in frontmatter, in the same commit as the code, reviewable in a diff — and the audience surface becomes a generated dashboard at help.purplecat.games.

Spec: Roadmap System · 21 tasks, one commit each. 1–4 are the system, 5–8 are the migration, 9–10 are the gates, in that order, so that a malformed page fails a check rather than being discovered six folders later.

Two things the re-spec established that shrink the work: ursa already parses frontmatter, emits a sibling .json per page, renders a frontmatter table, and supports hydrated .mdx — so what gets built here is a validator and one manifest, not a renderer. And visibility stops being a frontmatter field: a docs/internal/ folder in a repo going open source protects nothing, so private means a different repository.

1 — prove the UI substrate

  • [x] 1a — Spike .mdx hydration in ursa, and decide. It cannot be relied on here, so the dashboard is plain index.md with a <script> — the fallback the task named, taken on the evidence rather than on taste.

    ursa 0.88.0 does implement `.mdx` with `hydrate: true`. Its *browser* half
    is right: the compiled MDX takes `React` as a parameter and ursa passes
    its own. Its *server* half calls `mdx-bundler/client`'s `getMDXComponent`,
    which supplies whichever React resolves from `mdx-bundler` — and
    **`mdx-bundler` declares no react at all**, neither dependency nor peer.
    So that `require` escapes its isolated tree and lands on pnpm's hoisted
    `.pnpm/node_modules/react`, which is a property of the install and not of
    this repository. React 18 builds `Symbol(react.element)`; React 19's
    `renderToString` takes only `Symbol(react.transitional.element)`, so on
    the losing half of that coin every `.mdx` page fails, including one
    containing nothing but a heading.
    
    **And it is a coin.** *(Corrected after the fact — see below.)* The same
    committed lockfile hoists React 18.3.1 on one machine and 19.2.8 on
    another, because `apps/web` is on 18 while ursa depends on `^19.2.4` and
    both are in the store. On the second machine `.mdx` renders perfectly.
    That is not a reprieve: combined with the first finding below — a failed
    render does not fail the build — a dashboard built on `.mdx` would be
    correct or silently broken depending on who last ran `pnpm install`. The
    fallback stands for a better reason than the spike originally found.
    
    **Two findings the spike was not looking for.** A failed `.mdx` render
    **does not fail the build** — ursa catches it and ships a yellow banner
    over the raw markdown — so 10a's gate cannot lean on the build to notice.
    And ursa publishes **only `.html`** static files
    (`allStaticFiles = allSourceFilenamesThatAreHtml`), so the spec's claim
    that a `roadmap.json` beside the page "lands at `/roadmap/roadmap.json`
    and is fetchable" is wrong: it lands nowhere. A build step publishes it
    until ursa changes, and 4a inherits it.
    
    The dashboard renders five rows from a hand-written manifest, derives its
    filter options from the data, and filters — verified against the built
    page in a DOM, with no console errors. A guard fails the moment `.mdx`
    becomes worth re-testing.
    
    **The guard's first form was wrong, and it is worth saying how.** It compared
    ursa's resolved React against `mdx-bundler`'s and demanded they differ —
    one machine's outcome, asserted as if it were a fact about the code. It
    passed where it was written and failed the whole build on the next
    checkout, where nothing was broken. It now asserts the *ambiguity*
    instead: `mdx-bundler` pins no react, and more than one React major is in
    the store. Both halves are properties of the lockfile, both were checked
    against synthetic inputs to confirm they fire rather than merely pass, and
    the condition lifts exactly when the workspace settles on one React major
    or somebody upstream pins the resolution.
    

2 — the schema and its validator

  • [x] 2a — The frontmatter schema, and a validator that fails the build. Fifteen fields, four shapes, and the three conditional rules: shipped_in required when shipped, blocked_on required when a committed entry has no target_version, target_version forbidden unless committed or in-progress. An unknown field is an error too — nothing reads targetversion, and without that rule it would validate clean and appear nowhere.

    **It parses the frontmatter itself rather than using ursa's `yaml`.** That
    package is ursa's dependency, not this one's, and reaching for it would
    give `apps/help` its first runtime dependency — the property that lets
    these scripts run in a fresh checkout and keeps them clear of the ursa
    version situation. Task 1a is that argument in miniature. So the parser
    reads the subset the schema uses and **rejects anything else with a file
    and a line**, rather than misparsing it quietly.
    
    Sixteen fixtures, each rule passing and failing. Two of them exist because
    of bugs this repo has already shipped: a trailing `#` comment is stripped
    while a `#` inside a URL is not (v0.80.0's L.6), and a nested map — a
    construct the parser does not read — is an error rather than a silent
    drop.
    
  • [x] 2b — An effort scale anchored to this project's velocity. commit / release / multi-release, defined as units of this repo's release machinery — a commit with its own tests, a version branch of 8–30 tasks, or more than one branch because the phases ship separately — and each point carries anchors that actually shipped, which is the only calibration available. A definition without them is a definition everyone reads differently.

    CLAUDE.md already sizes work by what Claude Code can do rather than a
    human, so a scale borrowed from sprint planning is wrong here by about an
    order of magnitude. The validator prints the meanings rather than the
    labels when it rejects a value, because the failure mode is reading
    `release` as a quarter.
    

3 — the checks

  • [x] 3a — Identity integrity. Unique ids, every prerequisites and epic reference resolves, and an id cannot leave the tree while anything still references it — implemented from the other side, as "every reference resolves", which makes deleting a referenced page and renaming a page the same event with only one of them an error. Plus one rule the spec did not list: nothing is its own prerequisite. Fails the build, and the message says which of the two things happened, because the fix differs.

    Checks run only over pages that passed the schema: an entry missing its
    `id` would otherwise be reported once as a schema error and again as a
    dangling reference.
    
  • [x] 3b — Ordering, cycles, and the cross-check against ROADMAP.md. Nothing ships before what it needs — including the case the spec did not name, a slotted entry whose prerequisite has no slot and has not shipped, which is a release plan that cannot happen. Cycles are reported as the loop (a → b → c → a) rather than as their existence, because "there is a cycle" is not actionable.

    The cross-check runs in both directions, and **they are not equally
    enforceable yet**. A `target_version` `ROADMAP.md` has never heard of is
    an error: frontmatter cannot invent a release. A planned release no page
    claims **warns** until task 9b — task 5 lands 133 documents over several
    commits, so that state is the expected one throughout the move, and
    failing on it would break the build in the middle for a reason that
    resolves itself. 9b is where `ROADMAP.md` states its relationship to the
    tree, which is when the rule becomes true and enforceable.
    
    It reports five warnings today, one per planned release, which is an
    accurate description of a tree with nothing migrated into it.
    
  • [x] 3c — Staleness. git log --oneline <reviewed_at>..HEAD -- <touches>, recording the commit count and the newest offending commit, because the output is the evidence and a boolean is not actionable. Badges, does not fail: a schema violation breaks the machine, a stale document is information about a document, and a release that cannot ship until every spec is re-read is a release that does not ship.

    **A document with no baseline or no `touches` is `unknown`, not
    `fresh`** — the lie a two-state field would have to tell about an idea
    that has never been checked. So would a resolution failure: a
    `reviewed_at` git cannot find is reported with the ref in the message,
    not thrown.
    
    Verified against real history rather than only fixtures: a page pointing
    at `useAutoArtwork.ts` from `v0.79.0` reports one commit, `17c16c48`,
    which is v0.80.0 — the release that moved it.
    

4 — the manifest and the dashboard

  • [x] 4a — Generate roadmap.json. One manifest, sorted by id, carrying only declared fields with the empty ones dropped, written where the hoist runs. Committed, not gitignored, with the regenerate-and-diff guard in pnpm --filter @purple-cat/help test.

    **The spec asked for two things that cannot both be true**: the committed
    manifest carries each entry's staleness, *and* it regenerates
    byte-identical. Staleness is `git log <reviewed_at>..HEAD`, so it moves
    with every commit to a touched path — the file would show a diff on
    unrelated work, and the guard would cry wolf until somebody deleted it.
    So the manifest is what the tree says, and **staleness is computed at
    build time into the published copy**. The dashboard keeps its badges; the
    guard keeps its meaning.
    
    A tree that does not validate produces **no manifest at all** rather than
    a partial one: the dashboard would render the valid half and nothing would
    say the rest went missing. And ursa still does not publish the file — the
    build step task 1a wrote now regenerates it with staleness instead of
    copying.
    
  • [x] 4b — The dashboard. Six sortable columns, seven facets derived from the data, shipped and dropped hidden behind one checkbox — the plan is what has not happened yet — and a facet with only one value is not offered, because a select with one option states that a choice exists when it does not. Asking for a hidden status by name overrides the default, or "filter to shipped" would return nothing and read as a bug. It reads the manifest and never the tree.

    **It is a module, not an inline `<script>`**, so the parts that can be
    quietly wrong — deriving facets, combining them, sorting — are exported
    functions with nine fixtures. One of them pins `v0.9.0` sorting before
    `v0.81.0`, which is the bug every version list ships once.
    
    Freshness reads as a sentence rather than a state name: *needs review (4
    commits since v0.79.0)*, *current as of v0.80.0*, *not checked*. The
    audience is someone who has never seen the repository.
    
    **`render-frontmatter: true` is now required by the schema** rather than
    left to each author. It is what puts the status table on the page, ursa
    owns the default, and a page that omits it looks identical in the tree and
    blank on the site.
    
    Verified against the built page: the default view hides the shipped entry,
    the checkbox reveals it, filtering to `shipped` narrows to one, column
    sort works, links point at each entry's own page, and a missing manifest
    produces a sentence rather than an empty table.
    

5 — the migration, 133 documents

One commit per source tree, each carrying its disposition list, so deleting things is a reviewable decision rather than a gesture. Triage with the tools tasks 1–4 just built rather than by re-reading: the staleness query is the evidence for "has this already shipped", and quarter buckets come from git log --diff-filter=A, because filesystem mtimes are meaningless after a clone.

  • [x] 5a — docs/committed/** → roadmap pages. 40 files and 14 projects out, 47 entries in, because six projects could not be described by one status: an epic page plus one entry per independently shippable phase, each with its own. ROADMAP.md's 47 links were repointed in the same commit rather than left dangling.

    **The spec was wrong about the legal drafts, and the tree said so.** It
    listed the Privacy Policy and Terms of Service as *superseded — already
    published*. What is published is a **17-line placeholder whose first line
    says it is not the policy**, against 646- and 393-line drafts that are the
    real documents. They are `committed`, not `dropped`. This is the ninth such
    inconsistency in two releases and the first one the machine was used to
    find.
    
    **`v0.80.0` was never tagged here.** Every `reviewed_at` resolved to
    nothing and all 47 staleness queries failed. Local `main` is still at
    v0.79.0: v0.80.0's merge and tag were made in a codespace and only the
    `version/0.81.0` branch was ever pushed, so the release commit survives
    solely as this branch's base. The tag is restored on `17c16c48` — task
    L.2 retro-tagged four releases for this exact reason, and it turns out the
    release that did it shipped untagged itself.
    
    **Real data broke the dashboard's Version column.** It rendered
    `blocked: ${reason}`, which was fine against 4b's five hand-written rows
    and is not fine against 23 of 47 real ones whose reasons run 61 to 167
    characters. The cell now says `blocked` and carries the reason as its
    `title`; the widest Version cell went from 167 characters to 16.
    
    Two dispositions worth naming. The **style guide is not a plan item** — a
    ratified reference document has no honest `status` on a roadmap — so 2.2.0,
    its frozen 2.1.0 archive and the four design assets beside them moved to
    `docs/help/design/` instead. And **`architecture/` and `23-roadmap-system/`
    are not in this commit**: the first belongs to task 7b, and the second is
    the live spec for the release doing the migrating, so it leaves at 10a.
    
    Historical changelog citations into `docs/committed/` **degrade to their
    own link text rather than 404** — `hoistChangelog.mjs` already did that —
    and its self-test fixtures were repointed at a document that still exists.
    
  • [x] 5b — docs/shipped/** specs → dated roadmap pages. 45 files, 23 entries, in five quarter buckets from 2025-Q4 to 2026-Q3.

    **Bucketing by `--diff-filter=A` puts everything in two quarters, and both
    are wrong.** A `git mv` into `docs/shipped/` registers as an add, so the
    date recovered is the date a document was *filed away*. `--follow` recovers
    the date it was written, and the buckets go from two to five — the oldest
    being 2025-12-08, two months before `CHANGELOG.md` exists at all. Which is
    why **`v0.1.0` is `shipped_in` for four documents that predate every
    recorded release**: v0.1.0 opens "for some reason, I didn't have a
    CHANGELOG.md file before, so we'll start here", so it is the first release
    that exists and earlier work is in it by definition.
    
    **`docs/shipped/` is not all shipped, so `status: shipped` is not the
    answer for all 45.** The Character Sheet Overhaul reads 33 of 68 in one
    task list and 0 of 47 in another; the Map Generator reads 84 of 98 while
    `ROADMAP.md` separately carries it as "revisit procedural maps". **And the
    unticked boxes are not a backlog either** — `phase-2-validation.md`
    declares itself `status: stub`, and the validation work it describes
    demonstrably shipped in v0.49.0 and v0.52.0 under the changelog's own task
    list. So the Character Sheet Overhaul is an epic with three shipped phases
    and one that needs re-reading rather than a task list, and the Map
    Generator is `committed` with `shipped_in` recording what did land.
    
    Reading the three navigation passes in order — v0.1.0's context panels,
    v0.42.0's global nav, v0.36.0–v0.39.0's style-guide sweep — is the only way
    the current design reads as deliberate, so each entry says where it sits in
    that sequence.
    
  • [x] 5c — docs/ideas/** → one page each. 14 files, 48 entries, because three of the fourteen were dumps: ideas.md held eleven unrelated ideas under one heading, TODO TBD ideas.md seventeen at four different levels of readiness, and TODO beta.md eleven. alpha and GA stay whole — the spec named three dumps and those two are milestone wishlists whose value is the grouping.

    **The ability score wizard is the case this was built for.** It was
    specified, built, and deliberately scrapped during the Character Sheet
    Overhaul because it collided with inline editing — and the v0.75.0
    playtest then asked for it again, scheduled as v0.82.0's P.4. It is
    `dropped` with the reason attached, so the re-proposal meets its own prior
    rejection instead of starting over.
    
    **A fifth already-shipped document**: the selected-versus-impersonated
    click model was implemented in v0.35.0 and its note was never ticked. Kept
    as `shipped` rather than deleted, because a plain click impersonating
    rather than selecting is the thing people get wrong in that code.
    
    **Two dashboard defects that only 118 real entries could show.** Ideas are
    now 45 of the 87 rows in the default view, which is fine — the default
    sort is by version, so slotted work leads and unslotted ideas sink. But
    `versionKey` read `shipped_in` as a slot even on unfinished work, so three
    unscheduled leftovers carrying old releases sorted **above the in-flight
    release** — a plan that appears to start at v0.25.0. `shipped_in` now
    counts as a position only when the entry actually shipped.
    
  • [x] 5d — docs/temp_to_be_analyzed/, use it or lose it. 32 files: 20 entries, 3 reference documents handed to task 7b, 5 design assets moved, 5 deleted outright. No "keep for later" outcome, which is what the directory already was.

    **The folder had already triaged itself.** `analysis_result.md` held a
    disposition list — Keep, Analysis Required, Archive as Shipped, Archive as
    No Longer Interested, Archive as Obsolete, Delete — and this commit follows
    it, resolving the four items it left open rather than re-deriding the whole
    thing:
    
    - **Data tables** — the delta it asked for is real and now written down.
      `EntityList` uses an infinite window rather than pagination, and
      `X-Total-Count` is unreadable cross-origin while production serves the
      SPA and API from different origins. So the header half of the contract
      does not work where it matters. Kept as `committed`.
    - **TODO_TESTING** — 16 of 118 ticked and written a year before v0.67.0 put
      `apps/web`'s 8,635 tests into CI and v0.68.0 added the ratchets and
      `pnpm verify`. It does not reflect the current state, so `dropped`, per
      the triage's own condition.
    - **Recommended models** — filed as obsolete, actually **shipped** in
      v0.78.0's curated model tiers.
    - **Map layers** and **monster character sheets** — likewise filed as
      obsolete and likewise shipped, the latter with its §2 never built, which
      is worth knowing before citing it as a description of the code.
    
    Two constraints held: `ROADMAP.md` stays because `HelpTabs.tsx:36` still
    imports it with `?raw` until task 9a, and `architecture/` stays for 7b —
    now three documents larger, because the module author guide, module
    template and character-import architecture overview are **reference, not
    plan**, and a reference document has no honest `status` on a roadmap. The
    same call as the style guide in 5a.
    

6 — the changelog becomes authored

  • [x] 6a — docs/shipped/changelog/ → docs/help/changelog/. 132 notes, un-gitignored, title and render-frontmatter: false on each. docs/help is now literally the record of truth for everything in it.

    **The move is not a `git mv`: the links only worked because the hoist was
    rewriting them every build.** Baking that in permanently is what "becomes
    authored" means, and doing it by hand surfaced two things.
    
    **A repo-root-relative link never resolved.** A good number of notes write
    `docs/committed/17-…/index.md` rather than `../../committed/…`, and from
    the note's own directory that points at nothing — most of the "~130 links
    already broken in the repo" the hoist's own header describes. Resolving
    those against the repository root before giving up turned **133 dangling
    links into 26**: 112 now reach the roadmap pages phase 5 made for them,
    23 are sibling notes, 4 reach the index.
    
    **v0.52.0's notes never existed.** `CHANGELOG.md` has linked
    `v0.52.0.md` since the release shipped as `d8340156`, and the file was
    never written — the hoist unlinked it silently every build, so the gap was
    invisible. Recovered from that commit's own `CHANGELOG.md`, 326 lines, and
    committed with the other 132.
    
    Two mechanical corrections. The hoist's `rm -rf` of its output directory
    would now delete 132 authored files, so it removes only what it writes.
    And `classifyTarget` excluded the whole of `docs/help/changelog/` from
    being treated as wiki — right while every file there was its own output,
    and wrong the moment they were not: it unlinked **all 133** of the index's
    links to the notes, publishing a list of every release with none of them
    clickable.
    
    `HelpChangelog.contract.test.ts` pinned the directory as gitignored, which
    this reverses. Verified rather than assumed, as the task said: it failed,
    and now asserts the index alone is ignored.
    
  • [x] 6b — Collapse hoistChangelog.mjs. Both jobs are gone — 6a made the notes authored, task 5 made the specs roadmap pages — so the crawl, the flat/ref/ output topology and the two-sources-one-page guard go with them. 349 lines of script become 238, and 187 of self-test become 128.

    **Not the clean deletion the task predicted, and the difference is worth
    naming.** What survives is not "one line": it is `CHANGELOG.md` copied to
    `docs/help/changelog/index.md` **with its links rewritten**, and that
    rewriting is the part that can be quietly wrong. So the self-test is
    trimmed to what still exists rather than deleted — 6a is the proof it
    earns its keep, where one wrong exclusion unlinked all 133 links in the
    published index and nothing failed.
    
    The `ref/` tree was down to **5 files** before this commit, of which two
    were linked. Both are repository documents that are deliberately not
    published — `ROADMAP.md` and the live spec — so they now degrade to their
    own text, which is what a public reader with no checkout can use anyway.
    

7 — the API trees converge

  • [x] 7a — Restructure docs/help/api/. The 22-page Action API tree keeps its audience untouched, index.md becomes a hub that sends each of the two audiences the right way, and developer/ indexes all ten API surfaces plus the eight documented inside one of them.

    **It indexes them by what is true in the code, not by what a spec says.**
    The contract specs arrive in 7b; this page stands on `MODULE_API_VERSION`,
    the route modules and the type definitions, so it is useful before them and
    does not go stale behind them.
    
    Numbers corrected against the code: there are **58** route modules under
    `packages/server-core/src/routes/`, not the "~90" both old indexes state,
    and the schema defines **48** tables, not 47 — the count 7b's
    `data-index.md` owes a row each.
    
    *(Corrected in 7b: this entry first said both indexes were also wrong about
    `MODULE_API_VERSION`. Only the shipped one was, at 1.5.0. The master index
    said 1.35.0 and was right, because a guard in `packages/shared` holds it to
    the constant — and finding that guard is what corrected this.)*
    
  • [x] 7b — Collapse the four architecture trees into it. 25 documents and the fixture set, out of committed, shipped, ideas and temp_to_be_analyzed, into docs/help/api/developer/. docs/ now holds nothing but help/, the changelog, and the three things tasks 8–10 own.

    **The two rival `api-index.md` files are one page**, and the merge settled
    which was right: the master index tracked `MODULE_API_VERSION` correctly at
    **1.35.0** because a guard in `packages/shared` held it there; the shipped
    one had no guard and still said **1.5.0**, twenty-two minor versions
    behind. That guard read the file this commit deleted, so it now reads the
    published index — and its failure is what caught the merge.
    
    **`data-index.md` is generated from `dbSchema.ts`**, one row per table, so
    a new table appears as *no spec yet* rather than not appearing. **48 tables,
    22 with no specification** — an invisible gap converted into a countable
    one. The ~35 entity specs stay deferred behind the actor model, and the
    page says why.
    
    **The specs lost their version suffixes.** `rest-api-0.1.md` is
    `rest-api.md`: the version is stated in the document and in the index, and
    `module-api-1.6.md` had been naming a version it did not match for a year.
    
    **A third stale status, found in the moving.** The chat-invocation RFC
    still opened *"Nothing here is built"* — it shipped as v0.73.0's 28 tasks.
    The original wording is kept beneath the correction rather than edited
    away, because that sentence outliving the build is the exact failure this
    release exists to stop.
    
    Destinations for the documents that are not API contracts, named rather
    than discovered: **styling architecture** → `docs/help/design/`, beside the
    style guide it implements; **release tiers and LTS** → a roadmap idea,
    because it is a plan with four open questions; **the library classifier
    note** → `developer/`, with the dozen lines of search transcript it opened
    with removed.
    

8 — legacy-migrations leaves docs/

  • [x] 8a — → packages/server-core/legacy-migrations/. 53 quarantined SQL files and their README, out of docs/ and into the package whose tests read them. docs/ now contains only documentation, which is what makes task 10a's allowlist a rule rather than an aspiration.

    **Kept, because eleven test files read these as source-of-truth
    contracts** — each asserts that the runtime DDL in `db.ts` still matches
    what the migration declared. That is fixture data, and it had been sitting
    in the one tree a cleanup deletes things from.
    
    All eleven read repo-root-relative paths, so the change is mechanical; the
    twelfth reference was a relative path in a doc comment that had been
    **wrong since it was written** (`../../packages/…` from inside
    `packages/server-core/src/`) and is now correct. The README gains the
    reason it lives here, next to the reason it must never move back into
    `migrations/`: a `.sql` file in a drizzle folder that is absent from the
    journal reads like an executable migration and is not one.
    
    The historical changelog notes still say `docs/legacy-migrations/`. That is
    where the files were in v0.32.0, and rewriting history to match the present
    is the opposite of what those notes are for.
    

9 — retire the other two roadmaps

  • [x] 9a — The app's Roadmap tab points at the wiki. One panel, one config constant, and 58 lines out of HelpTabs.tsx — the bundled ?raw import, the accordion, parseMarkdownSections and the line-at-a-time renderer it fed. The Changelog tab was already the pattern.

    **The rendering was the smaller problem.** That file was one of *three*
    roadmaps, it had not been edited since June, and it was **the one users
    saw**. `docs/help/roadmap/` is generated from validated frontmatter, so the
    tab now shows the plan CI checks. Verified live in a browser against the
    built site: 94 rows, seven facets, correct ordering, no console errors.
    
    **Both contract tests had to be loosened, and both for the same reason** —
    they matched source text that incidental prose also matches.
    `not.toContain('parseMarkdownSections')` failed on the file's own header
    explaining what the *changelog* tab stopped doing in v0.74.0, and the
    changelog contract pinned `import { HELP_CHANGELOG_PAGE }` as an exact
    string, which a second constant in the same import breaks. Both now match
    code rather than prose, which is the standing trap with this repo's ~590
    source-text guards.
    
  • [x] 9b — ROADMAP.md stays, and states its relationship. A header saying what it is — the ordered pipeline, in prose, because sequencing is an argument and a table cannot hold one — and what it is not: the list of work, which is the 139 pages it links into. The project index is reframed as a closed redirect table, so an old reference still resolves and nothing new is added to it.

    **And task 3b's second direction is now an error.** A release
    `ROADMAP.md` plans that no page claims fails the build, as the reverse
    already did. Both halves of "the two roadmaps agree" are enforced, which
    is what makes the header's claim true rather than aspirational — verified
    by planning a fake v0.99.0 and watching the build exit 1 with a message
    that names both fixes.
    
    **Promoting it broke four fixtures, and the reason is worth keeping.**
    "Every planned release is claimed" is a property of the *complete* set of
    entries; run it over a two-page fixture directory and every release looks
    unclaimed. It was invisible while the finding was a warning. The
    cross-check is now scoped to the real tree — a partial directory still
    gets every check that is a property of the pages themselves — and a test
    pins that.
    
    The dead warning channel goes with it: nothing emits warnings any more,
    and its message still said "task 5 is still migrating".
    

10 — the gates

  • [x] 10a — A docs/ allowlist guard. CI asserts docs/ contains only help/, and it does — the last four directories went with this commit, including this project's own spec, synthesised into its roadmap page exactly as the other 133 documents were.

    **Nothing about the migration stopped it happening again**, which is the
    whole argument for fifteen lines: a new folder under `docs/` is one
    `mkdir`, fails no check, and by the time anyone reads it the documents
    inside are a year old. That is the entire history of
    `temp_to_be_analyzed/`.
    
    The error message says where things go instead — a plan is a page with
    frontmatter, documentation of code lives beside the code, and **anything
    private is a different repository, not a folder here**. Dot-files are
    deliberately ignored: `.DS_Store` appeared under `docs/` twice during this
    release, and failing the build on it would teach people to distrust the
    check.
    
    Five self-tests, and the ones that matter assert it still **fails** — on a
    new folder and on a loose file. A guard that has quietly stopped catching
    anything is worse than none, because it reads like coverage.
    
  • [x] 10b — Pre-open-source history audit. 3,552 blobs across 603 historical paths under docs/, plus a repository-wide credential sweep, against the five categories named in advance. Recorded as an entry; it gates the repository going public, not this release.

    **It found one thing that does not wait for open-sourcing, and the details
    are deliberately not in this file** — writing the location of a credential
    exposure into a published changelog is the exposure. They are in a private
    findings document handed over separately, with rotation as step one.
    
    Of the rest: **no financial or pricing analysis exists** (every match was
    the word "churn" applied to objects and diffs), **no personal information**,
    and no key material anywhere in `docs/` history. What is left is material
    that is *already published*, and one judgement call nobody has made — this
    changelog documents its own security defects in detail, which is good
    practice for a hosted service and **an exploit guide for every self-hosted
    deployment older than the release that fixed one**. LTS for older versions
    is an explicit goal, so that population is real.
    
    **Two follow-ups filed rather than left in prose.** The still-open finding
    is now
    [#68](https://github.com/purple-cat-games/platform/issues/68) — the
    codebase tells users their API keys are "never sent to server" while five
    call sites send them as `x-ai-*` headers. And the LTS question the audit
    surfaced has a committed strategy behind it:
    release tiers and long-term support,
    **committed in principle and unspecified in the numbers**, because a
    support window is a promise to other people rather than an engineering
    choice.
    
    **The audit also found what dropping `visibility` cost.** An earlier draft
    of this project marked four things `internal` and carried a frontmatter
    field to enforce it. Dropping the field was right — a `docs/internal/` in a
    repo going open source protects nothing. Not re-triaging the four things it
    had been protecting was the gap, and phases 5 to 7 published them.