Skip to content

Make the ADR 0001 ladder the only lifecycle vocabulary - #323

Merged
ProfessorPolymorphic merged 1 commit into
mainfrom
fix/single-status-vocabulary
Jul 27, 2026
Merged

Make the ADR 0001 ladder the only lifecycle vocabulary#323
ProfessorPolymorphic merged 1 commit into
mainfrom
fix/single-status-vocabulary

Conversation

@ProfessorPolymorphic

Copy link
Copy Markdown
Collaborator

The admin registry offered twelve status values, of which three were valid.

Offered Problem
in-development, staging Legacy submission states; never in the operational ladder
retired Not a status at all — a PublicStage slug
Planned, Tracked The union ADR 0001 supersedes
Prototype, Piloting, Production, Archived Right words, wrong caseisProjectStatus() is exact-match
idea, approved, production ✅ valid

Meanwhile nine real states — including paused and building — were not offered at all.

None of it 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.

Root cause: four hand-maintained copies

They had drifted apart. app/admin/registry/new/page.tsx happened 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.ts exports PROJECT_STATUSES, derived from OPERATIONAL_LABEL rather than hand-written. That's 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. This also removes the old hand-maintained STATUSES array that isProjectStatus() depended on — itself a latent drift source, since nothing forced it to track the union.
  • Both admin forms import it. The detail form is now a select rather 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.
  • Status pills colour by public stage via one shared helper — six stages instead of twelve statuses, and 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, so 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.

The hole underneath 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 wide open.

Verified against the running app

PATCH {"status":"in-development"} → 400 Invalid status
PATCH {"status":"Production"}     → 400 Invalid status
PATCH {"status":"production"}     → 200 OK
select options → idea, scoping, approved, building, prototype, piloting,
                 production, maintained, paused, sunsetting, archived, tracked
list pills     → production/piloting green · building yellow · approved grey · paused amber

A legacy Planned row in a local clone now sorts last via NULLS LAST instead of mid-list where the old CASE put 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, and npm run verify:portfolio all pass.

Notes

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

Vendored strategic-plan submodule is behind upstream

The vendored submodule pointer at vendor/strategic-plan is pinned to 434de6e, which was committed 117 day(s) ago (threshold: 14 days).

The submodule is pinned at upstream main HEAD, but that HEAD itself is older than the threshold. No action needed in this repo — upstream simply hasn't moved.

This is an advisory comment from strategic-plan-pr-summary.yml. It does not block the build. Threshold is configurable via the STALE_AFTER_DAYS workflow env var (currently 14). This comment is updated in place across pushes.

@ProfessorPolymorphic
ProfessorPolymorphic merged commit 5446b6f into main Jul 27, 2026
7 checks passed
@ProfessorPolymorphic
ProfessorPolymorphic deleted the fix/single-status-vocabulary branch July 27, 2026 16:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant