feat(ai): resolve the AI capability before any background model work

Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.

The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.

Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.

Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:07:16 +02:00
1 parent b36c7b6697
commit 3917a2face
42 files changed
+2389 -382

No files matched your search

@@ -230,7 +230,6 @@ async function handleTextExtract(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -347,7 +346,6 @@ async function handleStoredExtract(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -433,7 +431,6 @@ async function handleVisionExtract(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -192,7 +192,6 @@ async function handleVisionIndex(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -135,7 +135,6 @@ async function handleTextSuggest(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -228,7 +227,6 @@ async function handleVisionSuggest(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -332,7 +332,6 @@ async function handleTextSummary(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
} catch (err) {
// Refused before any dispatch — the slot goes back with the refusal.
@@ -403,7 +402,6 @@ async function handleVisionSummary(
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const rl = await checkDocumentAiRateLimit(userId);
@@ -56,7 +56,6 @@ export const POST = apiHandler(async (request) => {
await assertDocumentEgressConsent({
userId: user.id,
providerType: pick.providerType,
surface: "insights",
});
const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id);