Merge release/v1.39.0 into feat/ai-routes-insights-coach

Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:16:53 +02:00
commit c509afeb2e
256 files changed
+13048 -4642

No files matched your search

@@ -45,13 +45,18 @@ function code(path: string): string {
}
/** Whitespace-tolerant: a call split across lines still matches. */
const GATE_CALL = /\b(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"/g;
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const GATE_CALL =
/\b(?:(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"|(aiCapabilityToServe)\s*\([^,()]*,\s*"(\w+)")/g;
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
const REQUIRE_GATE = /\brequireAiCapability\s*\(/;
const RETIRED_GATE = /\brequireAssistantSurface\s*\(/;
function gateCalls(text: string): Array<{ fn: string; key: string }> {
return [...text.matchAll(GATE_CALL)].map((m) => ({ fn: m[1], key: m[2] }));
return [...text.matchAll(GATE_CALL)].map((m) => ({
fn: m[1] ?? m[3],
key: m[2] ?? m[4],
}));
}
function routeFiles(): string[] {
@@ -81,6 +86,9 @@ describe("AI capability route inventory", () => {
{ fn: "requireAiCapability", key: "coach" },
]);
expect(ANY_GATE.test("getAiCapability (\n")).toBe(true);
expect(
gateCalls('aiCapabilityToServe(\n user.id,\n "statusText",\n )'),
).toEqual([{ fn: "aiCapabilityToServe", key: "statusText" }]);
});
it("finds the routes it is meant to judge", () => {
+71 -12
View File
@@ -11,9 +11,11 @@
*
* - `action` routes call `requireAiCapability(key)` and refuse with the
* capability envelope;
* - `mixed` reads call `getAiCapability(key)`, answer 200, null the model
* text and say why in `ai`. They never refuse for an AI reason, so they
* never call `requireAiCapability`.
* - `mixed` reads call `getAiCapability(key)` (the viewer's view, for work
* they would start) or `aiCapabilityToServe(recordId, key)` (the record's
* own view, for stored model text they would show), answer 200, null the
* model text and say why in `ai`. They never refuse for an AI reason, so
* they never call `requireAiCapability`.
*
* Read by two guards: `ai-capability-route-inventory.test.ts` (the whole API
* tree) and `insights/__tests__/coach-route-gate-inventory.test.ts` (every
@@ -29,6 +31,67 @@ export interface AiRouteEntry {
}
export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
// Reading documents, lab scans and medication text.
"src/app/api/documents/inbound/[id]/chat/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "POST asks a model about one document; the history GET asks nothing.",
},
"src/app/api/documents/inbound/[id]/extract/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Reads a document's text with a model.",
},
"src/app/api/documents/inbound/[id]/index/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Indexes a document's content through a provider.",
},
"src/app/api/documents/inbound/[id]/suggest/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Suggests a title and category from the document with a model.",
},
"src/app/api/documents/inbound/[id]/summary/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Summarises a document with a model.",
},
"src/app/api/documents/inbound/reindex/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Re-indexes the vault through a provider.",
},
"src/app/api/documents/inbound/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
},
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The preference is stored whatever AI says; the catch-up it queues follows the capability.",
},
"src/app/api/insights/chat/fenced/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Document chat inside a Coach conversation.",
},
"src/app/api/insights/chat/[id]/attachments/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Attaching a document to a Coach conversation for the model to read.",
},
"src/app/api/labs/ocr/extract/route.ts": {
kind: "action",
capabilities: ["labsOcr"],
why: "Reads a lab report image with a model.",
},
"src/app/api/medications/extract/route.ts": {
kind: "action",
capabilities: ["medicationExtract"],
why: "Turns typed medication text into a schedule with a model.",
},
"src/app/api/insights/chat/route.ts": {
kind: "action",
capabilities: ["coach"],
@@ -189,23 +252,19 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/coach/reminders/[id]/route.ts": "One stored Coach reminder.",
"src/app/api/coach/reminder-suggestions/route.ts":
"Deterministic reminder suggestions.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data.",
"src/app/api/coach/suggested-actions/route.ts":
"Confirms a proposed action from a closed allowlist; no model call.",
};
/**
* Routes in the AI trees whose AI gating another change owns. Each still has
* Routes in the AI trees whose AI gating another change still owns. Each has
* to be classified; the entry names who moves it. An entry that starts
* calling a capability gate fails the guard until it moves to `AI_ROUTES`.
* Empty: every route in these trees is classified.
*/
export const PENDING_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/insights/chat/fenced/route.ts":
"Document chat inside a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/route.ts":
"Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data, stays ungated once the document routes move.",
};
export const PENDING_ROUTES: Readonly<Record<string, string>> = {};
/** The trees in which every route has to be classified above. */
export const AI_ROUTE_TREES = [
@@ -42,9 +42,6 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* module is the AI analysis opt-out and gates only AI parts;
* the data routes that happen to live under
* `/api/insights` are EXEMPT below.
* - coach → `requireAssistantSurface("coach")`, the retired gate,
* still recognised for the one route outside these trees
* that has not moved to its capability yet.
*
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* toggleable domain but are deliberately NOT gated, each with the
@@ -253,10 +250,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// affordance), not the row store. The extract route is read-only vision
// assistance, the commit route writes the user's own confirmed lab rows,
// and the capability probe is an infra availability check carrying no
// module data. All three are owner-scoped and AI-gated (consent / budget /
// rate); the module toggle hiding the surface does not need to wedge them.
// module data. The extract route is not listed: it names the `labsOcr` AI
// capability, which carries the labs module, so it counts as gated.
"src/app/api/labs/ocr/capability/route.ts",
"src/app/api/labs/ocr/extract/route.ts",
"src/app/api/labs/ocr/commit/route.ts",
// ── DATA LAYER (medications) ──────────────────────────────────────
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
@@ -277,9 +273,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// per-medication importer below: it writes intake rows and reads its own job.
"src/app/api/medications/intake/dose-history-import/route.ts",
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
// NB: `medications/extract` is NOT exempt — it gates on
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
// surface), so the inventory already counts it as a delegated gate.
// `medications/extract` is not listed: it is model work under the
// `medicationExtract` AI capability (no owning module; medications is a
// core domain for extraction), and a capability call counts as the gate.
"src/app/api/medications/[id]/route.ts",
"src/app/api/medications/[id]/api-endpoint/route.ts",
"src/app/api/medications/[id]/cadence/route.ts",
@@ -421,10 +417,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// An AI capability resolves its owning modules and the AI opt-out, so a route
// that names one is gated on them.
const AI_CAPABILITY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const AI_CAPABILITY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
@@ -679,7 +675,6 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (fileHasCapabilityGate(text)) continue;
@@ -801,7 +796,6 @@ describe("module API route gate inventory", () => {
if (
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
fileHasCapabilityGate(text)
) {