Skip to content

Commit d956910

Browse files
fix(objectql): the raw-statement door and the lifecycle sweep cut a driver fault by construction (#21345) (#21384)
Fixes #21345 Clause-②: no Security handling: this body, the commits, the changeset 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. ## What this changes The engine-boundary cut from #21274 left two positions. Both now take the same helper, `redactPropagatedDriverFault` in `packages/objectql/src/driver-fault-redaction.ts`. There is no second redaction and no copy of the cut, and the frozen verb list in `packages/types/src/error-leak.ts` is untouched. 1. **The raw-statement door (`ObjectQL.execute`).** The helper cut `message` and `stack` only when the shared leak predicate recognised the text as a driver dump, and the predicate reads a statement by four leading verbs. The door now passes `{ statementSent: true }`: it handed the driver a statement, so it knows by construction that one may lead every message on the fault's chain. The cut then runs without asking the predicate. Same split, same structural cut at the separator, same value templates, same property rules. A statement whose leading verb is not one of the four leaves the bare marker, with no verb kept. 2. **The lifecycle sweep (`lifecycle-service.ts`).** The sweep's per-object catch routes the fault through the same helper before it is pushed to `report.errors` or logged at WARN. Faults from the engine doors this loop also uses arrive already cut, and the helper hands those back unchanged. ## Measured baseline (before the fix) Probe: `ObjectQL.execute` over a real `SqlDriver`, one synthetic sentinel bound into the statement, an extractor printing only the carrier paths that hold it. Measured on better-sqlite3 13, live PostgreSQL 16 and live MySQL 8.0 at base `23365eaed`. | dialect | statement class | carriers holding the sentinel | |---|---|---| | sqlite | common-table-expression form, diagnostic matching no phrasing | `cause.message`, `cause.stack`, default `inspect` | | sqlite | dialect upsert verb, diagnostic matching no phrasing | `cause.message`, `cause.stack`, default `inspect` | | pg | common-table-expression form, value in the diagnostic | `cause.message`, `cause.stack`, default `inspect` | | pg | dialect merge verb, value in the diagnostic | `cause.message`, `cause.stack`, default `inspect` | | mysql | common-table-expression form, diagnostic matching no phrasing | `cause.message`, `cause.stack`, default `inspect` | | mysql | dialect upsert verb, value in the diagnostic | `cause.message`, `cause.stack`, default `inspect` | | all three | CONTROL: listed verb | none | In every row the envelope's own message was composed, `String()` and JSON were clean, and mysql2's `sql` was already cut by the unconditional property rule. The engine's logger received nothing on this door, because the execute door writes no engine log line. After the fix, every row reads "none", with the class, `code`, `status` and `cause.code` unchanged. ## Zone 2 hypotheses - **H1 confirmed, with one amendment.** The positions and the residue are as stated, and the residue reproduces on all three dialects. The amendment: there is no engine WARN line on the execute door. The only log line on that path is `driver-sql`'s own raw-terminal line (see "Out of scope" below). - **H2 partly falsified.** The engine does not know the text the message carries. knex prefixes the statement COMPILED with the bound values inlined (better-sqlite3, mysql2), so removing the sent text would match nothing and leave the values in place. What the door does know is that a statement leads the message, and the structural cut needs nothing more. So the helper takes the door's knowledge as its input, `{ statementSent: true }`, in one place. The engine door is the cleaner site: it covers every driver behind it, and the triage direction's second clause holds there too, because the propagated fault's `cause` leaves carrying no statement in any field. `driver-sql` is not edited. - **H3 confirmed.** The per-site table is below. - **H4 confirmed.** The REST answer (`mapDataError` status, code and `field`) and the missing-table classifier are pinned equal raw-versus-cut on every new shape. The #21274 suites stay green: `driver-fault-boundary-redaction.test.ts` with its byte-identical WARN-line controls, the full objectql suite, and the `plugin-auth` carrier pin against the rebuilt objectql dist. That pin's SQLite cell passed 5/5; its PostgreSQL and MySQL cells were a named skip, because the live servers had been stopped by then. ## Per-site table: direct-driver calls in `lifecycle-service.ts` | site | call | what it binds | status | |---|---|---|---| | Archiver, cold schema sync | `cold.syncSchema` | identifiers only (DDL) | redacted now, through the sweep's catch | | Archiver, hot page read | `hot.find` | the cutoff and tenant ids | redacted now, through the catch. On `driver-sql`, the read terminal already composes its message | | Archiver, cold copy | `cold.upsert`, per row | the archived row's values | **redacted now**: the card's position | | Archiver, hot delete | `hot.bulkDelete` | the copied ids | redacted now, through the catch | | Archiver, cold prune | `cold.deleteMany` | a cutoff | redacted now, through the catch | | Rotator | `driver.rotateShards` | identifiers and a clock | redacted now, through the catch | | space reclaim, after the loop | `driver.reclaimSpace` | nothing (a pragma or vacuum) | already safe: it binds no caller value | | governance row count | `driver.count(name)` with no filter | nothing | already safe: it binds no caller value, and a `driver-sql` read fault is a composed envelope | | governance snapshot, tenant scan, reference audit | engine doors (`engine.find`) | — | already safe: the engine boundary cuts them | ## Pins - `packages/objectql/src/driver-fault-redaction-residue.test.ts` holds 48 cases: - The six raw-path shapes above, mirrored property for property. Each pins non-vacuity (the cause carries the sentinel, and the predicate does not recognise it), no carrier (own properties including hidden ones, the cause chain, default and hidden `inspect`, `String`, JSON), class, codes, non-enumerable cause, diagnostic, REST answer and idempotence. - The door through `ObjectQL.execute`, with no sentinel in the engine logger. - The Archiver: three dialect cold-write faults and one hot-delete fault. Neither the sweep's WARN line nor its `report.errors` entry carries the sentinel, both keep the diagnostic, and nothing is hot-deleted. - Controls: a listed-verb raw fault is cut byte-identically with and without the flag, a non-dump error leaves as the same reference, and a non-driver sweep failure is reported word for word. - `packages/qa/dogfood/test/raw-statement-fault-redaction.test.ts` holds 10 cases on real stores: - Per cell, two unlisted-verb statements and a listed-verb control, through `ObjectQL.execute` over a real `SqlDriver`. SQLite always runs. The PostgreSQL and MySQL cells run when `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set, as a named skip otherwise, each in a schema or database derived from the file name. - The Archiver over two real SQLite stores, where a trigger makes the cold store refuse the copy. ## Reverse verification Both legs were run with the fix committed, the mutation applied through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed, restored to the HEAD blob with an empty `git diff HEAD`), objectql rebuilt, and `scripts/ablation-dist-preflight.mjs` reading `dist/` before each colour was believed. - **Leg A: the helper ignores the flag.** - Mutated: objectql 18 red / 300 green, and dogfood 6 red / 4 green. - Red: every position-1 case. Green: the position-2 pins, every control, the #21274 boundary suite and the lifecycle suite. - In dogfood the six unlisted rows went red, and the three listed controls and the Archiver stayed green. - The marker was absent from all 14 built files. - Restored: 318/318 and 10/10, with the marker present in 4 built files. - **Leg B: the sweep's catch skips the helper.** - Mutated: objectql 4 red / 314 green, and dogfood 1 red (the Archiver) / 9 green. - The DTS emit failed on the now-unused import (TS6133). The JS bundles built and carried the mutation, so the dogfood red shows it reached the consumed artifact, and the marker was absent from all 8 JS files. - Restored: 318/318 and 10/10, with the marker present and the tree clean. ## Verification (final head) Final head: `e036911f5b`. The full suites ran at `69ba7adfbd`, the merge of `origin/main` `f39760864c`. The one later commit touches only the two new test files, which were re-run there, as were both typechecks. - `pnpm --filter @objectstack/objectql test`: 363 files, 7325 tests passed. `test:repo`: 1 file, 5 tests passed. - `pnpm --filter @objectstack/objectql typecheck`: exit 0, test layer included (`check:test-typecheck` OK, no new debt). - `pnpm --filter @objectstack/dogfood typecheck`: exit 0 (its `include` is `test/**/*`). - `pnpm --filter @objectstack/dogfood test`, against freshly built dists with both live URLs set: 167 files passed and 1 skipped; 1379 tests passed and 3 skipped. The new file alone: 10/10 on SQLite, live PostgreSQL 16 and live MySQL 8.0. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 70 at `e036911f5b`. All 70 were run, every one exited 0, and `--ran` reconciles 70/70. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3, nothing measured). The 8 packages it named were then built (a full turbo cache hit), and it re-ran with exit 0. - Also run: `pnpm check:live-db-isolation` PASS (it sees the new live file), `node scripts/check-nul-bytes.mjs` exit 0, and a control-byte self-scan of every touched file with no hit. - ESLint, narrowed to the 5 touched TS files and measured as follows: - Population, read from `eslint.config.mjs`: `packages/**/*.{ts,tsx,mts,cts}`. JSON output: 5 files, 0 errors, 0 warnings. - Invariance: the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` is CI's. ## Acceptance notes and deviations - **File surface.** The claim said "Pins in `objectql`". The real-driver pin lives in `packages/qa/dogfood/test/`, because `objectql` has no `driver-sql` dependency and dogfood is where this repo pairs an objectql unit pin with a real-driver pin. Dogfood is private, so the changeset is unaffected. - **Local gate scope.** The dispatch asked for the whole `Lint & Repo Gates` script set locally. The agent definition rules that enumeration CI's, and says it wins on a conflict. The derived union, plus the named extras above, ran locally, and CI runs the full set. - **Temporary live servers.** The PostgreSQL and MySQL instances ran under a temporary directory, were stopped by recorded PID, and were removed. ## Out of scope (reported, not edited) - **A third position: `driver-sql`'s own server-log line for a refused raw statement.** `rawStatementFault` writes the statement and the dialect's message to the driver logger before composing the envelope. - Measured carrying the sentinel on all three dialects, for listed and unlisted verbs alike, wherever the dialect inlines the value. - The read terminal and the unresolvable-filter refusal do the same by their documented design ("kept server-side for an operator"). - This is outside this card's two positions and inside `driver-sql`, which this dispatch stops at, so the dev report raises it for the seat and the maintainer. The dogfood pin silences that logger and names the position in its header. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8963dbf commit d956910

6 files changed

Lines changed: 935 additions & 10 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a raw statement's driver fault, and a lifecycle sweep's direct-driver fault, no longer carry the statement or the caller's values
6+
7+
Clause-②: no
8+
9+
Two paths the engine-boundary cut did not reach now take it.
10+
11+
- **`ObjectQL.execute`.** The cut ran on a driver error's message only when the shared leak predicate recognised a statement in it, and the predicate recognises four leading verbs. A raw statement opening with any other word, such as a common-table-expression form or a dialect's own upsert or merge verb, kept the statement and the bound values on the declared fault's `cause` (its `message` and `stack`), where any logger that prints an error's cause chain wrote them out. The door now tells the cut that it sent a statement, so the cut runs whatever word the statement opens with. The predicate's list is unchanged.
12+
- **The lifecycle sweep.** The Archiver copies rows to the cold store and deletes them from the hot store through the drivers directly, not through an engine door. A driver fault there, such as a cold write the archive store refused, put the archived row's values into the sweep's warning line and its `report.errors` entry. The sweep now cuts the fault the same way before it reports or logs it.
13+
- **What stays.** The error's class, `code`, `status` and the database's own diagnostic, on the fault and on its `cause`. A raw statement opening with one of the four recognised verbs is cut exactly as before. A sweep failure that is not a driver error is reported word for word as before.
14+
- **What changes for a caller.** Code that read the statement or a value out of a raw statement's fault, or out of a lifecycle sweep's error entry, now gets a `[statement and bound values redacted]` marker followed by the diagnostic. Branch on the error's class and `code` instead.

0 commit comments

Comments
 (0)