1@README.md 2 3# Design contract 4 5Each rule below names the project it comes from: **pg-jev**, **jevQL** or 6**pg_typesafe**. Rules marked **new** are this repo's own. Evidence and 7the full comparison are in the brain at 8`~/brains/personal/technology/artificial-intelligence/jev/postjevsql.md` 9and the `ecosystem/` pages next to it. When a rule changes, update that 10page too. 11 12## SQL surface 13 14- **Functions** (pg-jev / jevQL names, kept compatible): 15 - `jev(row, q [, threshold])` returns `bool`. 16 - `jev_prob` returns the Noul probability. 17 - `jev_choice` and `jev_score` / `jev_score_norm` return the top label 18 and the level. 19 `jev_score(row, q, levels text[])` built 2026-09-26 20 (`tests/score.rs`): the vendor's probability-weighted level, 0 to 21 n − 1, and `jev_score_norm` that level over n − 1. Levels are sent 22 lowest first in the caller's order, so reordering them is another 23 question and another cache key. Fewer than 2 or more than 10 levels, 24 or a NULL level, raises 22023 before anything is sent. The two share 25 one judgment and one `jev_cache` row, which stores the score, 26 confidence, per-level probabilities and legend. 27 `jev_choice(row, q, options text[])` built 2026-09-26 28 (`tests/choice.rs`): the top label. Options are sent in the caller's 29 order as label keys with no description, so reordering them is 30 another question and another cache key. A single option is returned 31 without a request or a cache row. No options, more than 255, or a 32 NULL, empty or repeated option raises 22023 before anything is sent. 33 The `jev_cache` row stores the choice, confidence and per-option 34 probabilities as ordered pairs, since a jsonb object would sort them. 35 - `jev_confidence(row, q, kind, options text[])` returns the vendor's 36 confidence in a Choice or Score, stored exactly as returned and never 37 recomputed. Built 2026-09-26 (`tests/confidence.rs`) with pg-jev's 38 signature: `kind` is `'choice'` or `'score'`, and it asks the same 39 question as `jev_choice` or `jev_score` on the same options, so they 40 share one judgment, one request and one `jev_cache` row. A single 41 option is 1.0, as `jev_choice_full` reports it. 42 - **A Noul carries no confidence, and none is derived** (settled 43 2026-09-26). `'noul'` (or any other kind) raises 22023 before 44 anything is sent. The evidence: 45 - The vendor says "(Noul answers don't carry one.)" 46 (docs.typesafe.ai/confidence, read 2026-09-26). The wire contract 47 agrees: in `typesafe-sdk` 0.7.0 and 0.7.2, `_schemas/models.py` is 48 generated from `https://api.typesafe.ai/openapi.json`, and 49 `NoulAnswer` has only `type` and `noul`, while `ChoiceAnswer` and 50 `ScoreAnswer` require `confidence` (pypi.org/project/typesafe-sdk/ 51 0.7.2). The Noul page's response example has none either 52 (docs.typesafe.ai/primitives/noul). For a Noul the probability is 53 the answer and the certainty in one. 54 - jevQL derives one as `max(p, 1 − p)`, its own addition 55 (https://github.com/kylemclaren/jevql/blob/274532af852e8edfb7715ec6dca1113e589cb191/internal/typesafe/client.go#L59-L68). 56 That is a recomputation, and it disagrees with the vendor's own 57 formula (below) at n = 2, which is `2 · max(p) − 1`: at p = 0.8 the 58 vendor's form gives 0.6 and jevQL's 0.8, and jevQL's never falls 59 below 0.5, even at p = 0.5. A threshold tuned on Choice confidences 60 would mean something else on it. It is not taken. 61 - pg-jev reads the answer's `confidence` key, so its `kind = 'noul'` 62 silently returns NULL 63 (https://github.com/realZachi/pg-jev/blob/afd11fa856d7a2b831a1bfd8ee7f869ce8efcd62/sql/jev--0.2.0.sql#L527-L531). 64 postjevsql raises 22023 instead: refuse loudly, not guess. 65 - A caller who wants jevQL's figure writes `greatest(p, 1 - p)` over 66 `jev_prob` in their own query. 67 - `jev_eval` returns the Noul in full, as a composite (*Typed 68 results*), not raw JSON. 69 - Threshold precedence: argument, then the `jev.threshold` GUC, then 70 0.5. 71 Built 2026-09-26 (`tests/threshold.rs`): `jev` is two overloads, 72 `(anyelement, text)` and `(anyelement, text, float8)`, claimed by the 73 scan like `jev_prob`. True when the probability is `>=` the threshold 74 (pg-jev's comparison). `jev.threshold` is a Userset GUC in [0, 1] 75 whose default is 0.5, so `RESET` falls back to it; an argument 76 outside [0, 1] raises 22023 before anything is sent. The threshold is 77 applied to the judgment, never sent, so `jev` and `jev_prob` on the 78 same question share one request and one cache row. A NULL threshold 79 argument gives NULL, since every function is STRICT (pg-jev's 80 `DEFAULT NULL` falls through to the setting instead). 81- **Operational functions and modes** (from the newer Jev extensions: 82 prasanthj/duckdb-jev, parable-work/jev-datafusion): 83 - `jev_stats()` returns this backend's counters since it started: 84 requests that reached the server, retries, redials (never sent), 85 connections opened, cache hits and misses, input and output tokens, 86 and cost (input tokens at `jev.price_per_mtok` when answered; output 87 is not billed), and `in_flight`, the HTTP/2 streams open now. Per 88 backend, not cluster-wide. Built 2026-09-26 89 (`postjevsql-pg/src/stats.rs`). A stream is counted by a guard held 90 across the transport's send, so a timed-out, cancelled or failed 91 request leaves the count on `Drop`. `dedupe_hits` counts calls that 92 waited on an equal judgment instead of sending. 93 - Every retry, redial, connection and answer is logged at DEBUG1 with 94 its request id, through `jev-client`'s `Observer` port (review D4). 95 - `jev.on_error` = `error` (default) | `unsure`. `unsure` turns a failed 96 judgment into NULL: `jev()` is then false and the probability NULL, 97 so a network fault skips a row and never passes one. A failed 98 judgment is the remote or the path failing on this row: a 408, 429, 99 529, 5xx, context overflow, connect or interrupted exchange, timeout, 100 oversized response, or the failure of the request an equal call 101 waited on. Settings, the key (401/403), a request we built wrong, TLS, 102 a moved model, the budget and `jev.cache_only` still raise, since they 103 would fail every row alike or guard spend. Nothing is cached for a 104 NULL. Built 2026-09-26 (`Failure::is_failed_judgment`, 105 `tests/on_error.rs`); Userset. 106- **`row` is a table alias** (the whole row, pg-jev) **or a column list** 107 `jev((a, b), q)`, which sends only those columns (jevQL). Prefer the 108 column list. It sends less data and costs fewer tokens. 109- **Typed results** (pg_typesafe). `jev_eval` and the `*_full` variants 110 return composites, not bare text or jsonb: 111 - `jev_choice_result(choice text, confidence float8, probabilities jsonb, model text, input_tokens int, output_tokens int)` 112 - The same shape for Noul and Score. 113 114 Built 2026-09-26 (`tests/eval.rs`): `jev_eval(row, q)` returns 115 `jev_noul_result(probability, model, input_tokens, output_tokens)`, the 116 Noul having no confidence; `jev_score_full(row, q, levels)` returns 117 `jev_score_result(score, confidence, probabilities, legend, model, 118 input_tokens, output_tokens)`; `jev_choice_full(row, q, options)` 119 returns `jev_choice_result`. They are claimed by the scan and ask the 120 same question as their scalar function, so they share its judgment, 121 request and `jev_cache` row; a hit reads the row's `answered_model` and 122 tokens. `probabilities` is a JSON array in the order asked ([label, p] 123 pairs for a Choice). The tokens are the whole request's, as stored. A 124 single option has a NULL model and 0 tokens, since nothing was sent. 125- **A Choice sends 2–255 options; a Score sends 2–10 levels.** The API 126 reference caps Choice at "a maximum of 255 options per Choice", and 127 Score "should have at least two levels; the API accepts up to 10" 128 (docs.typesafe.ai/api, read 2026-09-22). The Rust type makes any other 129 count unconstructible. A one-option Choice is answered locally with 130 probability 1.0 and never sent, as the vendor's own hierarchical 131 cookbook does. 132- **Option names are sent to the model, and their order is part of the 133 question.** The docs say "the option names and their descriptions are 134 both sent to the model". Hume's `option-position.json` measured order 135 bias: 136 - Reversing a list moved a support classification from 0.84–0.89 to 137 0.93–0.96. 138 - The correct answer scored 0.34 when listed first and 0.83 when 139 listed last. 140 141 So: 142 - **Options keep the caller's order end to end and are never 143 shuffled.** Randomizing would make the same row give different 144 answers and break the cache key. 145 - **Options travel as an ordered list of `(label, description)` 146 pairs** (SQL `text[]` or a composite array). They never pass through 147 `jsonb`, which does not preserve key order. `serde_json` is built with 148 `preserve_order`, and the request serializer writes `criteria` in 149 that order. 150 - **The label itself is the option key.** Do not use opaque `c0…cN` 151 keys: at 200 options they cost +67% input tokens (4,244 against 2,546) 152 for the same accuracy (Hume, `option-position.json`). Siblings are 153 unique already. 154 - Offer an `other` / `none of the above` option when the list may not 155 cover every input (vendor guidance). 156- **More than 255 options** has two modes. Both are exact about what 157 they return. 158 - **Label tree** (default, when the labels have a meaningful 159 hierarchy). This is the vendor's documented method 160 (docs.typesafe.ai/cookbooks/hierarchical_classification), corrected 161 as follows: 162 - Group siblings by meaning. Never split on the bytes or bits of IDs, 163 because Jev cannot judge an arbitrary bucket. Jev also reads hex and 164 numeric encodings worse than names (jaggedness page). 165 - Keep fanout well below the cap: the vendor reports "a Choice works 166 reliably up to roughly 240 options" (classification_using_confidence 167 cookbook, jev-1.12). 168 - **Each option describes its subtree**: its direct children and a 169 sample of leaves. **The instructions name the parent path.** This is 170 the vendor's own guidance ("each option's value is the child's 171 tree… lets the model see what lives under a branch"); its 172 hierarchical cookbook does neither. 173 - **A node's score is the product of its edge probabilities, kept as 174 `Σ log max(p, 1e-9)`.** It is a real probability that can only fall 175 with depth, so a partial path bounds every leaf below it, and 176 best-first search with pruning is exact. Do **not** use the 177 cookbook's geometric mean `exp(mean(log p))`. It rises with depth 178 and favours deep paths of confident edges (five edges at 0.9 score 179 0.90 against one edge at 0.8, while the real probabilities are 0.59 180 and 0.8). A one-child edge is ×1 and needs no special case. 181 - **Beam with K = 3 by default, all frontier Choices in one request.** 182 K = 3 is the vendor's figure and has not been measured by us. 183 - **Lookahead:** each request also carries the child Choices of each 184 frontier node's top-m likely children, up to the batch token budget. 185 That scores two levels per round trip and roughly halves the round 186 trips (CPC runs to 12 levels). Questions in one request are 187 independent (Hume), and the vendor recommends speculative questions. 188 - **Close calls get an order-debiasing twin.** When a node's 189 separation is near 1×, a reversed-order copy of that Choice is 190 asked, and the log-probabilities are averaged (permutation 191 self-consistency, arXiv 2310.07712). The separation is known only 192 from the answer, so the twin cannot ride in the same request: the 193 node is queued again at its score and the twin takes a K slot in a 194 later round. It costs one copy of that node's option tokens, a 195 round trip only when nothing else is left to ask, and nothing when 196 the node is pruned first. 197 - Return the leaf, its path probability, and the **separation** 198 `exp(score_top − score_second)` (near 1× means ambiguous). 199 - The search is built 2026-09-26 (`postjevsql-core/src/label_tree.rs`), 200 sans-I/O; `jev_choice_tree` (below) drives it from SQL. 201 - Lookahead and the twin are built 2026-09-26 (`Lookahead`, 202 `TwinBelow`, on by default at m = 2, a 60,000-token round and 203 1.5×, all unmeasured and part of the release gate). Lookahead's 204 "likely" is a prior, since a child's Choice rides with its 205 parent's: the children with the most leaves, ties in the caller's 206 order. An answer held before its node is scored is applied without 207 a request once it is, so neither changes the result 208 (`exact_against_exhaustive` runs every combination). 209 K is the Choices asked per round, not a width that drops nodes: a 210 node is dropped only when its bound cannot change the top two 211 leaves (or, under τ, the deepest node at or above τ), so the result 212 and the separation are exact. A one-child node is passed through 213 at ×1 without a request. 214 - Each node's Choice is built by `Tree::choice` (2026-09-26): the 215 instructions are the question and, below the root, `Within: A > B`; 216 an internal child's description is `Contains:` its first 20 direct 217 children (`, and N more`) and `For example:` its first 5 leaves not 218 already listed, depth first; a leaf child has none. The 20 and 5 219 (`Describe`) are unmeasured and belong to the release gate. The scan 220 side is built (2026-09-26, `postjevsql-pg/src/scan/backend.rs`): a 221 judgement awaits `scan::on_backend(closure)`, the scan runs the 222 closure on the backend between waits (so a round's cache lookups 223 are SPI there) and the judgement resumes with the result. A closure 224 whose judgement was dropped is skipped. 225 - `jev_choice_tree(row, q, paths text[] [, tau])` built 2026-09-26 226 (`TreeCall` in `crates/postjevsql/src/lib.rs`; `tests/label_tree.rs`). 227 Each path is a leaf's labels joined by ` > `, the separator 228 `Within:` uses, and the result is the chosen node's path in the same 229 form, so it is unique where a bare label need not be. Under `tau` a 230 stop at the root (nothing at or above τ) is NULL. The tree is built 231 and refused (22023) before anything is sent. Each round's Choices 232 are looked up through `on_backend` and the misses sent in one 233 request with the row as the state; each node's Choice is its own 234 judgment, deduplicated and cached like a `jev_choice`. Every round 235 that sends counts as a row against `jev.max_rows`. A label 236 containing ` > ` cannot be expressed; `jsonb` input is not built. 237 - `jev_choice_tree_full(row, q, paths [, tau])` built 2026-09-26 238 (`tree_out` in `crates/postjevsql/src/lib.rs`; `tests/label_tree.rs`) 239 returns `jev_tree_result(path text, probability float8, separation 240 float8, depth int, leaf bool, fit float8)` from the core `Outcome`, sharing every 241 node's judgment with `jev_choice_tree`. Without τ it is the leaf, 242 its depth, `leaf` true and the separation (NULL when the tree has 243 one leaf). Under τ the separation is NULL, since a stop proves no 244 runner-up, and a stop at the root has a NULL path, probability 1 245 and depth 0. 246 - `Branch::from_paths` (2026-09-26) builds the tree from label paths, 247 top down, siblings in first-appearance order. A path given twice, 248 or one that is both a leaf and the parent of others, is refused: 249 without τ only leaves are returned, so such a label could never be 250 an answer. 251 - **Optional early stop** with `tau => …`: return the deepest node 252 whose path probability is ≥ τ, with its depth and a leaf/internal 253 flag. The default (τ unset) always returns a leaf. The vendor 254 measured the trade: forced leaves were right on 39/60, and reporting 255 the uncertain half one level up made 48/60 useful. Any τ is measured 256 against the pinned model version. 257 - **Chunk and shortlist** (opt-in, for label sets with no meaningful 258 hierarchy and N up to about 1,000). This is the vendor's shape in the 259 skill-suggestion and line-by-line-search cookbooks: 260 - Request 1 sends ⌈N/254⌉ chunk Choices together and keeps the top 261 few from each. 262 - Request 2 is one Choice over the finalists, with full descriptions. 263 - That is two round trips at any N. The cost is tokens: about 8.6 264 tokens per option (Hume: 20 × 200 options came to 34,590 tokens), so 265 it is roughly 10–40× the input of a tree. 266 - The search is built 2026-09-26 (`postjevsql-core/src/shortlist.rs`), 267 sans-I/O. Chunks are contiguous runs of the caller's list, as even 268 as ⌈N/254⌉ allows, sent as labels only; the top `keep` of each (3, 269 ours and unmeasured, part of the release gate) go to the finalist 270 Choice in the caller's order with their descriptions. Its 271 probability is among the finalists, not over all N. No labels, an 272 empty or repeated label, or a `keep` that leaves fewer than 2 or 273 more than 255 finalists is refused (22023). One label is returned 274 without a request. 275 - `jev_choice_shortlist(row, q, options text[] [, descriptions 276 text[]])` built 2026-09-26 (`ShortlistCall` in 277 `crates/postjevsql/src/lib.rs`; `tests/shortlist.rs`), opt-in beside 278 `jev_choice_tree` and driven the same way: each round's Choices are 279 looked up through `on_backend`, the misses sent in one request with 280 the row as the state, and each Choice is its own judgment, 281 deduplicated and cached. Every round that sends counts as a row 282 against `jev.max_rows`. Descriptions are one per option (a NULL one 283 sends none) and ride with the finalists only; a NULL option, or a 284 descriptions array of another length, is refused (22023) with the 285 core's refusals before anything is sent. It returns the label. 286 - `jev_choice_shortlist_full(row, q, options [, descriptions])` built 287 2026-09-26 (`ShortlistCall` in `crates/postjevsql/src/lib.rs`; 288 `tests/shortlist.rs`) returns `jev_shortlist_result` 289 (`jev_choice_result` plus `fit`), sharing every Choice's judgment 290 and `jev_cache` row with `jev_choice_shortlist`. 291 `choice` is the label the search chose (the vendor's may differ on a 292 tie). Confidence, `probabilities` ([label, p] pairs, the finalists 293 in the caller's order, among the finalists and not over all N), 294 model and tokens are the finalist Choice's, as stored; round 1's 295 tokens are in `jev_stats()` and EXPLAIN ANALYZE. A single option is 296 1.0 with a NULL model and 0 tokens, as `jev_choice_full` reports it. 297 - **"Does any label fit at all"** is a separate Noul in the same request 298 (the vendor's `gate` / `exists` pattern). Nouls never rank labels, 299 because Noul and Choice scores are not comparable. 300 Built 2026-09-26 (`postjevsql-core/src/gate.rs`, `Tree::gate`, 301 `Shortlist::gate`; `tests/label_tree.rs`, `tests/shortlist.rs`): the 302 `_full` functions ask it in their first round's request and return its 303 raw probability as `fit`, the last column of `jev_tree_result` and of 304 `jev_shortlist_result` (`jev_choice_result` plus `fit`). No threshold 305 is applied, since thresholds do not transfer. The scalar functions do 306 not ask it, as they could not return it. Its instructions are the 307 question and `Does any of these labels fit?` followed by the tree as 308 its root is described (`Contains:` / `For example:`), or every 309 shortlist label in the caller's order; it is its own judgment, 310 deduplicated and cached. `fit` is NULL when the search sends nothing 311 (one leaf or one option). 312 - The cookbook's "beam 4/4 against greedy 2/4" is one hand-picked 313 example per hierarchy, so it is anecdote, not accuracy evidence. The 314 literature picks no single winner: Yoshimura & Kashima (arXiv 315 2508.04219) find top-down and direct prompting each win on different 316 datasets, and LATTICE (arXiv 2510.13217) finds shortlist reranking 317 wins at low token budgets and tree search at high ones. This is why 318 both modes exist and the label-tree harness is a release gate 319 (*Testing*). 320 - This is hierarchical classification. It is not a radix tree. 321- **Thresholds do not transfer between question types or model 322 versions.** The jaggedness page shows a Noul and a yes/no Choice on the 323 same question disagreeing (0.22 against 0.01). `jev.threshold` applies 324 to Noul only, and any tuned threshold is recorded against the pinned 325 model version. 326- **Refuse loudly instead of guessing** (jevQL's discipline). jevQL's 327 refusal list is the set of cases this extension must eventually 328 support, not the set it may skip: 329 - `jev_*` in `OR` 330 - `HAVING` 331 - window functions 332 - DML (handled, with RETURNING still refused; below) 333 - CTEs and subqueries 334 - `DISTINCT` 335 - comparisons like `jev_prob(...) > 0.7` 336 - a `jev((a.x, b.y), q)` over two relations. That is a join condition, 337 which `set_rel_pathlist_hook` never sees; it needs a join or 338 upper-path hook. 339 340 Comparisons, `OR` and `AND` batch in the relation scan: it claims a 341 call anywhere in a restriction clause, and judges only where Postgres 342 reaches it (*Execution*). 343 344 Aggregates, `HAVING`, `DISTINCT` and window functions are handled 345 (2026-09-27, `postjevsql-pg/src/scan/lift.rs`; `tests/batching.rs`): a 346 `planner_hook` post-pass, after `standard_planner`, puts a jev scan 347 between an Agg, WindowAgg, Group or Result and its child wherever that 348 node evaluates a call reading only the child's columns (OUTER_VAR, no 349 Param, SubPlan or aggregate), laid out as `plan_custom_path` lays it 350 out, and rewrites the call to an OUTER_VAR column of the new scan. The 351 scan's `lefttree` is also the child, for EXPLAIN's deparser only. The 352 relation scan no longer claims calls under an Aggref or WindowFunc. 353 354 CTEs and subqueries are handled (2026-09-27, `tests/batching.rs`, on 355 17 and 18 against a per-row reference) with no code of their own: each 356 is planned by its own `PlannerInfo`, so the relation scan wraps its 357 relations there, and the post-pass and the `ExecutorStart` check walk 358 `PlannedStmt->subplans` and every plan state's `initPlan`/`subPlan`. 359 Covered: a `MATERIALIZED` CTE and one read twice (judged once), a scalar 360 InitPlan, `IN` (pulled up to a semi join), a hashed `NOT IN` SubPlan, 361 and a correlated scalar SubPlan. `EXISTS` with a jev qual stays a 362 correlated SubPlan, because `convert_EXISTS_sublink_to_join` refuses a 363 volatile WHERE. A correlated SubPlan runs once per outer row, so each 364 run's scan judges that run's rows and repeats are cache hits; it is 365 batched per run, never across outer rows. A call reading an outer column 366 (`jev_prob((t.body, o.id), q)` in a correlated subquery) gets it as a 367 Param: the scan evaluates it on every run, each run sends its own 368 value, and a repeated one is a cache hit, whether or not the child 369 depends on the Param. Under `WITH RECURSIVE`, a condition over the 370 worktable (`jev(r, q)`) and one over a table the recursive term joins 371 are judged per iteration, the recursion stopping where the condition 372 fails, and no row twice (`tests/batching.rs`, 2026-09-27). 373 374 Partitioned and inherited tables are handled (2026-09-27, 375 `wrap_paths` in `postjevsql-pg/src/scan/plan.rs`; `tests/batching.rs`, 376 on 17 and 18): the appendrel parent is not wrapped, each member is, 377 with its translated quals and the parent's select list translated by 378 `adjust_appendrel_attrs_multilevel`. An Append pulls members in turn, 379 so a member's rows are in flight together; the members share the 380 statement's dedupe and budget, and an equal judgment in two partitions 381 is one request. 382 383 DML and row locks are handled (2026-09-27, `tests/dml.rs`, on 17 and 384 18 against the mock's per-row answers; `tests/sidecar.rs` for UPDATE 385 through a foreign table): `wrap_paths`, `wrap_projections` and the 386 `lift.rs` post-pass no longer skip non-SELECT statements or rowMarks, 387 so UPDATE's SET and WHERE, DELETE's WHERE, INSERT … SELECT and 388 SELECT … FOR UPDATE are batched, and a call in both SET and WHERE is 389 one judgment. **An EvalPlanQual recheck never sends** (settled 390 2026-09-27). The scan's `scanrelid` is 0, so ExecScan's recheck asks it 391 to fill the slot: it pulls the child, whose scan substitutes the row's 392 current version, and judges it as any row. The recheck's executor has 393 its own `EState`; `Statement::new` follows `es_epq_active` to the 394 parent's, so the recheck shares the statement's dedupe registry and 395 budget, and `Statement::rechecking` gives it no client. An unchanged 396 version has the same key and is answered from the statement's 397 judgments or the cache. A version whose judged columns a concurrent 398 update changed is a question the statement never asked, so it is 399 refused with 40001 (serialization failure, retry the statement) 400 rather than sent or answered with the old version's answer. Under 401 `jev.on_error = unsure`, a failed judgment in `SET` writes NULL. 402 403 Still refused: a call over two relations (a join qual or a join's 404 select list); a call in RETURNING, ON CONFLICT or a MERGE action, 405 which ModifyTable evaluates row by row above the scan (the 406 `ExecutorStart` check reads those lists); and a whole-row argument 407 of a foreign table being updated, which setrefs matches to 408 postgres_fdw's own whole-row identity column (both varattno 0) and 409 which fails with 42804. Use a column list there. 410 411 Until one of the rest is handled, it raises a specific error. The 412 error comes before anything is sent: an `ExecutorStart` check 413 (`postjevsql-pg/src/scan/guard.rs`) refuses any plan in which a call 414 is evaluated outside a jev scan, so a scan elsewhere in the same plan 415 never judges a window first. 416- **Volatility.** `jev*` functions are `VOLATILE` (they call a remote 417 model whose answers vary between calls) and `PARALLEL RESTRICTED`, so 418 the relation gets no partial paths. Otherwise a parallel seq scan could 419 evaluate them one row at a time in the workers, because 420 `set_rel_pathlist_hook` runs before Gather paths are generated 421 (`allpaths.c:542-561`). 422 423## Execution 424 425- **Batching belongs to the plan, not to a function** (new). A CustomScan 426 node, added via `set_rel_pathlist_hook`, consumes child tuples, builds 427 requests and emits results. Do not use pg-jev's side-channel read-ahead 428 inside a scalar function, which falls back to one request per row for 429 subqueries and CTEs (pg-jev `AGENTS.md:70-80`). Do not make the caller 430 build a `text[]` (pg_typesafe's `*_many`). See *Implementation* §1 for 431 how the node is written over pgrx. 432 - **The node claims every `jev*` expression, not just the WHERE 433 quals.** `set_rel_pathlist_hook` sees only the relation's restriction 434 clauses. A `jev_prob(...) AS p` in the target list or ORDER BY would 435 otherwise run once per row in the projection, outside any batch. The 436 node reads `root->processed_tlist`, computes `jev*` there through 437 `custom_scan_tlist`, and sets `CUSTOMPATH_SUPPORT_PROJECTION`. 438 ParadeDB does the same for its score functions 439 (`pg_search/src/postgres/customscan/basescan/mod.rs:1109-1170`, AGPL, 440 design reference only). 441 - **It wraps every path in `rel->pathlist`, parameterized ones 442 included**, so index nested loops survive. 443 - **LIMIT is honoured by demand, not by a bound.** `ExecSetTupleBound` 444 never reaches a CustomScan (`execProcnode.c`), so the node sends 445 requests only as rows are pulled, keeping at most the in-flight 446 window ahead. 447 - **Settings are read in `begin`, not at plan time.** Generic plans 448 for prepared statements outlive a `SET`. 449 - **Rows the SQL filters out are never judged** by construction: the 450 child plan runs all non-`jev` quals before the node sees a row. 451 - **A call Postgres would not reach is never judged** (built 452 2026-09-27, `postjevsql-pg/src/scan/reach.rs`; `tests/batching.rs`). 453 The plan post-pass derives each call's reach from the scan's final 454 quals and projection: earlier `OR` arms `IS NOT TRUE`, earlier `AND` 455 arms `IS NOT FALSE`, the `CASE` tests before it `IS NOT TRUE` and its 456 own `IS TRUE`, earlier quals `IS TRUE`, and every qual for the 457 projection; OR over a call's occurrences. It is the last per-call 458 entry of `custom_exprs`. The scan evaluates it on each child row and 459 leaves an unreached call NULL, sending nothing. A condition that reads 460 another call's answer, a volatile function, a SubPlan or a 461 `CASE x WHEN` placeholder is dropped, which only widens the reach 462 (`jev(a) OR jev(b)` still judges both). 463- **A planner support function sets the cost** (`SupportRequestCost`), 464 so row estimates and join order account for the expense. It does 465 **not** order quals by selectivity. `order_qual_clauses` sorts by 466 security level and per-tuple cost only, and deliberately ignores 467 selectivity (`createplan.c:5416-5535`, REL_18_STABLE). 468 `SupportRequestSelectivity` applies only to a bare boolean 469 `jev(...)`; `jev_prob(...) > 0.7` is an operator expression and gets 470 the default inequality estimate (~⅓). 471- **Request layout: each row is judged alone** (new; this replaces "20 472 rows per shared state"). pg-jev's layout packs rows into one `state` as 473 `{"condition", "rows": [...]}`. Its "100% at 1–20 rows" measured 474 thresholded labels on three easy questions, not probabilities. On the 475 probabilities that layout is measurably wrong: 476 - jev-orderby-bench (commit 5239795, 2026-09-20, pre-registered, same 477 text at 40 rows per state against 1) found rank correlation with the 478 single-row baseline fell to 0.579, and 77 of 360 decisions flipped at 479 0.5. It is a position effect: rows in slots 0–7 moved 0.049, slots 480 16–23 moved 0.31, slots 24–39 about 0.42. A 20-row batch reaches into 481 the bad band. 482 - colliber/duckdb-jev issue #4 (2026-09-18) measured `score` answers 483 shifting by −0.29 levels on a 0–3 scale depending on which rows 484 shared the state, while `choice` labels agreed 269/270. 485 - The vendor's own re-ranking cookbook sends "one request per 486 candidate · no request sees another". MotherDuck's `prompt_jev()` 487 packs 32 rows per request by default; this contract rejects that on 488 the evidence above. 489 490 So a request is built one of two ways, and no row ever shares context 491 with another: 492 - **Row as state** when a row has two or more distinct questions in 493 the query. The state is the row, and each question is a branch. This 494 is the measured single-row baseline, and the state is billed once for 495 all of that row's questions. 496 - **Row in instructions** when a row has exactly one question. The 497 `state` holds only context shared by every row (often empty). Each 498 (row, question) becomes its own question whose structured 499 `instructions` carry the row. The vendor documents this form ("loop 500 over the potential records and build one of these questions per 501 field, all sent in a single call", primitives/advanced). Hume measured 502 that questions are isolated: a secret placed in a sibling question 503 scored 0.00. So many rows share a request without seeing each other. 504 Throughput is then bounded by tokens, not requests: at ~175 tokens 505 per row, 250k tokens/s is ~1,400 rows/s against 20 requests/s. 506 507 **Release gate:** row-in-instructions has no published accuracy 508 measurement against row-as-state, and Hume found facts placed in 509 *options* scored worse than in the state (0.49–0.89 against 1.00). 510 Before it is enabled, the jev-orderby-bench harness must compare 511 row-as-state against row-in-instructions on mean |Δp|, Spearman and 512 position effect (*Testing*). If it fails, the fallback is row as state 513 for every row, which is measured correct and caps throughput at the 514 account's request limit (20 rows/s). 515- **Deduplicate before sending** (new). Within a statement, identical 516 cache keys share one request: the first sends, and the rest wait on 517 its answer. The column-list form makes duplicates common. JevSQL and 518 LOTUS both deduplicate before calling. 519 Built 2026-09-26 (`crates/postjevsql/src/lib.rs`, `Shared`; 520 `tests/cache.rs`): the first call of a key registers it, and an equal 521 call waits on it without a cache lookup or a budget charge. The 522 registry is one per statement, held in the same `Statement` state as 523 the spend guards (`postjevsql-pg/src/scan/statement.rs`), so the scans 524 of a self-join share requests too. A request that fails fails its 525 waiters. One that is dropped unanswered (a rescan, a cancel) 526 unregisters the key, and each waiter then asks again itself, so 527 another scan's rescan never fails a call. 528- **Network I/O stays in the backend, non-blocking, and waits on the 529 latch** (pg_typesafe's principle; the mechanism is in *Implementation* 530 §2): 531 - Ctrl-C and `statement_timeout` must work while a request is in 532 flight. 533 - **Never call a Postgres API from a spawned thread.** 534- **Concurrency is bounded** by `jev.concurrency`, which counts HTTP/2 535 streams on the backend's one connection, not connections. It is 536 clamped to the server's `MAX_CONCURRENT_STREAMS` (100, measured; 537 *Implementation* §2). 538- **Retries follow the vendor SDKs**, checked in source: Python 539 `typesafe-sdk` 0.7.1 (`_core/retry.py`) and JS `@typesafe-ai/sdk` 0.6.0 540 (`dist/index.mjs:73-119`), fetched 2026-09-22. 541 - Retry 408, 429 and 500–599 (529 is inside that range), plus 542 connection errors and timeouts. 543 - At most 2 retries after the first attempt. 544 - Backoff starts at 0.5 s, doubles to a 5 s cap, and subtracts up to 25% 545 jitter. 546 - A server delay is read from `retry-after-ms` first, then 547 `Retry-After`, and capped at 60 s (the JS SDK's cap). 548 - Each attempt times out after 10 s (both SDKs' default). The whole 549 retry budget is at most 30 s (Python) and never beyond 550 `statement_timeout`. 551 - Retries send `X-TypeSafe-Retry-Count: n`, as both SDKs do. 552 - **Any other 4xx fails immediately.** A missing key returns **403** 553 (measured 2026-09-22); an invalid key returns **401** 554 `authentication_error` (measured 2026-09-23). Error bodies are 555 `{"detail": {"error_type", "message"}}`. 556 - **Context overflow is a 400 with `detail.error_type = 557 "max_tokens_exceeded"`**, not the 422 the vendor table implies (Hume's 558 `context-limit.json`, 20 trials; jev-axi `src/client.ts:311`). That 559 400 halves the request and retries. A single row that still overflows 560 fails with a clear error. 561 - Every error message includes the `x-typesafe-request-id` response 562 header, which is present even on auth errors. 563 - Responses are capped at 8 MB (pg_typesafe). An oversized answer 564 was already produced and billed, so it is never requested again. 565 - **Never sent is not a retry.** When hyper hands the request back 566 unsent, or h2 reports REFUSED_STREAM or a GOAWAY on it, the request 567 is resent at once on a fresh connection. There is no backoff and no 568 retry-count, at most twice. 569 - A TLS refusal (certificate, no h2) is never retried. 570- **Errors carry a SQLSTATE a caller can branch on**, with the request 571 id and attempt count in DETAIL (`crates/postjevsql/src/failure.rs`). 572 The classes follow postgres_fdw's use: 08 when the remote cannot be 573 reached, 38 when the remote failed. 574 575 | Failure | SQLSTATE | 576 |---|---| 577 | settings, invalid request | 22023 | 578 | no or two API keys, HTTP 401/403 | 28000 | 579 | DNS, connect, TLS | 08001 | 580 | interrupted, timed out | 08006 | 581 | 429/529 | 53000 | 582 | a miss under `jev.cache_only` | 55000 | 583| a changed row version in an EvalPlanQual recheck | 40001 | 584 | context overflow, response over 8 MB | 54000 | 585 | other API errors, wrong answer | 38000 | 586- **The account's rate limit is shared cluster-wide**, and the extension 587 enforces it rather than discovering it through 429s (new). jev-1.13.0 588 allows 250,000 tokens/s and 1,200 requests/min per account, and the 589 vendor warns the limits "can change without notice". No 590 `x-ratelimit-*` headers are documented, and none appear on error 591 responses. 592 - **GCRA, one `PgAtomic<AtomicU64>` per limit**, each holding a 593 theoretical arrival time. Admission is one compare-and-swap loop per 594 limit, and a request is admitted only if both limits pass. The token 595 cost is estimated, then corrected from `usage.input_tokens` after the 596 response. A token bucket would need two words and a lock; GCRA needs 597 one atomic. 598 - **The rate adapts to 429s:** a shared effective rate in shmem halves 599 on each 429 and rises gradually after successes. The GUCs 600 `jev.max_tokens_per_second` and `jev.max_requests_per_minute` are hard 601 ceilings. 602 - The wait for admission happens inside the `WaitEventSet`, so it 603 stays cancellable. 604 - Built 2026-09-26 (`postjevsql-core/src/gcra.rs`, 605 `postjevsql-pg/src/ratelimit.rs`; `tests/ratelimit.rs`). The three 606 words (two TATs and the adaptive scale) live in a PG17+ named DSM 607 segment (`GetNamedDSMSegment`), attached in `begin`, so 608 `shared_preload_libraries` is not needed; they are std `AtomicU64`s 609 timed on `CLOCK_MONOTONIC`, which every process shares. The 610 ceilings are `Sighup` (one account, one limit), default 250,000 and 611 1,200, and 0 is no limit. `Transport::admit` waits before every 612 attempt, retries included, on an executor timer, outside the attempt 613 timeout and the 30 s retry budget, so a queue is never read as a slow 614 server. The estimate is the body at the learned characters per token 615 (below; read once in `begin`), corrected from `usage.input_tokens`; a never-sent request returns its tokens. A 429 616 halves the rate and re-prices the throttled request's slot at the 617 halved rate, so its retry already waits the new interval. 618- **A request fits the model's context, and tokens are the only 619 per-request bound.** jev-1.13.0 takes 64k tokens per request, and 32k 620 for the state plus any one question. Hume's measurements: 621 - 6,893 questions in one request were accepted (65,402 tokens); 8,000 622 were refused, for tokens only. 623 - The largest accepted request was 65,756 tokens; the largest single 624 branch 33,002. 625 - A request's fixed overhead is ~267 tokens, plus ~8 per extra minimal 626 question. 627 628 The tokenizer is unpublished and matches none of 192 public ones, and 629 deriving it is barred by the terms (MCA §2.3(c)). So the planner 630 estimates with a ratio learned from cached `usage.input_tokens` per 631 model, falling back to `chars / 2.6` (jev-axi's conservative figure). 632 It validates option and level counts before sending. Overflow is 633 handled by the 400 rule above. 634 Built 2026-09-26 (`postjevsql-core/src/token_ratio.rs`, 635 `learned_ratio` in `crates/postjevsql/src/lib.rs`; `tests/budget.rs`): 636 per request in `jev_cache` for `jev.model` (rows sharing a request id, 637 the state once and each question), characters over the input tokens 638 above the 267-token overhead. Statement pricing, the spend guards and 639 EXPLAIN's `Estimated Input Tokens` use it, read once per statement, 640 and so do the rate limiter's admission estimate and the label tree's 641 lookahead budget, which take the statement's ratio in `begin` 642 (`tests/ratelimit.rs`, `tests/label_tree.rs`). 643- **The connection is reused across queries** (new), not rebuilt per 644 query. A reconnect happens only after more than 300 s idle, because 645 Cloudflare closes idle connections at 400 s (*Implementation* §2). 646 Measured here: a cold call costs 44.5 ms of our own (library load, 647 TLS setup, TCP/TLS/h2 handshake), and a warm one about 0.17 ms 648 (`tests/latency.rs`, 2026-09-23). 649 650## Cache 651 652- **Durable table, not per session** (new; pg-jev caches per session, 653 jevQL in a local SQLite file, and pg_typesafe not at all). TypeSafe 654 keeps nothing for you: "TypeSafe will be under no obligation to store 655 or retain Customer Data and may delete Customer Data at any time" (MCA 656 §10.3, "Last updated Sep 19, 2026"). Storing outputs is allowed: 657 "TypeSafe … assigns to Customer all of its right, title, and interest" 658 in Output (MCA §4.2). 659- **The key is the hash of what was actually sent**, so an incomplete 660 key cannot be written: 661 662 `sha256(pinned model ID ‖ prompt version ‖ namespace ‖ layout ‖ the exact 663 serialized bytes of the state and of this judgment's question object, 664 with the question id removed)` 665 666 - The question id is left out because it is "not sent to the 667 underlying model" (api.md). The vendor's cookbooks do the same: 668 "What goes on the wire and into the cache key: no id, no 669 bookkeeping." 670 - **Layout** (row as state or row in instructions) is in the key. 671 Answers are not assumed equal across layouts. 672 - **Namespace** is a GUC (`jev.cache_namespace`, from JevSQL). Changing 673 it on purpose re-judges every input. 674 - **Lookup uses `jev.model`**, because the answering model is unknown 675 before the call. The check that the response's `model` equals the pin 676 is what keeps that honest. 677- **`jev.model` accepts only versioned IDs.** A GUC check hook rejects 678 the aliases `jev-latest` and `jev-preview`. The vendor says an alias 679 "moves when a new release ships, so the answers behind it can change 680 without a change on your side", and advises pinning once thresholds are 681 tuned. Versioned IDs are accepted "whether or not they appear" in 682 `GET /v1/models`. If the response's `model` differs from the pin, the 683 statement fails, so a moved model is never cached under the wrong 684 version. No deprecation policy is published, and MCA §2.5 promises only 685 "commercially reasonable efforts" of notice. So **`jev.cache_only`** 686 serves hits and fails on misses; the cache stays usable after a pinned 687 version is withdrawn. 688- **Serialization is defined, not "sorted keys":** 689 - **Rows** are canonicalized with RFC 8785 (JCS): sorted keys, no 690 whitespace, RFC 3339 UTC timestamps, NULL columns written as `null`. 691 `numeric` values beyond f64 precision are written as JSON strings, so 692 no digits are lost. Built 2026-09-23: 693 - `jev-protocol/src/jcs.rs` keeps each number's lexeme. It emits the 694 ECMAScript form only when that form has exactly the original 695 value. Otherwise it emits a string of the exact value in the same 696 ECMAScript layout, so equal values give equal bytes whatever their 697 scale (`…890.10000` and `…890.1`). Generic canonicalizers round 698 through f64: serde_json_canonicalizer sent 9007199254740993 as …992. 699 - **Dates and times are written by the scan, not `to_json`** 700 (`postjevsql-pg/src/row.rs`, `RowJson`; `tests/canonical.rs`). 701 `to_json` writes BC and five-digit years outside RFC 3339 702 (`"0044-03-15T12:00:00+00:00 BC"`, `"10000-01-01T00:00:00"`) and 703 keeps a `timetz`'s own offset (`"12:00:00-05"`), measured on PG 18, 704 2026-09-26. So `timestamptz`, `timestamp`, `date` and `timetz` use 705 ECMAScript's `toISOString` years: astronomical (1 BC is `0000`), 706 four digits for 0000–9999, otherwise a sign and at least six 707 (`-000043`, `+010000`, `+5874897`). `timestamptz` ends `+00:00` 708 and `timetz` is shifted to UTC; the infinities stay 709 `"infinity"`/`"-infinity"`. `RowJson` walks records, arrays and 710 domains itself so nested values get the same form, and writes 711 every other leaf with `to_json`. 712 - The scan evaluates those leaves under a GUC nest level with 713 TimeZone=UTC, IntervalStyle=iso_8601, extra_float_digits=1, 714 bytea_output=hex and lc_monetary=C (`postjevsql-pg/src/row.rs`). 715 This is the mechanism of a function's SET clause, undone on return 716 and on abort. 717 - **Choice options** keep the caller's order (*SQL surface*) and are 718 never passed through JCS. 719 - The request body and the cache key both come from this one 720 serializer. 721- **A cached answer is one recorded draw, not a fixed point.** Repeats 722 spread by up to 0.06 on identical input (Hume, 30 calls × 24 questions) 723 and by up to 0.10 when only irrelevant state bytes change (the vendor's 724 Noul self-consistency cookbook: `covered` 0.43–0.53). The vendor says 725 plainly: "The model is no more deterministic for it." So a threshold's 726 unsure band is **±0.10** until it is measured on this estate's own 727 questions. The cache is still right to have, because it makes reruns 728 reproducible, but it does not rest on a determinism argument. 729- **`confidence` is stored exactly as the vendor returned it**, never 730 recomputed, and only a Choice or Score has one (*SQL surface*, 731 `jev_confidence`). The docs call it "a statistic computed from the 732 probability distribution" without giving a formula in the prose 733 (docs.typesafe.ai/confidence, read 2026-09-26). The page's interactive 734 widget, visible only in the raw page source, computes 735 `(n · max(p) − 1) / (n − 1)` clamped to [0, 1], where n is the number of 736 options or levels. The API reference's Choice example (0.88/0.12/0) 737 reports 0.81 where that formula gives 0.82 (docs.typesafe.ai/api), so 738 the formula is a close description, not a contract, and recomputing it 739 would not reproduce the vendor's number. The full distribution is 740 stored too. 741- **Built 2026-09-26** (`crates/postjevsql/src/lib.rs`, `tests/cache.rs`): 742 the `jev_cache` table, one row per (state, question) judgment. The scan 743 looks each call up (SPI) before building requests, sends only the misses, 744 and stores answers in `Judge::settle`, which runs on the backend between 745 waits and at scan end, since a judgement future may not call Postgres. 746 `jev_stats()` counts hits and misses per judgment, and EXPLAIN ANALYZE 747 per scan (*Cost and safety*). `jev.cache_only` (Userset) serves hits 748 and fails a miss with 55000 before anything is sent; it needs no API 749 key and is exempt from the spend guards, since it spends nothing 750 (`tests/cache.rs`, 2026-09-26). The 751 table is `REVOKE`d from PUBLIC, since a writer could forge answers; the 752 scan reaches it as its owner (`postjevsql-pg/src/owner.rs`), as a 753 SECURITY DEFINER function would. 754- **Each cache row is an audit receipt:** 755 - the key and the request bytes 756 - the requested and the answered model 757 - the `x-typesafe-request-id` (the only handle for a billing dispute) 758 - `usage` 759 - the full answer 760 - the time and the namespace 761## Cost and safety 762 763- **EXPLAIN shows the price before it is paid** (jevQL's `--explain`, 764 moved into `ExplainCustomScan`): 765 - candidate rows after SQL filters 766 - cache hits (cost $0) 767 - requests 768 - estimated input tokens (*Execution*'s estimator) 769 - dollars 770 771 **Cache hits are shown only under ANALYZE** (decided 2026-09-26). 772 Plain EXPLAIN runs no rows, and the hits depend on the rows' canonical 773 JSON, so counting them would mean running the child scan and hashing 774 every row: a full read of the data from a command that promises to 775 read none. Plain EXPLAIN therefore prices every candidate as a miss, 776 which is the bound `jev.max_cost` refuses on, and labels it 777 `Worst-Case Cost`. Built 2026-09-26 (`Judge::explain`, 778 `postjevsql-pg/src/scan/exec.rs` `explain`; `tests/cache.rs`): 779 - without `COSTS OFF`: `Candidate Rows`, `Estimated Requests`, 780 `Estimated Input Tokens` (one attempt) and `Worst-Case Cost` (three 781 attempts, `Budget::price`), from the child's row estimate; 782 that estimate is taken before the jev conditions (2026-09-27, 783 `rows_before` in `postjevsql-pg/src/scan/plan.rs`), since the child 784 no longer runs them and every row it emits is judged: a table's is 785 its tuples times the selectivity of the other clauses, as the planner 786 computes it, and any other child's is divided by the jev conditions' 787 selectivity (`tests/batching.rs`, `tests/budget.rs`); 788 - under ANALYZE, per scan: `Cache Hits`, `Cache Misses`, `Shared 789 Judgments` (deduplicated), `Requests` sent, `Input Tokens` reported 790 by the answers, and `Cost` at `jev.price_per_mtok`, counted as 791 `jev_stats()` counts them. 792 793 The price is a per-model GUC (`jev.price_per_mtok`, default 0.042 for 794 jev-1.13.0), not a constant: MCA §8.2 says rates "may vary based on … 795 the model". There is no batch endpoint, async discount, cached-input 796 discount or idempotency key (searched docs and both SDKs, 2026-09-22). 797- **Spend guards** (jevQL): `jev.max_rows` and `jev.max_cost`. 798 - Both are checked **before the first request**, against the 799 estimate. 800 - `jev.max_cost` budgets **3 attempts per request**. With no 801 idempotency key, a retry after a timeout can be billed twice. 802 - Actual `usage` is checked while the statement runs, and the 803 statement aborts once spent plus in flight would exceed the budget. 804 - Built 2026-09-26: both are `Userset`, with -1 meaning no limit (as 805 `temp_file_limit`), so 0 means "send nothing". `ExecutorStart` 806 (`postjevsql-pg/src/scan/guard.rs`) sums the jev scans' child row 807 estimates and refuses with 54000 before the first request. While 808 running, one budget per statement (`postjevsql-pg/src/scan/ 809 statement.rs`, keyed by the `EState` and dropped when its query 810 context resets) is shared by every jev scan of the plan. Before a 811 row is sent, its worst case (3 attempts, at the learned ratio) is 812 held in flight, and the row that would take the rows sent, or the 813 dollars spent plus in flight, over a guard is refused. When the 814 answers arrive, the worst case is replaced by their 815 `usage.input_tokens` times each one's attempts, since a timed-out 816 attempt may have been billed. A row dropped unanswered (an error, a 817 cancel, a LIMIT) keeps its worst case as spent. Built 2026-09-26 818 (`tests/budget.rs`). 819- **Terms that bind the design** (MCA, Sep 19 2026): 820 - §2.3(b): output may not be used to "train a model to imitate the 821 output of the Services". **Cached distributions must never train a 822 stand-in for Jev.** Using them as features downstream is allowed; the 823 vendor suggests it. 824 - §2.3(a): no offering the Services "as a standalone service". One API 825 key must never serve third parties through this extension. 826 - §2.3(c): no deriving the model's "algorithms, structure". So no 827 tokenizer fingerprinting. 828 - §16.7: terms change on 60 days' notice. 829- **Privileges** (pg_typesafe): `REVOKE ALL … FROM PUBLIC` on every 830 function at install. Spending quota needs an explicit `GRANT`. 831- **The API key never appears in SQL or logs** (pg_typesafe): 832 - Read it from `TYPESAFE_API_KEY` in the server environment, or from 833 the superuser-only `jev.api_key_file`. **Exactly one source:** both 834 set is an error rather than a precedence rule, which would silently 835 bill the wrong account. Errors name the sources, never the key. 836 - A session `SET` is superuser-only and documented as appearing in 837 logs. 838- The endpoint must be `https://`. `http://` is allowed only to localhost 839 for tests. 840- **`jev.mock_response` is superuser-only** (pg_typesafe), so a granted 841 role cannot forge answers. 842 843## Implementation: Rust's four weak spots and how they are closed 844 845Checked 2026-09-22. Evidence is in the brain page. 846 847### 0. Workspace: unsafe lives in one edge crate 848 849`jev-protocol`, `jev-client` and `jev-mock` moved to ~/jevcrates on 8502026-10-01, shared with jevsnes and jevhooks, and come in as the git 851submodule `third-party/jevcrates` (pinned by its gitlink, buckified by 852reindeer as path dependencies). Change them there, then move the pin. 853 854- **`jev-protocol`**: the System One protocol, with no I/O, no 855 runtime and no Postgres, so any Jev client can use it. It holds: 856 - question types (Choice 2–255 unique labels in caller order, Score 857 2–10 levels, enforced by construction) 858 - `ModelId`, which accepts only versioned ids 859 - `Json`, with RFC 8785 canonical form for row state 860 - the exact request bytes 861 - response verification against the questions asked, read through 862 typed keys 863 - structured API errors, including the `max_tokens_exceeded` kind 864 - the retry policy 865 866 It is our own because no published crate fits (surveyed 2026-09-23). 867 kunobi-jev requires reqwest and tokio. typesafe-client's types-only 868 mode sends state re-serialized through `serde_json::Value`, so its 869 bytes would differ from the canonical bytes a cache key hashes. Its 870 design points are credited in `lib.rs`. 871- **`jev-client`**: the policy, with no runtime or HTTP stack of 872 its own. It owns: 873 - retries and their budget, and per-attempt timeouts 874 - "never sent" redials, which are not retries 875 - request ids on every error, and error classification 876 877 It reaches the world through two ports: 878 - `Transport`: send one request, and classify the failure as 879 NotSent, Connect, Interrupted, Tls, TooLarge or Config 880 - `Runtime`: time, sleep, timeout and jitter 881 882 `postjevsql-pg` implements both. The tests implement them with a 883 script and a virtual clock, so every policy case is exact and instant 884 (review D1–D3, 2026-09-23). 885- **`crates/postjevsql-core`** (added with the cache): no pgrx and no 886 I/O. It holds: 887 - the batch planner 888 - the cache key 889 - the rate limiter's GCRA 890 - the label-tree search 891 - chunk-and-shortlist 892 893 The batch planner (`src/batch.rs`, built 2026-09-26) chooses each 894 row's layout from its distinct questions and builds its state, and the 895 cache key hashes the `Placement` it returns, so a key's layout is the 896 one sent. Row in instructions is unreachable until the layout-parity 897 gate passes: its evidence type, `LayoutParity`, has no values, so every 898 row is row as state. A row is placed only from a `Planned` the planner 899 returned, never from a bare `Layout`, and row in instructions carries 900 the evidence, so no caller can ask for it. `crates/postjevsql/src/lib.rs` calls it for every 901 request. It has `#![forbid(unsafe_code)]` and is tested with plain `rust_test`. 902- **`jev-mock`**: a mock System One endpoint for tests. 903- **`crates/postjevsql-pg`**: the **only** crate that may contain 904 `unsafe`. It holds: 905 - the CustomScan wrapper (§1) 906 - the WaitEventSet executor (§2) 907 - the memory-context reset glue 908 - **every hook registration**: `set_rel_pathlist_hook`, and the GUC 909 check hooks, which pgrx 0.19.2 made `unsafe` (#2348) 910 911 It exports safe types only. It may depend on the pure crates 912 (`jev-protocol`), never the reverse. 913- **`crates/postjevsql`**: the pgrx extension. It glues 914 `jev-protocol` to `postjevsql-pg` through their safe APIs, under 915 `#![forbid(unsafe_code)]`. That is compatible with pgrx's macros: 916 - pgrx emits its `unsafe` code with call-site or 917 `mixed_site().located_at(..)` spans (`pgrx-sql-entity-graph/src/ 918 pg_extern/mod.rs:49-56`, `pgrx-macros/src/rewriter.rs:92`). 919 - A rustc 1.98.1 proc-macro experiment confirmed that those spans do 920 not trip the lint; only user-hygiene spans do (2026-09-22). 921 922 No published project was found doing this, so CI builds the crate 923 with the lint on to keep the claim true. 924 925### 1. pgrx has no CustomScan wrapper, so write one, once 926 927pgrx v0.19.2 exposes CustomScan and the planner hooks only as raw 928`pg_sys` bindings. All of that `unsafe` lives in `postjevsql-pg` behind a 929safe trait. The Jev scan then implements that trait and never touches 930`pg_sys`: 931 932- `create_custom_path` 933- `plan_custom_path` 934- `begin` 935- `exec` (returns the next slot) 936- `explain` 937- `end` 938 939No safe Rust CustomScan crate is published: crates.io has none, and 940pgrx issue #1405 ("Add `ExtensionNode` support") has been open since 9412024. **Model the trait on lagodb-core's `LagodbCustomScanProvider`** 942(github.com/lagodb/lagodb, `lagodb-core/src/customscan`, Apache-2.0, 943@db35f59). It has `begin`, `next_slot`, `rescan`, `end`, DSM, re-checks 944for rows locked after a change, and a `custom_private` envelope that 945survives `copyObject`. It may be copied with attribution. xataio/deltax 946(Apache-2.0) and darthunix/pg_fusion (BSD-2) are further permissive 947references. ParadeDB's `pg_search` (`trait CustomScan`) proves the 948shape, but **ParadeDB is AGPL-3.0**: read it for the design, and do not 949copy its code into this MIT-intended repo. Every `unsafe` block carries a 950`// SAFETY:` comment naming the Postgres invariant it relies on. 951 952The wrapper is sound only if it enforces these. Each one is a trap: 953 954- **Every callback in `CustomScanMethods` / `CustomExecMethods` is 955 `#[pg_guard] extern "C-unwind"`.** A Postgres `ERROR` is a `longjmp`. 956 Jumping across Rust frames that own `Drop` values is undefined 957 behaviour. `pg_guard` turns an `ERROR` into a Rust panic and turns a 958 panic at the boundary back into an `ERROR`. One unguarded callback 959 voids the whole wrapper. 960- **Never put a Rust pointer in `custom_private`.** Plans are copied 961 (`copyObject`), cached in prepared statements, and serialized to 962 parallel workers. Plan-time state must be a List of Nodes. Runtime 963 state belongs in the scan state, built in `begin`. 964- **`EndCustomScan` is not called when the query errors.** Anything 965 that owns an OS resource (the connection, sockets) must also be freed by 966 a `MemoryContextRegisterResetCallback` on the executor's context. 967 Otherwise every cancelled query leaks sockets. 968- **The scan state lives in palloc'd memory, which never runs `Drop`.** 969 Embed `CustomScanState` as the first field of a `#[repr(C)]` struct. 970 Drop the Rust part in place, exactly once: in `end`, or in the reset 971 callback, whichever runs first. 972- **References into Postgres memory carry the context's lifetime.** 973 Use pgrx's `MemCx<'mcx>`. Nothing borrowed from a per-tuple context 974 may outlive the next `exec`. 975- **The safe types are `!Send` and `!Sync`**, so safe code cannot move 976 them to another thread. 977- **`rescan` is required, not optional.** `ReScanCustomScan` is listed 978 under "Required executor methods", and any scan on the inner side of a 979 nested loop is rescanned. It resets the batch state; repeats are 980 answered from the cache. 981- **Path flags cover only BACKWARD_SCAN, MARK_RESTORE and PROJECTION** 982 (PG18 `extensible.h`). Don't set the first two. Set PROJECTION 983 (*Execution*). Parallelism is controlled through `parallel_safe` and 984 partial paths, and the `jev*` functions are `PARALLEL RESTRICTED`. 985 986### 2. Async inside the backend, on Postgres's own wait 987 988Async runs in the backend on a single-threaded executor whose reactor 989**is** a Postgres `WaitEventSet`. There is one design and no fallback. 990 991- **Executor** (in `postjevsql-pg`): single-threaded, with no `Send` 992 bound on futures. The ready queue lives on the backend thread. Wakers 993 push to that queue. There are no other threads, so nothing wakes from 994 elsewhere; debug builds assert the calling thread. 995- **Reactor**: a `WaitEventSet` **built for each wait** from the 996 currently registered fds, holding: 997 - `WL_LATCH_SET` on `MyLatch` 998 - `WL_EXIT_ON_PM_DEATH` 999 - `WL_SOCKET_READABLE` / `WL_SOCKET_WRITEABLE` for every registered 1000 socket 1001 1002 It is freed by a `Drop` guard. It is not kept long-lived, because 1003 there is no `RemoveWaitEvent` in any supported major or master. A long-lived 1004 set could never drop a closed socket after a reconnect or a DNS UDP 1005 exchange, and its size is fixed at creation. `CreateWaitEventSet` 1006 takes a `ResourceOwner` in every supported major (17 and later), so 1007 there is no version shim. With the usual 1008 single h2 socket this is `WaitLatchOrSocket`, and the extra epoll 1009 syscalls cost nothing against 70–500 ms requests. 1010 1011 The wait timeout is the executor's earliest timer. After each wake: 1012 `ResetLatch`, `CHECK_FOR_INTERRUPTS()` (guarded), then poll the woken 1013 tasks. There is one blocking point, with no polling slices and no 1014 threads. 1015- **Task scopes.** The connection task is scoped to the backend and held 1016 in a thread-local. Each scan's tasks belong to a **scan guard**, and 1017 the guard's `Drop` removes them from the executor. The guard is 1018 dropped by `end` or by the memory-context reset callback. Otherwise a 1019 cancel either kills the shared connection (if the executor is 1020 dropped) or leaves the cancelled query's futures in the ready queue (if 1021 it is not). 1022- **Cancellation is `Drop`.** Cancel or `statement_timeout` raises an 1023 `ERROR` from `CHECK_FOR_INTERRUPTS()`. `pg_guard` turns it into a 1024 panic, which unwinds out of the executor and drops the in-flight 1025 futures. That closes their streams, which is async's native cancel 1026 semantics. 1027- **Stack** (runtime-agnostic crates only): 1028 - `hyper` 1.x client, with our socket type implementing 1029 `hyper::rt::Read`/`Write` over a non-blocking `std::net::TcpStream` 1030 registered in the reactor, and our executor as hyper's spawner. 1031 - `rustls`, which needs no runtime, with the system trust store loaded 1032 once. 1033 - `hickory-resolver` on the same executor, verified 2026-09-22 1034 against 0.26.3: 1035 - It accepts custom runtimes through `hickory_net::runtime:: 1036 RuntimeProvider` (`crates/net/src/runtime.rs:245` at tag 1037 `v0.26.3`), plugged in with 1038 `Resolver::builder_with_config(config, provider)`. 1039 - Its I/O traits are `futures-io`, not tokio. 1040 - Built (2026-09-23): `postjevsql-pg/src/dns.rs`. Its sockets are 1041 `Send + Sync` bare fds, and debug builds assert the backend 1042 thread. `jev.dns_servers` (superuser; `ip[:port], …`) overrides 1043 `/etc/resolv.conf`. That is what lets the tests use a mock DNS 1044 server on a high port. Resolved addresses are tried in the 1045 resolver's order (IPv4 first). 1046 - hickory's cache is moka's `sync::Cache`, whose `thread::spawn` 1047 calls are all in tests or doc comments (checked in moka 0.12.16), 1048 so it adds no threads. 1049 - It is kept over `getaddrinfo` because DNS is the one step where 1050 plain libc would block without seeing a cancel (glibc waits 5 s 1051 per attempt, per nameserver), and reconnects now happen after 1052 every 300 s idle gap. The resolved address is cached for its TTL 1053 across reconnects. 1054 - Stated cost: hickory reads `/etc/resolv.conf` and `/etc/hosts` 1055 (`crates/resolver/src/hosts.rs:226`) but not nsswitch, so nscd, 1056 nss-resolve, LDAP and mDNS names do not resolve. 1057- **HTTP/2, one connection per backend.** `api.typesafe.ai` negotiates 1058 `h2` over ALPN (checked 2026-09-22 with `openssl s_client -alpn h2`). 1059 Every batch of every query is a stream on that one connection, so 1060 there is no per-request connection setup and no pool. 1061 - **Server limits** (raw h2 handshake, 2026-09-22; the path is a 1062 Cloudflare edge in front of an Envoy origin): 1063 - `SETTINGS_MAX_CONCURRENT_STREAMS` = 100 1064 - `INITIAL_WINDOW_SIZE` = 65536 1065 - `MAX_FRAME_SIZE` = 16777215 1066 1067 `jev.concurrency` is clamped to the peer's 1068 `MAX_CONCURRENT_STREAMS`. Above that limit, hyper's `poll_ready` 1069 queues without saying so, and the setting would not mean what it 1070 says. hyper keeps h2's view of the peer's SETTINGS private, so 1071 `net.rs`'s `PeerSettings` reads the value off the server's frames as 1072 hyper reads them, and the scan asks for its window before each row 1073 it pulls. Until a backend's first connection has sent SETTINGS, the 1074 limit is taken as 100 (RFC 9113 §6.5.2's recommended minimum); a 1075 reconnect starts from the last value seen. Request bodies near the 64k-token limit (~256 KB) exceed the 1076 server's 64 KiB stream window and pay window-update round trips; that 1077 is a known cost, not a bug. 1078 - **Our receive windows are adaptive** (hyper's `adaptive_window`, 1079 set in `https.rs`). hyper's defaults (5 MB per connection, 1080 2 MB per stream, `src/proto/h2/client.rs:48-49`) are below 1081 `8 MB × concurrency`. 1082 - **The connection is reused across queries, not held for the 1083 backend's lifetime.** Cloudflare closes idle client HTTP/2 1084 connections after **400 s, not configurable** (developers.cloudflare 1085 .com/fundamentals/reference/connection-limits, updated 2026-07-23). 1086 An idle backend runs no executor, so it cannot ping between queries. 1087 At the start of each scan, if the connection has been idle for more 1088 than 300 s, drop it and reconnect before sending. Pings run only 1089 while a scan is active: hyper's keep-alive on the executor's timers, 1090 every 20 s without a frame, closing the connection when one goes 1091 unanswered for 10 s. Both are superuser GUCs 1092 (`jev.keepalive_interval`, `jev.keepalive_timeout`) and part of the 1093 `Endpoint`, so changing one opens a new connection. `tests/keepalive.rs` 1094 shows against jev-mock that pings go out while a scan waits and not 1095 between statements, and that an unanswered one closes the connection 1096 and the request is retried on a new one (2026-09-26). 1097 - **On `GOAWAY`**, retry only streams above `last-stream-id` or those 1098 refused with `REFUSED_STREAM`. A stream cut mid-flight goes through 1099 the normal retry policy. 1100 - **No HTTP proxy support comes for free.** hyper does not read 1101 `HTTPS_PROXY`. If a deployment needs an egress proxy, CONNECT 1102 tunnelling is built explicitly. 1103 1104**Hickory constraints (verified in source):** 1105 1106- **`default-features = false, features = ["system-config"]`.** The 1107 default features include `tokio`, and `hickory-net`'s `tokio` feature 1108 enables `tokio/rt-multi-thread`. Every DNS-over-TLS, DNS-over-HTTPS and 1109 QUIC feature forces `tokio` through `__tls`. So the backend resolves 1110 with plain DNS from `/etc/resolv.conf` only. 1111- **Its traits demand `Send + Sync`:** 1112 - `RuntimeProvider: Clone + Send + Sync + Unpin` 1113 - `Spawn::spawn_bg` takes `Future + Send` 1114 - `DnsUdpSocket`, `DnsTcpStream` and `Time` are `Send + Sync` 1115 1116 So the executor must accept `Send` futures as well as local ones. The 1117 socket and handle types must be `Send + Sync` by construction: they 1118 hold a raw fd and a token, and reach the reactor through a 1119 backend-thread-local. They are never a pointer into Postgres memory. 1120 Debug builds assert that they are used on the backend thread. 1121 1122**Process hazards:** 1123 1124- **Create nothing in `_PG_init`.** Under `shared_preload_libraries` it 1125 runs in the postmaster, before the fork. The rustls `ClientConfig`, 1126 RNG, TLS session state and connection are all built lazily in the 1127 backend on first use. Do not rely on aws-lc's fork detection. 1128- **No Rust dependency may install a signal handler.** Postgres's own 1129 SIGINT/SIGALRM handlers set `MyLatch`, which is how cancel reaches the 1130 wait. 1131- **`Drop` of futures, streams and connections must never panic.** 1132 Cancellation unwinds (`panic = "unwind"`, from the cargo-pgrx 1133 template), and a panic during unwind aborts the backend. 1134- **Incompatible with extensions that call Postgres from their own 1135 threads.** pgrx #2228: pg_duckdb does this, so another extension's 1136 thread can run our planner and executor hooks, where pgrx's thread 1137 check aborts. Loading both is unsupported; the README says so. 1138 1139**Traps (each looks like a simplification and is a regression):** 1140 1141- **tokio in the backend.** Its multi-thread runtime runs futures on 1142 worker threads, where Postgres APIs corrupt the backend. Even its 1143 current-thread runtime sleeps in its own `epoll`, which retries after 1144 `EINTR` and never sees the latch, so a cancel waits for the request to 1145 finish. reqwest, and hyper-util's default resolver, bring 1146 `spawn_blocking` threads. 1147- **Running tokio in 10 ms slices with interrupt checks in between.** 1148 That is polling: it adds latency and burns CPU. 1149 1150 This is a project rule, not a pgrx rule. pgrx's own 1151 `docs/src/design-decisions.md` allows threads that never touch 1152 Postgres. We use none, so no Postgres call can ever happen off-thread. 1153 pgrx #1067 (async design, still open) concedes that tokio 1154 `current_thread` does not handle interrupts. 1155- **A proxy daemon for the HTTP.** It adds a process, an IPC hop and a 1156 serialization step to every request, and buys nothing that one 1157 multiplexed connection per backend does not already give. 1158- **Threads in a bgworker.** Never. 1159 1160**Considered and not chosen:** 1161 1162- **libcurl's `multi_socket` interface.** It is a correct alternative. 1163 It multiplexes over HTTP/2 by default (`CURLMOPT_PIPELINING` defaults 1164 to `CURLPIPE_MULTIPLEX`). `CURLMOPT_SOCKETFUNCTION` and 1165 `TIMERFUNCTION` let it register sockets in the WaitEventSet, and 1166 curl-rust exposes them (`src/multi.rs:157`, `256`, `515`, `378`). It 1167 also honours `HTTPS_PROXY` and resolves through nsswitch. Supabase 1168 pg_net drives exactly this, from a bgworker with its own epoll. hyper 1169 is chosen so that the edge crate's types stay in Rust. The price is 1170 building proxy support explicitly and accepting hickory's DNS limits. 1171 1172### 3. Packaging per major is nix's job 1173 1174**Supported majors are PostgreSQL 17 and later** (17 and 18 today; 1175`PG_MAJORS` in `build/defs.bzl`). The floor is 17 because the rate 1176limiter's only path is `GetNamedDSMSegment` (PG17+) and 1177`CreateWaitEventSet` takes a `ResourceOwner` from 17 on, so no code 1178path carries a version shim. Supporting 16 and earlier is a deliberate 1179later pass, not an omission: it would add a second rate-limiter path 1180(`shared_preload_libraries` shmem) and the WaitEventSet shim back. 1181Both packages build (2026-09-26). The test harness serves either 1182major: `tests/support/postgres.rs` uses `extension_control_path` on 18, 1183and on 17, which lacks it, runs the server from a symlink prefix whose 1184extension directory links the built tree (nixpkgs' relative-to-symlinks 1185patch makes the server read that prefix's sharedir; measured 11862026-09-26). The devshell writes `postgres_bin_NN` and `pg_config_NN` 1187for every major into `.buckconfig.local`. 1188 1189**One buck graph builds every major** (built 2026-09-26). The major is 1190a configuration: `//platforms:pgNN` is the host platform plus the 1191`pg_major` constraint, and everything that differs by major branches on 1192it through `pg_major_select` in `build/defs.bzl` (unconstrained 1193configurations get the newest): 1194 1195- pgrx's and pgrx-pg-sys's `pgNN` features, and pgrx-pg-sys's bindgen 1196 run's `PGRX_PG_CONFIG_PATH` (from `pg_config_NN`), are set by the 1197 wrappers in `third-party/pg_major.bzl`, which `reindeer.toml`'s 1198 `buckfile_imports` loads over the prelude's `cargo` and 1199 `buildscript_run`, so a `reindeer buckify` keeps them. 1200- First-party crates' `pgNN` cfg, and the tests' server 1201 (`POSTJEVSQL_POSTGRES_BIN`) and `POSTJEVSQL_PG_MAJOR`, select the same 1202 way (`tests/BUCK`, `tools/record-gates/BUCK`). 1203- `first_party_test` also emits `<test>-pgNN` for every older major, 1204 with `default_target_platform = //platforms:pgNN`, so `buck2 test 1205 //...` runs the whole suite on 17 and 18. 1206 1207**Built 2026-09-26 through buck, not cargo-pgrx** (`nix/package.nix`, 1208flake `legacyPackages.<system>.postgresqlNNPackages.postjevsql`). The 1209first-party crates exist only as BUCK targets, so `buildPgrxExtension` 1210would need a second, hand-kept Cargo graph beside them. The package 1211builds `//crates/postjevsql:ext` in the sandbox, the tree the tests 1212install, and puts it in nixpkgs' layout (`lib/`, 1213`share/postgresql/extension/`), so `withPackages` and 1214`services.postgresql.extensions` take it. The traps it closes: 1215 1216- Every `http_archive` in `third-party/BUCK` is fetched by nix from the 1217 url and sha256 read out of that file at eval, and 1218 `nix/offline_archive.bzl` is loaded over the builtin in the sandbox: 1219 buck2's downloader refuses `file://` (measured 2026-09-26). 1220- buck2 loads a trust store at startup, so `SSL_CERT_FILE` is set 1221 though nothing is fetched. 1222- The prelude's wrapper scripts start `#!/usr/bin/env bash`, which the 1223 sandbox lacks, so buck runs under bwrap with that one path added. 1224- Release builds turn `-Cdebug-assertions` off in `toolchains/BUCK`. 1225- The majors built are `PG_MAJORS` in `build/defs.bzl`, read by 1226 `nix/majors.nix` for the flake and the package, which builds with 1227 `--target-platforms //platforms:pgNN` and writes that major's 1228 `postgres_bin_NN` and `pg_config_NN`. Any major not listed fails at 1229 eval with the supported list, rather than compiling against the wrong 1230 headers. 1231 1232The cargo-pgrx bullets below are the reasoning for the pin, which buck 1233carries in `third-party/Cargo.toml`; no `cargo-pgrx` is built. 1234 1235- **Pin `pgrx = "=0.19.2"`, and build `cargo-pgrx` 0.19.2 in this repo's 1236 flake.** Build it with `rustPlatform.buildRustPackage`, the same shape 1237 as nixpkgs' `generic` helper; it becomes a one-liner if NixOS/nixpkgs#526051 1238 merges. Pass it explicitly to `buildPgrxExtension`. 1239- **Never use nixpkgs' default `cargo-pgrx`.** nixpkgs' own 1240 `cargo-pgrx/default.nix` says the default is "Not to be used with 1241 buildPgrxExtension, where it should be pinned". Every in-tree pgrx 1242 extension takes a pinned attribute. 1243- **Why not 0.18.x** (this reverses an earlier pin to 0.18.1): 1244 - There is no `cargo-pgrx_0_18_1` attribute in nixpkgs, so the pin 1245 breaks the day the default moves. 1246 - pgrx 0.18.0 and 0.18.1 wipe the DETAIL line of every ERROR that pgrx 1247 catches and rethrows (#2262, fixed by #2361 in 0.19.2). Every 1248 `pg_guard` boundary in this design would lose error detail. 1249 - 0.18.1 supports pg13 to pg18; 0.19.2 adds pg19. nixpkgs already 1250 ships `postgresql_19` (beta), and GA is expected around the end of 1251 October 2026. 1252 - 0.19.2 adds safe `PgNode` casting (#2347) and extra executor 1253 headers (#2353), both useful for the CustomScan wrapper. 1254 - No nixpkgs PR bumps cargo-pgrx to 0.19 (searched 2026-09-22), so 1255 carrying our own is the only route. 1256- **Per-major builds are ours to wire.** Only packages inside nixpkgs' 1257 `ext/` directory land in `postgresqlNNPackages` automatically, through 1258 `packagesFromDirectoryRecursive`. The flake calls 1259 `postgresql_NN.pkgs.callPackage ./nix/package.nix {}` for each 1260 supported major in `PG_MAJORS`, as the nixpkgs PostgreSQL manual 1261 documents. The sidecar module uses 1262 `services.postgresql.extensions = ps: [ (ps.callPackage ./nix/package.nix {}) ]`. 1263- **The test suite is a separate flake check.** The package sets 1264 `doCheck = false`, as every in-tree pgrx package does, because "pgrx 1265 tests try to install the extension into the postgresql nix store". 1266 nixpkgs' `postgresqlTestExtension` provides the smoke test. 1267- Outside nix, a CI matrix runs `cargo pgrx package` once per major. 1268- **The Cargo workspace is generated from buck** (built 2026-09-26, 1269 `tools/cargo-gen`). Each `first_party_*` macro records its kind, crate 1270 root and deps; the tool writes the root `Cargo.toml` (members, 1271 `[workspace.dependencies]` from `third-party/Cargo.toml`, pgrx's 1272 `=0.19.2` pin with its major feature stripped, pgrx's `panic = 1273 "unwind"` profiles) and one manifest per package, with a `pgNN` 1274 feature for each of `PG_MAJORS` in `build/defs.bzl` (the last is the 1275 default) on every crate that reaches pgrx. Every manifest is `MIT OR 1276 Apache-2.0`, `authors = ["The postjevsql Authors"]`, no `repository`. 1277 `//tools/cargo-gen:drift` fails when the committed manifests differ. 1278 Integration tests (`tests/*.rs`) stay buck-only: they need buck's 1279 location of the built extension. 1280- **Licences are checked, not assumed.** `deny.toml` (built 2026-09-26) 1281 is pgrx 0.19.2's allowlist plus `BSD-2-Clause` and 1282 `CDLA-Permissive-2.0`; cargo-deny rejects anything unlisted, so AGPL 1283 is denied (verified by clarifying a crate as AGPL-3.0-only). The repo 1284 is `MIT OR Apache-2.0` (`LICENSE-MIT`, `LICENSE-APACHE`, "The 1285 postjevsql Authors"). 1286- **`THIRD-PARTY` carries the linked crates' notices** (built 2026-09-26, 1287 `tools/third-party-notices`): every crates.io crate linked into the 1288 `.so` (the target deps of `//crates/postjevsql:postjevsql`, so build 1289 scripts, proc-macros and test crates are left out), its SPDX expression 1290 and every licence text, identical texts once. Among them ring is 1291 Apache-2.0 **and** ISC and webpki-roots is CDLA-Permissive-2.0, both 1292 compatible with MIT distribution. aws-lc is not linked: rustls is built 1293 on ring. The nix package installs it in `share/doc/postjevsql`. 1294- **`THIRD-PARTY-sidecar` does the same for the sidecar CLI** (built 1295 2026-09-27): the target deps of `//crates/postjevsql-sidecar:cli` 1296 (tokio, tokio-postgres, toml, …), which links no pgrx. Each binary ships 1297 only its own set: `nix/sidecar-cli.nix` installs it as 1298 `share/doc/postjevsql-sidecar/THIRD-PARTY`. Both files come from one 1299 run of the tool (`:notices[extension]`, `:notices[sidecar]`), because 1300 one `texts/` serves both and its staleness check is against the union. 1301 - The archives are read through `$(query_outputs …)`, which reaches the 1302 private `http_archive` targets in `third-party/BUCK` where a dep 1303 cannot (a `third-party/PACKAGE` visibility is overridden by the 1304 targets' own `visibility = []`, and reindeer hard-codes it). So no 1305 second list of urls and hashes exists. 1306 - A crate with no licence expression or no text fails the build. pgrx, 1307 pgrx-pg-sys, pgrx-sql-entity-graph and seahash ship no text in their 1308 archives, so theirs is in `tools/third-party-notices/texts/`, fetched 1309 from upstream at the version and named in `SOURCE`; an entry there 1310 for a crate linked into neither binary, or one that ships its own, 1311 fails too. 1312 - `//tools/third-party-notices:drift` fails when either committed file 1313 is stale; `buck2 run //tools/third-party-notices:update` rewrites both. 1314 1315### 4. Everything outside Rust is generated or thin 1316 1317- **Install SQL**: generated by pgrx from `#[pg_extern]`, 1318 `PostgresType` and `extension_sql!`. Under buck, `tools/pgrx-schema` 1319 reads the `.pgrxsc` section of the built `.so` (what `cargo pgrx 1320 schema` does). Do not hand-write `postjevsql--x.y.sql`. Grants and 1321 revokes go in `extension_sql!` blocks. 1322- **The version is written once**, in `build/defs.bzl`. The control 1323 file carries pgrx's `@CARGO_VERSION@` placeholder, filled at build. 1324- **No Perl TAP.** Rust integration tests (*Testing*) start a 1325 throwaway instance and connect with `tokio-postgres`. They cover 1326 what pg_typesafe's TAP tests cover: 1327 - retries on 429/529 1328 - `pg_cancel_backend` from a second connection mid-request 1329 - the 8 MB cap 1330 - https enforcement 1331 1332 The details are in *Testing*. 1333- What remains in SQL is the user interface itself and the expected 1334 output, which is correct: SQL is the product's surface. 1335 1336## Deployment: in-database or sidecar 1337 1338The same extension ships two ways. Choose per target and never fork the 1339code. 1340 1341- **In-database.** Install the extension into the Postgres that owns the 1342 data. This is the default whenever you control that server. 1343- **Sidecar** (new). A local Postgres runs the extension and exposes the 1344 target's tables as `postgres_fdw` foreign tables. The target installs 1345 nothing. Clients connect to the sidecar with the ordinary protocol: 1346 psql, JDBC and GUI tools all work. 1347 1348**Managed hosts cannot run it in-database** (vendor docs, 2026-09-22): 1349 1350- **No custom compiled extensions at all:** RDS, Aurora, Cloud SQL, 1351 AlloyDB, Azure, Supabase and PlanetScale. pg_tle takes "JavaScript, 1352 Perl, Tcl, PL/pgSQL, and SQL", no C. Trusted PL/Rust cannot open 1353 sockets. 1354- **Possible on request:** Neon, Xata and Crunchy may host a custom 1355 extension if you ask their support. Xata also offers 1356 bring-your-own-cloud. 1357- **Their built-in HTTP calls are per row:** `http`, `pg_net` (async; 1358 results land after the query ends), `aws_lambda`, 1359 `google_ml.predict_row` and `azure_ml`. They lose the batch node, the 1360 cache and the cost guard. 1361 1362So the sidecar is the supported mode for managed hosts. Proxies were 1363checked and rejected as hosts: PgDog's plugin API has only 1364`init`/`route`/`fini` and cannot rewrite queries or see results, and 1365PgCat is unmaintained. 1366 1367### How sidecar mode splits a query 1368 1369postgres_fdw ships a function or operator to the remote only if it is 1370built in, or belongs to an extension listed in the server's `extensions` 1371option, **and** is IMMUTABLE (`deparse.c:285`, 1372`contain_mutable_functions`). `jev*` is VOLATILE, so it never ships. 1373 1374A `jev*` qual that stays on the sidecar is a **local condition**, and 1375postgres_fdw then refuses to push down (PG18 `postgres_fdw.c`): 1376 1377- joins involving that table (L5837-5842) 1378- aggregates over it (L6527-6532) 1379- LIMIT/OFFSET (L7196-7200) 1380 1381On a foreign table that carries a `jev*` qual, only the other WHERE 1382clauses and ORDER BY run on the target. Everything else runs on the 1383sidecar. So `count(*) … WHERE jev(…)` pulls every row that passes the 1384target's filters. A qual referencing two tables is a join qual, so that 1385join can still push down. LIMIT still stops early, because postgres_fdw 1386reads through a cursor `fetch_size` rows at a time. 1387 1388Everything else in this contract works unchanged, including queries 1389jevQL refuses: CTEs, subqueries, and `UPDATE … WHERE jev(…)` through the 1390foreign table. 1391 1392The CustomScan wraps whatever scan the planner chose, `ForeignScan` 1393included. The child plan is built with the `jev*` quals removed, and the 1394node applies them. This holds in both modes: a child that kept them would 1395judge rows one at a time, outside any request. 1396 1397### Sidecar invariants 1398 1399- **Never list this extension in a foreign server's `extensions` 1400 option.** Volatility already stops `jev*` from shipping, so this is a 1401 guard for the day someone marks a helper IMMUTABLE. At plan time the 1402 CustomScan raises an `ERROR` if the foreign server of any `ForeignScan` 1403 it wraps lists this extension. 1404 Built 2026-09-26 (`refuse_shipping_servers` in 1405 `postjevsql-pg/src/scan/plan.rs`; `tests/sidecar.rs`, a postgres_fdw 1406 loopback): `plan_custom_path` walks the child plan, so a `ForeignScan` 1407 under a Sort (ORDER BY) is found too, and splits the option as 1408 postgres_fdw does. The error is 22023, raised while planning, so 1409 EXPLAIN is refused as well and nothing is sent. 1410- **`fetch_size` is at least the node's in-flight window** (default 100 1411 rows). Otherwise each window costs several network round trips. 1412 `batch_size` does the same for INSERT. `async_capable`, 1413 `parallel_commit` and `parallel_abort` help only with several foreign 1414 scans or servers, so they stay off for a single target. 1415- **The target's credentials never live in the repo.** 1416 - On PG18, prefer `use_scram_passthrough`. No password is stored, but 1417 both servers need the same SCRAM secret, clients must log into the 1418 sidecar with SCRAM, and the target must require `scram-sha-256`. 1419 - Otherwise, the user mapping's password is written from a secret when 1420 the module activates (the estate's opnix / systemd-creds path). 1421 - The connection to the target uses `sslmode=verify-full`. 1422- **The planner gets real remote statistics.** Set 1423 `use_remote_estimate 'true'` on the server, or `ANALYZE` the foreign 1424 tables on a timer. Without either, the high cost of `jev*` is weighed 1425 against guessed row counts, and the batch plan is wrong. 1426- **The cache lives in the sidecar** (a local table). The target is 1427 never written to for bookkeeping. 1428 1429### Costs to state, not hide 1430 1431- Every row that passes the target-side filters crosses the network to 1432 the sidecar, as with jevQL. 1433- postgres_fdw is not two-phase commit ("it is currently not supported 1434 by postgres_fdw to prepare the remote transaction for two-phase 1435 commit"). The remote COMMIT runs before the local one 1436 (`connection.c:1072-1089`). So a failure between the two leaves the 1437 target committed and loses only the sidecar's cache rows, which is 1438 harmless. `PREPARE TRANSACTION` fails on the sidecar once a transaction 1439 has touched a foreign table. 1440- Pushdown is narrower than in-database (see above): joins, aggregates 1441 and LIMIT involving a `jev`-filtered foreign table run on the sidecar. 1442- There is one extra network hop for every query, `jev` or not, that 1443 goes through the sidecar. 1444 1445### Launching it 1446 1447**The Rust CLI `postjevsql-sidecar` is the single engine** 1448(`crates/postjevsql-sidecar`). It reads one TOML config and: 1449 1450- `converge`: creates `postgres_fdw` and `postjevsql`, declares the 1451 foreign server (target host, `sslmode` 1452 defaulting to verify-full, `use_remote_estimate`) with exactly the 1453 declared options, and the user mappings (password from a secret, or 1454 `use_scram_passthrough` on PG18 when none is given) 1455- `sync`: compares the target's catalog with the foreign tables and 1456 plans CREATE, ALTER FOREIGN TABLE (added, altered, renamed or dropped 1457 columns) and DROP, never drop-and-reimport. It prints the plan, and 1458 applies it in one transaction only with `--apply`. A change that would 1459 break a sidecar view, grant or function (pg_depend) is refused, naming 1460 each dependent, and nothing is changed. 1461 1462Each secret (the target password, the Jev API key) comes from a file or 1463an env var, exactly one; both or neither is refused, naming the sources, 1464never the value. 1465 1466Everything that launches a sidecar calls it: the NixOS module 1467(`nix/sidecar.nix`, whose oneshot runs `converge` then `sync --apply`, 1468with opnix or systemd-creds writing the secret files), the container 1469image, and a Postgres the user already runs. The module keeps only what 1470nix owns: `services.postgresql`, this extension from 1471`postgresqlNNPackages.postjevsql`, `postgres_fdw` (contrib) and the unit. 1472 1473**This reverses an earlier rejection** (owner, planning interview, 14742026-09-26). The contract used to say a Rust launcher would be a second 1475configuration engine beside nix. That held only while nix was the one 1476route. The audience is now any Postgres user, including managed-host 1477users with no nix, served by a container image and by setup for their 1478own Postgres, and the owner ruled that setup tooling is a Rust CLI, 1479never Bash. With three launchers, the configuration engine has to live 1480below all of them, so the CLI is the one engine and nix is one caller; 1481declaring the same state in both would be the second engine. 1482 1483**Status (2026-09-27):** the binary and its pg_depend refusal are built 1484(`cli/main.rs`, postjevsql `cac7966`; `tests/sidecar_cli.rs`), and so is 1485the module around it. So is the container image (2026-09-27, 1486`nix/sidecar-image.nix`, flake `packages.<system>.postjevsql-sidecar-image`, 1487a `docker load`-able tarball): PostgreSQL 18 with the per-major package 1488and postgres_fdw, dash as `/bin/sh` only (initdb runs `postgres -V` 1489through popen), uid 999, the cluster in the volume 1490`/var/lib/postgresql`. Its entrypoint is the CLI's `serve`, not a 1491script: resolve every secret (so file-and-env is refused before 1492anything starts), `initdb` if `$PGDATA` is empty, start postgres, wait 1493until it accepts a connection, `converge` and `sync --apply`, then stop 1494it with `pg_ctl stop` and exec `postgres` in its place, so the server is 1495PID 1 and the runtime's signals reach it; a refusal stops the server and 1496exits 1 with the CLI's message. Postgres options go after `--`. Its VM 1497test (`nix/sidecar-image-test.nix`, flake check `sidecar-image`) runs it 1498in docker against `nix/test-target.nix`'s TLS- and SCRAM-only target. 1499 1500The module (`nix/sidecar.nix`, flake `nixosModules.sidecar`) takes the 1501extension as `extension = ps: …` (default `nix/package.nix` for the 1502server's major; the flake's module passes it the pinned buck2) and the 1503CLI as `package` (default `nix/sidecar-cli.nix`, the same derivation 1504building `//crates/postjevsql-sidecar:cli`; also flake 1505`packages.<system>.postjevsql-sidecar`). A oneshot after postgresql 1506writes one TOML per declared server with `pkgs.formats.toml` and runs 1507`converge` then `sync --apply` on it. `converge` also creates 1508`postgres_fdw` and `postjevsql`. The config holds paths only: each 1509mapping's password file is loaded with `LoadCredential` and named by 1510its `/run/credentials/postjevsql-sidecar.service/…` path, and 1511`apiKeyFile` (required, since the unit has no other source) is the 1512server's `jev.api_key_file`. Schemas import into same-named local 1513schemas, as the CLI does. The nixosTest (`nix/sidecar-test.nix`, flake 1514check `sidecar`) boots it against a target cluster on loopback port 15155433, TLS and SCRAM only, and checks `CREATE EXTENSION postjevsql`, the 1516imported tables read over verify-full with the mapping's password, a 1517jev call planned as the scan over a `ForeignScan`, that a restart of the 1518unit picks up a new target column and drops a hand-added `extensions` 1519option, and that neither the unit script nor the config holds the 1520password. 1521 1522## Testing 1523 1524- **Build and test through buck2** from the nix devshell 1525 (`nix develop -c buck2 test //...`). nix provisions toolchains and 1526 Postgres; buck owns the graph. Third-party crates come from 1527 `third-party/Cargo.toml` via reindeer (`vendor = false`), with 1528 `cargo_env = true` so build scripts see the full Cargo environment. 1529 Toolchain paths reach buck through `.buckconfig.local`, which the 1530 devshell writes, because the prelude's cc shim execs without a PATH 1531 search. 1532- **`tests/run-check.sh` is the repo's one check entry and only 1533 delegates** to `nix develop -c buck2 test //...`. mefi-studio runs it 1534 to verify a finished task (its `baseCheckForProject` finds 1535 `test/` or `tests/run-check.sh` off Windows, mefi-studio `94350fc`). 1536 Without a check, every finished run was recorded as "unverified" and 1537 re-dispatched, so each cache plan ran four times (2026-09-26). Do not 1538 add a `package.json`: Studio prefers its scripts over this file, so it 1539 would become a second entry point. 1540- **One buck daemon per checkout, shared by every session in it.** Never 1541 `buck2 kill` it and never run concurrent agents in one checkout. Each 1542 agent works in its own git worktree, which gets its own daemon and 1543 `buck-out`. On 2026-09-23 two agents in the main checkout killed each 1544 other's daemon ("Forkserver is unavailable"), both stalled, and the 1545 cache-key work sat uncommitted for three days. 1546- **Warnings fail tests.** buck prints no compiler output for actions 1547 that succeed, so a warning is invisible. Every first-party target is 1548 declared through `build/defs.bzl`'s `first_party_*` macros, which add 1549 a `<name>-lint` test. That test fails with the report when clippy 1550 (which includes every rustc warning) says anything. Dev and test 1551 builds keep debug assertions on. 1552- **Unit tests**: `jev-protocol` (in ~/jevcrates, `cargo test`) has plain tests, plus proptests 1553 asserting that every parser of network bytes rejects rather than 1554 panics. 1555- **Integration tests run a throwaway Postgres (17 and 18, `PG_MAJORS`) from the devshell** 1556 (`tests/support/postgres.rs`): `initdb` into a temp dir, `postgres` as 1557 a child process, the built extension served in place through 1558 `extension_control_path` / `dynamic_library_path`. The API is 1559 testcontainers-shaped (builder, `start`, teardown on `Drop`). There is 1560 no docker (ruled 2026-09-23, replacing a testcontainers setup that 1561 needed a nix-built image, host networking and a container reaper). 1562 - **Teardown cannot leak.** The postmaster runs with 1563 `PR_SET_PDEATHSIG` = SIGQUIT, so the kernel shuts it down when the 1564 spawning test thread dies, SIGKILL included (verified). 1565 - Every test sets `statement_timeout`, so a hang fails the test 1566 instead of reaching buck's 10-minute kill. 1567 - `tests/latency.rs` measures the extension's own cost per call 1568 against the instant mock (2026-09-23: warm p50 310 µs against a 1569 143 µs floor for the bare query; cold 44.5 ms). 1570- **One mock endpoint, our own** (`jev-mock`): hyper h2 1571 + tokio-rustls + an rcgen CA, trusted through `jev.ca_file` and 1572 answering from a closure. It serves every HTTP case (429/529 with 1573 `Retry-After`, the 400 `max_tokens_exceeded` split, the 403 on a 1574 missing key, the 8 MB cap, https enforcement). It can also observe 1575 `RST_STREAM` through a handler `Drop` guard, which httpmock cannot, 1576 so httpmock is not added as a second mock. 1577- **Cancellation proves the reset**, not merely that the query returned: 1578 - Cancel through both `pg_cancel_backend` and tokio-postgres' 1579 `CancelToken`, from a second connection to the same instance. 1580 - Assert that no socket leaks after 100 cancels. 1581- **Offline regression** runs against `jev.mock_response` 1582 (pg_typesafe's pattern). 1583- **Release gates** (ground-truth runs, not just a passing suite; each is 1584 recorded against the pinned model version): 1585 - **Layout parity:** the jev-orderby-bench harness compares row as 1586 state against row in instructions on mean |Δp|, Spearman and 1587 position effect. Row in instructions stays disabled until it passes. 1588 - **Label tree:** beam width K, lookahead, the order twin and τ are 1589 measured on a labelled hierarchy (the vendor's CPC, Shopify and MeSH 1590 sets are pinned and public), against chunk-and-shortlist. 1591 - **Unsure band:** repeat-spread on this estate's own questions 1592 replaces the provisional ±0.10. 1593 - **Ranking:** `ORDER BY jev_prob … LIMIT` passes a pairwise-inversion 1594 gate (jev-orderby-bench's ≤ 0.15). Probabilities come back at two 1595 decimals, and ties are common (53 of 360 rows at 0.99 in that 1596 bench). The README tells users to add their own tie-break column. 1597 - **Recording is approved; do not ask again.** The owner decided (planning 1598 interview, 2026-09-26) that this pass records each gate ONCE on the 1599 live key with `tools/record-gates --send`, capped by `--max-cost`, and 1600 commits the result as replay fixtures; tests replay them from then on 1601 with no further spend. Running that recording needs no further owner 1602 step. Recorded 2026-09-26 against jev-1.13.0 (`tests/fixtures/`) and 1603 replayed by `tests/gate_*.rs`. What stays owed to a later pass: the 1604 measuring benchmarks themselves, and a replay that stores N 1605 responses per request.