Skip to content

fix(objectql): redact a driver fault where it leaves the engine, not only in the engine's log line - #21335

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21274-driver-fault-boundary-redaction
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21274-driver-fault-boundary-redaction

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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, in packages/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-auth source, 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.

Driver Carries the statement or a caller value (cut) Carries the class and code (kept)
better-sqlite3 SqliteError message, stack (knex inlines the bound values) class, code
node-postgres DatabaseError message, 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 enables log_parameter_max_length_on_error, measured both ways); internalQuery (statement text) class, name, code, severity, routine, file, line, position, schema, table, column, dataType, constraint, hint
mysql2 (plain Error) message, stack, sql; sqlMessage (value-bearing for 1062 and 1366) code, errno, sqlState

Each property has one cut rule, kept in one table in the helper:

  • message and stack take the shared statement cut.
  • sql and internalQuery become the statement marker.
  • sqlMessage takes the existing measured value templates.
  • A key-shaped pg detail keeps its identifier half (Key (cols)=( plus the value marker); any other detail and every where become a marker.
  • cause is 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's severity. The result is a native Error with 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: splitDriverDump is 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:

Form Answers moved (of 26)
appended marker (the log line's form) 6: SQLite unique-violation field lost (2; the column reader reads to end of line); pg 22P02 and 22001, MySQL 1366 and 1406 move from 500 DATABASE_ERROR to 500 INTERNAL_ERROR (the leak predicate recognised them only by the statement's leading verb)
verb-head (this PR) 0: every status, code, field and leak verdict is equal

If 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.ts at this PR's head.

Site Driver calls reached Verdict
executeWithMiddleware executor, :5164–:5178 find :11768, findOne :12068, count :16991, aggregate :17103 (native and the find fallback), insert :12712 (single, bulkCreate, the autonumber-resync creates), update :13764 (by id and updateMany, prior reads included), delete :16457 (by id and deleteMany, pre-reads included) redacted now, one site; innermost, so middlewares see the cut error too
execute :17570 driver.execute (raw command, caller params bound) redacted now (:17618)
ObjectQL.transaction :17668 commit / rollback, and a callback that reached the driver through the handle redacted now at the rethrow (:17737); a callback error that already crossed a boundary comes back as the same reference
ScopedContext.transaction the same, scoped face redacted now (:18632)
resolveSecretField :9027, resolveInternalField :9111 driver.find with record ids bound redacted now (:9052, :9148)
the three write-door log lines (insert :13689, update :15459, delete :16978) unchanged: still computed from the raw error, before the boundary
SummaryRecomputeError.failures[].error (recomputeSummaries :10910) this.update / this.aggregate already safe by construction: both exit through the seam; pinned
insertMany partial-mode outcomes { ok: false, error } validation, hooks, secret encryption, autonumber seeding (this.find), reference checks (this.findOne) already safe by construction: no direct driver call feeds a row error; the reads go through the seam
ScopedContext.beginTransaction :18746 / commitTransaction / rollbackTransaction; ObjectQL.transaction's beginTransaction BEGIN / COMMIT / ROLLBACK already safe: those statements bind no caller value
init :9580 (connect), checkDriversHealth :9643, destroy / close connect, health, disconnect out of scope: no statement and no caller value
syncSchemas :18030, syncObjectSchema, dropObjectSchema, introspectDatasource :17995 DDL and introspection out of scope: statements composed from metadata, with no caller-bound value; boot and admin paths
datasource() :18117, getDriverByName, getDriverForObject hand the raw driver to the caller out of scope: no engine boundary is crossed

H1–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 where carries a value only under a server setting (measured both ways). pg's statement text binds by placeholder, so the pg value rides detail, or message for 22P02. The required kept-set (class, codes, identifiers, diagnostic) survives on every measured shape.

  • H3: confirmed. DuplicateRecordError.cause carried the raw statement and value out of the engine. It is cut now. field is computed in the engine from the raw error, before the boundary, and REST's envelope arm reads field off the envelope, so it does not move. The readers found are:

    • isUniqueViolationError and uniqueViolationColumn (code, errno, message, pg detail, 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. sanitizeRowError measured 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 (mapDataError status, code and field equal, 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-auth source edit was needed.

Pins

  • packages/objectql/src/driver-fault-boundary-redaction.test.ts (new):

    • per measured shape: no carrier holds the value; no carrier holds the bound statement (only its kind); class, codes and diagnostic kept; every shared classifier and the REST answer equal raw against cut; idempotent; the input is not mutated;
    • a non-driver error is returned as the same reference;
    • DuplicateRecordError over sqlite, pg and mysql (own fields kept, cause cut);
    • the driver's DATABASE_ERROR read envelope (non-enumerable cause cut, targeted-table symbol kept);
    • 13 engine doors × 3 dialects;
    • the envelope through insert and update;
    • a middleware sees the cut error;
    • SummaryRecomputeError failures;
    • control: the WARN line's meta is byte-identical to 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 when OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL are set, each in its own schema or database:

    • a unique index forced on 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;
    • no console line carries the sentinel, rendered as console renders it plus the hidden-field inspect, and neither do the engine's or the manager's logs or the HTTP bodies;
    • the envelope's class, code, cause.code and 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 400 INVALID_FIELD answer and the unique-violation verdicts;
    • engine-insert-duplicate-record.test.ts, 8 assertion sites;
    • engine-update-duplicate-record.test.ts, 9 assertion sites: cause is 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 in engine.ts to an identity function. That turns all eight boundary sites off and leaves the helper intact.

  • Mutation leg: the anchor went from 1 to 0 and the blob changed. objectql was rebuilt, and ablation-dist-preflight.mjs @objectstack/objectql MARKER exited 0.
    • objectql pins: 61 red / 220 green. The 47 engine-door pins and the 14 re-judged pins went red. Every helper-level shape pin and all 16 WARN byte-identity controls stayed green.
    • plugin-auth carrier pin (3 dialects): 6 red / 9 green. Per cell, the two carrier pins went red, while non-vacuity, the engine-log control and the HTTP-answer pins stayed green.
  • Restore leg: the blob equals HEAD and git diff HEAD is empty. objectql was rebuilt, and --absent exited 0 with a clean tree. objectql 281/281, plugin-auth 15/15.

Verification at fd19d3661b

Package 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.
  • The new carrier pin with OS_TEST_POSTGRES_URL (PostgreSQL 16) and OS_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.
  • H3 consumers: every test file that reads a driver error's properties through an ObjectQL engine, 33 files in 10 packages, run at 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.
    • The one red was rest-duplicate-record-arm.test.ts. Its precondition line asserted that the envelope's cause still carried the value into REST, which is the overturned contract. It is re-judged in fd19d3661b (33/33 above). REST's answer assertions in that test did not change.
  • Typecheck: the typecheck script 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 --commands derived 68 families at fd19d3661b. All 68 ran, and --ran with exit codes recorded reports: 68 run, 0 NOT-MEASURED (a derived zero, since every family recorded an exit code).
  • On the first pass, two were red and three needed a second look:
    • check:query-options-erasure was red: the new aggregate door erased its options. It now uses satisfies EngineAggregateOptions, and the ratchet holds.
    • check:dual-build-cjs-loads reported PREREQUISITE NOT MET (nothing measured) until every package had a dist/; the re-run is green (105 entry points across 66 packages load).
    • check:type-check-debt was cut by my 300 s per-gate timeout; the re-run with a longer cap is OK.
  • The ratchet and test-reading families (16) were re-run at fd19d3661b; all green.
  • The first derivation flagged its tree as behind origin/main with 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. Its cause note 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 in packages/objectql, as claimed. Changeset: .changeset/21274-driver-fault-boundary-redaction.md (@objectstack/objectql patch, 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.
  • Import row text (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.
  • The engine's Roll-up summary recompute failed warn logs err.message of the failed parent update. That was raw and is now the cut message, because the error arrives after the inner boundary.
  • For a nested engine call (a hook's write that fails inside an outer write), the outer door's WARN line is computed from an already-cut error. The log cut is idempotent on the verb-head form, so the line's bytes match the raw-derived line on the measured shapes.
  • A pre-existing residue of the shared predicate, for both faces: a dump whose statement opens with a verb outside the predicate's list (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 raw execute command 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

claude added 9 commits October 2, 2026 01:59
…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>
…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 22 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/duplicate-record-error.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via resolveSecretField (symbol, a method of class ObjectQL))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/duplicate-record-error.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 222ecc27f9050857d4620e7010d8b85f9d35e378 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2b3e3eb671cf020c872d32c817de8bc12da0c476 — the merge of head fd19d3661bcd7a4ccf2ede82eaa1e5a74e59919c into base 222ecc27f9050857d4620e7010d8b85f9d35e378, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 222ecc27f9050857d4620e7010d8b85f9d35e378 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fd19d3661bcd7a4ccf2ede82eaa1e5a74e59919c
Local-runs: none

① Derived judgments

Inputs: 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 c7396f1a99 (the git three-dot diff equals the API diff: 10 files, +1490/-83), head sources through git show, and the head's check-runs last. Classes and positions only: no statement, value, sentinel or log line is quoted here.

The boundary helper (packages/objectql/src/driver-fault-redaction.ts :889 redactPropagatedDriverFault) is RIGHT.

  • One rule, no copy. Both faces split the dump through the same splitDriverDump (:738), the same statementCut (:979: the last separator, or the last measured head, so a value that contains the separator can only over-redact, never expose), the same redactDiagnosticValues (:1009) and the same redactStack (:1067: the header is rebuilt from the first frame line, so a multi-line statement cannot survive in the stack). The log face redactStatementFromMessage (:711) was refactored onto splitDriverDump and is equivalent to its base form by inspection.
  • Each per-shape cut is right. better-sqlite3: message and stack, its only carriers. node-postgres: message and stack; detail, where a key-shaped line keeps its identifier half so uniqueViolationColumn still names the column and any other line is replaced whole; where and internalQuery replaced whole; the three pg-generic names gated on severity. mysql2: sql to kind-plus-marker, sqlMessage through the measured value templates. The property rules run regardless of the leak predicate, so on a shape whose message is not recognised the properties are still cut; only message and stack depend on recognition (see ③). The cause chain is cut to depth 4 (:859), the depth the shared predicates read. Nothing statement-bearing is left on any measured shape: hint, the pg identifier fields, code, errno and sqlState are value-free as kept.
  • The copy keeps the prototype and every own key (symbol-keyed and non-enumerable included, enumerability preserved), is a native Error, never mutates the input, returns the same reference when nothing is cut, and is idempotent on its own output. All pinned in driver-fault-boundary-redaction.test.ts across 15 shapes.
  • driver-sql's own envelopes (packages/drivers/driver-sql/src/sql-driver.ts :1061, :1126, :1165, :1307) attach code, status, a non-enumerable cause and symbol-keyed flags only; there is no own sql, bindings or query property there for the helper to miss.

The sites are RIGHT; no engine exit is missed. Verified on head engine.ts by placing every driver call inside an executor closure: find :11878 in [:11862 to :11941]; findOne :12154 in [:12135 to :12209]; insert :13508, :13511 and :13525 (the autonumber-resync creates live in createWithAutonumberResync :6404, called only from :13525) in [:12745 to :13694]; update :14355 and :14478 (the prior reads), :15000 and :15297 in [:14022 to :15464]; delete :16660, :16800, :16853 and :16883 in [:16516 to :16986]; count :17030 in [:17027 to :17036]; aggregate :17507 and :17551 in [:17452 to :17553]; insertMany :13727 delegates to insert. The seam's catch is :5166 to :5177. Outside the seam every driver call is wrapped or classified as the PR's table says: resolveSecretField :9052, resolveInternalField :9148, execute :17616 to :17618 (the await sits inside the try), ObjectQL.transaction :17737, ScopedContext.transaction :18632. BEGIN, COMMIT and ROLLBACK at :17714 and :18767 to :18804 bind no caller value, and driver-sql declares no deferrable constraint, so a commit cannot surface a row. Connect and disconnect (:9589, :10469) and the DDL and introspection paths are outside the ruling's class. The "already safe" rows hold: SummaryRecomputeError failures and insertMany row outcomes exit through the seam, both pinned. The one other driver call in the package, lifecycle/lifecycle-service.ts :899 count, binds nothing. The getDriverForObject out-of-scope row is right as an engine-boundary judgment; its one consequence is flagged in ③.

The engine's WARN lines are CONFIRMED unchanged: :13690, :15460 and :16979 still pass redactBoundStatement(raw) to writeFailureLogMeta inside the executor, before the seam's catch; the control suite pins the meta equal to the raw-derived line on 3 doors by 5 shapes and spells the logged message out for three dialects.

REST answers are CONFIRMED: mapDataError status, code and field are pinned equal raw-versus-cut on all 15 shapes and on the three envelope cases; every shared classifier (looksLikeInternalErrorLeak, isUniqueViolationError, uniqueViolationColumn, isMissingTableError) is pinned equal too; rest-duplicate-record-arm.test.ts keeps its 409, DUPLICATE_RECORD and field answer unchanged.

DuplicateRecordError.cause is RIGHT: field is computed in the engine from the raw error before the seam, the envelope's own fields are carried, and the cut cause keeps its class and code, pinned over sqlite, pg and mysql. duplicate-record-error.ts :51 to :57 is a doc edit only.

The re-judged pins are RIGHT; nothing is weakened beyond the overturned identity contract. The two driver-fault-redaction.test.ts pins now assert both sentinels absent from message and stack, the diagnostic and code kept, and the 400 INVALID_FIELD answer, which is stronger than before. The insert and update suites replace raw identity with prototype, cut-message and code equality through expectDriverErrorAsItLeaves; the two nested-envelope pins keep the one-envelope-deep protection (instanceof, field, cause not an envelope); the update statement regex flips from must-match to must-not-match with an exact cut form. Counted on the diff: 8 assertion sites in insert, 9 in update, 2 pins in the redaction suite, consistent with the seat's correction of the PR body.

The auth-library carrier pin (packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts) PROVES the ruling's pin: a synthetic sentinel bound through sign-up and update-user against a database-enforced unique index; every console carrier rendered the way console renders it plus hidden-field inspect at depth 8; the error objects' properties inspected the same way; the control keeps class, code, cause.code and the diagnostic, and the engine WARN carries the marker; the HTTP bodies are checked. SQLite always runs; pg and mysql are describe.skipIf on OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL with a named skip, each in its own schema or database. @objectstack/driver-sql and @objectstack/objectql are already devDependencies of plugin-auth, with five sibling tests as precedent. No plugin-auth source edit, as declared on #21118.

② Semver level

.changeset/21274-driver-fault-boundary-redaction.md: @objectstack/objectql patch, Clause-②: no. RIGHT. redactPropagatedDriverFault is exported from its module only; neither published entry (src/index.ts, src/core.ts; package.json exports . and ./core) re-exports it, so no public export is added and the minor precedent does not apply. The prose is accurate against the diff (the site list, the cut and kept sets, the identity note on cause, the import-row note) and holds no value: it names the marker and property names only. One wording note, not a defect: "every REST answer keeps its status, code and field" is a claim over the measured set (26 raised errors, 15 pinned shapes) stated as universal.

③ Boundary flags

The 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 (STATEMENT_KIND :755, captured by alternation so no other text can ride along): the operation kind, which the door the caller invoked and the error code already disclose; it carries no table, no column and no value, and the shared predicate recognises a dump by exactly that token. In substance the bullet is met. In letter the propagated message is not identical to the log line (it leads with the kind where the log line appends the marker), so "the same redaction" holds for the cut and not for the presentation. B's measured cost (6 of 26 answers relabelled, the SQLite field lost) contradicts the ruling's third bullet, and C grows a list the predicate's header rules frozen. The seat's answer A stands; the maintainer may overturn it at the stated cost.

Deviations: accepted as recorded in 5946136544. duplicate-record-error.ts is a doc comment only; the rest test change is one precondition re-judged in the ruling's direction with REST source untouched, declared on #6024; the live databases under /tmp are reported stopped by recorded PID and removed, which a diff cannot verify, and the stop-first rule the seat restated applies to the next card.

The out-of-scope note needs a card. The residue is real and read-only: looksLikeInternalErrorLeak (packages/types/src/error-leak.ts :226 to :238) recognises a dump by four leading verbs or a dialect limb, so a raw execute (:17616) whose command opens with another verb and whose diagnostic matches no limb is cut on neither face. It is bounded: the property cuts run regardless, so the residue is message and stack only, through the execute door only, because the engine composes its own statements with the recognised verbs. It is still a contract gap inside the ruling's class with a named position, and "the next PR that touches the shared leak predicate" is an unscheduled carrier; Prime Directive #10 says file it. One shape for the fix: the execute door knows it bound a statement, so its cut need not consult the predicate. Not a FAIL condition here: pre-existing on both faces, and the measured path is closed.

One further read-only finding, outside the ruling's class, for a card. packages/objectql/src/lifecycle/lifecycle-service.ts archiveObject (:1236) reaches the cold store directly through getDriverForObject with row data bound (cold.upsert :1405, hot.find :1373); a driver fault there never crosses a door, and the sweep's caller writes the error's message into the report and a warn line (:683 to :686). Same disclosure class, different route. No auth object declares an archive policy at this head, so it is not p1. The PR's out-of-scope row is right for the engine boundary; the consumer bypassing it is the position to file.

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 Test Core rollup was still in_progress; it is no longer. No run on the head failed, was cancelled or timed out; nothing is still queued; the combined status is success.

Implemented-by: claude/issue-21274-driver-fault-boundary-redaction
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 05:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 05:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 04f0cc4 Oct 2, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21274-driver-fault-boundary-redaction branch October 2, 2026 06:12
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants