Make the ADR 0001 ladder the only lifecycle vocabulary - #323
Merged
Conversation
The admin registry offered twelve status values, of which three were valid. The rest were the legacy submission states (in-development, staging, retired) and the capitalised union ADR 0001 explicitly supersedes. `retired` isn't even a status — it's a PublicStage slug. Meanwhile nine real states, including paused and building, were not offered at all. None of that was caught because applications.status is plain TEXT with no CHECK (deliberately — ADR 0001 makes the typed union the source of truth), and publicStageFromStatus() buckets anything unrecognised as `exploring` so the UI never crashes. A capitalised "Production" looked right in the form and in the row, and rendered to a Dean as not-yet- being-built. The root cause was four hand-maintained copies of the list, which had drifted apart — app/admin/registry/new/page.tsx happened to be correct while its sibling in [id]/ was three years of vocabulary behind. So the fix is one list, not four correct ones: - lib/portfolio.ts exports PROJECT_STATUSES, derived from OPERATIONAL_LABEL rather than hand-written. That record is Record<ProjectStatus, string>, so tsc refuses to compile if a union member is missing, and JS preserves string-key insertion order, so it comes out in ladder order for free. The old hand-maintained STATUSES array — which isProjectStatus() depended on, and which could itself have silently fallen behind the union — is gone. - Both admin forms import it. The detail form is now a select rather than a free-text input with suggestions, because the API validates and an unconstrained field that 400s on submit is worse than a constrained one. A stored value outside the taxonomy stays selectable as a marked option, so opening an old record doesn't silently rewrite it. - Status pills colour by public stage via a single shared helper, so a new status inherits a colour through the rollup instead of falling through to grey. - GET /api/registry orders by array_position over a Record<ProjectStatus, number> rank — tsc forces a rank for any new status. The previous CASE ranked six statuses that no longer exist and none of the six added since. And the hole underneath all of it: POST /api/registry and PATCH /api/registry/[id] now reject an out-of-union status with a 400 naming the twelve valid values. Fixing the form alone would have left the endpoint open. Verified against the running app, not just the compiler: PATCH with "in-development" and with "Production" both return 400; a valid status succeeds; the select renders exactly the twelve in ladder order; the list page pills colour by stage; and a legacy `Planned` row in a local clone now sorts last via NULLS LAST instead of mid-list. Remote dev (29 rows) and prod (15) were checked directly and are already clean, so there is no data to repair. /docs/admin-guide's warning about this is replaced with a description of the constraint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vendored strategic-plan submodule is behind upstreamThe vendored submodule pointer at The submodule is pinned at upstream This is an advisory comment from |
ProfessorPolymorphic
added a commit
that referenced
this pull request
Jul 28, 2026
#323 closed the write path — the admin form is a select over PROJECT_STATUSES and both registry endpoints 400 on anything outside the union. What neither it nor verify:portfolio covers is the rows already in the table: the verifier reads lib/portfolio.ts, the typed seed source, so a status that predates the constraint (or arrives by a hand-run UPDATE) is invisible to it while publicStageFromStatus() files it under "exploring" and /portfolio renders it as Exploring. #323 checked remote dev and prod by hand and found them clean; this is the check that means nobody has to do that again. verify:portfolio now reads applications.status directly and errors on any value outside the union. It needs a live database, so it runs only when DATABASE_URL is set — CI has none and says it skipped rather than implying it passed. VerificationProblem gains an optional observedStatus so the report can name the offending value; claimedStatus is pinned to ProjectStatus, which is exactly what a bad row doesn't have. Also corrects two API-reference param descriptions that #323's 400 outdated, and points the admin guide's status InfoBox at the audit as the way to find pre-taxonomy rows in bulk. Verified against a local database: clean at 29 rows; a seeded `Planned` row is reported as an error naming "Planned", and clears when the row goes. build, lint, and verify:portfolio pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProfessorPolymorphic
added a commit
that referenced
this pull request
Jul 28, 2026
#324) #323 closed the write path — the admin form is a select over PROJECT_STATUSES and both registry endpoints 400 on anything outside the union. What neither it nor verify:portfolio covers is the rows already in the table: the verifier reads lib/portfolio.ts, the typed seed source, so a status that predates the constraint (or arrives by a hand-run UPDATE) is invisible to it while publicStageFromStatus() files it under "exploring" and /portfolio renders it as Exploring. #323 checked remote dev and prod by hand and found them clean; this is the check that means nobody has to do that again. verify:portfolio now reads applications.status directly and errors on any value outside the union. It needs a live database, so it runs only when DATABASE_URL is set — CI has none and says it skipped rather than implying it passed. VerificationProblem gains an optional observedStatus so the report can name the offending value; claimedStatus is pinned to ProjectStatus, which is exactly what a bad row doesn't have. Also corrects two API-reference param descriptions that #323's 400 outdated, and points the admin guide's status InfoBox at the audit as the way to find pre-taxonomy rows in bulk. Verified against a local database: clean at 29 rows; a seeded `Planned` row is reported as an error naming "Planned", and clears when the row goes. build, lint, and verify:portfolio pass. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The admin registry offered twelve status values, of which three were valid.
in-development,stagingretiredPlanned,TrackedPrototype,Piloting,Production,ArchivedisProjectStatus()is exact-matchidea,approved,productionMeanwhile nine real states — including
pausedandbuilding— were not offered at all.None of it was caught because
applications.statusis plainTEXTwith no CHECK (deliberately — ADR 0001 makes the typed union the source of truth), andpublicStageFromStatus()buckets anything unrecognised asexploringso the UI never crashes. A capitalisedProductionlooked right in the form and in the row, and rendered to a Dean as not-yet-being-built.Root cause: four hand-maintained copies
They had drifted apart.
app/admin/registry/new/page.tsxhappened to be correct while its sibling in[id]/was several vocabulary generations behind. Fixing one more copy by hand just resets the clock, so:lib/portfolio.tsexportsPROJECT_STATUSES, derived fromOPERATIONAL_LABELrather than hand-written. That'sRecord<ProjectStatus, string>, so tsc refuses to compile if a union member is missing, and JS preserves string-key insertion order, so it comes out in ladder order for free. This also removes the old hand-maintainedSTATUSESarray thatisProjectStatus()depended on — itself a latent drift source, since nothing forced it to track the union.selectrather than free-text-with-suggestions: the API validates, and an unconstrained field that 400s on submit is worse than a constrained one. A stored value outside the taxonomy stays selectable as a marked option, so opening an old record doesn't silently rewrite it.GET /api/registryorders byarray_positionover aRecord<ProjectStatus, number>rank, so tsc forces a rank for any new status. The previousCASEranked six statuses that no longer exist and none of the six added since.The hole underneath it
POST /api/registryandPATCH /api/registry/[id]now reject an out-of-union status with a 400 naming the twelve valid values. Fixing the form alone would have left the endpoint wide open.Verified against the running app
A legacy
Plannedrow in a local clone now sorts last viaNULLS LASTinstead of mid-list where the oldCASEput it — an accidental live demonstration.No data to repair. Checked both databases directly: remote dev (29 rows) and prod (15) are already entirely on the current taxonomy.
npm run build,npm run lint,npx tsc --noEmit, andnpm run verify:portfolioall pass.Notes
/docs/admin-guidein Refresh the technical docs against the current codebase #321 with a description of the constraint.🤖 Generated with Claude Code