From 2d61f4845f20b25818fb03c1b14197a5ade43d19 Mon Sep 17 00:00:00 2001 From: ProfessorPolymorphic <116023536+ProfessorPolymorphic@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:04:35 -0700 Subject: [PATCH] Make the ADR 0001 ladder the only lifecycle vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 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 --- app/admin/registry/[id]/page.tsx | 59 ++++++++++++++---------------- app/admin/registry/new/page.tsx | 10 ++--- app/admin/registry/page.tsx | 17 +-------- app/admin/registry/status-style.ts | 35 ++++++++++++++++++ app/api/registry/[id]/route.ts | 20 ++++++++++ app/api/registry/route.ts | 57 +++++++++++++++++++++-------- app/docs/admin-guide/page.tsx | 38 +++++++++---------- lib/portfolio.ts | 31 ++++++++-------- 8 files changed, 165 insertions(+), 102 deletions(-) create mode 100644 app/admin/registry/status-style.ts diff --git a/app/admin/registry/[id]/page.tsx b/app/admin/registry/[id]/page.tsx index 89ecfae..fd6c55b 100644 --- a/app/admin/registry/[id]/page.tsx +++ b/app/admin/registry/[id]/page.tsx @@ -17,34 +17,20 @@ import { type DeploymentEnvironment, type EnterpriseReplacementStatus, } from "@/lib/project-governance"; +import { PROJECT_STATUSES } from "@/lib/portfolio"; +import { statusColor } from "../status-style"; -// Status options now span both submission lifecycle (idea/approved/in-development/ -// staging/production/retired) and operational lifecycle (Planned/Prototype/ -// Piloting/Production/Tracked/Archived). The DB column is plain TEXT and admins -// may enter custom values too. -const STATUS_OPTIONS = [ - "idea", "approved", "in-development", "staging", "production", "retired", - "Planned", "Prototype", "Piloting", "Production", "Tracked", "Archived", -]; +// The ADR 0001 operational ladder, imported rather than retyped — see +// PROJECT_STATUSES in lib/portfolio.ts. This list previously carried the +// legacy submission states and the superseded capitalised union; because +// applications.status is plain TEXT with no CHECK, those saved fine and +// then rendered as "Exploring" on /portfolio. +const STATUS_OPTIONS = PROJECT_STATUSES; const VISIBILITY_OPTIONS = ["public", "embargoed", "internal"] as const; const AI4RA_OPTIONS = ["None", "Core", "Adjacent", "Reference", "UI-parallel"]; const REVIEW_STATUS_OPTIONS = ["", "OIT-endorsed", "Under OIT review", "N/A"]; -const statusColors: Record = { - production: "bg-green-100 text-green-700", - Production: "bg-green-100 text-green-700", - Piloting: "bg-blue-100 text-blue-700", - staging: "bg-blue-100 text-blue-700", - "in-development": "bg-yellow-100 text-yellow-700", - Prototype: "bg-yellow-100 text-yellow-700", - approved: "bg-purple-100 text-purple-700", - idea: "bg-gray-100 text-gray-600", - Planned: "bg-gray-100 text-gray-600", - retired: "bg-red-100 text-red-500", - Archived: "bg-red-100 text-red-500", - Tracked: "bg-violet-100 text-violet-700", -}; const visibilityColors: Record = { public: "bg-green-100 text-green-700", @@ -545,7 +531,7 @@ export default function RegistryDetailPage() {
{app.status} @@ -613,20 +599,31 @@ export default function RegistryDetailPage() {
- setForm({ ...form, status: e.target.value })} className={inputCls} - /> - + > + {!STATUS_OPTIONS.includes(form.status as (typeof STATUS_OPTIONS)[number]) && ( + + )} {STATUS_OPTIONS.map((s) => ( - ))} - + = { internal: "bg-gray-200 text-gray-700", }; -const statusColors: Record = { - production: "bg-green-100 text-green-700", - Production: "bg-green-100 text-green-700", - Piloting: "bg-blue-100 text-blue-700", - staging: "bg-blue-100 text-blue-700", - "in-development": "bg-yellow-100 text-yellow-700", - Prototype: "bg-yellow-100 text-yellow-700", - approved: "bg-purple-100 text-purple-700", - idea: "bg-gray-100 text-gray-600", - Planned: "bg-gray-100 text-gray-600", - retired: "bg-red-100 text-red-500", - Archived: "bg-red-100 text-red-500", - Tracked: "bg-violet-100 text-violet-700", -}; const usd = new Intl.NumberFormat("en-US", { style: "currency", @@ -189,7 +176,7 @@ export default async function AdminRegistryPage() { {apps.map((app) => { - const sc = statusColors[app.status] || statusColors.idea; + const sc = statusColor(app.status); const tc = tierLabels[app.tier] || tierLabels[1]; const vc = visibilityColors[app.visibility_tier] || diff --git a/app/admin/registry/status-style.ts b/app/admin/registry/status-style.ts new file mode 100644 index 0000000..5e2db1e --- /dev/null +++ b/app/admin/registry/status-style.ts @@ -0,0 +1,35 @@ +// Status pill colours for the admin registry surfaces. +// +// Colours key on the PUBLIC STAGE, not the operational status. There are +// twelve operational statuses and only six stages, so this is both fewer +// entries and — more importantly — self-maintaining: adding a status to +// the ADR 0001 ladder gives it a colour automatically via the rollup, +// instead of falling through to a default. +// +// `Record` is exhaustive, so tsc fails the build if +// a new stage ever lands without a colour here. +// +// Deliberately not the public palette in lib/portfolio.ts +// (PUBLIC_STAGE_CHIP): these are solid admin pills, not the bordered +// chips the public surfaces use, and .impeccable.md's restraint rules +// govern the public site rather than internal tooling. + +import { publicStageFromStatus, type PublicStage } from "@/lib/portfolio"; + +const stageColors: Record = { + exploring: "bg-gray-100 text-gray-600", + building: "bg-yellow-100 text-yellow-700", + live: "bg-green-100 text-green-700", + paused: "bg-amber-100 text-amber-800", + retired: "bg-red-100 text-red-500", + tracked: "bg-violet-100 text-violet-700", +}; + +/** + * Tailwind classes for a status pill. Accepts a raw DB string — anything + * outside the union rolls up to `exploring` via publicStageFromStatus, + * matching what the public site would show for the same row. + */ +export function statusColor(status: string): string { + return stageColors[publicStageFromStatus(status)]; +} diff --git a/app/api/registry/[id]/route.ts b/app/api/registry/[id]/route.ts index 95cb162..16376c6 100644 --- a/app/api/registry/[id]/route.ts +++ b/app/api/registry/[id]/route.ts @@ -4,6 +4,7 @@ import { isDeploymentEnvironment, isEnterpriseReplacementStatus, } from "@/lib/project-governance"; +import { isProjectStatus, PROJECT_STATUSES } from "@/lib/portfolio"; // All applications columns the registry detail page reads. Stays in lockstep // with the registry schema through db/migrations/012_project_governance_tracking.sql. @@ -98,6 +99,25 @@ export async function PATCH( ); } + // applications.status carries no DB CHECK by design (ADR 0001 — the + // typed union is the source of truth). That left the column open to + // any string via this endpoint, and publicStageFromStatus() buckets + // an unrecognised value as `exploring`, so a typo or a stale + // vocabulary would quietly misfile a project on /portfolio rather + // than fail. Reject it here instead. + if ( + "status" in body && + (typeof body.status !== "string" || !isProjectStatus(body.status)) + ) { + return NextResponse.json( + { + error: "Invalid status", + detail: `status must be one of the ADR 0001 operational states: ${PROJECT_STATUSES.join(", ")}`, + }, + { status: 400 } + ); + } + const replacementFields = [ "enterprise_replacement_status", "existing_enterprise_system_name", diff --git a/app/api/registry/route.ts b/app/api/registry/route.ts index a7bfed5..926b0b3 100644 --- a/app/api/registry/route.ts +++ b/app/api/registry/route.ts @@ -4,6 +4,31 @@ import { isDeploymentEnvironment, isEnterpriseReplacementStatus, } from "@/lib/project-governance"; +import { isProjectStatus, PROJECT_STATUSES, type ProjectStatus } from "@/lib/portfolio"; + +// Admin list ordering: most-live first, then winding-down, then +// externally-owned. Typed as Record so tsc forces +// a rank for any status added to the ADR 0001 ladder — the previous +// hand-written CASE ranked six statuses that no longer exist and none of +// the six that had been added since. +const STATUS_RANK: Record = { + production: 1, + maintained: 2, + piloting: 3, + building: 4, + prototype: 5, + approved: 6, + scoping: 7, + idea: 8, + paused: 9, + sunsetting: 10, + archived: 11, + tracked: 12, +}; + +const SORT_ORDER = (Object.keys(STATUS_RANK) as ProjectStatus[]).sort( + (a, b) => STATUS_RANK[a] - STATUS_RANK[b] +); // GET /api/registry — list all applications export async function GET() { @@ -23,22 +48,14 @@ export async function GET() { submission_id, created_at, updated_at FROM applications ORDER BY - CASE status - WHEN 'production' THEN 1 - WHEN 'Production' THEN 1 - WHEN 'staging' THEN 2 - WHEN 'Piloting' THEN 2 - WHEN 'in-development' THEN 3 - WHEN 'Prototype' THEN 3 - WHEN 'approved' THEN 4 - WHEN 'idea' THEN 5 - WHEN 'Planned' THEN 5 - WHEN 'retired' THEN 6 - WHEN 'Archived' THEN 6 - WHEN 'Tracked' THEN 7 - END, + -- Ladder order, most-live first. Driven by PROJECT_STATUSES so a + -- new ADR 0001 status can't silently sort last; anything outside + -- the union sorts to the end (array_position returns NULL, and + -- NULLS LAST makes that explicit rather than accidental). + array_position($1::text[], status) NULLS LAST, updated_at DESC - LIMIT 500` + LIMIT 500`, + [SORT_ORDER] ); return NextResponse.json(rows); } catch (error) { @@ -140,6 +157,16 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + // Same rule as PATCH — the column has no CHECK, so guard it here. + if (status !== undefined && (typeof status !== "string" || !isProjectStatus(status))) { + return NextResponse.json( + { + error: "Invalid status", + detail: `status must be one of the ADR 0001 operational states: ${PROJECT_STATUSES.join(", ")}`, + }, + { status: 400 } + ); + } const existingSystemName = typeof existing_enterprise_system_name === "string" diff --git a/app/docs/admin-guide/page.tsx b/app/docs/admin-guide/page.tsx index 8938f07..8a1ecb7 100644 --- a/app/docs/admin-guide/page.tsx +++ b/app/docs/admin-guide/page.tsx @@ -120,31 +120,29 @@ export default function AdminGuideDocsPage() {
  • tracked — externally owned; IIDS observes but did not build
  • - +

    - STATUS_OPTIONS in{" "} - app/admin/registry/[id]/page.tsx still offers the legacy - submission states (in-development, staging,{" "} - retired) and the pre-ADR-0001 capitalised union - (Planned, Prototype, Piloting,{" "} - Production, Tracked, Archived). - None of those are members of the current taxonomy. + The status control is a select built from{" "} + PROJECT_STATUSES in lib/portfolio.ts, and{" "} + POST /api/registry and{" "} + PATCH /api/registry/[id] both reject a status outside + the union with a 400. You cannot save a value the + taxonomy doesn't know.

    - applications.status is plain TEXT with no - CHECK constraint, so such a value writes successfully, and{" "} - publicStageFromStatus() buckets anything unrecognised as{" "} - Exploring so the UI never crashes. The practical effect: - setting a status here can silently misfile a project on{" "} - /portfolio.{" "} - npm run verify:portfolio will not catch it — the verifier - reads lib/portfolio.ts, not the database. + This used to be a free-text input with a suggestion list, and the + suggestions were the legacy submission states plus the pre-ADR-0001 + capitalised union. Because applications.status is plain{" "} + TEXT with no CHECK constraint, those saved successfully, + and publicStageFromStatus() buckets anything + unrecognised as Exploring — so a project could be quietly + misfiled on /portfolio. A July 2026 audit found four + divergent copies of the list; there is now one.

    - Until that list is fixed, prefer editing{" "} - lib/portfolio.ts and re-seeding over changing a - status here. Tracked as a follow-up to the July 2026 documentation - audit. + If you open a record whose stored status predates the taxonomy, the + select shows it as a marked option so the record isn't silently + rewritten on load — pick a replacement and save.

    diff --git a/lib/portfolio.ts b/lib/portfolio.ts index 43d026e..ca5226a 100644 --- a/lib/portfolio.ts +++ b/lib/portfolio.ts @@ -1105,23 +1105,8 @@ export function computePublicStage(status: ProjectStatus): PublicStage { } } -const STATUSES: ReadonlyArray = [ - "idea", - "scoping", - "approved", - "building", - "prototype", - "piloting", - "production", - "maintained", - "paused", - "sunsetting", - "archived", - "tracked", -]; - export function isProjectStatus(s: string): s is ProjectStatus { - return (STATUSES as readonly string[]).includes(s); + return (PROJECT_STATUSES as readonly string[]).includes(s); } // String-input version of computePublicStage. Postgres-sourced rows @@ -1251,6 +1236,20 @@ export const OPERATIONAL_LABEL: Record = { tracked: "Tracked", }; +// The operational ladder as an ordered list — the single source any UI +// should build a status picker from. Derived from OPERATIONAL_LABEL +// rather than hand-maintained: that record is `Record`, so tsc refuses to compile if a union member is missing, and +// JS preserves insertion order for string keys, so this comes out in +// ladder order for free. +// +// Hand-maintained copies of this list are how the taxonomy drifted before +// (a July 2026 audit found four divergent copies, two of them wrong). +// Import this instead of retyping the values. +export const PROJECT_STATUSES = Object.keys( + OPERATIONAL_LABEL +) as ProjectStatus[]; + // Tooltip-grade definitions for the colored chips on PortfolioCard. Each // string is short enough to read in a native `title` tooltip; together // they replace the bottom-of-page "How to read this inventory" callout.