Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 28 additions & 31 deletions app/admin/registry/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, string> = {
public: "bg-green-100 text-green-700",
Expand Down Expand Up @@ -545,7 +531,7 @@ export default function RegistryDetailPage() {
<div className="flex flex-col items-end gap-2">
<span
className={`rounded-full px-3 py-1 text-sm font-semibold ${
statusColors[app.status] || statusColors.idea
statusColor(app.status)
}`}
>
{app.status}
Expand Down Expand Up @@ -613,20 +599,31 @@ export default function RegistryDetailPage() {
<div className="grid gap-4 md:grid-cols-3">
<Field
label="Status"
hint="Free-text; common values listed."
hint="ADR 0001 operational ladder. The API rejects anything else."
>
<input
type="text"
list="status-options"
{/* Was a free-text input with a datalist of suggestions, which
is how legacy values kept getting saved. The API now
validates, so the control is constrained to match — an
unconstrained field that 400s on submit is worse than a
select. Values that predate the taxonomy stay selectable
only if already on the row (see below), so opening an old
record doesn't silently rewrite its status. */}
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className={inputCls}
/>
<datalist id="status-options">
>
{!STATUS_OPTIONS.includes(form.status as (typeof STATUS_OPTIONS)[number]) && (
<option value={form.status}>
{form.status} — not in the taxonomy, pick a replacement
</option>
)}
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s} />
<option key={s} value={s}>
{s}
</option>
))}
</datalist>
</select>
</Field>
<Field
label="Visibility tier"
Expand Down
10 changes: 5 additions & 5 deletions app/admin/registry/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import {
type DeploymentEnvironment,
type EnterpriseReplacementStatus,
} from "@/lib/project-governance";
import { PROJECT_STATUSES } from "@/lib/portfolio";

// Lifecycle taxonomy from ADR 0001 — see lib/portfolio.ts ProjectStatus.
const STATUS_OPTIONS = [
"idea", "scoping", "approved", "building", "prototype", "piloting",
"production", "maintained", "paused", "sunsetting", "archived", "tracked",
];
// Imported, not retyped. This copy happened to be correct while its
// sibling in [id]/page.tsx had drifted to the legacy vocabulary — which
// is the argument for having exactly one list.
const STATUS_OPTIONS = PROJECT_STATUSES;

const VISIBILITY_OPTIONS = ["public", "embargoed", "internal"] as const;
const AI4RA_OPTIONS = ["None", "Core", "Adjacent", "Reference", "UI-parallel"];
Expand Down
17 changes: 2 additions & 15 deletions app/admin/registry/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type DeploymentEnvironment,
type EnterpriseReplacementStatus,
} from "@/lib/project-governance";
import { statusColor } from "./status-style";

export const dynamic = "force-dynamic";

Expand All @@ -21,20 +22,6 @@ const visibilityColors: Record<string, string> = {
internal: "bg-gray-200 text-gray-700",
};

const statusColors: Record<string, string> = {
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",
Expand Down Expand Up @@ -189,7 +176,7 @@ export default async function AdminRegistryPage() {
</thead>
<tbody className="divide-y divide-gray-100">
{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] ||
Expand Down
35 changes: 35 additions & 0 deletions app/admin/registry/status-style.ts
Original file line number Diff line number Diff line change
@@ -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<PublicStage, string>` 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<PublicStage, string> = {
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)];
}
20 changes: 20 additions & 0 deletions app/api/registry/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 42 additions & 15 deletions app/api/registry/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectStatus, number> 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<ProjectStatus, number> = {
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() {
Expand All @@ -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) {
Expand Down Expand Up @@ -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"
Expand Down
38 changes: 18 additions & 20 deletions app/docs/admin-guide/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,31 +120,29 @@ export default function AdminGuideDocsPage() {
<li><strong>tracked</strong> — externally owned; IIDS observes but did not build</li>
</ul>

<InfoBox type="warning" title="The registry form's status list is stale">
<InfoBox type="info" title="How the status field is constrained">
<p>
<code>STATUS_OPTIONS</code> in{" "}
<code>app/admin/registry/[id]/page.tsx</code> still offers the legacy
submission states (<code>in-development</code>, <code>staging</code>,{" "}
<code>retired</code>) and the pre-ADR-0001 capitalised union
(<code>Planned</code>, <code>Prototype</code>, <code>Piloting</code>,{" "}
<code>Production</code>, <code>Tracked</code>, <code>Archived</code>).
None of those are members of the current taxonomy.
The status control is a <strong>select</strong> built from{" "}
<code>PROJECT_STATUSES</code> in <code>lib/portfolio.ts</code>, and{" "}
<code>POST /api/registry</code> and{" "}
<code>PATCH /api/registry/[id]</code> both reject a status outside
the union with a <strong>400</strong>. You cannot save a value the
taxonomy doesn&apos;t know.
</p>
<p className="mt-2">
<code>applications.status</code> is plain <code>TEXT</code> with no
CHECK constraint, so such a value writes successfully, and{" "}
<code>publicStageFromStatus()</code> buckets anything unrecognised as{" "}
<em>Exploring</em> so the UI never crashes. The practical effect:
setting a status here can silently misfile a project on{" "}
<code>/portfolio</code>.{" "}
<code>npm run verify:portfolio</code> will not catch it — the verifier
reads <code>lib/portfolio.ts</code>, 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 <code>applications.status</code> is plain{" "}
<code>TEXT</code> with no CHECK constraint, those saved successfully,
and <code>publicStageFromStatus()</code> buckets anything
unrecognised as <em>Exploring</em> — so a project could be quietly
misfiled on <code>/portfolio</code>. A July 2026 audit found four
divergent copies of the list; there is now one.
</p>
<p className="mt-2">
<strong>Until that list is fixed, prefer editing{" "}
<code>lib/portfolio.ts</code> and re-seeding</strong> 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&apos;t silently
rewritten on load — pick a replacement and save.
</p>
</InfoBox>

Expand Down
31 changes: 15 additions & 16 deletions lib/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,23 +1105,8 @@ export function computePublicStage(status: ProjectStatus): PublicStage {
}
}

const STATUSES: ReadonlyArray<ProjectStatus> = [
"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
Expand Down Expand Up @@ -1251,6 +1236,20 @@ export const OPERATIONAL_LABEL: Record<ProjectStatus, string> = {
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<ProjectStatus,
// string>`, 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.
Expand Down
Loading