# AI features: one model registry, a flag per feature, plan quotas

The repository's architectural decision, rationale and consequences.

# AI features: one model registry, a flag per feature, plan quotas

**Status:** accepted · **Date:** 2026-10-23 · **Related:** [ADR-0004](0004-payments-providers.md), [ADR-0005](0005-effect-boundary.md), [ADR-0007](0007-ai-assistant-and-tool-catalog.md), [ADR-0009](0009-row-level-security.md), [ADR-0010](0010-procedures-run-one-effect-program.md)

**Enforcement:** `packages/api/src/features/ai/ai.models.test.ts` (registry, scrubber), `ai.service.test.ts` (fake adapters, `TestClock` deadline), `ai.procedures.test.ts` (quotas, concurrency, RLS), `expense.structure.test.ts` (QUOTA on the capture path), `packages/db/src/rls-catalog.test.ts` and `db:verify-rls` (grants).

The AI features spec (`docs/specs/ai-features.md`) adds a scanner, the Cinematic Studio, a generative-UI copilot, insights and extras, each with its own model kind (text with structured output, image, transcription, embedding), a flag, and a monthly quota by plan. Until now two callers used AI: the assistant (ADR-0007) and receipt/voice structuring (`expense.structure`), and both read one `AI_PROVIDER`/`AI_MODEL`. Nothing counted calls against a plan, and only structuring scrubbed what it sent.

## Decision [#decision]

**One registry.** `packages/api/src/features/ai/ai.models.ts` lists every AI feature with its kind, its flag and the quota it counts against:

| Feature         | Kind          | Flag           | Quota       |
| --------------- | ------------- | -------------- | ----------- |
| `assistant`     | text          | `ai.assistant` | copilot     |
| `structuring`   | text          | `ai.capture`   | structuring |
| `insights`      | text          | `ai.insights`  | none        |
| `studio`        | image         | `ai.studio`    | studio      |
| `studioLocate`  | text          | `ai.studio`    | none        |
| `transcription` | transcription | `ai.capture`   | structuring |
| `embedding`     | embedding     | `ai.extras`    | none        |
| `listing`       | text          | `ai.extras`    | structuring |
| `translation`   | text          | `ai.extras`    | structuring |
| `manualPlan`    | document      | `ai.extras`    | structuring |

The model is configuration. `AI_MODELS` is JSON from feature to `provider:model` (`{"studio":"gemini:gemini-3-pro-image-preview"}`); a text feature without an entry falls back to `AI_PROVIDER` and `AI_MODEL` (ADR-0007's defaults). A `document` feature is a text model that also reads a PDF the request carries; it falls back the same way when that provider's adapter reads PDFs (Anthropic and Gemini; OpenAI's adapter sends none). Image, transcription and embedding features have no default: an unverified model id would fail at the first paid call, so they stay off until named. The provider's existing key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`) must be set, or the feature is off. An entry whose provider has no TanStack AI adapter for the kind (Anthropic serves text only; OpenAI all four; Gemini text, image and embedding), or a malformed `AI_MODELS`, throws on first use: a wrong model must not fail quietly. Adapters come from `@tanstack/ai-anthropic`, `-openai` and `-gemini` through `extendAdapter`, with the SDK's own retries off. No code calls a provider SDK directly.

**Capability and visibility, per feature.** As in ADR-0004 and ADR-0007, a configured model is the capability and the flag is the visibility. The registry adds `ai.studio`, `ai.genui`, `ai.insights` and `ai.extras` next to `ai.assistant` and `ai.capture`, all off by default. `ai.genui` gates how the copilot renders, not a model, so it has no row above.

**Model calls are Effect steps.** `ai.service.ts` exposes `generateStructured` (`chat` with `outputSchema`), `generateImages` (`generateImage`), `transcribe` (`generateTranscription`) and `embedTexts` (`embed`). Each is one outbound step with the `AI_TIMEOUT_MS` deadline; the deadline interrupts it and aborts the request (`embed` takes no signal yet: its request runs on unread). A rejection or a timeout is `AiProviderFailed`; it is never retried, because a generation costs money (ADR-0005). The answer is validated with Zod (the caller's schema, then the shape of images, text or vectors) before anyone sees it, and the caller keeps it a draft (RN10). The assistant's chat stays behind ADR-0007's Promise/SSE boundary; it only takes its adapter from the registry.

**The scrubber on every outbound text (RN09, RNF07).** `scrubOutbound`, a TanStack AI chat middleware, rewrites the provider's copy of the conversation (`providerMessages`) before every model call: the user's words, tool results and earlier turns lose their plate, VIN and email shapes (`scrubIdentifiers`). The stored Thread keeps what the person typed. It is the last middleware of the assistant and of every structured call. Image prompts and embedding inputs are scrubbed the same way. Audio cannot be scrubbed; a transcript is the person's own words returned to them. An address has no shape to find in free text, so only the screens that never send one protect it.

**Admission, in order.** `admitAi` lets one call of a feature through for a User: a model is configured and the flag is on (else `AiUnavailable`), the User is within `AI_REQUESTS_PER_MINUTE` for that feature (`AiRateLimited`, the existing Postgres limiter), and within the plan's quota (`AiQuotaExceeded`), which the call then counts against. It returns the model, so a model call cannot be made without being admitted. The assistant keeps its own gate and rate limit and counts a new question against the copilot quota; answering an approval or a question the assistant asked does not count.

**Quotas.** `ai.rules.ts` holds the spec's table: structuring 20 Free / 300 Plus, Studio images 3 / 60, copilot questions 20 / 500 per calendar month (UTC). The on-device scanner is never metered. The plan is `planOf` (RN11): Plus with paid access, Free without; a deployment that sells nothing treats everyone as Plus. A call is counted **before** the model runs: providers bill failed and timed-out calls too, and counting first keeps the limit exact. Counting runs in one transaction under a per-User, per-quota advisory lock, so two concurrent calls cannot both take the last one; no network I/O happens inside it. Past the quota the procedure answers `QUOTA` (HTTP 402, like `PLAN_LIMIT`; a 429 would invite retries that cannot succeed until the 1st) with copy in pt-BR, en and de. On the receipt and voice path the phone's own draft still stands and a toast says why; the mobile assistant shows the same copy without a retry.

**Usage table.** `ai_usage` (`user_id`, `meter`, `created_at`) belongs to a User, not an Organization: the quota is the person's in every household they are a Member of. RLS admits the context User's own rows for SELECT and INSERT; `app_tenant` has no UPDATE or DELETE grant, and deleting the User deletes the rows. A row holds no prompt, answer, amount or identifier. `ai.usage` reads this month's count for every quota whose feature is on; M-Perfil shows it as one row under the plan, and shows nothing while every AI feature is off.

## Consequences [#consequences]

- A new AI feature is a registry row, a flag (if it is a new one) and a call to `admitAi` plus one of the four programs; its model is set in `AI_MODELS` without a deploy of code.
- The receipt/voice structuring path now answers `QUOTA` past the plan's limit instead of a silent null draft; every other refusal (off, rate-limited, provider failure) still returns no draft.
- `ai_usage` grows by one row per metered call and is never pruned. shortcut: delete rows older than a year in a cron job when the table grows past a few million rows.
- Proactive insights are "basic" on Free and "all" on Plus: a tier, not a count. The insights feature defines the tier; nothing meters it here.
- Studio counts one generation request as one image call. Decided with the Studio (#56): a request is **one** Studio image whatever its variations (three to choose from are one try), counted once the vision step (`studioLocate`, unmetered) has found a car. No regeneration round runs on its own after a failed fidelity check (#86): it doubled the paid calls behind one image unasked; the person tries again from Studio-Pick. See `/docs/features/studio`.
- The web assistant shows its generic error on `QUOTA`; only the mobile app names the quota.
- The extras (#59): a sale listing, a translation and the plan from the owner's manual each count one structuring call; search across documents embeds the question and the car's records unmetered (rate-limited only). The manual's PDF is the one input sent unscrubbed: a manufacturer's manual holds no owner's data, and a PDF cannot be scrubbed. A document's translation sends only the lines the phone read, scrubbed. See `/docs/features/ai-extras`.

