From 6f0409844cbe3a09a6d102c8cbd6a5cd9a98d018 Mon Sep 17 00:00:00 2001 From: satvikOS Date: Thu, 20 Aug 2026 20:32:00 -0400 Subject: [PATCH 1/6] feat(capability): the surfaces that act resolve availability, not just the nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth slice of the capability L, and it starts with a correction. ── The binding was paper ──────────────────────────────────────────────────── The item's remaining work read "31 of 35 routes are deferred rather than bound", which implies the other four resolved through the registry. They did not. ROUTE_CAPABILITY was read by CI and by nothing at runtime: the side nav resolved the capability and dropped the LINK, and the route behind it rendered every figure to anyone who typed the URL, its server action still wrote, and its endpoint still answered a tab that had been open since before the manifest changed. Availability was enforced in the navigation and nowhere else, so the honest count was 0 of 35 ENFORCED — and a binding nothing enforces is worse than no binding, because it reads as done. ── One gate, three forms ──────────────────────────────────────────────────── `capability-registry/gate.ts` is how a server surface asks: offeredTo(id, institutionId) a decision, for a surface that renders requireOffered(id, institutionId) throws, for a surface that must not act capabilityRefusal(decision) the response, for a route handler It takes an institutionId, never a session — nothing under `lib/capability-registry/` imports a role, a user or `lib/rbac`, and a test still asserts that. Both gates run on the mutating surfaces, as separate expressions, because a promotion must not unlock a capability the cell was never certified to offer. Wired to the surface that mutates (createMemoryCard), the surface that is polled (/api/reports/pulse) and all four bound pages. Availability now travels with a `name`, so a refusal can be rendered without a hand-typed label beside the id it gates on. ── 501, not 403 ───────────────────────────────────────────────────────────── The pulse handler already answers 403 to someone with no OSE role, which is the answer to WHO is asking. If a withheld capability answered 403 too, the one place a client can tell the two questions apart would stop showing the difference. 404 would read as a typo and could state no reason; 503 promises that retrying works, and a module a tenant's manifest does not declare is not a temporary condition. ── Enforced, and accounted for ────────────────────────────────────────────── `enforcement.test.ts` asserts the join every binding claims: a bound page, a bound API handler, and any `actions.ts` beside a bound route resolve the id they are bound to, through the gate module rather than a same-named local. The action rule is DERIVED from the page binding, so there is no second list to keep in step — it reaches one file today and will reach the next one on the day it is written, which is exactly when nobody is thinking about capabilities. `surfaces.ts` extends the route ratchet to the 23 API handlers, which were in no list at all: 1 bound, 22 deferred with reasons. One of those reasons is a decision rather than a gap — /api/platform/reconcile is where the manifest ARRIVES, and gating it on a capability read from that manifest would mean a tenant needs a manifest before it can be given one. ── Controls ───────────────────────────────────────────────────────────────── Each run red, then restored. Manifest without the module: /reports renders the refusal and no figures leave the server; createMemoryCard throws and `$transaction` is never reached; /api/reports/pulse answers 501 with no counts in the body. Gate deleted from a page, from the action and from the handler: three separate assertions fail. A new API route with no entry, and a binding to an unregistered capability: the ratchet fails. 1383 jest across 96 suites, clean tsc, clean next build. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/(app)/orgs/[slug]/handoff/page.tsx | 9 ++ .../(app)/orgs/[slug]/memory/actions.test.ts | 120 ++++++++++++++ .../app/(app)/orgs/[slug]/memory/actions.ts | 14 ++ .../src/app/(app)/orgs/[slug]/memory/page.tsx | 12 ++ .../src/app/(app)/reports/finance/page.tsx | 9 ++ apps/web/src/app/(app)/reports/page.test.tsx | 66 ++++++++ apps/web/src/app/(app)/reports/page.tsx | 10 ++ .../src/app/api/reports/pulse/route.test.ts | 106 +++++++++++++ apps/web/src/app/api/reports/pulse/route.ts | 10 ++ .../src/components/CapabilityUnavailable.tsx | 83 ++++++++++ .../capability-registry/enforcement.test.ts | 150 ++++++++++++++++++ .../src/lib/capability-registry/gate.test.ts | 145 +++++++++++++++++ apps/web/src/lib/capability-registry/gate.ts | 124 +++++++++++++++ .../src/lib/capability-registry/resolve.ts | 2 + .../lib/capability-registry/surfaces.test.ts | 82 ++++++++++ .../src/lib/capability-registry/surfaces.ts | 77 +++++++++ apps/web/src/lib/capability-registry/types.ts | 9 ++ docs/PROGRAM-BACKLOG.md | 30 +++- 18 files changed, 1054 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts create mode 100644 apps/web/src/app/api/reports/pulse/route.test.ts create mode 100644 apps/web/src/components/CapabilityUnavailable.tsx create mode 100644 apps/web/src/lib/capability-registry/enforcement.test.ts create mode 100644 apps/web/src/lib/capability-registry/gate.test.ts create mode 100644 apps/web/src/lib/capability-registry/gate.ts create mode 100644 apps/web/src/lib/capability-registry/surfaces.test.ts create mode 100644 apps/web/src/lib/capability-registry/surfaces.ts diff --git a/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx index af4babb9..0321ff9e 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx @@ -9,6 +9,8 @@ import { toSeatFacts, } from "@/lib/seats" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { canViewOrg, getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -73,6 +75,13 @@ export default async function HandoffPage({ const ctx = await getUserContext(session.user.id) if (!canViewOrg(ctx, org)) notFound() + // Bound to `collaboration.institutional-memory` in routes.ts — the handoff + // packet is the preserved knowledge for a seat, so it is the same capability + // the memory route carries, and a tenant that does not have one does not + // have the other. + const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) + if (!memory.available) return + const now = new Date() const [deliverables, pendingApprovals, budgetLines] = await Promise.all([ db.deliverable.findMany({ diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts new file mode 100644 index 00000000..d4729bc9 --- /dev/null +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.test.ts @@ -0,0 +1,120 @@ +import { CapabilityUnavailableError } from "@/lib/capability-registry/gate" +import { createMemoryCard } from "./actions" + +/** + * The mutating surface refuses, and the refusal is reachable. + * + * ── Why this test exists at all ───────────────────────────────────────────── + * + * /orgs/[slug]/memory has been bound to `collaboration.institutional-memory` + * since the second slice, and until this one the binding was read by CI and by + * nothing at runtime. The side nav resolved the capability and dropped the + * link; the page still rendered its "Add to memory" form, and this action still + * wrote. A gate that only removes a link cannot refuse a form POST, and a form + * POST is what writes to the database. + * + * So the assertion that matters is not that an error is thrown — it is that + * `$transaction` is never reached. A refusal that fires after the write is not + * a refusal. + * + * ── The two questions, kept apart ─────────────────────────────────────────── + * + * `canContribute` is mocked TRUE throughout. Every refusal below is therefore + * the deployment's answer, not the person's: someone who unambiguously may + * write is still refused because this tenant is not offered the capability. + * That is the property the registry exists for — promotion is not procurement + * — and it would be invisible in a test where the role could also explain it. + */ + +const mockState: { modules: string[] | null; wrote: boolean } = { + modules: ["organizations@1.0.0"], + wrote: false, +} + +jest.mock("next/cache", () => ({ revalidatePath: () => undefined })) + +jest.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "user-1" } }) })) + +jest.mock("@/lib/tenant-scope", () => ({ + withTenantScope: (_userId: string, fn: () => Promise) => fn(), +})) + +jest.mock("@/lib/rbac", () => ({ + getUserContext: async () => ({ institutionRoles: [], orgRoles: [] }), + // The permission question, answered YES for every case below. + canContribute: () => true, +})) + +jest.mock("@/lib/db", () => ({ + db: { + organization: { + findUnique: async () => ({ id: "org-1", institutionId: "inst-1", slug: "chess" }), + }, + role: { findFirst: async () => null }, + auditEvent: { + findFirst: async () => + mockState.modules === null ? null : { metadata: { modules: mockState.modules } }, + create: () => ({}), + }, + memoryRecord: { create: () => ({}) }, + $transaction: async () => { + mockState.wrote = true + return [] + }, + }, +})) + +function card(): FormData { + const form = new FormData() + form.set("type", "LESSON") + form.set("title", "Book the hall before the caterer") + form.set("body", "The hall goes first every year and the caterer never does.") + return form +} + +beforeEach(() => { + mockState.modules = ["organizations@1.0.0"] + mockState.wrote = false +}) + +describe("createMemoryCard resolves availability before it writes", () => { + it("writes when the tenant's manifest declares the module", async () => { + // The control's control: without this, every assertion below could pass + // because the action never works. + await createMemoryCard("chess", card()) + expect(mockState.wrote).toBe(true) + }) + + it("refuses, and writes nothing, when the manifest does not declare it", async () => { + mockState.modules = ["reporting@1.0.0"] + + await expect(createMemoryCard("chess", card())).rejects.toBeInstanceOf( + CapabilityUnavailableError, + ) + expect(mockState.wrote).toBe(false) + }) + + it("names the module the tenant is missing, not just 'unavailable'", async () => { + mockState.modules = [] + + let refusal: CapabilityUnavailableError | null = null + try { + await createMemoryCard("chess", card()) + } catch (e) { + refusal = e as CapabilityUnavailableError + } + if (!refusal) throw new Error("the action wrote; the capability gate never fired") + + expect(refusal.decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") + expect(refusal.decision.refusals[0].detail).toContain("organizations") + }) + + it("still writes for a tenant nobody has reconciled", async () => { + // null is not []. A hand-built pilot has no manifest, and refusing it here + // would take a working product dark on a document that does not exist. + mockState.modules = null + + await createMemoryCard("chess", card()) + expect(mockState.wrote).toBe(true) + }) +}) diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts index 46469d74..702c41ec 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/actions.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache" import { auth } from "@/lib/auth" +import { requireOffered } from "@/lib/capability-registry/gate" import { db } from "@/lib/db" import { canContribute, getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -19,6 +20,19 @@ export async function createMemoryCard(slug: string, formData: FormData) { const ctx = await getUserContext(userId) if (!canContribute(ctx, org)) throw new Error("You need an active role to add memory") + // A second, different question, asked as a separate expression on purpose. + // `canContribute` says this PERSON may write; this says this DEPLOYMENT + // offers institutional memory to this club's institution at all. Merging + // them would let a president's seat unlock a capability the cell was never + // certified to offer — promotion is not procurement. + // + // The page refuses first and renders the reason, so nobody meets this throw + // by clicking. What reaches it is an invocation with no page behind it: a + // tab open since before the manifest changed, a replayed POST, a form + // action called directly. That is exactly the case a nav-only gate could + // not refuse, and the reason a mutating surface resolves for itself. + await requireOffered("collaboration.institutional-memory", org.institutionId) + const roleIdRaw = String(formData.get("roleId") ?? "") const parsed = knowledgeCardSchema.safeParse({ title: String(formData.get("title") ?? "").trim(), diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx index 0d1e8333..b7ef2961 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx @@ -10,6 +10,8 @@ import { Timer, } from "@/components/ui/icons" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { canContribute, canViewOrg, getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -55,6 +57,16 @@ export default async function MemoryPage({ const ctx = await getUserContext(session.user.id) if (!canViewOrg(ctx, org)) notFound() + + // What this person may do, and what this deployment offers, asked + // separately. `canViewOrg` above is the first; this is the second, and it + // is the one the side nav has been answering alone — a club officer who + // typed this URL reached the page and its "Add to memory" form whatever the + // tenant's manifest said. `createMemoryCard` refuses too, so the form + // cannot be replayed past this; the page refuses first so the person gets a + // reason instead of an error card. + const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) + if (!memory.available) return const canAdd = canContribute(ctx, org) const allCards = await db.memoryRecord.findMany({ diff --git a/apps/web/src/app/(app)/reports/finance/page.tsx b/apps/web/src/app/(app)/reports/finance/page.tsx index e1cccbe2..31736b2f 100644 --- a/apps/web/src/app/(app)/reports/finance/page.tsx +++ b/apps/web/src/app/(app)/reports/finance/page.tsx @@ -1,6 +1,8 @@ import { notFound, redirect } from "next/navigation" import Link from "next/link" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { getUserContext } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" @@ -30,6 +32,13 @@ export default async function PortfolioFinancePage() { const institutionId = ctx.institutionRoles[0]?.institutionId if (!institutionId) notFound() // OSE only + // Bound to the same capability as /reports, and gated the same way. A + // tenant without reporting must not reach the consolidation view through + // the link on /reports either — one binding, enforced at every route that + // carries it. + const reporting = await offeredTo("collaboration.reporting", institutionId) + if (!reporting.available) return + const orgs = await db.organization.findMany({ where: { institutionId, status: "ACTIVE" }, select: { diff --git a/apps/web/src/app/(app)/reports/page.test.tsx b/apps/web/src/app/(app)/reports/page.test.tsx index 9a3ad91b..28203944 100644 --- a/apps/web/src/app/(app)/reports/page.test.tsx +++ b/apps/web/src/app/(app)/reports/page.test.tsx @@ -43,6 +43,17 @@ jest.mock("@/lib/seats-data", () => ({ ], })) +/** + * What this tenant's DeploymentManifest declares, per test. + * + * The page resolves `collaboration.reporting` before it renders anything, and + * the resolver's input is the newest `Tenant.Reconciled` audit row — so the + * manifest is set by answering that read. `mock`-prefixed because `jest.mock` + * is hoisted above every other statement in the file and may only close over + * names that say so. + */ +const mockManifest: { modules: string[] | null } = { modules: ["reporting@1.0.0"] } + jest.mock("@/lib/db", () => { const decidedAt = new Date(Date.now() - 2 * 86_400_000) return { @@ -74,6 +85,10 @@ jest.mock("@/lib/db", () => { findMany: async () => [{ type: "LESSON", createdAt: new Date(Date.now() - 86_400_000) }], }, auditEvent: { + // The manifest read. `null` is a tenant nobody has reconciled, which is + // deliberately not the same answer as a manifest declaring nothing. + findFirst: async () => + mockManifest.modules === null ? null : { metadata: { modules: mockManifest.modules } }, // 10pm on July 31st in Rochester, which is already August at a server // running in UTC — the row exists to make the zone visible. findMany: async () => [ @@ -95,6 +110,10 @@ async function render(searchParams: { range?: string } = {}): Promise { return renderToStaticMarkup(await ReportsPage({ searchParams: Promise.resolve(searchParams) })) } +beforeEach(() => { + mockManifest.modules = ["reporting@1.0.0"] +}) + describe("reports page", () => { it("renders the median once, and the tile and the card subtitle are byte-identical", async () => { const html = await render() @@ -156,3 +175,50 @@ describe("range filter", () => { expect(html).toContain('href="/reports?range=all"') }) }) + +/** + * The route enforces its own binding. + * + * `capability-registry/routes.ts` has bound /reports to + * `collaboration.reporting` since the second slice, and for two slices the only + * thing that read the binding was the side nav: a tenant whose manifest dropped + * the reporting module lost the LINK and kept the page, in full, to anyone who + * typed the URL or had it bookmarked. This is that hole, held shut — and the + * assertion runs the whole page, so it fails if the gate is moved below the + * queries as easily as if it is deleted. + */ +describe("availability is enforced at the route, not only in the nav", () => { + it("renders the refusal instead of the metrics when the manifest omits reporting", async () => { + mockManifest.modules = ["organizations@1.0.0"] + + const html = await render() + + expect(html).toContain("is not available here") + expect(html).toContain("MODULE_NOT_DECLARED") + // The figures are the point: not a hidden link, not a greyed tile — the + // institution's numbers do not leave the server. + expect(html).not.toContain("Median time to decision") + expect(html).not.toContain("Refreshes every 15 seconds") + }) + + it("names the capability and its owner, so the refusal is actionable", async () => { + mockManifest.modules = [] + + const html = await render() + + expect(html).toContain("Institution-wide reporting and analytics") + expect(html).toContain("owned by platform") + }) + + it("still renders for a tenant nobody has reconciled", async () => { + // null is not []. Filtering against a manifest that does not exist would + // take the hand-built pilot dark, which is the failure the third slice's + // `unpublished` state exists to prevent. + mockManifest.modules = null + + const html = await render() + + expect(html).toContain("Median time to decision") + expect(html).not.toContain("is not available here") + }) +}) diff --git a/apps/web/src/app/(app)/reports/page.tsx b/apps/web/src/app/(app)/reports/page.tsx index aad1b8fa..55c335b8 100644 --- a/apps/web/src/app/(app)/reports/page.tsx +++ b/apps/web/src/app/(app)/reports/page.tsx @@ -4,6 +4,8 @@ import { capSeatNames, summariseSeats, summariseSeatsByName } from "@/lib/seats" import { institutionSeatsWhere, loadSeatFacts } from "@/lib/seats-data" import { buildReportsAnalytics, parseReportsRange, REPORTS_RANGES } from "@/lib/reports-analytics" import { auth } from "@/lib/auth" +import { offeredTo } from "@/lib/capability-registry/gate" +import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" import { db } from "@/lib/db" import { getUserContext } from "@/lib/rbac" import { institutionTimeZone } from "@/lib/institution-time" @@ -41,6 +43,14 @@ export default async function ReportsPage({ const institutionId = ctx.institutionRoles[0]?.institutionId if (!institutionId) notFound() // OSE only + // The binding in `capability-registry/routes.ts` says this route depends on + // `collaboration.reporting`. Until this call it said so only to CI: the side + // nav resolved the capability and dropped the link, and the route itself + // rendered every figure to anyone who typed the URL. Enforcing it in the + // navigation alone is a cosmetic gate. + const reporting = await offeredTo("collaboration.reporting", institutionId) + if (!reporting.available) return + // Every calendar boundary on this page is the institution's, not the // server's. Production runs in UTC, so a term or a month decided by the // server's clock files an evening event under the next day — see the diff --git a/apps/web/src/app/api/reports/pulse/route.test.ts b/apps/web/src/app/api/reports/pulse/route.test.ts new file mode 100644 index 00000000..03a27937 --- /dev/null +++ b/apps/web/src/app/api/reports/pulse/route.test.ts @@ -0,0 +1,106 @@ +import { GET } from "./route" + +/** + * The polled endpoint answers the availability question for itself. + * + * ── What was reachable before ─────────────────────────────────────────────── + * + * This is the endpoint behind the strip on /reports, polled every fifteen + * seconds. Availability was enforced in the side nav, so a tenant whose + * manifest dropped the reporting module lost the nav LINK — and any tab already + * open kept polling this handler, which kept answering with the institution's + * live approval, event, seat and conflict counts. Nothing about hiding a link + * closes a socket. + * + * ── 501, and why the status is asserted ───────────────────────────────────── + * + * The handler already answers 403 to a signed-in person with no OSE role. That + * is the answer to WHO is asking. A withheld capability is a different claim — + * this deployment does not offer the functionality to this tenant at all — and + * if both answered 403 the one place a client can tell them apart would stop + * showing the difference. So the status is part of the contract, not an + * incidental. + */ + +const mockState: { modules: string[] | null; institutionRoles: { institutionId: string }[] } = { + modules: ["reporting@1.0.0"], + institutionRoles: [{ institutionId: "inst-1" }], +} + +jest.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "user-1" } }) })) + +jest.mock("@/lib/tenant-scope", () => ({ + withTenantScope: (_userId: string, fn: () => Promise) => fn(), +})) + +jest.mock("@/lib/rbac", () => ({ + getUserContext: async () => ({ institutionRoles: mockState.institutionRoles }), +})) + +jest.mock("@/lib/seats-data", () => ({ + institutionSeatsWhere: () => ({}), + loadSeatFacts: async () => [ + { + organizationId: "org-1", + name: "President", + assignments: [{ status: "ACTIVE", key: "pres@example.edu" }], + holdings: [], + }, + ], +})) + +jest.mock("@/lib/db", () => ({ + db: { + approvalRequest: { count: async () => 2 }, + event: { count: async () => 7 }, + conflictRecord: { count: async () => 1 }, + auditEvent: { + findFirst: async () => + mockState.modules === null ? null : { metadata: { modules: mockState.modules } }, + }, + }, +})) + +beforeEach(() => { + mockState.modules = ["reporting@1.0.0"] + mockState.institutionRoles = [{ institutionId: "inst-1" }] +}) + +describe("GET /api/reports/pulse", () => { + it("answers with the counts when the tenant is offered reporting", async () => { + const response = await GET() + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ publishedEvents: 7, hardConflicts: 1 }) + }) + + it("refuses with 501 when the manifest does not declare the reporting module", async () => { + mockState.modules = ["organizations@1.0.0"] + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(501) + expect(body.error).toBe("capability_unavailable") + expect(body.refusals[0].code).toBe("MODULE_NOT_DECLARED") + // No numbers leave the server on a refusal — the counts are the thing being + // withheld, so a refusal that still carried them would be decoration. + expect(body.publishedEvents).toBeUndefined() + }) + + it("keeps 403 for the person question, so the two refusals stay distinguishable", async () => { + mockState.institutionRoles = [] + + const response = await GET() + + expect(response.status).toBe(403) + }) + + it("answers for a tenant nobody has reconciled", async () => { + // null is not []. The pilot has no published manifest, and this endpoint + // must keep working for it. + mockState.modules = null + + expect((await GET()).status).toBe(200) + }) +}) diff --git a/apps/web/src/app/api/reports/pulse/route.ts b/apps/web/src/app/api/reports/pulse/route.ts index 6002f58c..b526af13 100644 --- a/apps/web/src/app/api/reports/pulse/route.ts +++ b/apps/web/src/app/api/reports/pulse/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server" +import { capabilityRefusal, offeredTo } from "@/lib/capability-registry/gate" import { summariseSeats } from "@/lib/seats" import { institutionSeatsWhere, loadSeatFacts } from "@/lib/seats-data" import { auth } from "@/lib/auth" @@ -31,6 +32,15 @@ export async function GET() { return NextResponse.json({ error: "forbidden" }, { status: 403 }) } + // Two questions, and the 403 above answers only the first. Holding an OSE + // role says this PERSON may read institution metrics; it says nothing about + // whether this DEPLOYMENT offers reporting to their tenant at all. The nav + // entry has resolved that since the first slice — but an endpoint is not + // behind the nav, and a tenant whose manifest drops the reporting module + // kept getting live counts here from a polling tab nobody had reloaded. + const reporting = await offeredTo("collaboration.reporting", institutionId) + if (!reporting.available) return capabilityRefusal(reporting) + const [pendingPresident, pendingOse, publishedEvents, seatFacts, hardConflicts] = await Promise.all([ db.approvalRequest.count({ where: { institutionId, status: "PENDING_PRESIDENT" } }), db.approvalRequest.count({ where: { institutionId, status: "PENDING_OSE" } }), diff --git a/apps/web/src/components/CapabilityUnavailable.tsx b/apps/web/src/components/CapabilityUnavailable.tsx new file mode 100644 index 00000000..589445d8 --- /dev/null +++ b/apps/web/src/components/CapabilityUnavailable.tsx @@ -0,0 +1,83 @@ +import { AlertCircle } from "@/components/ui/icons" +import { Card } from "@/components/ui/Card" +import type { Decision, RefusalCode } from "@/lib/capability-registry/types" + +/** + * What a route renders when this deployment does not offer it. + * + * ── Why a panel and not notFound() ────────────────────────────────────────── + * + * `UNAVAILABLE` is a value in this vocabulary, not an absence — the whole + * registry exists because "we decided not to offer it" and "somebody forgot" + * had been answering identically. A 404 would collapse them again at the last + * step: the person in front of the screen would get "no such page" for a + * deliberate, owned, recorded decision, and would go looking for a broken link. + * + * So the refusal says which thing is unavailable, why in one sentence, and who + * owns it. The last part is not decoration: `owner` is what turns "it's + * broken" into a support ticket that reaches the team that can answer it. + * + * The sentence comes from the refusal CODE rather than the resolver's `detail`. + * Detail is written for whoever is fixing the deployment — it names manifest + * modules and scope axes — and putting that in front of a club treasurer would + * be a different failure of the same kind. The code is on screen too, in small + * text, so a screenshot in a ticket is still actionable. + */ +export function CapabilityUnavailable({ decision }: { decision: Decision }) { + const label = decision.name ?? decision.capability + + return ( +
+ + {/* Outline-only, matching ComingSoon: a hairline ring, never a tinted + plate — this is an ordinary answer, not an alarm. */} +
+ +
+

{label} is not available here

+

{explain(decision)}

+

+ {decision.refusals[0]?.code ?? "UNAVAILABLE"} + {decision.owner ? ` · owned by ${decision.owner}` : ""} +

+
+
+ ) +} + +/** + * One sentence per refusal code, written for the person reading the screen. + * + * The switch is exhaustive by construction: `never` below stops compiling the + * day a sixth `RefusalCode` is added, so a new way to be refused cannot ship + * with a blank panel or a fallback that says nothing. + */ +function explain(decision: Decision): string { + const code: RefusalCode | undefined = decision.refusals[0]?.code + switch (code) { + case "MODULE_NOT_DECLARED": + return "This institution's Tenure deployment does not include it. Nothing is wrong with your account, and nothing here has been lost." + case "STATUS_NOT_LIVE": + return decision.runtimeStatus === "PLANNED" || decision.runtimeStatus === "ARCHITECTED" + ? "It is planned but not built yet, so it is not offered to anyone." + : "It is not at a stage where it can be offered yet." + case "SCOPE_NOT_DECLARED": + return "It is not offered to this institution." + case "CONDITION_NOT_MET": + return "It is built, but it has not cleared every check required before it can be offered." + case "NOT_REGISTERED": + // Reachable only from a surface gating on an id nobody registered, which + // `routes.test.ts` and `surfaces.test.ts` both refuse. If it renders, the + // binding is the bug — so the sentence sends someone to look at it rather + // than telling a user something false about their institution. + return "This part of Tenure is not registered as something this deployment offers. Please report it." + case undefined: + // An unavailable decision always carries at least one refusal; the + // resolver has no path that returns none. Stated rather than assumed. + return "It is not available here." + default: { + const unhandled: never = code + return unhandled + } + } +} diff --git a/apps/web/src/lib/capability-registry/enforcement.test.ts b/apps/web/src/lib/capability-registry/enforcement.test.ts new file mode 100644 index 00000000..53a56dea --- /dev/null +++ b/apps/web/src/lib/capability-registry/enforcement.test.ts @@ -0,0 +1,150 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" +import { API_CAPABILITY } from "./surfaces" +import { ROUTE_CAPABILITY } from "./routes" + +/** + * A binding must be ENFORCED by the surface, not merely declared about it. + * + * ── The defect this closes ────────────────────────────────────────────────── + * + * `routes.ts` has bound four routes to capabilities since the second slice, and + * for two slices nothing read those bindings at runtime. CI checked them, the + * side nav resolved the capability and dropped the link, and the routes + * themselves rendered every figure to anyone who typed the URL — their server + * action still wrote and their polled endpoint still answered. Availability was + * enforced in the NAVIGATION and nowhere else, which is a cosmetic gate: a + * kept-open tab, a bookmark or a replayed POST walks straight past it. + * + * A binding nothing enforces is worse than no binding, because it reads as + * done. So this asserts the join: every route in `ROUTE_CAPABILITY` and every + * handler in `API_CAPABILITY` calls the gate, with the id it is bound to, from + * the gate module rather than from a same-named local. + * + * ── Server actions are DERIVED, not listed ────────────────────────────────── + * + * An `actions.ts` beside a bound route inherits that route's binding, so there + * is no second list to keep in step. Today that rule reaches exactly one file; + * its value is the day someone adds an action next to a route somebody else + * bound, which is precisely when nobody is thinking about capabilities. + * + * ── Why a source scan ─────────────────────────────────────────────────────── + * + * The same reason as `middleware-covers-every-app-route` and the sign-in + * refusal suite: rendering these pages needs a session, a database and a tenant + * scope, and would then prove one path through one of them. The property is + * "this file consults the gate", and the file is where that is legible. + */ + +const APP = path.resolve(__dirname, "../../app") + +/** `/orgs/[slug]/memory` → `src/app/(app)/orgs/[slug]/memory`. */ +function appDirFor(route: string): string { + return path.join(APP, "(app)", route === "/" ? "" : route.slice(1)) +} + +/** `/api/reports/pulse` → `src/app/api/reports/pulse/route.ts`. */ +function apiFileFor(route: string): string { + return path.join(APP, route.slice(1), "route.ts") +} + +const IMPORTS_GATE = /from "@\/lib\/capability-registry\/gate"/ + +/** `fn(""` — the call, with the id it is bound to. */ +function gateCall(fn: "offeredTo" | "requireOffered" | "offeredTo|requireOffered", capabilityId: string): RegExp { + return new RegExp(`(?:${fn})\\(\\s*"${capabilityId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`) +} + +/** + * Does this source resolve `capabilityId` through the gate? + * + * The import is checked as well as the call, so a local function that happened + * to be named `offeredTo` could not satisfy this — the point is that the one + * resolver decided, not that a similarly-named thing was called. + */ +function callsGateWith(source: string, capabilityId: string): boolean { + return IMPORTS_GATE.test(source) && gateCall("offeredTo|requireOffered", capabilityId).test(source) +} + +/** + * Each `export async function` body in a `"use server"` module. + * + * Split rather than parsed: every action in this codebase is a top-level + * `export async function`, and a body runs to the next one. A parser would be + * more correct and would also be the only thing here anyone had to maintain. + */ +function actionBodies(source: string): { name: string; body: string }[] { + const parts = source.split(/^export async function /m).slice(1) + return parts.map((part) => ({ name: part.slice(0, part.indexOf("(")), body: part })) +} + +describe("every declared binding is enforced at the surface it binds", () => { + const pageRoutes = Object.entries(ROUTE_CAPABILITY) + const apiRoutes = Object.entries(API_CAPABILITY) + + it("finds bindings to check, so this suite is not vacuously passing", () => { + expect(pageRoutes.length).toBeGreaterThan(0) + expect(apiRoutes.length).toBeGreaterThan(0) + }) + + it("every bound page resolves its own capability", () => { + const unenforced = pageRoutes + .filter(([route, capabilityId]) => { + const page = path.join(appDirFor(route), "page.tsx") + // A missing page is `routes.test.ts`'s assertion, not this one. + if (!existsSync(page)) return false + return !callsGateWith(readFileSync(page, "utf8"), capabilityId) + }) + .map(([route, id]) => `${route} is bound to ${id} and never resolves it`) + expect(unenforced).toEqual([]) + }) + + it("every server action beside a bound page resolves that page's capability", () => { + // Derived from the page binding: an action next to a bound route is part of + // that route's surface, and a mutation that skips the gate is the version + // of this defect that writes to the database. + const unenforced = pageRoutes.flatMap(([route, capabilityId]) => { + const actions = path.join(appDirFor(route), "actions.ts") + if (!existsSync(actions)) return [] + const source = readFileSync(actions, "utf8") + const required = gateCall("requireOffered", capabilityId) + return actionBodies(source) + .filter(({ body }) => !IMPORTS_GATE.test(source) || !required.test(body)) + .map(({ name }) => `${route} → ${name}() writes without resolving ${capabilityId}`) + }) + expect(unenforced).toEqual([]) + }) + + it("the derived rule reaches a real file, so it is not passing on an empty set", () => { + const withActions = pageRoutes.filter(([route]) => + existsSync(path.join(appDirFor(route), "actions.ts")), + ) + expect(withActions.length).toBeGreaterThan(0) + }) + + it("every bound API handler resolves its own capability", () => { + const unenforced = apiRoutes + .filter(([route, capabilityId]) => { + const handler = apiFileFor(route) + if (!existsSync(handler)) return false + return !callsGateWith(readFileSync(handler, "utf8"), capabilityId) + }) + .map(([route, id]) => `${route} is bound to ${id} and never resolves it`) + expect(unenforced).toEqual([]) + }) + + it("an action refuses by throwing, so the refusal cannot be ignored by the next line", () => { + // `offeredTo` returns a decision a page renders; `requireOffered` throws. + // A mutating surface must use the throwing form — a returned flag is one + // `if` away from being skipped, and the line after it writes. + const wrongForm = Object.entries(ROUTE_CAPABILITY).flatMap(([route]) => { + const actions = path.join(appDirFor(route), "actions.ts") + if (!existsSync(actions)) return [] + const source = readFileSync(actions, "utf8") + return /\bofferedTo\(/.test(source) + ? [`${route} → actions.ts uses offeredTo; an action must use requireOffered`] + : [] + }) + expect(wrongForm).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/capability-registry/gate.test.ts b/apps/web/src/lib/capability-registry/gate.test.ts new file mode 100644 index 00000000..37bf5082 --- /dev/null +++ b/apps/web/src/lib/capability-registry/gate.test.ts @@ -0,0 +1,145 @@ +import { CapabilityUnavailableError, capabilityRefusal, offeredTo, requireOffered } from "./gate" +import { declaredModules } from "./manifest" +import type { Decision } from "./types" + +/** + * The gate a server surface calls, and the refusal it produces. + * + * The manifest read is mocked because the property under test is what the gate + * DOES with a tenant's declared modules, not that Prisma can find an audit row + * — `manifest.ts`'s own read is exercised end to end against a real database by + * the third slice's proof. Mocking it here also keeps the three states the + * resolver distinguishes (null, [], and a list) legible in one file, which is + * the distinction the whole design turns on. + */ + +jest.mock("./manifest", () => ({ declaredModules: jest.fn() })) + +const modulesFor = declaredModules as jest.MockedFunction + +/** A capability the real registry offers the pilot, and its module. */ +const REPORTING = "collaboration.reporting" + +beforeEach(() => { + modulesFor.mockReset() +}) + +describe("offeredTo", () => { + it("reads THIS tenant's manifest and answers from the real registry", async () => { + modulesFor.mockResolvedValue(["reporting@1.0.0", "organizations@1.0.0"]) + + const decision = await offeredTo(REPORTING, "inst_1") + + expect(modulesFor).toHaveBeenCalledWith("inst_1") + expect(decision.available).toBe(true) + expect(decision.manifestState).toBe("published") + }) + + it("refuses when the tenant's manifest does not declare the module", async () => { + // The refusal a nav-only gate could not deliver: this tenant is not offered + // reporting, and every surface bound to it must say so, not just the link. + modulesFor.mockResolvedValue(["organizations@1.0.0"]) + + const decision = await offeredTo(REPORTING, "inst_1") + + expect(decision.available).toBe(false) + expect(decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") + expect(decision.mode).toBe("UNAVAILABLE") + }) + + it("does not read a manifest for a tenant it was not given, and says it did not", async () => { + // A signed-in account with no institution has no manifest. Reading one + // anyway would need an institution to guess at; filtering as though it had + // read one would take the product dark on a document that does not exist. + const decision = await offeredTo(REPORTING, null) + + expect(modulesFor).not.toHaveBeenCalled() + expect(decision.manifestState).toBe("unpublished") + expect(decision.available).toBe(true) + }) + + it("carries the registry row's name, so a refusal can be rendered without a hand-typed label", async () => { + modulesFor.mockResolvedValue([]) + + const decision = await offeredTo(REPORTING, "inst_1") + + expect(decision.name).toBe("Institution-wide reporting and analytics") + expect(decision.owner).toBe("platform") + }) + + it("has no name for a capability nobody registered", async () => { + const decision = await offeredTo("never.registered", null) + + expect(decision.name).toBeNull() + expect(decision.refusals[0].code).toBe("NOT_REGISTERED") + }) +}) + +describe("requireOffered — the form a mutating surface uses", () => { + it("returns the decision when the capability is offered", async () => { + modulesFor.mockResolvedValue(["reporting@1.0.0"]) + await expect(requireOffered(REPORTING, "inst_1")).resolves.toMatchObject({ available: true }) + }) + + it("throws rather than returning a flag the next line can ignore", async () => { + modulesFor.mockResolvedValue([]) + + await expect(requireOffered(REPORTING, "inst_1")).rejects.toBeInstanceOf( + CapabilityUnavailableError, + ) + }) + + it("carries the decision on the error, so the reason survives the throw", async () => { + modulesFor.mockResolvedValue([]) + + let error: CapabilityUnavailableError | null = null + try { + await requireOffered(REPORTING, "inst_1") + } catch (e) { + error = e as CapabilityUnavailableError + } + if (!error) throw new Error("requireOffered resolved; the refusal never happened") + + expect(error.decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") + // The message names the capability and the code — a log line that says only + // "unavailable" sends nobody anywhere. + expect(error.message).toContain(REPORTING) + expect(error.message).toContain("MODULE_NOT_DECLARED") + }) +}) + +describe("capabilityRefusal — what an API route answers", () => { + const refused = (): Decision => ({ + capability: REPORTING, + name: "Institution-wide reporting and analytics", + manifestState: "published", + available: false, + mode: "UNAVAILABLE", + architectureScore: "S", + runtimeStatus: "TENANT_PILOT", + owner: "platform", + refusals: [{ code: "MODULE_NOT_DECLARED", detail: "not in this tenant's manifest" }], + }) + + it("answers 501, not 403 — the two questions must stay distinguishable on the wire", async () => { + // 403 is the answer to WHO is asking, and the pulse route already uses it + // for exactly that. If a withheld capability answered 403 too, the one + // place a client can see the difference would stop showing it. + const response = capabilityRefusal(refused()) + expect(response.status).toBe(501) + }) + + it("states the refusal code and the owner, so a screenshot is a support ticket", async () => { + const body = await capabilityRefusal(refused()).json() + expect(body).toMatchObject({ + error: "capability_unavailable", + capability: REPORTING, + owner: "platform", + refusals: [{ code: "MODULE_NOT_DECLARED" }], + }) + }) + + it("is never cached — an availability answer that outlives a reconcile is wrong", () => { + expect(capabilityRefusal(refused()).headers.get("cache-control")).toBe("no-store") + }) +}) diff --git a/apps/web/src/lib/capability-registry/gate.ts b/apps/web/src/lib/capability-registry/gate.ts new file mode 100644 index 00000000..a4155291 --- /dev/null +++ b/apps/web/src/lib/capability-registry/gate.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server" +import { declaredModules } from "./manifest" +import { CAPABILITY_REGISTRY, PILOT_SCOPE } from "./registry" +import { resolveCapability } from "./resolve" +import type { Decision } from "./types" + +/** + * How a SERVER SURFACE asks the resolver. One function, one answer shape. + * + * ── The hole this closes ──────────────────────────────────────────────────── + * + * `resolveCapability` has been the single decision point since the first slice, + * and `routes.ts` has bound four routes to capabilities since the second. But + * nothing joined them: the binding was read by CI and by nothing at runtime. The + * only consumer was the side nav, so availability was enforced in the + * NAVIGATION and nowhere else — hide the entry, and the route it pointed at + * still rendered, its server action still wrote, and its API route still + * answered. A gate that only removes the link is a cosmetic gate; anyone who + * typed the URL, kept a tab open, or replayed a form POST walked straight past + * it. + * + * So the surfaces that act now resolve for themselves. A page renders the + * refusal, an action refuses to write, a route handler answers 501 — all from + * this one call, so "unavailable" cannot acquire a second vocabulary at the + * third call site. + * + * ── Still no identity, on purpose ─────────────────────────────────────────── + * + * This takes an `institutionId`, never a session or a user. WHO MAY ACT is + * `lib/admin/capabilities.ts` and stays there; a test asserts nothing in this + * directory imports a role, a session or `lib/rbac`. Both gates run on the + * mutating surfaces below, in that order and as separate expressions, because a + * promotion must not unlock a capability the cell was never certified to offer. + */ + +/** + * Does this deployment offer `capabilityId` to this tenant? + * + * `institutionId` may be null: a signed-in account with no institution has no + * manifest to read, which is `manifestState: "unpublished"` — not filtered, and + * recorded as not filtered. That is the same answer the pilot's own tenant gets + * today, and it is deliberately not the same as an empty manifest. Filtering + * against a document that does not exist would take the product dark. + */ +export async function offeredTo(capabilityId: string, institutionId: string | null): Promise { + const modules = institutionId ? await declaredModules(institutionId) : null + return resolveCapability(capabilityId, PILOT_SCOPE, CAPABILITY_REGISTRY, modules) +} + +/** + * A refusal carrying the decision that produced it. + * + * The decision travels with the error because the refusal codes are the whole + * point — `MODULE_NOT_DECLARED` sends a reader to the tenant's manifest and + * `STATUS_NOT_LIVE` sends them to the owning team, and an error whose message + * said only "unavailable" would send them to neither. + */ +export class CapabilityUnavailableError extends Error { + readonly decision: Decision + + constructor(decision: Decision) { + super( + `This deployment does not offer "${decision.capability}". ` + + decision.refusals.map((r) => `${r.code}: ${r.detail}`).join(" "), + ) + this.name = "CapabilityUnavailableError" + this.decision = decision + } +} + +/** + * The gate for a surface that ACTS — a server action, or anything whose refusal + * must stop work rather than render. + * + * Throws rather than returning a flag because a returned flag can be ignored by + * the next line, and the next line writes to the database. The thrown form is + * also why the pages below gate too: a person should meet the refusal as page + * state, with a reason, rather than as an error card. Reaching this throw means + * the action was invoked without the page that gates it — a stale tab, a + * replayed POST, a capability withdrawn mid-session — which is precisely the + * case a nav-only gate could not refuse. + */ +export async function requireOffered( + capabilityId: string, + institutionId: string | null, +): Promise { + const decision = await offeredTo(capabilityId, institutionId) + if (!decision.available) throw new CapabilityUnavailableError(decision) + return decision +} + +/** + * The refusal an API route answers with. + * + * 501, and the alternatives were all worse: + * + * 403 is the answer to WHO is asking, which this deliberately does not know. + * Reusing it would merge the two questions the registry exists to keep + * apart, in the one place a client can see the difference. + * 404 reads as "no such URL", so a withheld capability would be + * indistinguishable from a typo, and the reason could not be stated. + * 503 promises the caller that retrying later will work. A capability this + * tenant's manifest does not declare is not a temporary condition. + * + * RFC 9110 §15.6.2 is "the server does not support the functionality required + * to fulfil the request", which is exactly the claim being made. + * + * The body carries the refusal codes because the caller here is a colleague + * with a support ticket, not the public internet: `MODULE_NOT_DECLARED` plus an + * owner is a sentence a person can act on, and "unavailable" is not. + */ +export function capabilityRefusal(decision: Decision): NextResponse { + return NextResponse.json( + { + error: "capability_unavailable", + capability: decision.capability, + refusals: decision.refusals.map((r) => ({ code: r.code, detail: r.detail })), + owner: decision.owner, + runtimeStatus: decision.runtimeStatus, + manifestState: decision.manifestState, + }, + { status: 501, headers: { "cache-control": "no-store" } }, + ) +} diff --git a/apps/web/src/lib/capability-registry/resolve.ts b/apps/web/src/lib/capability-registry/resolve.ts index 7ab06cea..1d4a1958 100644 --- a/apps/web/src/lib/capability-registry/resolve.ts +++ b/apps/web/src/lib/capability-registry/resolve.ts @@ -52,6 +52,7 @@ export function resolveCapability( if (!definition) { return { capability: id, + name: null, manifestState, available: false, mode: "UNAVAILABLE", @@ -71,6 +72,7 @@ export function resolveCapability( const base = { capability: id, + name: definition.name, manifestState, architectureScore: definition.architectureScore, runtimeStatus: definition.runtimeStatus, diff --git a/apps/web/src/lib/capability-registry/surfaces.test.ts b/apps/web/src/lib/capability-registry/surfaces.test.ts new file mode 100644 index 00000000..26ace4ce --- /dev/null +++ b/apps/web/src/lib/capability-registry/surfaces.test.ts @@ -0,0 +1,82 @@ +import { readdirSync, statSync } from "node:fs" +import path from "node:path" +import { CAPABILITY_REGISTRY } from "./registry" +import { API_CAPABILITY, API_PENDING_BINDING } from "./surfaces" + +/** + * The ratchet, extended to the surfaces that answer without a page. + * + * `routes.test.ts` accounts for every page under `(app)`. Twenty-three route + * handlers under `src/app/api` were in no list at all — including the endpoint + * the reports strip polls every fifteen seconds and the two that write + * documents. A page is at least conspicuous by its absence from a nav; an + * endpoint is visible to nobody, which is how they stayed unexamined through + * three slices of an item whose title says "every surface". + * + * Same three refusals as the route ratchet, for the same reasons: a handler in + * neither list fails, a binding to an unregistered capability fails (it reads + * as done and resolves NOT_REGISTERED at runtime), and a deferral with no + * reason fails. Whether a bound handler actually CALLS the gate is + * `enforcement.test.ts` — that assertion is separate because it is a separate + * claim, and because for two slices the route bindings satisfied this one + * while being enforced nowhere. + */ + +const API_DIR = path.resolve(__dirname, "../../app/api") + +/** Every route handler, as the URL path it serves. */ +function handlers(dir: string, prefix = "/api"): string[] { + const found: string[] = [] + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry) + if (statSync(full).isDirectory()) { + found.push(...handlers(full, `${prefix}/${entry}`)) + } else if (entry === "route.ts") { + found.push(prefix) + } + } + return found +} + +describe("every API route declares its capability, or declares that it has not", () => { + const discovered = handlers(API_DIR).sort() + + it("finds the handlers, so this suite is not vacuously passing", () => { + expect(discovered.length).toBeGreaterThan(15) + }) + + it("no handler is unaccounted for", () => { + const unaccounted = discovered.filter( + (r) => !(r in API_CAPABILITY) && !(r in API_PENDING_BINDING), + ) + expect(unaccounted).toEqual([]) + }) + + it("no handler claims both a binding and a deferral", () => { + const both = Object.keys(API_CAPABILITY).filter((r) => r in API_PENDING_BINDING) + expect(both).toEqual([]) + }) + + it("every binding names a capability that actually exists", () => { + const known = new Set(CAPABILITY_REGISTRY.map((c) => c.id)) + const dangling = Object.entries(API_CAPABILITY) + .filter(([, id]) => !known.has(id)) + .map(([route, id]) => `${route} → ${id}`) + expect(dangling).toEqual([]) + }) + + it("every deferral gives a reason", () => { + const silent = Object.entries(API_PENDING_BINDING) + .filter(([, reason]) => !reason.trim()) + .map(([route]) => route) + expect(silent).toEqual([]) + }) + + it("no stale entries — every listed handler still exists", () => { + const live = new Set(discovered) + const stale = [...Object.keys(API_CAPABILITY), ...Object.keys(API_PENDING_BINDING)].filter( + (r) => !live.has(r), + ) + expect(stale).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/capability-registry/surfaces.ts b/apps/web/src/lib/capability-registry/surfaces.ts new file mode 100644 index 00000000..fb771844 --- /dev/null +++ b/apps/web/src/lib/capability-registry/surfaces.ts @@ -0,0 +1,77 @@ +/** + * Which capability each API route depends on. + * + * ── Why routes.ts was not enough ──────────────────────────────────────────── + * + * `routes.ts` accounts for every page under `(app)`. An API route is not under + * `(app)` and never appeared there, so twenty-three handlers — including every + * one the client polls, and the two that write documents — were outside the + * only list that could have noticed them. A page is at least visible in the nav + * it is missing from; a route handler is visible to nobody. + * + * The ratchet is the same shape as the route one, for the same reason: a + * handler must appear in exactly one list below, so a NEW endpoint cannot ship + * without its author either binding it or writing down that they deferred. + * Wildcards and counts are both refused — a reader closing this gap needs the + * names, and a reviewer needs to see one leave the list in the diff. + * + * ── A binding here is enforced, not declared ──────────────────────────────── + * + * `surfaces.test.ts` asserts that a bound handler's source actually calls the + * gate. That assertion exists because of what this slice found: the four page + * bindings in `routes.ts` had been read by CI and by nothing at runtime for two + * slices. Availability was enforced in the navigation, so hiding the link was + * the entire gate, and typing the URL walked past it. A binding nothing + * enforces is worse than no binding — it reads as done. + */ + +/** API routes whose availability is decided by a registered capability. */ +export const API_CAPABILITY: Readonly> = { + "/api/reports/pulse": "collaboration.reporting", +} + +/** + * API routes not yet bound, with the reason. Every entry here is a real gap. + * + * Three kinds of reason, and they are genuinely three rather than twenty-two: + * the capability does not exist yet, the handler is infrastructure rather than + * a tenant-facing feature, or binding it would be circular. + */ +export const API_PENDING_BINDING: Readonly> = { + // The capability these belong to is not registered. Binding them would mean + // inventing registry rows to satisfy a checker, which is the fiction the + // registry exists to prevent — a capability is declared because it is + // offered, not because a handler needed something to point at. + "/api/ai/chat": "assistant capability not yet registered", + "/api/ai/diagnostic": "assistant capability not yet registered", + "/api/ai/draft": "assistant capability not yet registered", + "/api/search": "search capability not yet registered", + "/api/calendar/event/[id]": "events/calendar capability not yet registered", + "/api/calendar/ics/[token]": "events/calendar capability not yet registered", + "/api/calendar/reschedule": "events/calendar capability not yet registered", + "/api/documents/[id]/content": "documents capability not yet registered", + "/api/documents/[id]/save": "documents capability not yet registered", + "/api/attachment/[id]": "messaging capability not yet registered", + "/api/attachment/[id]/content": "messaging capability not yet registered", + "/api/templates/budget": "finance capability not yet registered", + "/api/integrations/slack/install": "integrations capability not yet registered", + "/api/integrations/slack/callback": "integrations capability not yet registered", + "/api/admin/directory": "admin plane — role-gated; tenant-level withholding undecided", + + // Infrastructure: no tenant is asking, or the answer cannot depend on one. + "/api/auth/[...nextauth]": + "sign-in transport — a capability gate here would make a tenant unable to authenticate its way to being told why", + "/api/health": "load-balancer health check — has no tenant and no session", + "/api/jobs/reminders": "scheduled job — runs for every institution at once, with no tenant asking", + "/api/notifications": "per-user shell, like /notifications — not per-tenant-capability", + "/api/org-image/[orgId]": "image bytes for the club spine", + "/api/profile-image/[userId]": "image bytes, per-user", + + // Circular, and the one entry here that is a decision rather than a gap. + // This handler is where a signed DeploymentManifest ARRIVES. The resolver's + // module filter reads that manifest, so gating this on a capability would + // mean a tenant needs a manifest before it can be given one — an unreconciled + // cell could never receive its first. It is authenticated by a signature + // instead, which is the right authority for a control-plane push. + "/api/platform/reconcile": "control plane — publishes the manifest the resolver reads; gating it would deadlock the first reconcile", +} diff --git a/apps/web/src/lib/capability-registry/types.ts b/apps/web/src/lib/capability-registry/types.ts index 714d7686..c4aca323 100644 --- a/apps/web/src/lib/capability-registry/types.ts +++ b/apps/web/src/lib/capability-registry/types.ts @@ -185,6 +185,15 @@ export type ManifestState = "published" | "unpublished" export interface Decision { readonly capability: string + /** + * The registry row's human name, or null when nothing is registered. + * + * Carried on the decision so a refusal can be RENDERED without the surface + * hand-typing a label beside the id it gates on — two names for one + * capability drift, and the one on screen is the one nobody updates. + * Reported for display only; never an input to `available`. + */ + readonly name: string | null /** Whether module filtering was applied, and why not if it was not. */ readonly manifestState: ManifestState readonly available: boolean diff --git a/docs/PROGRAM-BACKLOG.md b/docs/PROGRAM-BACKLOG.md index c9a3aeca..3ea23300 100644 --- a/docs/PROGRAM-BACKLOG.md +++ b/docs/PROGRAM-BACKLOG.md @@ -153,10 +153,32 @@ Two structural facts that change how items are scheduled: `null` and `[]` are deliberately different answers — `[]` is a manifest that grants nothing, `null` is a tenant nobody has reconciled. Recording that distinction in every decision is what makes "Simon OSE is hand-built, not distro-deployed" a measurable fact rather than an assumption. -- **STILL OPEN in this item**: server actions, API routes and AI answers do not resolve through the registry; - 31 of 35 routes are deferred rather than bound; the registry is code rather than tenant data; and - `declaredModules` reads the manifest out of an append-only AUDIT row, which is where the data actually is - but not where configuration belongs — a purge policy could one day delete a tenant's entitlements. +- **~~FOURTH SLICE — DONE~~**: the surfaces that ACT resolve for themselves, through one gate + (`capability-registry/gate.ts`): `offeredTo` for a surface that renders the refusal, `requireOffered` for one + that must not proceed, `capabilityRefusal` for a route handler. Wired to the mutating surface + (`createMemoryCard`), the polled API route (`/api/reports/pulse`, answering **501** — not 403, which is the + answer to *who* is asking) and all four bound pages. A second ratchet accounts for every one of the 23 API + routes: 1 bound, 22 deferred with reasons. + **A correction to the line below, which this slice found to be wrong**: "31 of 35 routes are deferred rather + than bound" implied the other 4 resolved through the registry. They did not. `ROUTE_CAPABILITY` was read by + CI and by nothing at runtime — the side nav resolved the capability and dropped the *link*, and the route + behind it rendered in full to anyone who typed the URL, its server action still wrote, and its endpoint still + answered a polling tab. Availability was enforced in the navigation and nowhere else. So the real count was + **0 of 35 enforced**, and a binding nothing enforces is worse than none because it reads as done — + `enforcement.test.ts` now asserts the join, for pages, for API handlers, and for any `actions.ts` beside a + bound route (derived from the page binding, so there is no second list to keep in step). + Controls, each run red then restored: manifest without the module → /reports renders the refusal and no + figures leave the server; same → `createMemoryCard` throws and `$transaction` is never reached; same → + `/api/reports/pulse` answers 501 with no counts in the body; gate deleted from a page, from the action, and + from the handler → three separate assertions fail; a new API route with no entry, and a binding to an + unregistered capability → the ratchet fails. +- **STILL OPEN in this item**: AI answers do not resolve through the registry (no assistant/search capability + is registered, and inventing one so the assistant has something to resolve against is the fiction this + registry exists to prevent); 31 of 35 page routes and 22 of 23 API routes are deferred rather than bound; + `OrgTabs` is the next static array to route through the resolver, exactly as `SideNav` was; the registry is + code rather than tenant data; and `declaredModules` reads the manifest out of an append-only AUDIT row, + which is where the data actually is but not where configuration belongs — a purge policy could one day + delete a tenant's entitlements. ### ✅ [governance] Make BLOCKED_ARCHITECTURE an enumerable register with a CI check - **Done**: Tenure@9552515 — `apps/web/src/lib/governance/{blocked-architecture,register}.ts` and a 13-assertion From 1d7e171cdf7c2c3495290512ef62ede75399547f Mon Sep 17 00:00:00 2001 From: satvikOS Date: Thu, 20 Aug 2026 20:36:17 -0400 Subject: [PATCH 2/6] perf(capability): index the manifest read the availability gate now depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the gate onto the routes, the action and the polled endpoint put `declaredModules` on ordinary request traffic. It asks for the newest `Tenant.Reconciled` audit row for a tenant, and ("institutionId", "occurredAt") cannot serve that: the planner walks the institution's whole audit history backwards discarding every other action. For a tenant with no reconcile row — every tenant today, the pilot included — that is a full walk to return nothing, on a table that only grows. Measured on a scratch database, 50,000 audit rows for one institution and no reconcile row among them: with "AuditEvent_institutionId_action_occurredAt_idx" Index Scan, 3 buffers, 0.013 ms without Seq Scan, 667 buffers, 2.495 ms, 50,000 rows removed by filter So this also removes a cost the app layout has been paying on every authenticated page render since the third slice, where it was invisible because nothing else was reading the manifest. Whole migration chain applied to a fresh database to prove it deploys, and the index verified present afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 19 +++++++++++++++++++ apps/web/prisma/schema.prisma | 8 ++++++++ 2 files changed, 27 insertions(+) create mode 100644 apps/web/prisma/migrations/20260820140000_index_the_manifest_lookup/migration.sql diff --git a/apps/web/prisma/migrations/20260820140000_index_the_manifest_lookup/migration.sql b/apps/web/prisma/migrations/20260820140000_index_the_manifest_lookup/migration.sql new file mode 100644 index 00000000..29198ae4 --- /dev/null +++ b/apps/web/prisma/migrations/20260820140000_index_the_manifest_lookup/migration.sql @@ -0,0 +1,19 @@ +-- Index the read that decides capability availability. +-- +-- `capability-registry/manifest.ts` answers "what does this tenant's manifest +-- declare?" with the newest `Tenant.Reconciled` audit row for the institution. +-- Until this slice that ran once per page render, from the app layout. It now +-- also runs on every route bound to a capability and on every fifteen-second +-- poll of /api/reports/pulse, so its cost sits on ordinary traffic. +-- +-- The existing ("institutionId", "occurredAt") index cannot serve it: the +-- planner walks that institution's audit history backwards, discarding every +-- row whose action is not Tenant.Reconciled. For a tenant with NO reconcile row +-- — which is every tenant today, the pilot included — that is a walk of the +-- whole history to return nothing, and an append-only log only grows. +-- +-- Putting "action" between the two makes it a single index seek. The cost is +-- one more index to maintain on an insert-only table, which is the right side +-- of the trade for a read that is now on the request path. +CREATE INDEX "AuditEvent_institutionId_action_occurredAt_idx" + ON "AuditEvent"("institutionId", "action", "occurredAt"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 538fe8c4..8a069a3e 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -1005,6 +1005,14 @@ model AuditEvent { @@index([institutionId, occurredAt]) @@index([actorId, occurredAt]) @@index([resourceType, resourceId]) + // The capability resolver's input. `capability-registry/manifest.ts` reads the + // newest `Tenant.Reconciled` row for a tenant, and since the availability gate + // moved onto the routes and the polled endpoint that read runs on ordinary + // page traffic rather than once in a while. On [institutionId, occurredAt] + // alone, a tenant with NO reconcile row — which is every tenant today — costs + // a backwards walk of its entire audit history to return nothing, and that + // history only grows. + @@index([institutionId, action, occurredAt]) } // ─── Institution role transfers ─────────────────────────────────────────────── From 1d4671746c72d5dd02b42c37d46a6a086c0e23ee Mon Sep 17 00:00:00 2001 From: satvikOS Date: Thu, 20 Aug 2026 20:37:46 -0400 Subject: [PATCH 3/6] fix(capability): a refused club page keeps the club's tab strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of the two org refusals rendered the panel alone, which made the page a dead end: Members, Finance, Documents and Impact are unaffected by `collaboration.institutional-memory`, and the person had no way back into the club from the page telling them one section is unavailable. Withholding one capability must not also strand someone. The refusal now renders inside the club's own chrome — its name and `OrgTabs` — so the sections that do work are one click away. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/(app)/orgs/[slug]/handoff/page.tsx | 14 +++++++++++++- .../web/src/app/(app)/orgs/[slug]/memory/page.tsx | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx index 0321ff9e..b2832f2f 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/handoff/page.tsx @@ -80,7 +80,19 @@ export default async function HandoffPage({ // the memory route carries, and a tenant that does not have one does not // have the other. const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) - if (!memory.available) return + if (!memory.available) + return ( +
+
+

{org.name}

+
+ {/* The refusal keeps the club's tab strip. Members, Finance, + Documents and Impact are unaffected by this capability, and a page + that withholds one thing must not also strand the person on it. */} + + +
+ ) const now = new Date() const [deliverables, pendingApprovals, budgetLines] = await Promise.all([ diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx index b7ef2961..afe1957e 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx @@ -66,7 +66,20 @@ export default async function MemoryPage({ // cannot be replayed past this; the page refuses first so the person gets a // reason instead of an error card. const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) - if (!memory.available) return + if (!memory.available) + return ( +
+
+

{org.name}

+
+ {/* The refusal keeps the club's tab strip. Members, Finance, + Documents and Impact are unaffected by this capability, and a page + that withholds one thing must not also strand the person on it. */} + + +
+ ) + const canAdd = canContribute(ctx, org) const allCards = await db.memoryRecord.findMany({ From b6406a84a9e1fcab8fe98fe81485be5ecca8b91e Mon Sep 17 00:00:00 2001 From: satvikOS Date: Thu, 20 Aug 2026 20:38:10 -0400 Subject: [PATCH 4/6] docs(backlog): record the fourth slice, its correction, and the index it needed Co-Authored-By: Claude Opus 5 (1M context) --- docs/PROGRAM-BACKLOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/PROGRAM-BACKLOG.md b/docs/PROGRAM-BACKLOG.md index 3ea23300..a27517e2 100644 --- a/docs/PROGRAM-BACKLOG.md +++ b/docs/PROGRAM-BACKLOG.md @@ -170,8 +170,14 @@ Two structural facts that change how items are scheduled: Controls, each run red then restored: manifest without the module → /reports renders the refusal and no figures leave the server; same → `createMemoryCard` throws and `$transaction` is never reached; same → `/api/reports/pulse` answers 501 with no counts in the body; gate deleted from a page, from the action, and - from the handler → three separate assertions fail; a new API route with no entry, and a binding to an - unregistered capability → the ratchet fails. + from the handler → three separate assertions fail; a page bound to a *different* registered capability → the + enforcement assertion still fails, so it checks the id and not just the presence of a call; a new API route + with no entry, a stale entry, and a binding to an unregistered capability → the ratchet fails. + **One cost this exposed and paid off**: putting the gate on the routes moved `declaredModules` onto ordinary + request traffic, and `AuditEvent`'s indexes could not serve it — measured on 50,000 rows for one institution + with no reconcile row (every tenant's state today), the read was a 667-buffer sequential scan at 2.495 ms. + `20260820140000_index_the_manifest_lookup` makes it a 3-buffer seek at 0.013 ms, which also removes a cost + the app layout has been paying on every authenticated page render since the third slice. - **STILL OPEN in this item**: AI answers do not resolve through the registry (no assistant/search capability is registered, and inventing one so the assistant has something to resolve against is the fiction this registry exists to prevent); 31 of 35 page routes and 22 of 23 API routes are deferred rather than bound; From 5f02caa34dd1688551df0d86de3e5c45827f0131 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:30:47 -0400 Subject: [PATCH 5/6] fix(capability): /admin/metering resolves through the gate, not a copy of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought in `"/admin/metering": "payments.seat-metering"`, added to ROUTE_CAPABILITY on main while this branch was making those bindings mean something at runtime. Nothing was wrong on either side and the merge was clean, which is why this needed looking for rather than reporting. The page was not ungated — it resolved the capability by hand, assembling `resolveCapability(id, PILOT_SCOPE, CAPABILITY_REGISTRY, await declaredModules(institutionId))` at the call site. That is `offeredTo` verbatim, written out, because it predates `gate.ts`. Which is exactly the drift the gate exists to stop: four arguments repeated at each surface stay identical only for as long as everybody remembers to repeat them identically, and the next scope or registry change has to find every copy. So it now calls `offeredTo`. Behaviour is unchanged — same resolver, same arguments, same `notFound()` refusal, which stays `notFound()` rather than `CapabilityUnavailable` because this is the admin plane and a page that says "you may not see this" has told you it exists. `enforcement.test.ts` asserts the binding is enforced FROM the gate module rather than by a same-named local, so without this the merged tree is red. Verified by negative control: reverting just this file fails that suite with "/admin/metering is bound to payments.seat-metering and never resolves it". --- .../web/src/app/(app)/admin/metering/page.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/(app)/admin/metering/page.tsx b/apps/web/src/app/(app)/admin/metering/page.tsx index e771a3a7..f7a4b7ff 100644 --- a/apps/web/src/app/(app)/admin/metering/page.tsx +++ b/apps/web/src/app/(app)/admin/metering/page.tsx @@ -6,9 +6,7 @@ import { hasCapability } from "@/lib/admin/capabilities" import { withTenantScope } from "@/lib/tenant-scope" import { currentTerm } from "@/lib/tenant/term" import { institutionTimeZone } from "@/lib/institution-time" -import { CAPABILITY_REGISTRY, PILOT_SCOPE } from "@/lib/capability-registry/registry" -import { declaredModules } from "@/lib/capability-registry/manifest" -import { resolveCapability } from "@/lib/capability-registry/resolve" +import { offeredTo } from "@/lib/capability-registry/gate" import { academicYearPeriod, meteredQuantityToDate, reconcileSeatMeter } from "@/lib/billing/seat-meter" import { Card, CardHeader } from "@/components/ui/Card" import { Badge } from "@/components/ui/Badge" @@ -26,11 +24,19 @@ export const dynamic = "force-dynamic" * * `billing.viewMeter` asks WHO MAY SEE IT and is Director-only: the quantity is * a term of a contract, and the seat that signs the contract is the Director's. - * `resolveCapability("payments.seat-metering")` asks WHETHER THIS DEPLOYMENT - * OFFERS IT AT ALL, which is a per-tenant question with no user in it. Audit - * §8.2 requires the second — availability must never be a consequence of the - * code existing — and `capability-registry/types.ts` is explicit that merging - * the two would be a security bug rather than a tidy-up. + * `offeredTo("payments.seat-metering")` asks WHETHER THIS DEPLOYMENT OFFERS IT + * AT ALL, which is a per-tenant question with no user in it. Audit §8.2 + * requires the second — availability must never be a consequence of the code + * existing — and `capability-registry/types.ts` is explicit that merging the + * two would be a security bug rather than a tidy-up. + * + * The second gate went through `resolveCapability` directly, assembling the + * scope, the registry and the tenant's declared modules at this call site. That + * predates `capability-registry/gate.ts`, which exists so that every acting + * surface asks the same question the same way — and a hand-assembled copy of + * the resolver call is how the four arguments drift apart, one surface at a + * time. `offeredTo` is that exact call; this now makes it rather than repeating + * it, and `enforcement.test.ts` holds the binding in `routes.ts` to it. * * Both refuse with `notFound()` rather than a message, matching every other * admin surface: a page that says "you may not see this" has told you it @@ -61,12 +67,7 @@ export default async function AdminMeteringPage() { const { userId, ctx, institutionId } = await requireAdminContext() if (!hasCapability(ctx, "billing.viewMeter", institutionId)) notFound() - const offered = resolveCapability( - "payments.seat-metering", - PILOT_SCOPE, - CAPABILITY_REGISTRY, - await declaredModules(institutionId), - ) + const offered = await offeredTo("payments.seat-metering", institutionId) if (!offered.available) notFound() return withTenantScope(userId, async () => { From 5ce911df0d3975e06ae148de4762d77cef98fb5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:33:18 -0400 Subject: [PATCH 6/6] docs(backlog): re-measure the route counts the merge invalidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three claims in this item were true when written and false after merging `main`, with no textual conflict to announce any of them: - "all four bound pages" — there are five. `/admin/metering` was bound on main while this branch was making bindings mean something. - "31 of 35 page routes ... are deferred rather than bound" — measured against the merged tree it is 33 of 38: main added `/workspace`, `/admin/exceptions` and `/admin/metering`, the last of them bound. Counted by walking `app/(app)` for `page.tsx` and diffing against `ROUTE_CAPABILITY` and `ROUTES_PENDING_BINDING`, which agree at 5 + 33 with nothing unaccounted and nothing stale. - "22 of 23 API routes" — re-measured and unchanged; main added no handlers. The two historical figures are left as history and marked as such, because "0 of 35 enforced" was a true measurement of the tree at that time and restating it against today's 38 would be inventing a number nobody took. Also repoints the migration reference to `20260821163000_index_the_manifest_lookup`, renamed in the merge to clear a timestamp collision with main's `20260820140000_idempotent_accounting_intake`. --- docs/PROGRAM-BACKLOG.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/PROGRAM-BACKLOG.md b/docs/PROGRAM-BACKLOG.md index 4c1a3b9a..5c99d40a 100644 --- a/docs/PROGRAM-BACKLOG.md +++ b/docs/PROGRAM-BACKLOG.md @@ -157,14 +157,20 @@ Two structural facts that change how items are scheduled: (`capability-registry/gate.ts`): `offeredTo` for a surface that renders the refusal, `requireOffered` for one that must not proceed, `capabilityRefusal` for a route handler. Wired to the mutating surface (`createMemoryCard`), the polled API route (`/api/reports/pulse`, answering **501** — not 403, which is the - answer to *who* is asking) and all four bound pages. A second ratchet accounts for every one of the 23 API + answer to *who* is asking) and all five bound pages. A second ratchet accounts for every one of the 23 API routes: 1 bound, 22 deferred with reasons. - **A correction to the line below, which this slice found to be wrong**: "31 of 35 routes are deferred rather - than bound" implied the other 4 resolved through the registry. They did not. `ROUTE_CAPABILITY` was read by + The fifth bound page, `/admin/metering`, arrived from `main` during this merge: bound there to + `payments.seat-metering`, and resolving it by writing out `resolveCapability(id, PILOT_SCOPE, + CAPABILITY_REGISTRY, await declaredModules(...))` at the call site — the gate's four arguments, copied, + because the page predates the gate. It now calls `offeredTo`. The merge was clean and git reported nothing; + `enforcement.test.ts` is what made it visible, and reverting that one file fails it. + **A correction to the line below, which this slice found to be wrong**: as it stood — "31 of 35 routes are + deferred rather than bound" — it implied the other 4 resolved through the registry. They did not. `ROUTE_CAPABILITY` was read by CI and by nothing at runtime — the side nav resolved the capability and dropped the *link*, and the route behind it rendered in full to anyone who typed the URL, its server action still wrote, and its endpoint still answered a polling tab. Availability was enforced in the navigation and nowhere else. So the real count was - **0 of 35 enforced**, and a binding nothing enforces is worse than none because it reads as done — + **0 of 35 enforced** as the tree stood then, and a binding nothing enforces is worse than none because it + reads as done — `enforcement.test.ts` now asserts the join, for pages, for API handlers, and for any `actions.ts` beside a bound route (derived from the page binding, so there is no second list to keep in step). Controls, each run red then restored: manifest without the module → /reports renders the refusal and no @@ -176,11 +182,13 @@ Two structural facts that change how items are scheduled: **One cost this exposed and paid off**: putting the gate on the routes moved `declaredModules` onto ordinary request traffic, and `AuditEvent`'s indexes could not serve it — measured on 50,000 rows for one institution with no reconcile row (every tenant's state today), the read was a 667-buffer sequential scan at 2.495 ms. - `20260820140000_index_the_manifest_lookup` makes it a 3-buffer seek at 0.013 ms, which also removes a cost + `20260821163000_index_the_manifest_lookup` makes it a 3-buffer seek at 0.013 ms, which also removes a cost the app layout has been paying on every authenticated page render since the third slice. - **STILL OPEN in this item**: AI answers do not resolve through the registry (no assistant/search capability is registered, and inventing one so the assistant has something to resolve against is the fiction this - registry exists to prevent); 31 of 35 page routes and 22 of 23 API routes are deferred rather than bound; + registry exists to prevent); 33 of 38 page routes and 22 of 23 API routes are deferred rather than bound + (measured against the merged tree, not carried over — `main` added three page routes while this branch was + open, one of them bound); `OrgTabs` is the next static array to route through the resolver, exactly as `SideNav` was; the registry is code rather than tenant data; and `declaredModules` reads the manifest out of an append-only AUDIT row, which is where the data actually is but not where configuration belongs — a purge policy could one day