diff --git a/apps/web/prisma/migrations/20260821163000_index_the_manifest_lookup/migration.sql b/apps/web/prisma/migrations/20260821163000_index_the_manifest_lookup/migration.sql
new file mode 100644
index 00000000..29198ae4
--- /dev/null
+++ b/apps/web/prisma/migrations/20260821163000_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 d179fc1e..f91873c7 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -1050,6 +1050,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 ───────────────────────────────────────────────
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 () => {
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..b2832f2f 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,25 @@ 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 (
+
+
+
{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([
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..afe1957e 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,29 @@ 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 (
+
+
+
{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({
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 c0bfb1b4..baa390be 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 { auth } from "@/lib/auth"
import { getUserContext } from "@/lib/rbac"
import { loadInstitutionPulse } from "@/lib/reports-data"
@@ -33,6 +34,18 @@ 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.
+ //
+ // The gate runs BEFORE the loader, not after: a refusal that had already
+ // read the counts would have spent the query it exists to withhold.
+ const reporting = await offeredTo("collaboration.reporting", institutionId)
+ if (!reporting.available) return capabilityRefusal(reporting)
+
const pulse = await loadInstitutionPulse(institutionId)
return NextResponse.json(
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 516d1f19..5c99d40a 100644
--- a/docs/PROGRAM-BACKLOG.md
+++ b/docs/PROGRAM-BACKLOG.md
@@ -153,10 +153,46 @@ 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 five bound pages. A second ratchet accounts for every one of the 23 API
+ routes: 1 bound, 22 deferred with reasons.
+ 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** 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
+ 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 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.
+ `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); 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
+ 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