Repository navigation
fix(objectql): redact a driver fault where it leaves the engine, not only in the engine's log line - #21335
Conversation
…only in the engine's log line The error a failed driver call propagates out of the engine now carries the same statement cut the engine's own log line applies: no bound statement and no caller values on message, stack, the driver's statement-bearing properties (mysql2 sql/sqlMessage, node-postgres detail/where/internalQuery) or the cause chain. The class, name, codes and the database's own diagnostic are kept. Applied once at the middleware seam and at the public methods that reach a driver outside it. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…the auth library's log carriers Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
They protected the caller's 400 INVALID_FIELD answer and the unique-violation verdicts by asserting the statement and value survived on the thrown error. They now assert those answers directly, and that the value is gone. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…he boundary cut The cause is the driver error as it leaves the engine (same class, code and diagnostic, statement and values cut); an envelope still nests one deep. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
… sys_user.name is TEXT Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
… cut Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…them Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…ayer typechecks Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…the engine-boundary cut The cause no longer carries the value into the REST boundary; the database's diagnostic it reads stays. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2b3e3eb671cf020c872d32c817de8bc12da0c476 && git checkout 2b3e3eb671cf020c872d32c817de8bc12da0c476
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 222ecc27f9050857d4620e7010d8b85f9d35e378 fd19d3661bcd7a4ccf2ede82eaa1e5a74e59919c && git checkout -B drift-repro 222ecc27f9050857d4620e7010d8b85f9d35e378 && git merge --no-ff fd19d3661bcd7a4ccf2ede82eaa1e5a74e59919c
node scripts/docs-audit/affected-docs.mjs --json 222ecc27f9050857d4620e7010d8b85f9d35e378
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #21274 (body; 5943536760, 5943907420, 5946109461, 5946136544), the cross-lane posts 5943919189 (#21118) and 5946141400 (#6024), PR #21335's body, its 10-file list and the net diff against merge base The boundary helper (
The sites are RIGHT; no engine exit is missed. Verified on head The engine's WARN lines are CONFIRMED unchanged: :13690, :15460 and :16979 still pass REST answers are CONFIRMED:
The re-judged pins are RIGHT; nothing is weakened beyond the overturned identity contract. The two The auth-library carrier pin ( ② Semver level
③ Boundary flagsThe dev's open question (A, B, C): A is right, and the fork is declared, not silent. The ruling's first bullet asks for no statement and no bound values. A keeps one token from a closed four-entry vocabulary ( Deviations: accepted as recorded in 5946136544. The out-of-scope note needs a card. The residue is real and read-only: One further read-only finding, outside the ruling's class, for a card. Check-runs on the head (read last, 2026-10-02T05:43:38Z): all seven required contexts conclude success: Lint & Repo Gates (05:37:25Z), TypeScript Type Check (05:39:00Z), Test Core (05:41:25Z, its six shards all success), Dogfood Regression Gate (05:29:21Z), Build Core (05:24:55Z), Temporal Conformance (live PG + MySQL) (05:31:04Z), Governed Surface Queue Guard (05:22:25Z). At an earlier read (05:41:10Z) the Implemented-by: VERDICT: PASS Generated by Claude Code |
… Node, re-arming the reroute probe CONTROL leg (objectstack-ai#21356) Fixes objectstack-ai#21308 Clause-②: no ## What this changes `tsx` 4.23.15 moved its ESM-API registration out of a `dist/` chunk and into `dist/esm/api/index.cjs`, two directories deeper. It did not rebase three file-relative specifiers. On a Node without tsx's `module.registerHooks` path, the CommonJS build of `tsx/esm/api` then calls `module.register()` with a hooks URL that names `dist/esm/api/esm/index.mjs`, a file that does not exist. `@oclif/core`'s `ts-path` is the caller in this repo. It swallows the error, logs `Could not find tsx`, and stays on `dist/`. This PR adds a `pnpm patch` of `tsx@4.23.15` that points the three specifiers back at the files 4.23.14 resolved. No version moves. - `patches/tsx@4.23.15.patch`: new, written by `pnpm patch` / `pnpm patch-commit`. - `pnpm-workspace.yaml`: the `patchedDependencies` entry and its note, next to the existing `tsup@8.5.1` entry. - `pnpm-lock.yaml`: regenerated by `pnpm install`, not edited by hand. - `packages/cli/test/published-entry-node-env-source-reroute.test.ts`: a docblock pointer only. No leg, assertion or bound changes. ## Measured ### H0: CI's reading of the CONTROL leg is NOT MEASURED (log hosts unreachable) Both log hosts refused this session at CONNECT (`gateway answered 403`): - `productionresultssa14.blob.core.windows.net`, the job logs of all six `Test Core` shards of run 36964705223 on 6091136; - `results-receiver.actions.githubusercontent.com`, the run log zip. `node scripts/pm/ci-failure.mjs --run 36964705223` exited 2 with `job log NOT RETRIEVED`. What was measured instead is the mechanism that decides it. tsx 4.23.12, 4.23.14 and 4.23.15 all take the synchronous `registerHooks` path only on Node 22.22.3+, 24.11.1+, 25.1.0+ or 26+ (version table `[[22,22,3],[24,11,1],[25,1,0],[26,0,0]]`). That path never reaches the broken specifier. Node 22.22.3 was released on 2026-05-13. CI's `setup-node` asks for `node-version: '22'`, and the dev containers run v22.22.0. Same tree (6091136), unpatched tsx: | Node | `published-entry-node-env-source-reroute.test.ts` | |---|---| | v22.22.0 | 1 failed (CONTROL), 4 passed | | v22.22.3 (official tarball, sha256 checked against `SHASUMS256.txt`) | 5 passed | So CI arms the leg if its runner resolved `'22'` to 22.22.3 or later. The runner's actual version is in the log this session could not read. ### H1: confirmed, with a version bound `DEBUG=oclif:config:ts-path` on v22.22.0, CONTROL preload, the test's own env (no `NODE_PATH`): ```text Could not find tsx. Skipping tsx registration for .../packages/cli. Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../tsx/dist/esm/api/esm/index.mjs' imported from .../tsx/dist/esm/api/index.cjs ``` Next it logs `Cannot find module 'ts-node'`, and `tsPath` returns the `dist/` path. In the tarballs, 4.23.12, 4.23.13 and 4.23.14 keep the `register()` call in `dist/register-*.cjs`, where it resolves. 4.23.15 inlines it into `dist/esm/api/index.cjs`, which grows from 1,298 to 16,770 bytes. The `.mjs` build of `tsx/esm/api` is unaffected. oclif reaches the `.cjs` build because it locates the module with `require.resolve`. ### H2: holds `npm view tsx dist-tags` gives `latest` as 4.23.15 (2026-09-20). There is no newer release, so no UP move (remedy d) exists. ### A second casualty of the same defect On v22.22.0 with unpatched tsx, `bin/run-dev.js` also lands on `dist/commands`. That file is the CLI's SOURCE entry, the one `runServe` and the other spawning tests launch (104 test files under `packages/cli` mention it). `tsx bin/run-dev.js --version` under `DEBUG=oclif:config:ts-path` logs `Could not find tsx` and never `Found source directory`. With the patch it logs `Found source directory for .../dist/commands at .../src/commands`. So on such a Node, source-entry tests have measured the build output rather than `src/` since objectstack-ai#21162. The same patch fixes this with nothing added. ## Why (b), and not (a) or (c) **(a)** pins tsx down to 4.23.14. That is a DOWN move and needs a ruling, and measurement does not make it the only sound remedy. Not taken. **(c)** reworks the CONTROL leg. To arm without oclif's own tsx registration, the child would need a test-only change to how it resolves `tsx/esm/api`: a `Module._resolveFilename` shim in the preload, or `--conditions=import` on the child. That fails the second acceptance criterion as measured. The absence legs do not load the preload, so on v22.22.0 with unpatched tsx, deleting the declaration leaves them green (row 4 below). Arming them too would put the shim in every leg, and then every leg measures a resolution no real install has. **(b)** patches the component that fails. The test's behaviour is unchanged, and both acceptance criteria hold on v22.22.0 and on v22.22.3. Four axes: - **Real business need:** measured. Two consumers break on Node below 22.22.3. One is this probe's CONTROL leg. The other is the source entry `bin/run-dev.js`, which is the larger one, because the CLI's spawning tests silently ran `dist/`. The published CLI is not reached: `bin/run.js` sets `settings.enableAutoTranspile = false`, so oclif never registers tsx there, and nothing in `packages/**` source imports `tsx/esm/api`. - **Long-term soundness:** the patch is a workaround for an upstream packaging defect, and it has a cost. It replaces one 16 KB minified line, which nobody can review by eye. The token comparison below is how to review it. The key names the exact version, so a tsx bump fails `pnpm install` (`ERR_PNPM_UNUSED_PATCH`, the same rule the `tsup@8.5.1` entry records), and the patch cannot outlive its reason silently. Retire it when a tsx release resolves the three paths itself, or when the toolchain's Node floor reaches 22.22.3. (c) would carry a test shim with no such tripwire. After an upstream fix it would become dead code that still makes every leg diverge from a real install. - **Making AI mistakes harder:** (b) gives the probe one meaning in every environment. Today the same file is green in CI and red in the dev containers, and three devs reported that red in passing, without a card, because it read as "environment". (c) adds a second resolution dialect inside a test fixture, which is the consumer-side tolerance that contract-first rules out. - **Startup focus:** no new gate, no new dependency, no version movement. Three tracked files, plus the lockfile the tooling wrote. ## CONTROL leg and reverse verification (Node v22.22.0 unless stated) | tree | tsx | declaration in `bin/run.js` | `development` / `test` legs | CONTROL (development, neutralised) | |---|---|---|---|---| | 6091136 (main) | unpatched | present | pass | FAIL: output is only `@objectstack/cli/17.6.0 linux-x64 node-v22.22.0` | | cc0c05b | patched | present | pass | pass: card signature present, exit non-zero | | cc0c05b | patched | deleted | FAIL x2: `expected '(node:...) [MODULE_NOT_FOUND] Warni...' not to contain 'Cannot find module './registry''` | pass | | cc0c05b, `packages/cli/node_modules/tsx` linked to the pristine 4.23.15 | unpatched | deleted | pass (vacuous: 1 failed, 4 passed) | FAIL | | 8569d5f (final) | patched | present | pass | pass (5 passed) | | 8569d5f, Node v22.22.3 | patched | present | pass | pass (5 passed) | `scripts/ablation-replace.mjs --delete` removed the declaration (anchor 1 to 0, blob `569e6b6f` to `40a368ce`). The same tool restored it and showed the blob equal to HEAD's, with `git diff HEAD` empty. The tsx link swap ran under an EXIT/INT/TERM trap, and `readlink` confirmed the restore. `bin/run.js` runs unbuilt, so no `dist/` step is involved. ## Reviewing the patch The patch replaces one minified line. Review it with this comparison, which splits the pristine and the patched file on commas and prints every token that differs: ```sh npm pack tsx@4.23.15 && tar xzf tsx-4.23.15.tgz node -e 'const fs=require("fs");const a=fs.readFileSync(process.argv[1],"utf8").split(","),b=fs.readFileSync(process.argv[2],"utf8").split(",");let n=0;for(let i=0;i!==a.length;i++)if(a[i]!==b[i]){n++;console.log("-",a[i].slice(-60));console.log("+",b[i].slice(-60))}console.log(n,"of",a.length,"differ")' package/dist/esm/api/index.cjs node_modules/.pnpm/tsx@4.23.15_patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6/node_modules/tsx/dist/esm/api/index.cjs ``` Output: ```text - se=[new URL("loader.mjs" + se=[new URL("../../loader.mjs" - new URL("esm/index.mjs" + new URL("../index.mjs" - essageChannel;R.register(`./esm/index.mjs?${_.randomUUID()}` + e.MessageChannel;R.register(`../index.mjs?${_.randomUUID()}` 3 of 514 comma-separated tokens differ ``` The `register()` specifier is the one that breaks things. The two `new URL(...)` entries feed tsx's `isTsxImport` set and carry the same wrong base. They are fixed in the same patch because they are the same defect, in the same file, with the same mechanical change. Both rebased targets exist on disk (`dist/esm/index.mjs`, `dist/loader.mjs`). No test here reaches those two entries, because no child in this suite passes a TypeScript preload ahead of an absolute tsx loader path. ## Lockfile `pnpm install` regenerated it. With the tsx patch hash normalised away, every removed line equals an added line, except for the three new `patchedDependencies` lines. 0 versions moved, 0 DOWN, 0 packages added or removed. Ten snapshot keys change only by the hash suffix: `tsx` itself, and the peer suffix of `tsup`, `postcss-load-config`, `fumadocs-mdx`, `vite` x2, `vitest` x2 and `@vitest/mocker` x2. `pnpm install --frozen-lockfile` passes on the final head. ## Verification - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at 8569d5f derives 58 commands from 4 paths. All 58 were run with exit codes captured before any pipe, and all exited 0. `--ran` reports 58 derived, 58 run, 0 NOT-MEASURED. (In an earlier pass at 6a22902, `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET while sibling packages had no `dist/`. Once those were built it passed: 105 entry points across 66 packages.) - `@objectstack/cli` unit tier at 6a22902: 244 files, 3461 tests passed. - `@objectstack/cli` integration tier at 6a22902, two runs under the verify lock: 71 files, 607 tests passed, 1 skipped. The skip is the existing `it.skipIf` in `test/migrate-meta-default-range.test.ts`, a file this branch does not touch. - `pnpm --filter @objectstack/cli typecheck` at 6a22902: exit 0, `check:test-typecheck` included. - 6a22902 to 8569d5f is two merges of `main`: objectstack-ai#21317 (plugin-security), objectstack-ai#21335 (objectql, rest, plugin-auth), and two docs-only commits (objectstack-ai#21337, objectstack-ai#21343). None touches a CLI file, the lockfile, `pnpm-workspace.yaml` or tsx, so the CLI tiers were not re-run for them. The packages the merge touched (objectql, plugin-auth, rest) were rebuilt. Then the reroute file (5 passed on v22.22.0, 5 passed on v22.22.3) and all 58 gates were re-run on 8569d5f. - Not run here: `pnpm lint` (repo-wide, CI's run). **skip-changeset:** nothing published moves. `@objectstack/cli` ships only `dist`, `README.md` and `CHANGELOG.md`, so the test file is not published. `patches/`, `pnpm-workspace.yaml` and `pnpm-lock.yaml` are root files no package ships. The CLI's dependency range on tsx is unchanged. ## Acceptance notes - The dev containers run Node v22.22.0, while CI's `setup-node` floats on `'22'`. That gap is why the same file read red in three dev containers and green in `Test Core`. Observation only, no card. Carrier: none. - Upstream: whether tsx already has an issue or fix in flight was not read, because this session cannot reach `privatenumber/tsx` through the API. The retirement condition is in the `pnpm-workspace.yaml` note. - Published reach: `@objectstack/cli` depends on `tsx ^4.23.15` at runtime, so a customer on Node 22.0 to 22.22.2 installs the defective build. No published code path calls `require('tsx/esm/api')`, so no public entry point reaches it. Measured by a grep of `packages/**` source and by the published entry's `enableAutoTranspile = false`. - `packages/cli/README.md` and `packages/cli/package.json` belong to objectstack-ai#21310 and are not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ult cut, moved to core (objectstack-ai#21414) Fixes objectstack-ai#21385 Clause-②: no Security handling: this body, the commits, the changesets and the test names carry classes and positions only. Every pin plants a synthetic sentinel and asserts it is ABSENT. No measured log line is copied anywhere, and the local probe printed booleans only. ## What this changes This implements the maintainer's ruling on objectstack-ai#21385 (comment 5950942037, letter A): one cutter for every log face. The cutter's home is `@objectstack/types`, per the claim's amendment 1 (5954587444) and triage's pointer (5954318919). See "Patch round 1" below. 1. **The redaction module moves to `@objectstack/types`.** `packages/objectql/src/driver-fault-redaction.ts` becomes `packages/types/src/driver-fault-redaction.ts`. Against `main`, that is the PR's one git rename (`R092`). The cut is the same code: the same split, the same structural cut at the separator, the same value templates and the same property rules. - The module's one import, `looksLikeInternalErrorLeak`, is now the package-local `./error-leak.js`. `error-leak.ts` and its frozen list are not edited. - The types entry exports four names, listed by name: `redactBoundStatement`, `redactStatementFromMessage`, `redactPropagatedDriverFault` and the `DriverFaultOrigin` type. - The template table and its load-time guard stay package-internal. The guard's eight cases moved verbatim beside it, to `packages/types/src/driver-fault-redaction.test.ts`. - Why `types`: it is the lowest package every face of this family can reach. `driver-sql`, `objectql` and `core` all depend on it, and the family's fourth position, `operatorFacingErrorText`, lives inside it (objectstack-ai#21418). The module header says so. - There is no copy of the cut, no edit to `error-leak.ts` or `driver-error-classification.ts`, and no row added to the frozen predicate list. `packages/core` is not in the diff. 2. **One widening, on the log face.** `redactStatementFromMessage` takes an optional second argument: the same `{ statementSent: true }` that `redactPropagatedDriverFault` already took. Without it, the answer is unchanged. 3. **objectql calls the moved code.** `engine.ts` and `lifecycle/lifecycle-service.ts` now import from `@objectstack/types`, with the same arguments as before. Five objectql test files repoint their import. None of the moved names was on objectql's entries. 4. **driver-sql's five lines call it.** In `sql-driver.ts`, each refusal line writes the dialect's text through `redactStatementFromMessage(text, { statementSent: true })`, imported from `@objectstack/types`. The lines are: - the unresolvable WHERE column (`INVALID_FILTER`), on `find` and on `count`; - the read terminal (`DATABASE_ERROR`); - the raw-statement terminal (`DATABASE_ERROR`), which no longer writes the sent statement as a separate field; - the unresolvable groupBy / aggregation column (`INVALID_FIELD`); - the unresolvable listed-distinct column (`INVALID_FIELD`). `statementSent` holds by construction: each line writes a fault raised by a statement this driver sent, which is the same knowledge the engine's raw door passes. The read terminal's cut sits where its text is computed, so the two debug lines that demote it inside a scope take the cut too (see Acceptance notes). 5. **`driver-turso`'s remote transport is covered by the same line.** Its refusals reach the base class's raw terminal. Its transport error carries no statement (the bare shape), so the line now writes the engine's diagnostic and nothing of the statement it was sent. 6. **Two envelope sentences are corrected.** The read terminal's and the raw terminal's composed `DATABASE_ERROR` messages said the statement was written to the server log. After this change that is not true, so each now says the diagnostic was written, with the statement and its bound values cut. Code, status, `cause` and the withheld text are unchanged. See Acceptance notes, deviation 1. ## Measured: sentinel on each line, before and after Probe: drive each line through the public driver method on better-sqlite3, a live PostgreSQL 16 and a live MySQL 8.0.46, with one synthetic sentinel. An extractor printed only whether the captured line held the sentinel, and on which field. BEFORE was measured at `ecb6ca0258` (base), and AFTER at `0cbd86d55e`, with the same extractor. The committed pins re-measure AFTER at the final head, `992e940fff`. | line | drive | sqlite before → after | pg before → after | mysql before → after | |---|---|---|---|---| | WHERE column | `find` | present → absent | absent → absent | present → absent | | WHERE column | `count` | present → absent | absent → absent | present → absent | | read terminal | missing table (sqlite, mysql); 22P02 value (pg) | present → absent | present → absent | present → absent | | raw terminal | bound value | present (dialect field) → absent | present (dialect field) → absent | present (dialect field) → absent | | raw terminal | value spelled inline | present (statement field and dialect field) → absent | same → absent | same → absent | | groupBy / aggregation column | — | present → absent | absent → absent | present → absent | | listed-distinct column | — | present → absent | absent → absent | present → absent | After the change, on every row: the lead (code and class of fault, object and column) is kept, the dialect's own diagnostic is kept, and the cut's marker stands where the statement was. On the pg read and raw rows, the 22P02 value slot reads as the value marker. The raw line no longer carries a `statement:` field. The `driver-turso` remote transport, with a sentinel spelled inline in the sent statement: the raw terminal was handed it in the statement and not in the transport's bare error, and the line carries none of it. No marker appears there, because the bare error has no statement to cut. ## Zone 2 hypotheses - **H1 confirmed, with one amendment.** All five lines were driven on all three dialects. At base they sit at `sql-driver.ts` `:10087`, `:10215`, `:10284`, `:10960` and `:11253`. The amendment: on PostgreSQL, the three unresolvable-column lines never carried the sentinel. pg positions bindings as `$n` before knex formats the message, and its column diagnostic names identifiers only. Those three pg cells are recorded in the pin as non-regression cells, not as non-vacuous ones. The pg read and raw terminals did carry it, through the 22P02 diagnostic and through the raw statement's own text. - **H2 superseded in patch round 1.** Round 0 confirmed that `packages/core` could hold the module. Amendment 1 moved it to `packages/types`, where it also fits. Measured, the package takes a new runtime module: - its `tsup` entry is `src/index.ts` (edge-safe; the module uses no `node:` builtin); - its `tsconfig` includes `src/**/*`, and `--listFiles` shows both the module and its test; - `check-dts-emitted` runs in its build, and `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:published-files` all pass; - no gate refused it, and none was edited. The WHOLE module moved, not only the cutter. The two faces and the property rules are thin wrappers over the module's private split, so objectql no longer has a wrapper of its own. - **H3 confirmed.** Each line keeps its lead, plus `(CODE)` on the read and raw lines, and the dialect's diagnostic. It loses the statement and any template-owned value. The raw terminal's separately logged `statement:` field carried an inline sentinel on all three dialects at base, and it is no longer written. - **H4 partly falsified.** Every envelope's code, status, `cause` and withheld text are unchanged. But two composed messages described the log half ("the statement … written to the server log"), and this change made them false. They were corrected; see deviation 1. The engine's existing pins for the two earlier positions (the cards behind PR objectstack-ai#21335 and PR objectstack-ai#21384) stay green: - the full objectql suite, which includes `driver-fault-boundary-redaction.test.ts` and `driver-fault-redaction-residue.test.ts`; - the dogfood suite, which includes `raw-statement-fault-redaction.test.ts`; - the plugin-auth carrier pin, 15/15 on three dialects. ## Pins - **New: `packages/drivers/driver-sql/src/sql-driver-21385-refusal-log-line-redaction.test.ts`, 46 cases.** It runs on every cell of the driver axis: SQLite always, live PostgreSQL and MySQL when their URLs are set. - Per cell, each of the seven drives has two cases: - a SENTINEL case: the text handed to the line carried the sentinel as measured, the written line does not carry it, and the marker is present; - a CONTROL case: the envelope's code and status, the lead, the diagnostic, the dialect code and the named object and column. - Each cell also has one success control. - One case pins the deferred-DDL debug line. - Non-vacuity is read off what each protected refusal method was HANDED, through a recording subclass. Every assertion on a line is a boolean, so a red names the line and never prints it. - **Flipped: nine existing pins in eight files.** Each of these pinned the retired design, in which the statement was in the log line: - in `driver-sql`: `sql-driver-11455-…`, `-11541-…`, `-16019-…`, `-17639-…`, `-17857-…`, `sql-driver-backend-fault-envelope` (two cases) and `sql-driver-unresolvable-where-column-refusal` (the positive control); - in `driver-turso` (patch round 1): `turso-driver-16019-remote-raw-statement-fault-envelope`. Each now asserts the diagnostic and that the statement is absent. The `driver-sql` ones also assert the marker. The positive controls, and the turso pin, read the sentinel's presence on what the refusal was handed (or on the envelope's `cause`) instead of on the log. ## Reverse verification **The five calls (round 0).** The change was committed first (head `7445ed5cda`, `sql-driver.ts` blob `ede93583a0f1`). Only the five calls were reverted: the four identical call sites and the read terminal's one. This used two nested `scripts/ablation-replace.mjs` legs: anchor x4 → x0 (blob `ede93583a0f1` → `e4243feaf00e`), then anchor x1 → x0 (blob `e4243feaf00e` → `a8ea87a6014b`). The driver-sql pin loads `./sql-driver.js` from source, so no build sits between the mutation and the run. - **Mutated: 22 red / 24 green of 46.** - Red on "the sentinel reached the line": sqlite 7/7, mysql 7/7, pg 3 (read, raw bound, raw inline), and the debug-line case. That is 18. - Red on "says a statement was cut": the 4 pg cells that were never handed the sentinel (WHERE find and count, aggregate, distinct). - Green: all 21 CONTROL cases and the 3 success controls. - **Restored.** Proven by the tool twice and by a script trap once: blob == HEAD, and `git diff HEAD` is empty. **The turso flip (patch round 1, at `992e940fff`).** Only the `driver-sql` change this pin depends on was reverted: the raw terminal's dropped `statement:` field was put back. `driver-turso` reads `driver-sql` from its dist, so each leg was rebuilt and its dist checked before the run. - The first attempt was a no-op. The replacement text contained the anchor, so `ablation-replace.mjs` refused it (anchor x1 → x1) and ran nothing. The anchor was changed and the leg re-run. - **Mutate.** Anchor x1 → x0, blob `ae07547bcd68` → `025ec6208050`. The on-disk marker count was 1. `driver-sql` was rebuilt, and `ablation-dist-preflight.mjs` found the marker present in 2 built files. - The turso pin went 1 red / 3 green; the red is "the sentinel reached the server log". - The driver-sql raw pins went 7 red / 46 green: the six raw-terminal sentinel cases on three cells, and the `16019` raw-line pin. - **Restore.** Proven by the tool and by a trap: blob == HEAD `ae07547bcd68`, and `git diff HEAD` is empty. After a rebuild, the preflight with `--absent` found the marker absent from all 6 built files. The turso pin went back to 4/4, and the driver-sql raw pins to 53/53. ## Verification Final head: `992e940fff`. Live servers: PostgreSQL 16 and MySQL 8.0.46, throwaway instances that were stopped by recorded PID and removed. `TZ=America/New_York` was set, as the live CI job sets it. - `@objectstack/types`: `test` 23 files / 696 tests passed; `test:repo` 1 file / 7 tests passed; `typecheck` exit 0. - `@objectstack/core` (the module left it): `test` 76 files / 2156 tests passed; `typecheck` exit 0. - `@objectstack/objectql`: `test` 365 files / 7379 tests passed; `test:repo` 1 file / 5 tests passed; `typecheck` exit 0, with `check:test-typecheck` OK. - `@objectstack/driver-sql`, full suite (`vitest run --maxWorkers=2`), with both live URLs: 227 files passed; 5472 tests passed and 1 skipped; 0 sentinel occurrences in the run log. `typecheck`: exit 0. - `@objectstack/driver-turso`: `test` 88 files passed; 2365 tests passed and 33 skipped. `typecheck`: exit 0. - `@objectstack/driver-sqlite-wasm`: `test` 36 files / 675 tests passed; `typecheck` exit 0. - The `Test Core (1/6)` packages that CI never reached: - `spec`: `test` 600 files, 17606 passed + 1 todo; `test:repo` 48 files / 849 passed; - `service-settings` 33 / 591; - `cloud-connection` 31 / 401; - `service-messaging` 46 / 507; - `example-showcase` 31 / 394; - `plugin-pinyin-search` 2 / 21; - `connector-mcp` 3 / 23; - `knowledge-memory` 1 / 8. All exited 0. - Dogfood, against freshly built dists (`turbo run build --filter=@objectstack/dogfood... …`, 63/63), with both live URLs: `test` 171 files passed and 1 skipped; 1402 tests passed and 3 skipped. - `typecheck` exit 0, after `pnpm install --frozen-lockfile`. The first attempt failed on a missing link for `@objectstack/trigger-api`: the `main` merge had added that dependency, and this worktree's install predated the merge. - The plugin-auth carrier pin (`driver-fault-auth-log-carriers.test.ts`), on three dialects: 15/15. - The runtime files that read these lines (`seed-tenancy-autonumber-split`, `expected-read-refusal-noise.channel-asymmetry`, `metadata-list-ambient-vs-bare-transaction`, `first-boot-migration-gate-read`): 4 files, 22 tests passed. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 70 at `992e940fff`. All 70 ran with exit 0, and `--ran` reconciles 70 derived / 70 run / 0 NOT-MEASURED / 0 UNRUN. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) for 8 packages without a dist. After those were built, it exited 0. - Also run: `pnpm check:live-db-isolation` PASS, and a control-byte self-scan of the 26 touched files, with no hit. - ESLint, narrowed to the 23 touched TS files, at `992e940fff`: - the population read from `eslint.config.mjs` is `packages/**/*.{ts,tsx,mts,cts}`; - `--format json` gives 23 files, 0 errors and 0 warnings; - invariance: the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move an untouched file's verdict. - The repo-wide `pnpm lint` is CI's. ## Patch round 1 - **Why.** CI on `7445ed5cda` went red in `Test Core (1/6)`, on the `driver-turso` pin that asserted the statement on the raw terminal's line. That pin is this PR's, and it is flipped here. The shard's first attempt had failed earlier, in an unrelated `spec` hook timeout (objectstack-ai#21421). Separately, the seat moved the cutter's home to `@objectstack/types` (amendment 1, 5954587444), so that objectstack-ai#21418 can call it from `operatorFacingErrorText`. - **What moved.** The module, its guard test and the four named exports moved from `packages/core` to `packages/types`, and core's export block was removed. Every importer was repointed, with the module-path strings and comments that named core. The core `minor` changeset became a types `minor` one, and the driver-sql and objectql changesets now name `@objectstack/types`. Net against `main`: one rename, `objectql` → `types`; `git diff origin/main...HEAD -- packages/core` is empty. - **The sweep for other pins of the retired line.** - Command: `git grep -n -E` over the test files of all 18 packages that depend on `@objectstack/driver-sql`. The pattern covered the five lines' leads, the `[sql-driver]` prefix, `refused a raw statement` / `refused a read on` / `could not be resolved on`, `kept server-side`, `statement: `, and an `includes('DATABASE_ERROR' | 'INVALID_FILTER' | 'INVALID_FIELD')` filter. - Result: 35 hit lines in 28 files. Read one by one, exactly one is a pin of the retired content, the `driver-turso` pin above. - The rest are prose, unrelated words, the objectstack-ai#7929 withheld-filter line, or line consumers that key on the lead and the diagnostic. The four runtime consumers among them were run, and are green. - No other lane's package needed an edit. - **Does `dispatch-gates --commands` derive the suites of packages that depend on a changed package?** Measured: no. - It maps a file surface to `check:*` gate families. Of its 70 commands at `992e940fff`, none is a package test suite: the only `--filter` command is `spec`'s `check:duration-unit-keys`. - So it derives neither the changed packages' own suites nor their dependents'. That is why round 0's local runs never reached `driver-turso`. ## Acceptance notes - **Deviation 1: two caller-facing envelope messages changed by one sentence each.** The dispatch said no caller-facing envelope changes. The read terminal's and the raw terminal's composed messages stated that the statement was written to the server log, and this change makes that false. The agent definition requires a released string that a change makes false to be corrected in that change, and it wins on a conflict. So each sentence now states what is written: the diagnostic, with the statement and its bound values cut. - The disclosure, code, status and `cause` are unchanged. - `operatorFacingErrorText`'s copy matches only the leading "refused to run a raw statement" fragment, which is kept; the producer pin `sql-driver-16657-…` is green. - Six hand-built fixture copies in five test files still carry the old second sentence, in `metadata`, `metadata-protocol`, `rest`, `types` and `objectql`. They stay green because the matcher keys only on the leading fragment. They are left as they are: four of the files sit outside this claim's surface, and editing only the fifth would split the copies. - **Bounded in-place extension: the read terminal's two debug lines.** The read terminal computes its dialect text once and writes it on the warn line or on one of two debug lines (a pre-DDL question, or a table whose DDL the driver deferred). The cut sits where the text is computed, so all three take it. It is the same defect class and the same mechanical call, in the claimed file, with the same gate family. It is pinned on the deferred-DDL debug line. - **The turso pin keeps no marker assertion.** The remote transport's error is bare (no statement in front of the diagnostic), so the cut has nothing to cut and adds no marker. The pin asserts the diagnostic, the class of fault, no sentinel and no `statement:` field. Non-vacuity is read off the sent statement the raw terminal was handed. - **The process timezone.** Three live-matrix files require the process zone to differ from UTC. Local runs set `TZ=America/New_York`, as CI's live job does. ## Out of scope (reported, not edited) - **`operatorFacingErrorText` (`@objectstack/types`) hands the raw path's `cause` text back whole.** Measured on SQLite with a synthetic sentinel bound into a raw statement: the envelope's message carries none, and the helper's answer carries it. This is objectstack-ai#21418's (the family's fourth position), which can now call the cutter from the same package. Its docblock also says the driver writes the statement one line earlier, which is no longer true. - **Stale prose outside this surface.** These no longer match the driver's lines: - `packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts` (its header says the driver's raw line writes the statement); - `sql-driver-diagnostic-value-probe.test.ts`'s rationale for not importing the redactor (the module paths in its failure messages are updated here). --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21274
Clause-②: no
What this changes
A driver error that leaves the ObjectQL engine is now cut the way the engine's own log line has long been cut: the bound statement and the caller's values are removed. Its class, codes and the database's own diagnostic are kept. The cut runs once, at the engine boundary, through one helper,
redactPropagatedDriverFault, inpackages/objectql/src/driver-fault-redaction.ts. Every consumer that logs a propagated error gets it, and the auth library is the one measured.⛔ No edit to
plugin-authsource, its ObjectQL adapter, or its logger wiring. Its only change is one new test file.Security handling: this PR body, the commits, the changeset and the test names carry classes and positions only. Every pin uses a synthetic sentinel and asserts that it is absent.
Measured driver shapes (H2)
Measured off thrown errors (knex 3.3, better-sqlite3 13, node-postgres 8 on PostgreSQL 16, mysql2 3 on MySQL 8.0.46). The families were unknown column on insert, update and select; unique; not-null; type error; value too long.
SqliteErrormessage,stack(knex inlines the bound values)codeDatabaseErrormessage,stack(the statement with placeholders; the value itself for 22P02);detail(unique: key-shaped line; not-null: the failing row);where(a bound value once the server enableslog_parameter_max_length_on_error, measured both ways);internalQuery(statement text)name,code,severity,routine,file,line,position,schema,table,column,dataType,constraint,hintError)message,stack,sql;sqlMessage(value-bearing for 1062 and 1366)code,errno,sqlStateEach property has one cut rule, kept in one table in the helper:
messageandstacktake the shared statement cut.sqlandinternalQuerybecome the statement marker.sqlMessagetakes the existing measured value templates.detailkeeps its identifier half (Key (cols)=(plus the value marker); any otherdetailand everywherebecome a marker.causeis cut recursively, to depth 4, the depth the shared predicates read to.The Postgres-generic names (
detail,where,internalQuery) are cut only on an error carrying pg'sseverity. The result is a nativeErrorwith the input's prototype and every own property, symbol-keyed and non-enumerable ones included, with enumerability kept. The input is never mutated. Nothing to cut returns the same reference, and the cut is idempotent.One measured fork from the ruling's wording, reported rather than chosen silently
The ruling says the propagated error "carries the same redaction the engine's own log line already applies". The cut is literally the same function:
splitDriverDumpis shared by both faces, and the log helper's output is byte-identical (its existing 50+ case suite is green). The presentation differs. The log line appends the marker. The propagated message keeps the statement's position and its leading verb:VERB [statement and bound values redacted] - DIAGNOSTIC. The verb names the statement's kind only, with no identifier and no value.The reason is measured (H4). The propagated message is read by classifiers the log line never feeds. For 9 families on each of 3 dialects (26 raised errors), I compared the REST answer (
mapDataError: status, code,field) for the raw message, the appended form and the verb-head form:fieldlost (2; the column reader reads to end of line); pg 22P02 and 22001, MySQL 1366 and 1406 move from500 DATABASE_ERRORto500 INTERNAL_ERROR(the leak predicate recognised them only by the statement's leading verb)fieldand leak verdict is equalIf the maintainer prefers the appended form, the cost is those six answers.
Every engine site where a driver error leaves the engine (H1)
Positions are
packages/objectql/src/engine.tsat this PR's head.executeWithMiddlewareexecutor,:5164–:5178find:11768,findOne:12068,count:16991,aggregate:17103(native and thefindfallback),insert:12712(single,bulkCreate, the autonumber-resync creates),update:13764(by id andupdateMany, prior reads included),delete:16457(by id anddeleteMany, pre-reads included)execute:17570driver.execute(raw command, callerparamsbound):17618)ObjectQL.transaction:17668:17737); a callback error that already crossed a boundary comes back as the same referenceScopedContext.transaction:18632)resolveSecretField:9027,resolveInternalField:9111driver.findwith record ids bound:9052,:9148):13689, update:15459, delete:16978)SummaryRecomputeError.failures[].error(recomputeSummaries:10910)this.update/this.aggregateinsertManypartial-mode outcomes{ ok: false, error }this.find), reference checks (this.findOne)ScopedContext.beginTransaction:18746/commitTransaction/rollbackTransaction;ObjectQL.transaction'sbeginTransactioninit:9580(connect),checkDriversHealth:9643,destroy/closesyncSchemas:18030,syncObjectSchema,dropObjectSchema,introspectDatasource:17995datasource():18117,getDriverByName,getDriverForObjectH1–H5
H1: confirmed. At the base, the three write doors logged a redacted copy and rethrew the raw error, and every other site above rethrew raw.
H2: confirmed and refined. pg
wherecarries a value only under a server setting (measured both ways). pg's statement text binds by placeholder, so the pg value ridesdetail, ormessagefor 22P02. The required kept-set (class, codes, identifiers, diagnostic) survives on every measured shape.H3: confirmed.
DuplicateRecordError.causecarried the raw statement and value out of the engine. It is cut now.fieldis computed in the engine from the raw error, before the boundary, and REST's envelope arm readsfieldoff the envelope, so it does not move. The readers found are:isUniqueViolationErroranduniqueViolationColumn(code, errno, message, pgdetail,cause);mapDataError;isMissingTableError(code, errno, message, the targeted-table symbol,cause);sanitizeRowError(import rows);defaultIsTransientError(core bulk-write).Each reader's verdict is pinned equal, raw against cut, on every measured shape.
sanitizeRowErrormeasured equal on 8 of 10 families; the other 2 are covered under acceptance notes.SummaryRecomputeError's failures are already safe (pinned).H4: confirmed. The REST answers do not move. That is pinned per shape (
mapDataErrorstatus, code andfieldequal, raw against cut) in the new objectql file. The existing REST pins are listed under Gates.H5: confirmed. After the engine fix no carrier shows the sentinel on SQLite, live PostgreSQL or live MySQL. The adapter rethrows the engine's error verbatim, so no
plugin-authsource edit was needed.Pins
packages/objectql/src/driver-fault-boundary-redaction.test.ts(new):DuplicateRecordErrorover sqlite, pg and mysql (own fields kept,causecut);DATABASE_ERRORread envelope (non-enumerablecausecut, targeted-table symbol kept);SummaryRecomputeErrorfailures;writeFailureLogMeta(redactBoundStatement(raw))on all three doors, and spelled out for three dialects.packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts(new), on SQLite always and on live PostgreSQL / MySQL whenOS_TEST_POSTGRES_URL/OS_TEST_MYSQL_URLare set, each in its own schema or database:sys_user.name, then two auth-table writes refused by the database: the sign-up user insert, which hits the library's error line plus the error object, and update-user, which hits the router's server-error line plus the error object;consolerenders it plus the hidden-field inspect, and neither do the engine's or the manager's logs or the HTTP bodies;cause.codeand diagnostic survive.The live cells mirror the REST and runtime precedent of local env-gated cells, because the driver-sql testkit is test-only and not exported.
Re-judged, because they pinned the overturned "rethrown raw" contract. Each now asserts what it protected, not identity:
driver-fault-redaction.test.ts, 2 pins: the caller's 400INVALID_FIELDanswer and the unique-violation verdicts;engine-insert-duplicate-record.test.ts, 8 assertion sites;engine-update-duplicate-record.test.ts, 9 assertion sites:causeis the driver error as it leaves (same class, code and cut message), and an envelope still nests one deep.Reverse verification (one-shot, no permanent file)
node scripts/ablation-replace.mjs(WRAP, trap-restored) rebinds the boundary name inengine.tsto an identity function. That turns all eight boundary sites off and leaves the helper intact.ablation-dist-preflight.mjs @objectstack/objectql MARKERexited 0.git diff HEADis empty. objectql was rebuilt, and--absentexited 0 with a clean tree. objectql 281/281, plugin-auth 15/15.Verification at
fd19d3661bPackage tests. Each exit was captured before any pipe; heavy runs went through
scripts/pm/os-verify-lock.sh.pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 361 files, 7242 passed.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 117 files, 2489 passed, 10 skipped. The 10 are the new file's live cells, which skip without the env.OS_TEST_POSTGRES_URL(PostgreSQL 16) andOS_TEST_MYSQL_URL(MySQL 8.0.46): 15/15, with zero sentinel occurrences in the run log. These ran against local servers. No CI job provisions those variables for plugin-auth, so in CI the live cells are red-capable but do not run.pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2: 214 files passed, 11 skipped; 3578 passed, 202 skipped (live cells). driver-sql has no dependency path to objectql.packages/rest/src/rest-duplicate-record-arm.test.ts: 33/33.c2cf5b1924. Results: core 23, metadata-protocol 64, plugin-audit 47, plugin-security 86, plugin-sharing 157, rest (local) 94/95, rest (repo) 50, runtime 22, service-automation 31, cli (integration) 7.rest-duplicate-record-arm.test.ts. Its precondition line asserted that the envelope'scausestill carried the value into REST, which is the overturned contract. It is re-judged infd19d3661b(33/33 above). REST's answer assertions in that test did not change.typecheckscript of objectql, plugin-auth, driver-sql and rest, test layer included, is green for each.Gates:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 68 families atfd19d3661b. All 68 ran, and--ranwith exit codes recorded reports: 68 run, 0 NOT-MEASURED (a derived zero, since every family recorded an exit code).check:query-options-erasurewas red: the new aggregate door erased its options. It now usessatisfies EngineAggregateOptions, and the ratchet holds.check:dual-build-cjs-loadsreported PREREQUISITE NOT MET (nothing measured) until every package had adist/; the re-run is green (105 entry points across 66 packages load).check:type-check-debtwas cut by my 300 s per-gate timeout; the re-run with a longer cap is OK.fd19d3661b; all green.origin/mainwith one changed input,scripts/pm/fleet-write/dispatch.mjs, which this diff does not touch. The re-derivation at the final head gave the same 68.File surface
As claimed, plus two additions, reported here:
packages/objectql/src/duplicate-record-error.ts: doc comment only. Itscausenote promised the driver error "WHOLE and unmodified".packages/rest/src/rest-duplicate-record-arm.test.ts: one precondition line re-judged (above). REST source is untouched.The re-judged objectql pins (
driver-fault-redaction.test.ts,engine-insert-duplicate-record.test.ts,engine-update-duplicate-record.test.ts) are pins inpackages/objectql, as claimed. Changeset:.changeset/21274-driver-fault-boundary-redaction.md(@objectstack/objectqlpatch,Clause-②: no).Acceptance notes (not filed)
defaultIsTransientError(core bulk-write) classifies by message text. After the cut it judges only the database's own words, so a bound value or column name in the statement can no longer steer a retry verdict either way.sanitizeRowError) is unchanged on 8 of 10 measured families. For pg 22P02 and MySQL 1366 the row text used to repeat the caller's rejected value; it now reads the value marker. The changeset says so.Roll-up summary recompute failedwarn logserr.messageof the failed parent update. That was raw and is now the cut message, because the error arrives after the inner boundary.with,replace) and whose diagnostic matches no dialect limb is not recognised, so nothing is cut. The engine's own statements open with the four listed verbs, while a rawexecutecommand may not. That was observed and not reproduced through a public door, so it is not filed. The carrier would be the next PR that touches the shared leak predicate.Generated by Claude Code