@ultimat3/ai 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,363 @@
1
+ # @ultimat3/ai — boundary
2
+
3
+ Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
4
+ query jobs realtime`. **Never** `mcp manifest render pwa ui admin testing cli`.
5
+
6
+ Declared today: `action` (the primitive `llm()` returns), `cache` (semantic cache), `core`,
7
+ `db` (pgvector), `money`, `policy`, `schema`, `time`.
8
+
9
+ `mcp` is the same tier, so the LLM-tool projection is restated structurally in `tools.ts`
10
+ rather than imported. Same contract, two wire formats — and the same *decision*: `toLlmTools` and
11
+ `runLlmToolCall` ask `isMcpExposed` from `@ultimat3/core` (tier 0, reachable by both), never a
12
+ local `=== true`. An in-app agent and an external one must be offered exactly the same tools.
13
+
14
+ **And the same NAME**: `toLlmTool` passes `action.name` through untouched, which is the export name
15
+ `@ultimat3/mcp` serves. Never derive one here. `llm()` and `agent()` return actions, so their
16
+ `.tool()` name is the verbatim export name too — it read `summarize_post` / `projecting_agent`
17
+ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts`).
18
+
19
+ ## Owns
20
+
21
+ | File | Job |
22
+ |---|---|
23
+ | `models.ts` | the model REGISTRY: `registerModel`, limits, prices, the reasoning controls each one accepts |
24
+ | `provider.ts` | `Provider` interface, the request half, the money arithmetic, Anthropic + Echo |
25
+ | `wire.ts` | the response half: `usage` / `stop_reason` shapes, and the SSE `MessageStream` |
26
+ | `error-body.ts` | what a failure body SAYS (`detailOf`) and what must never survive into it (`withoutKey`) — one copy, both transports |
27
+ | `sse.ts` | Server-Sent Events framing — protocol only, knows nothing about Anthropic |
28
+ | `openai-provider.ts` | `openAiProvider()` — the socket, the credential and the errors for any endpoint speaking the OpenAI chat-completions FORMAT |
29
+ | `openai-messages.ts` | the format mapping's request half: `AiMessage` blocks → OpenAI messages, `LlmTool` → functions, `tool_choice` |
30
+ | `openai-body.ts` | one chat-completions body, and the per-model reasoning mapping |
31
+ | `openai-wire.ts` | its response half: one completion, and `ChatCompletionStream` (fragmented tool calls, trailing usage, `[DONE]`) |
32
+ | `openai-models.ts` | the OpenAI-format price rows, registered through the same `registerModel` |
33
+ | `gateway.ts` | routing, retries, cache, budget wiring |
34
+ | `budget.ts` | token ledgers per request/actor/org, ALS carrier |
35
+ | `prompt.ts` | `definePrompt`, content hashing, version registry |
36
+ | `embeddings.ts` | `Embedder`, `HashEmbedder`, cosine helpers |
37
+ | `remote-embedder.ts` | `RemoteEmbedder` — the production `/v1/embeddings` client |
38
+ | `evals.ts` | `defineEval`, the run, the baseline gate, prompt coverage |
39
+ | `eval-baseline.ts` | the recorded scores: path, read/write, what counts as a regression |
40
+ | `scorers.ts` | what a `Scorer` is, the built-in ones, and `llmJudge` |
41
+ | `vector.ts` | `VectorStore`, in-memory cosine + BM25, RRF hybrid |
42
+ | `pg-vector.live.test.ts` | the same store against a real pgvector — DDL, fusion, scope, plan |
43
+ | `vector-scope.ts` | the tenant + policy envelope, and the tighten-only derive rule |
44
+ | `pg-vector-sql.ts` | every pgvector statement: DDL, upsert, cosine, FTS, RRF fusion |
45
+ | `pg-vector.ts` | `PgVectorStore` — the production store |
46
+ | `rag.ts` | chunker, retriever, reranker, budgeted context assembler |
47
+ | `tools.ts` | action → LLM tool definition; `runLlmToolCall` |
48
+ | `llm.ts` | `llm()` — the model call, declared as an `action`; and what a streamed answer must satisfy |
49
+ | `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
50
+ | `agent.ts` | `agent()` — the tool loop, declared as an `action` |
51
+ | `redaction.ts` | the one gate between `vars()` and the provider: a `Secret` never reaches a prompt |
52
+ | `eval-errors.ts` | the five `X_EVAL_*` classes; their codes and titles stay in `errors.ts` |
53
+ | `runtime.ts` | the ambient gateway / embedder / semantic caches an `llm()` reaches |
54
+ | `fix-line.ts` / `fix-line.evals.ts` / `fix-line.v1.baseline.json` / `fix-line.eval.test.ts` | the package's own dogfood eval — the first framework-level `*.eval.test.ts`, proving the `defineEval`/baseline convention actually fails a build |
55
+
56
+ ## Invariants
57
+
58
+ - **`llm()` returns an `action`. It is not a ninth primitive** (root `CLAUDE.md`, 2026-08). It
59
+ never re-implements parse, authz or invoke — `action()` owns those and `invoke` runs them.
60
+ - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an `llm` file imports one
61
+ package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
62
+ access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
63
+ - The model half is the only thing `llm.ts` adds: render the prompt, project `output` into the
64
+ one tool the model may answer through, reserve the budget, consult the semantic cache.
65
+ - One repair turn, then `X_LLM_OUTPUT_INVALID`. Two schema failures is a prompt/schema
66
+ disagreement; a third attempt only spends money.
67
+ - Semantic scopes are separate cache INSTANCES, never a filter over a shared one — cosine
68
+ similarity has no notion of a tenant. The instance key carries the prompt hash too, which is
69
+ what makes a version bump invalidate the cache.
70
+ - A per-call budget `derive`s from the ambient ledger, so it can only TIGHTEN the actor and org
71
+ ceilings it runs inside. Widening them from a declaration would be a budget that is not one.
72
+ **A derived ledger reports back up the chain**: every debit and every recorded cost lands on it
73
+ AND on every ledger it was derived from, and `reserve` checks the `request` scope of each one.
74
+ Without that link a child was a fresh counter with no parent — `llm()` derives one per call, so
75
+ `gateway.spent()` answered zero after a hundred calls and a `request` ceiling of 5,000 was
76
+ re-granted in full to each of them. The STORE is written once, by the ledger the call was made
77
+ on: a child shares its parent's store and keys, so writing through both bills the actor twice.
78
+ Reservations queue on the ROOT's turnstile for the same reason — a per-ledger queue serialises
79
+ nothing once every call has its own ledger.
80
+ - `cache.invalidates` from `docs/idea/05-caching.md` is **not** on `llm()` yet: `invalidateTags`
81
+ fans out to `CacheTier`s, and a `SemanticCache` is not one. Storing tags nothing visits would
82
+ read as wired and silently not be. Version bump + `ttl` is the invalidation today.
83
+ - The gateway is ambient (`configureAi`) because a declaration is evaluated at module scope,
84
+ long before a provider exists. Absent at call time is `X_AI_GATEWAY_MISSING`, never a default
85
+ provider — a silent fallback would spend real money on a boot mistake.
86
+
87
+ - **`BudgetStore` is where `actor` and `org` actually live, and the default is per PROCESS.**
88
+ `createGateway({ budgetStore })` is the one install point; omitted, it is `MemoryBudgetStore`,
89
+ so an `org` ceiling is multiplied by the replica count exactly as `jobs`' `LimitConfig` is.
90
+ `request` is unaffected — it is one call chain and never crosses a process. `add` takes a
91
+ **negative** `tokens` (releasing an unspent reservation is a credit), so a store that clamps at
92
+ zero leaks the ceiling upward on every release.
93
+ - Cost is `Money` (integer minor units), rounded **up**. Never a float, never a division
94
+ that loses a fraction.
95
+ - Every non-2xx and every in-band `error` frame becomes `AiTransportError`, which carries a real
96
+ `status` field — that field IS the gateway's retry rule. A body parsed as a message would read
97
+ as an empty, successful answer, which is the one outcome nothing downstream can detect.
98
+ - **The two wire formats answer the same question the same way, and `provider-parity.test.ts` is
99
+ what makes that a build error — added 2026-08.** Four rules were held by one format and not the
100
+ other, all of them in the RESPONSE half, and each one is a failure that reads as a success:
101
+ - an in-band `error` object in a **200 body** is `AiTransportError` on both. `parseMessage` read
102
+ one as an empty `end_turn` answer while `MessageStream` — the *same provider's* streamed half —
103
+ had always refused it. `throwInBandError` is `wire.ts`'s, exported, so one status table decides
104
+ the gateway's retry on both transports.
105
+ - a `stopDetails` of type `refusal` **forces** `stopReason: 'refusal'`, because `llm()` and
106
+ `agent()` branch on the reason and nothing reads the detail. `parseStopReason` answers
107
+ `end_turn` for a spelling this build has never seen, so a refusal in a new vocabulary arrived
108
+ as a complete answer that happened to be empty. The OpenAI-format read always forced it.
109
+ - a tool call's `input` is **parsed, never cast**: `asToolInput`, one copy. `(b['input'] ?? {}) as
110
+ Record<string, unknown>` put a string under that type, and `runLlmToolCall` indexes it.
111
+ - the **credential is scrubbed** out of `AiTransportError.detail` on both. `withoutKey` was the
112
+ OpenAI provider's alone, so a proxy echoing `x-api-key` into its 400 body put an Anthropic key
113
+ in an error — and an error reaches a log index, a span and a problem document. Both providers
114
+ now call `error-body.ts`'s pair; `detailOf` moved there from `provider.ts` with it (internal,
115
+ never in `src/index.ts`).
116
+ What is NOT parity: the two status tables (529 vs 503 for "overloaded") and the OpenAI-format
117
+ `estimatedUsage` fallback. Those are the formats differing, and the parity suite asserts them
118
+ *as* differences rather than flattening them.
119
+ - A stream that ends without `message_stop` throws. A truncated answer that returns `end_turn`
120
+ is a confidently wrong answer with no signal, which the budget rule already forbids.
121
+ - A tool call is emitted whole. `input_json_delta` fragments are not arguments until the block
122
+ closes, so nothing partial reaches a caller.
123
+ - Thinking chunks are never appended to `text`. A consumer concatenating every chunk must not
124
+ end up shipping the reasoning to the user.
125
+ - One `RemoteEmbedder` for every vendor: `baseUrl` selects the provider, the wire shape is the
126
+ same. Vectors are L2-normalised on arrival so `cosine` stays a dot product, and a width other
127
+ than the declared one is `X_VECTOR_DIM_MISMATCH` before anything reaches a store.
128
+ - A budget throws `X_AI_BUDGET_EXCEEDED` **before** the provider call. Never truncate.
129
+ - Anthropic body: no `temperature`/`top_p`/`top_k`, no `budget_tokens`, `effort` inside
130
+ `output_config`. All 400s otherwise.
131
+ - **`ModelId` is `string`, and the catalogue is an open registry — decided 2026-08.** The routing
132
+ seam (`Provider`, `createGateway`) was always open; the VOCABULARY was not, so a company's own
133
+ gateway serving `llama-internal-70b` could not be typed and the only way past `tsc` was to claim
134
+ a Claude id — after which `costOf` charged list price for a model nobody ran, `BudgetLedger`
135
+ reserved against the wrong number and the manifest recorded a model the company does not use.
136
+ What replaces the union as the guard is `modelSpec(id)`: an unregistered id is
137
+ `X_AI_MODEL_UNKNOWN` at the first read, naming the registered set. **The three built-ins register
138
+ through the same `registerModel` an app calls**, at the bottom of `models.ts`, so the default
139
+ path is the app's path and there is one way to put a model in the catalogue.
140
+ - **Re-registering an id REPLACES its spec and keeps its rung.** That is the negotiated-rate
141
+ mechanism, and the reason there is no `overrideModel`: an app whose contract prices
142
+ `claude-opus-5` below list registers it again with its own prices, and every `costOf`, every
143
+ reservation and every recorded cost is that number from then on. Boot runs after this module is
144
+ imported, so the app always wins.
145
+ - **Registration order IS the ladder within a `family`, most capable first, and `moreCapableThan`
146
+ is its only reader.** `ModelSpec.family` is what makes that true once more than one vendor's
147
+ list is registered: the built-ins are `anthropic` then `openai`, so without it the rung above
148
+ `gpt-5.6-sol` was `claude-haiku-4-5` — the cheapest model in the catalogue, from a vendor the
149
+ app's gateway may not serve, offered as an UPGRADE in `X_LLM_REFUSED`'s fix line. Absent is its
150
+ own family, so an app that registers its whole catalogue in one order still compares across all
151
+ of it. A refusal is worth retrying upward and nowhere else: `MODEL_IDS.find((id) => id !==
152
+ refused)` answered a refusal on the default model with the next entry DOWN, so `X_LLM_REFUSED`'s
153
+ fix line told an operator to buy the same refusal from a weaker model. When there is no rung
154
+ above — including for a model nobody registered — `alternative` is `undefined` and the fix line
155
+ drops the suggestion rather than inventing a downgrade. A model appended after the built-ins is
156
+ the least capable rung, because that is what appending to a most-capable-first list means; an app
157
+ wanting its own ladder registers its whole catalogue in order.
158
+ - `AnthropicProvider.models` is `ANTHROPIC_MODEL_IDS`, its OWN list, never the registry's — an
159
+ app's internal model must not be routed to Anthropic. `EchoProvider.models` is a getter over the
160
+ registry, because a test double has to serve whatever the test registered.
161
+ - **The reasoning half of the body is PER MODEL, and `models.ts` owns which model takes what.**
162
+ `output_config.effort` and adaptive thinking arrived with 4.6, so one body sent to the whole
163
+ catalogue is a guaranteed 400 on the oldest entry — which is how `claude-haiku-4-5` shipped
164
+ blessed and uncallable. A control the caller never asked for is omitted; a control they DID
165
+ ask for is refused locally with `X_AI_REQUEST_INVALID`, never dropped, because a declaration
166
+ reading `effort: 'max'` that quietly runs at the default is the failure nobody can see. Adding
167
+ a model is a row in `MODELS`, never an `if` in the request builder. Omission is literal: an
168
+ absent `thinking` sends no block at all, where `(thinking ?? 'adaptive')` sent an adaptive one
169
+ for every adaptive-capable model — harmless on the wire, since adaptive is the server default,
170
+ but it made a defaulted control indistinguishable from a declared one, which is the whole
171
+ distinction this rule draws.
172
+ - **`openAiProvider()` is a FORMAT, not a vendor — decided 2026-08.** Azure OpenAI, vLLM, Ollama,
173
+ LiteLLM, OpenRouter, Together and most company gateways speak the OpenAI chat-completions wire
174
+ format, so one provider plus `baseUrl` is what makes "point Ultimate at our internal gateway"
175
+ real. Never add a second class per vendor: `baseUrl`, `auth` and `headers` are the differences.
176
+ - **Structured output is the `respond` tool, never `response_format`.** `llm()` already projects
177
+ `output` into one tool and reads the answer out of `toolCalls`; `json_schema` + `strict` would
178
+ be a SECOND structured-output path (axiom 1), would need the provider to synthesise a
179
+ `respond` call out of a content string, and is the one feature most OpenAI-*compatible* servers
180
+ do not implement. Forcing the function is what buys the reliability instead: `tool_choice`
181
+ names the tool when the request offers **exactly one**, which is precisely `llm()`'s shape and
182
+ never `agent()`'s — forcing a name inside a tool loop decides the model's next step for it.
183
+ - **`strict: true` is derived from the schema, never forwarded.** `LlmTool.strict` is `true` on
184
+ every projection; on this wire the server CHECKS it, and one optional field (a key in
185
+ `properties` absent from `required`) is a 400. `satisfiesStrictMode` is the gate, recursive
186
+ because the server's check is.
187
+ - `max_completion_tokens`, never `max_tokens`: the old field is rejected outright by every
188
+ current reasoning model. No `temperature`/`top_p`, same rule as the Anthropic body.
189
+ - **`stream_options: { include_usage: true }` on every streamed request, and an ESTIMATE when
190
+ usage never arrives.** Usage comes once, in a trailing chunk with an empty `choices` array, and
191
+ only when that field was sent — a compatible server that ignores it would otherwise leave the
192
+ budget reconciling a real call against zero, refunding the whole reservation. Zero is wrong by
193
+ all of it; an estimate is wrong by a few percent in the safe direction.
194
+ - `prompt_tokens` INCLUDES the cached prefix here, where Anthropic's `input_tokens` excludes it.
195
+ Subtract `prompt_tokens_details.cached_tokens` out of the input count or the cached half is
196
+ billed twice — once at the input rate, once at the cache rate.
197
+ - Tool-call deltas are merged by `tool_calls[].index`. The id and the name arrive on the FIRST
198
+ fragment and on no other, so merging by array position builds one call per chunk and keeps only
199
+ the last slice of arguments. The call is emitted whole at the finish reason — there is no
200
+ per-block stop event in this format.
201
+ - `isComplete()` accepts `[DONE]` **or** a finish reason: plenty of servers in the family close
202
+ the socket straight after the finish chunk, and a finish reason is the model saying why it
203
+ stopped, which a cut connection cannot produce.
204
+ - `role: 'system'`, not `developer` — the newer role is OpenAI's alone and every other server in
205
+ the family knows only `system`.
206
+ - **Only three models are priced** (`gpt-5.6-sol` / `-terra` / `-luna`, list price read
207
+ 2026-08-16). `gpt-4o` and the `o1` family cache at 0.5x input where `costOf` assumes 0.1x, and
208
+ the `pro` tiers publish no cached rate — a wrong price is worse than a missing one, because
209
+ `costOf` answers confidently either way and `X_AI_MODEL_UNKNOWN` at least says so.
210
+ - The specs register at module scope, like the Anthropic three — so a suite that calls
211
+ `resetModels()` drops them. `registerOpenAiModels()` is exported for exactly that, and every
212
+ `openai-*.test.ts` calls it in `beforeEach`.
213
+ - **No new `X_*` code.** A non-2xx and an in-band `error` object are `AiTransportError`, a missing
214
+ key is `X_AI_KEY_MISSING`, a control the endpoint has not got is `X_AI_REQUEST_INVALID` — the
215
+ failures are the same failures, and a second code per provider would be a vocabulary that grows
216
+ with the driver list. What DID change: `AiTransportError` now takes the provider's `envVar`,
217
+ because the 401 fix line was a hardcoded `ANTHROPIC_API_KEY` for every provider in the package.
218
+ - The key is revealed as late as possible, never stored on the instance, and scrubbed out of the
219
+ error `detail` — a proxy echoing request headers into its 4xx body is the one path by which a
220
+ key reaches an error, and an error reaches a log index, a span and a problem document.
221
+ - Model IDs are exact aliases. Never append a date suffix.
222
+ - **No fix line may name `x ai`.** That command is PLANNED and throws (`packages/cli/src/cmd-planned.ts`),
223
+ so a fix citing `x ai reindex` sends an operator to a wall — an axiom-4 violation. Two shipped;
224
+ both now name the app-code fix instead.
225
+ - **An eval is selected with `x test eval --filter <name>`, never `x test <name>`.** `x test`'s
226
+ positional is a `TestType` (`unit contract live job e2e eval`), so the eval's own name there is
227
+ `X_CLI_BAD_FLAG`. `X_EVAL_THRESHOLD` shipped that fix line until 2026-08; `eval-errors.test.ts`
228
+ now asserts every `x test <word>` these five classes emit is one of the six types. The
229
+ `errors` step's `fix-command.ts` resolves the *command*, not its positional, so nothing else
230
+ would have caught it.
231
+ - The introductory price on a model is deliberately not modelled. A price that lapses on a date
232
+ makes a recorded cost depend on when it was read, and under-reporting spend after the lapse is
233
+ a budget that is not one. List price over-reserves, which is the safe direction.
234
+ - `generate()` above `STREAM_ONLY_MAX_TOKENS` runs the STREAMING transport and assembles the
235
+ result, rather than refusing. The ceiling is the transport's, not the model's.
236
+ - **`llm()` streams through `.stream()`, and it is the SAME action — decided 2026-08.** The
237
+ invocation is an ordinary one, marked with an ambient sink; policy, input parse, budget scope,
238
+ semantic cache, span, audit and `.tool()` all still apply, because there is no second execution
239
+ path. Before it existed, the first feature needing tokens on a screen called `aiGateway()`
240
+ directly and lost every one of them.
241
+ - **Output schema:** a schema cannot be checked until the last token lands, so a stream yields
242
+ UNVALIDATED text and one final `done` carrying the value that DID satisfy `output`. **No repair
243
+ turn** — the consumer has already read the tokens, and a second answer over the top is two
244
+ answers to one question. One attempt, then `X_LLM_STREAM_INVALID`, whose fix is the
245
+ non-streaming call.
246
+ - **Budget:** unchanged and still reserved before the provider is touched. `Gateway.stream`
247
+ debits the worst-case estimate on the first pull and reconciles at `done`, releasing in a
248
+ `finally`. The whole stream is driven inside the handler, so reservation and reconciliation sit
249
+ on one async chain; abandoning the iterator stops delivery, never the accounting.
250
+ - A streamed call offers **no `respond` tool**: a tool call is emitted whole, so forcing one
251
+ leaves nothing to stream. The answer is prose, and its JSON parse is what a non-string `output`
252
+ validates. A semantic-cache hit yields `done` alone, with no text increments.
253
+ - `.stream()` is LAZY. Nothing is authorised, budgeted or sent until the first pull. `named()` is
254
+ re-narrowed for the same reason `stream` is assigned in place: `action()`'s `named` builds a
255
+ fresh twin that would silently not stream.
256
+ - **`agent()` is a job for the tool loop, and the third instance of the factory rule** (after
257
+ `llm()` and `backfill()`) — it returns an `action`, never a ninth primitive. It exists because
258
+ the alternative is a hand-rolled loop, and a hand-rolled loop is where the dangerous mistake
259
+ lives: **taking the actor from the model's output.** `ctx.actor` is read once and is the only
260
+ identity any tool runs as; nothing the model emits can reach it. Bounded by `maxTurns`
261
+ (`X_AGENT_MAX_TURNS`, never a partial answer), by `budget.tokensPerRun` (the ledger's `request`
262
+ scope, which accumulates across turns) and by `maxToolResultChars` — the transcript IS the
263
+ request, so an untruncated tool result is re-billed once per remaining turn.
264
+ - A tool listed in `agent({ tools })` that is not `mcp: { expose: true }` is
265
+ `X_AGENT_TOOL_UNEXPOSED` **at declaration**, not filtered at the call: a silently dropped tool
266
+ reads as offered and is not. `isMcpExposed` is the one predicate, so an in-app agent and an
267
+ external MCP client see the same catalogue.
268
+ - **No semantic cache on `agent()`.** Similar prompts do not have similar answers once the answer
269
+ depends on what `lookupOrder` returned this second.
270
+ - `AiMessage.content` widened to `string | readonly AiContentBlock[]` for this: a `tool_result`
271
+ has to name the `tool_use` it answers and a string has nowhere to put the id. The block field
272
+ names are the Messages API's, so `body()` passes them through untouched.
273
+ - **`configureAi({ redact })` is the one seam between `vars()` and the provider.** `vars()` is the
274
+ one declared place a model call loads data, so it is the one place a redactor can see the row
275
+ before it leaves the process; the redactor sees the whole RENDERED prompt and the system prompt,
276
+ template as well as values. WHAT to remove is the app's (a PII classifier is a model choice —
277
+ axiom 8). The framework ships the seam, the `llm.redacted` span attribute, and the one rule it
278
+ can enforce structurally: **a `Secret` in `vars()` is `X_AI_PROMPT_SECRET`**, whether or not a
279
+ redactor is installed. Not a leak — `Secret` renders `[redacted]` by value — but a prompt that
280
+ reads fine, means something else, and costs full price.
281
+ - **Fallback is across PROVIDERS serving one model, never across models — decided 2026-08.** The
282
+ wiki's LLM-gateway table claimed an ordered model list; there never was one, and building one was
283
+ rejected: a silent model swap changes what answered, what it cost, and which eval baseline the
284
+ answer belongs to, and `X_LLM_REFUSED` already names a more capable model for the DECLARATION to
285
+ adopt. What was missing is the other half of the claim — "never silent" — so the gateway now
286
+ stamps `GenerateResult.provider` with the provider that actually answered and `llm()` puts it on
287
+ the span as `llm.provider`. Stamped by the gateway, not the provider: routing is a gateway
288
+ concept, and an app's own `Provider` cannot report on a decision it did not make.
289
+ - A **refusal is a 200 with no answer in it**, so it becomes `X_LLM_REFUSED` at the `llm()` seam,
290
+ before the output is parsed. Parsing it first reports a schema disagreement — wrong cause,
291
+ inapplicable fix — and spends a repair turn buying the same refusal again. A truncated answer
292
+ that also fails its schema is `X_LLM_TRUNCATED` for the same reason: the ceiling does not move
293
+ between attempts. `stopDetails.category` is carried, not dropped: it is the only thing that
294
+ says whether another model would answer.
295
+ - The gateway does not cache a refusal. Caching one keeps serving a classifier decision long
296
+ after the prompt that provoked it was fixed.
297
+ - Server-side `fallbacks` (beta) are deliberately NOT sent. The provider speaks the stable
298
+ `2023-06-01` surface, and a 1.0 package that promises semver cannot pin a beta wire contract;
299
+ the typed refusal plus the gateway's own model routing is the framework's answer instead.
300
+ - `definePrompt` refuses a re-registered version whose hash moved.
301
+ - Every eval result carries the prompt hash. A score without one is not a measurement.
302
+ - An eval gates on the DROP from its recorded baseline, never on an absolute score. An absolute
303
+ floor fails every eval at once the day a provider ships a slightly different model, which
304
+ teaches everyone to lower thresholds until they measure nothing.
305
+ - The run mean AND every case are compared. A mean that holds while one case collapses is the
306
+ regression an eval exists to catch.
307
+ - A baseline that has never been recorded is `X_EVAL_BASELINE_MISSING`, and a corrupt one is
308
+ `X_EVAL_BASELINE_INVALID` — never "absent, so pass". A step that cannot fail is not running.
309
+ - `baseline` is `import.meta.resolve('./…')`. A cwd-relative path resolves to a different file
310
+ depending on where the suite was started, which is how a gate silently stops gating.
311
+ - Every registered prompt must be named by an eval (`promptsWithoutEvals`, `X_EVAL_MISSING`).
312
+ Coverage is by prompt ID, not ref: old versions are retained, and an eval on the current one
313
+ evaluates that lineage.
314
+ - `ULTIMATE_EVAL_RECORD=1` writes baselines instead of gating on them. A test that deliberately
315
+ scores a worse model calls `run`, never `assert` — `assert` would re-record during that pass.
316
+ - **Recording and the gate are mutually exclusive.** `x verify` with that variable set is
317
+ `X_EVAL_RECORDING` and runs no eval suite at all. Recording passes by definition, so a gate run
318
+ that inherited the flag is green over numbers it wrote itself — and rewrites every committed
319
+ baseline on its way through, which is the half a red step would not undo. Hence refuse *before*
320
+ the suite, never after it.
321
+ - The gate asks whether an eval has a baseline, not only whether one is declared. `defineEval`
322
+ proves a prompt is named; it proves nothing measured it, and an eval whose numbers were never
323
+ recorded — one no test asserts, one whose `baseline:` is a cwd-relative string — would otherwise
324
+ satisfy `X_EVAL_MISSING` while gating on nothing.
325
+ - Retrieval is hybrid by default. Do not add a vector-only convenience path.
326
+ - `PgVectorStore` is the ONLY production vector path — pgvector and Postgres FTS in the app's own
327
+ Postgres, never a second datastore. `MemoryVectorStore` is the dev twin and enforces the same
328
+ envelope; a leak that only reproduces against real Postgres is a leak nobody finds.
329
+ - **It is proved against a real pgvector, not only against a recording client.**
330
+ `pg-vector.live.test.ts` runs the whole chain — `ddl()` -> a live server -> `upsert` -> cosine,
331
+ FTS and the RRF fusion -> decoded hit — and REFUSES to skip when `TEST_DATABASE_URL` names a
332
+ Postgres without the extension, because a suite that stands down reports green for the one
333
+ store that runs in front of real traffic. CI's service container is `pgvector/pgvector:pg17`
334
+ for that reason. Asserting statement *text* cannot catch a statement Postgres rejects, nor a
335
+ filter that compiles cleanly and excludes nothing: that is exactly how metadata shipped bound
336
+ `::jsonb`. A new operator, read path or scope rule is not done until it round-trips there.
337
+ - The distance ordering lives in a subquery, ascending and raw, because that is the only shape
338
+ hnsw answers — `order by 1 - (…) desc` is a sequential scan. Both halves are pinned by a plan
339
+ assertion in the live suite, since only a planner can say which one shipped.
340
+ - hnsw applies the scope AFTER the index scan, so an approximate index can return fewer rows
341
+ than asked for once a tenant filter is selective. The planner takes the exact path instead
342
+ when it has stats — which is why a bulk backfill that skips `analyze` is how a search that
343
+ used the index yesterday scans today. Assert the rows a scoped read returns, never the node.
344
+ - Tenant and policy filters go **in SQL**, on every statement, through `conditionsSql` — and on
345
+ BOTH halves of the fusion. Filtering after the rows are loaded is not filtering.
346
+ - `(tenant, id)` is the primary key. A cross-tenant overwrite is impossible at the storage layer
347
+ rather than conditional on every upsert remembering to check.
348
+ - `scoped()` only ever TIGHTENS: tenants are set once, allow-lists intersect. Widening is
349
+ `X_VECTOR_SCOPE_WIDENED`. Same rule as `budget.derive`, for the same reason.
350
+ - Metadata is bound `::text::jsonb`. A bound string cast straight to `::jsonb` is JSON-encoded
351
+ twice, reads back correctly, and makes every `metadata ->> key` filter match nothing.
352
+
353
+ ## Commands
354
+
355
+ ```
356
+ bun test packages/ai
357
+ bun run --filter @ultimat3/ai typecheck
358
+
359
+ # the live vector suite — needs the extension, not just a Postgres
360
+ docker run -d -e POSTGRES_PASSWORD=ultimate -p 5432:5432 pgvector/pgvector:pg17
361
+ TEST_DATABASE_URL=postgres://postgres:ultimate@localhost:5432/postgres \
362
+ bun test packages/ai/src/pg-vector.live.test.ts
363
+ ```