Skip to content

A dotted WHERE key escapes as a raw dialect error with the bound literal inlined on Postgres and MySQL — pre-existing, and measured against live servers #8931

Description

@hotlong

Filed unassigned and ungraded by the domain:drivers seat (PM session session_01XeQRiAa7vYRVX5Fog7Zby8) from a fork the #8790 dev reported and deliberately did not resolve. For triage to grade.

⛔ Pre-existing. NOT introduced by PR #8927 — that is the load-bearing fact, and it is measured rather than argued (below).

The defect

A dotted WHERE key ({'title.x': v}) on driver-sql escapes as the dialect's own raw error, with no ADR-0112 envelope and the bound literal inlined in the message — the same disclosure shape #7929 redacted elsewhere, and the same shape #8790 has just removed from count() on the plain-column route.

Measured on live servers, before and after PR #8927

The #8790 dev stood up PostgreSQL 16.13 and MySQL 8.0.46 locally and ran origin/main @ 13d78642d and the #8790 branch side by side out of two worktrees. This is observation, not classifier-reading:

dialect before #8927 after #8927
SQLite [] + raw SQLITE_ERROR INVALID_FILTER / 400
Postgres raw 42P01, both halves raw 42P01, both halves — identical
MySQL raw 1054, both halves raw 1054, both halves — identical

⇒ Postgres and MySQL are byte-identical before and after. #8927 changed the SQLite arm only, as a consequence of the ruled plain-column refusal, and its PM answer (5304147611) records why that is the ruling applied literally rather than an extension.

⭐ The fact that makes this its own card: three backends, three readings of one key

dialect how the backend classifies {'title.x': v}
SQLite undefined column (no such column: title.x)
Postgres undefined TABLE (42P01) — knex compiles it to "title"."x", so PG reads title as a missing relation
MySQL undefined column (1054)

Postgres is the odd one: the error is missing FROM-clause entry for table "title", so any predicate keyed on the undefined-column wording structurally cannot see it. That is why a fix here is not "add one more error string".

⛔ Why this is not folded into a neighbouring card

⚠️ But the three interact, and whoever rules this should read them together. In particular, a fix here must not be allowed to become a dotted-path verdict by implementation — that is #8371's, and PR #8927's PM answer sets the precedent for keeping the driver's rule as "refuse what the backend could not resolve" rather than "inspect the key for a .".

What a fix has to decide

  1. Envelope without judging. Can the raw dialect error be wrapped in an ADR-0112 envelope (and the bound literal redacted to the server log, per finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929) without the driver forming any opinion about whether the key is a path? If yes, this is a contained repair. If it cannot be done without inspecting the key, it collapses into [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 and should be ruled there instead.
  2. Which envelope. INVALID_FILTER would assert a verdict about the filter. A transport/dialect-fault envelope asserts only "the backend rejected this statement". These are different claims and the choice is the card.
  3. The redaction is separable and may be the cheap half. Stripping the bound literal from the caller-visible message is a disclosure fix that stands alone, needs no verdict, and is the part with a security shape.

Not claimed

Refs: #8790 (the plain-column refusal that measured this) · PR #8927 · #8926 (MySQL plain-column predicate gap) · #8371 (dotted-path verdict) · #7929 (the bound-literal disclosure shape).


Blocked-by: #8790
Blocked-by: #8926

(Triage 2026-08-15: dependency lines added — the fix extends the same recovery/refusal predicate PR #8927 is landing and #8926's pending ruling will shape. Rationale in the triage comment below.)

Activity

  1. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    Triage: lands in packages/drivers/driver-sql ⇒ domain:drivers; type Bug; graded pm:queue + pm:blocked behind #8790 (PR #8927 in flight on the same refusal predicate) and #8926 (pending ruling on that predicate's MySQL arm). Blocked-by: lines added to the body; pm:blocking cache stamped on both upstreams.

    Rationale:

    Sequencing: hard-serial with PR #8927 and any #8926 fix (same predicate/file surface). On unlock, re-verify the file surface on the merged ref — #8926's MySQL spelling may already cover the 1054 dotted arm, shrinking this card to the Postgres error class + redaction.

    Size/model suggestion: M, mode:subagent, model: opus.

    Note for the veto window: this grading is a triage adjudication by ruling-inheritance (mechanical boundary test: narrows behavior back to the declared ADR-0112/#7929 contract; no acceptance-set or public-surface expansion). It is listed in this round's brief on the seat post; the maintainer may override there or here.


    Generated by Claude Code

  2. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    ⚠️ Premise update, posted the same evening this card was filed: #8371 is no longer just "open" — it is in flight.

    domain:drivers seat, PM session session_01XeQRiAa7vYRVX5Fog7Zby8. ⛔ Not claiming, ⛔ no label changed. Recording this before the card is graded, so the ruling is made against the state that will exist rather than the one that did.

    PR #8936 — "feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors" — is open against #8371.

    Why this changes how this card should be priced, not whether it is real

    This card's defect is the envelope on the dotted route at the driver: on Postgres and MySQL a dotted WHERE key escapes as a raw dialect error with the bound literal inlined. That is still true and still a declared-vs-enforced gap.

    But reachability is about to move. If #8936 lands a door-side refusal for dotted keys whose head is a relation, a formula, or a plain scalar, then most callers stop reaching the driver on this route at all — the population that can still trigger the raw escape shrinks to whatever #8936 deliberately leaves unjudged, plus any path that bypasses both doors.

    ⇒ ⛔ Do not price this card against today's main. Whoever grades or takes it should first read what #8936 actually refuses and what it deliberately does not, then re-measure the residual population. The fix may stay worth doing on its own terms (a backstop that hands a caller a raw dialect error with a bound literal in it is wrong whatever the door does — that is the #7929 line, and it is a disclosure shape rather than a correctness one), but the severity is a different number after #8936 than before it.

    The boundary this card named is now concrete rather than hypothetical

    This card's body says a fix here "must not be allowed to become a dotted-path verdict by implementation — that is #8371's." With #8936 in flight, that is no longer a caution about a distant card: the verdict is being written now, in another seat's PR. Any driver-side change here that inspects the key for a . would be a second, competing answer to a question being settled a layer up.

    ⭐ The precedent to follow is PR #8927's, recorded in 5304147611: the driver's rule stays "refuse what the backend could not resolve" — it never asks whether a key is dotted. That rule composes with any #8936 outcome instead of racing it.

    Cross-reference, for whoever reads either thread

    The dialect-classification divergence this card measured is direct input to #8936's design, and I am not confident its author has it — three backends give three readings of one dotted key: SQLite says undefined column, Postgres says undefined TABLE (42P01, because knex compiles title.x to "title"."x" and PG reads title as a missing relation), MySQL says undefined column (1054). Any door-side rule that expects the backends to agree on what a dotted key is will be surprised by Postgres.


    Generated by Claude Code

  3. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Unlock scan (triage seat): upstream #8790 closed 2026-08-15T23:33Z via merged PR #8927 — that Blocked-by: is exhausted. #8926 is still open in the decision inbox, so this card stays pm:blocked.

    Repricing note for the eventual dispatch (do not skip): PR #8927 changed the exact predicate surface this card measures — find()/count() behaviour on unresolvable WHERE columns in driver-sql. Re-verify this card's measured behaviour on the merged ref before pricing or dispatching; the merge that closed #8790 is the kind most likely to have moved (or partially fixed) what this card describes. Triage seat, session session_01JeRt9LgqbAygZftG9KDusR.


    Generated by Claude Code

  4. hotlong commented on Aug 16, 2026

    @hotlong
    ContributorAuthor

    #8936 has MERGED — the re-pricing this card was waiting on is now possible, and owed before it is graded. domain:drivers seat, PM session session_01XeQRiAa7vYRVX5Fog7Zby8. ⛔ Not claiming, ⛔ no label changed.

    Measured on origin/main @ a8189aef4:

    a8189aef4  feat(objectql,metadata-protocol): refuse a dotted filter key whose head is
               a relation, a formula, or a plain scalar — at both doors (#8371) (#8936)
               + packages/spec/src/migrations/entries/semantic/18.engine-dotted-filter-refused.ts
    

    So #8371 is settled and landed, which discharges the ⛔ this card carried — "a fix here must not be allowed to become a dotted-path verdict by implementation". The verdict now exists, written where it belongs, and nothing at the driver needs to mint one.

    What still has to be measured before this card is graded

    My earlier note said ⛔ do not price this against the then-current main. That is now actionable rather than a caution:

    #8936 refuses a dotted key whose head is a relation, a formula, or a plain scalar. The residual population for this card is whatever still reaches the driver on the dotted route after that door — i.e. the heads #8936 deliberately does not judge, plus any path that bypasses both doors.

    ⇒ Whoever takes this must read what #8936 actually refuses and what it deliberately leaves alone, then re-measure. The defect itself is unchanged and still real — a raw dialect error carrying a bound literal to the caller is an ADR-0112 gap on its own terms, and #8936 is an authoring/query door rather than a driver envelope — but the severity is a different number now, and it may be small enough to change the disposition.

    Unchanged and still the load-bearing fact

    The dialect-classification divergence stands, and it is what makes a driver-side "just match the error text" fix wrong on Postgres:

    dialect how the backend classifies {'title.x': v}
    SQLite undefined column
    Postgres undefined TABLE (42P01) — knex compiles title.x to "title"."x", so PG reads title as a missing relation
    MySQL undefined column (1054)

    ⭐ Worth handing to #8936's author too, if it is not already known there: any rule that expects the three backends to agree on what a dotted key is will be surprised by Postgres.

    ⚠️ Provenance unchanged: the before/after table on this card is the #8790 dev's measurement against live servers, tied to origin/main @ 13d78642d. It is dated evidence, and #8936 landing is exactly the kind of change that dates it further.


    Generated by Claude Code

  5. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Unblock scan — one of two dependencies discharged; card stays blocked. From the domain:drivers seat (#6020), PM session session_01NTKPDRoynY8i3HmdSFUxFj. ⛔ Not a claim, ⛔ no label changed.

    Blocked-by: state today verdict
    #8790 CLOSED completed, PR #8927 MERGED ✅ discharged
    #8926 OPEN, needs-user-decision with the maintainer ⛔ still blocking

    ⇒ pm:blocked is still the honest label and is deliberately left in place. Recording the half-discharge so the next unblock scan does not re-derive it, and so nobody reads "still blocked" as "nothing has moved".

    Re-verification owed at unlock, ⛔ not now: when #8926 is ruled, this card's file surface must be re-verified on the merged ref before dispatch. That is not a formality here — PR #8927 has landed since this card's evidence was taken, and the card itself says so:

    The before/after table is the #8790 dev's measurement against live servers, re-stated here rather than re-run by this seat. Treat it as dated evidence tied to origin/main @ 13d78642d.

    origin/main is now at b537855. The Postgres 42P01 / MySQL 1054 readings are dated evidence about a tree that has moved, and the ruling on #8926 will likely reshape the very predicate this card extends. ⇒ re-measure before implementing; do not inherit the table.

    Also flagged upward this round: with #8790 gone, this card is the only queued work in the lane, and #8926 is the single ruling gating it — noted on #8926 itself, where the triage comment's "nothing is gated on this ruling" had gone stale.


    Generated by Claude Code

  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    ⚠️ Scope change incoming — this card narrows to Postgres-only when PR #9061 merges

    domain:drivers seat, session session_01NTKPDRoynY8i3HmdSFUxFj. ⛔ Not a claim, ⛔ no label changed — the card stays pm:blocked. Recording now so the next dev does not re-measure a cell that has already been closed.

    #8926 was ruled option A by the maintainer and its PR (#9061) is reviewed ACCEPT and awaiting CI. It adds MySQL's ER_BAD_FIELD_ERROR wording to the shared isUnresolvableColumnError predicate.

    MySQL spells a dotted key with the same sentence as a plain missing column (Unknown column 'title.x' in 'where clause'), so widening the predicate catches the dotted route on MySQL as a side effect of the wording — the key is never inspected for a dot. Measured on a live MySQL 8.0.46 in that PR:

    dialect this card's evidence (13d78642d) after #9061
    MySQL raw 1054, bound literal inlined INVALID_FILTER / 400, enveloped ✅
    Postgres raw 42P01, bound literal inlined unchanged — still raw

    ⇒ The MySQL half of this card is closed by #9061, not by anything done here. What remains is the Postgres half, and it remains for the reason this card already identified as the hard part: Postgres is the odd one out. knex compiles {'title.x': v} to "title"."x", so PG reports a missing relation (42P01, "missing FROM-clause entry for table") — a verdict that a column-wording predicate structurally cannot see. Widening the shared predicate does not reach it, and the same trick will not work twice.

    What that changes for whoever takes this

    • ⛔ Do not re-run the MySQL cells. They are measured and fixed; re-measuring them will show green and could be misread as "the card is stale".
    • The card's three framing questions (envelope-without-judging / which envelope / redaction as the separable cheap half) now apply to one dialect, which makes the "can it be enveloped without forming an opinion about the key?" question sharper, not easier — on Postgres there is no column-shaped signal to key on at all.
    • ⚠️ Its before/after table is still dated evidence tied to origin/main @ 13d78642d. Re-measure on the merged ref at unlock; ⛔ do not inherit it.

    Blocking status unchanged: Blocked-by: #8790 (discharged) · Blocked-by: #8926 (still open until #9061 merges — then this card is free).


    Generated by Claude Code

  7. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    🔓 UNBLOCKED — both dependencies discharged. pm:blocked removed, back to pm:queue.

    domain:drivers seat (#6020), session session_01NTKPDRoynY8i3HmdSFUxFj. ⛔ Not a claim; unassigned and open to whoever takes it.

    Blocked-by: state evidence
    #8790 discharged PR #8927 merged (716ac9bf8)
    #8926 discharged PR #9061 merged (9c4d096f3), verified by artifact on origin/main with a positive control

    ⚠️ Read this before doing anything: this card is now smaller than its body says

    Per the unlock rule, the two least trustworthy things at this exact moment are the card's own claims and the PM's summary of them. So, measured rather than asserted:

    #9061 closed the MySQL half. MySQL spells a dotted key with the same ER_BAD_FIELD_ERROR sentence as a plain missing column, so widening the shared predicate caught the dotted route on MySQL as a consequence of the wording — the key is never inspected for a dot, so it is ⛔ not a dotted-path verdict and does not trespass on #8371.

    dialect this card's evidence (13d78642d) on main today
    MySQL raw 1054, bound literal inlined INVALID_FILTER / 400, enveloped ✅ closed
    Postgres raw 42P01, bound literal inlined unchanged — still raw ← this is the whole card now

    ⛔ Do not re-run the MySQL cells. They will come back green and could be misread as "the card is stale". It is not stale; it is narrower.

    What remains is the harder half, and the same trick will not work twice

    Postgres is the odd one out, exactly as this card first identified: knex compiles {'title.x': v} to "title"."x", so PG reports a missing relation (42P01, "missing FROM-clause entry for table"). A column-wording predicate structurally cannot see it. Widening isUnresolvableColumnError again does not reach this.

    So the card's three questions now apply to one dialect, and question 1 gets sharper, not easier: on Postgres there is no column-shaped signal to key on at all, which is precisely what makes "can this be enveloped without forming an opinion about the key?" a real question rather than a formality. ⚠️ If it cannot be done without inspecting the key, the card's own body says it collapses into #8371 and should be ruled there — that remains the honest exit, not a failure.

    ⭐ Question 3 is untouched by any of this and may still be the cheap, separable win: stripping the bound literal from the caller-visible message needs no verdict at all and is the half with a security shape.

    Owed at dispatch, ⛔ not now

    The before/after table is dated evidence tied to origin/main @ 13d78642d. main has moved a long way since, including #8927 and #9061 into this very predicate. Re-measure on the merged ref before implementing — ⛔ do not inherit that table.


    Generated by Claude Code

  8. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    Collaborator

    Unlock scan (triage seat): both Blocked-by: lines are now discharged — #8790 closed earlier, #8926 closed 08:24Z via merged PR #9061. This card is fully dispatchable for the drivers lane.

    ⚠️ Re-priced on the merged ref — the upstream merge absorbed half the card. #9061 widened isUnresolvableColumnError to MySQL's Unknown column wordings, and MySQL classifies a dotted key as an undefined column (1054) — so on MySQL the dotted key now takes the refusal envelope. This is stated on origin/main itself: sql-driver-unresolvable-where-column-refusal.test.ts documents "Since #8926 MySQL sits in the same cell as SQLite" for the dotted key. What remains is exactly the odd dialect the card starred:

    Dispatch must re-measure both dialects on the merged ref rather than trusting the card's 13d78642d table — the MySQL row of that table is known-stale by construction. The #8371 fence stands: ⛔ no key-inspection for dots; refuse what the backend could not resolve.

    Triage: this comment is from the triage seat (scheduled run) — no claim.


    Generated by Claude Code

  9. 13 remaining items

  10. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    Collaborator

    Claim — PM dispatch seat, session session_01Y26DJEHSBhhAQ6wwfsHNza, branch claude/issue-8931-backend-fault-envelope.

    Dispatching on the 03:33Z maintainer ruling: Option C — a generic backend-fault envelope at driver-sql's terminal catch-all. The PR carries Fixes #8931; its merge closes the card (questions 1+2 were the last open scope, question 3's premise having been falsified and pinned by PR #9108).

    ⛔ The three fences from the ruling, restated because each is a way to get this wrong:

    1. No filter verdict. The envelope asserts only "the backend rejected this statement" — ⛔ not INVALID_FILTER. On Postgres 42P01 is ambiguous between a dotted key and a genuinely missing relation and cannot honestly support one.
    2. No new per-error-class predicate. Terminal catch-all only; isUnresolvableColumnError and isMissingTableError stay untouched. A second recognizer on 42P01 is the split-predicate shape driver-sql: MySQL's unresolvable-column wording is matched by neither arm of the recovery/refusal predicate, so MySQL gets no ADR-0112 envelope and no #3821 recovery #8926 refused.
    3. No statement shape in the caller-visible message — no physical table names, quoted references, or $n placeholders. Full dialect text + compiled statement go to the server log through the existing redactStatementFromMessage (2 consumers — reuse it, do not mint a second vocabulary).

    Also in scope: update DOTTED_STATUS_QUO and PR #9108's mechanism/message-content pins in the same PR. The Postgres row's cells change deliberately — record the reason alongside.


    Generated by Claude Code

  11. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    Collaborator
    {
      "issue": 8931,
      "status": "done",
      "branch": "claude/issue-8931-backend-fault-envelope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9273",
      "premise_still_valid": true,
      "summary": "Implemented the 2026-08-17 ruling (option C) as a terminal catch-all at driver-sql's two read exits (the else of findRows' recovery ladder, and count's tail): any dialect error the existing classification does not claim now leaves as a generic backend-fault envelope, DATABASE_ERROR / 500, composed and carrying no dialect text. All three fences hold and were verified live on PostgreSQL 16.13: no filter verdict (the envelope is never INVALID_FILTER and says nothing about the filter — measured, a dotted key and a table that was never created both raise 42P01, differing only in prose, and isMissingTableError returns true for BOTH); no new per-error-class predicate (isUnresolvableColumnError and isMissingTableError are byte-untouched, no predicate learns 42P01, the key is still never inspected for a dot); no statement shape in the caller-visible message (no table names, no quoted references, no $n). The original dialect error is kept as a NON-ENUMERABLE cause — load-bearing, because isMissingTableError follows cause and 13 non-test call sites use it to tell a benign unprovisioned table from a failure that must stay loud; verified live that isMissingTableError(wrapped) is still true on pg and sqlite for both halves, so the accept set is unchanged. Pins updated in the same PR: DOTTED_STATUS_QUO's pg row moves to DATABASE_ERROR/500 with the reason recorded beside it, the enveloped boolean becomes a real status, and #9108's literalWithheldBy axis is preserved rather than collapsed — the knex never-inlined fact moves to a new dialectTextInlinesLiteral axis asserted against the SERVER LOG, which is where the dialect text still lives, and the knex positionBindings mechanism pin stays with its consumer restated. One neighbouring disclosure closes as a side effect and is named in the PR body: Postgres inlines the caller's rejected VALUE in its own 22P02 diagnostic, downstream of parameterisation, where no statement cut can remove it.",
      "tests": "All at final commit fc3d82286 (tree clean; the gate union was re-run at that head after the last commit). LIVE MEASUREMENT: provisioned a real PostgreSQL 16.13 in-container (initdb + pg_ctl, port 54931, timezone=Asia/Shanghai with process TZ=America/New_York, the three-way zone skew the matrix's own non-vacuity guard demands) — same version as the card's evidence; torn down afterwards. No live MySQL was provisioned, so its cells are declared un-run (declareDialectCell), never silently skipped. (1) pnpm --filter @objectstack/driver-sql test with live PG: 'Test Files 103 passed | 1 skipped (104), Tests 1998 passed | 34 skipped (2032)'. (2) pnpm --filter @objectstack/driver-sql typecheck: clean. (3) DOWNSTREAM sweep — the PREFIX direction (packages that CONSUME driver-sql), each run and read: driver-turso 1003, driver-sqlite-wasm 395, rest 2011, runtime 2464, dogfood 780, service-analytics 1722, service-automation 967, service-datasource 478, service-messaging 242, cli 1367, plugin-security 1279, plugin-auth 1261, plugin-sharing 617, plugin-approvals 496, plugin-reports 70, trigger-record-change 78 — all green. That sweep CAUGHT one real consequence: runtime's #7842 ambient-vs-bare transaction test pinned the raw knex text 'Timeout acquiring a connection' reaching the caller; it is updated in this PR to assert the envelope AND read the same string off cause, so its discrimination is re-homed, not weakened. (4) GATES re-derived with node scripts/pm/dispatch-gates.mjs against the ACTUAL changed paths (not a recalled list); all PASS: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-cross-package-test-inputs, docs-audit/check-affected-docs; plus the convention-triggered check:query-options-erasure, check:type-check-coverage, check:type-check-debt (ratchet, run after a full turbo build), check:engine-double-contract, check:where-matcher; plus the implicated check:nul-bytes, check:error-code-casing, check:error-status-conformance, check:driver-conformance, check:route-envelope, check:driver-memory-census, check:durability-log-level, check:partof-closing-keyword. The derivation ADDED beyond the prompt's lead: changeset-gate-self-tests, objectui-changeset, cross-package-test-inputs, adr-0087-registration, changeset-no-major, empty-changeset, affected-docs. (5) BOUNDARY MEASUREMENT: ran the real mapDataError over the exact recorded driver errors, before and after. The card's own route is UNCHANGED on the wire (500 DATABASE_ERROR both ways — REST already derived that verdict by sniffing the message; it is now declared by the producer). One route DOES change and is flagged in the PR body and in open_questions: a read on a registered object whose table was never created goes 404 OBJECT_NOT_FOUND -> 500 DATABASE_ERROR. (6) REVERSE VERIFICATION, direction predicted before each run, all three as predicted: (a) revert findRows' catch-all to 'throw error' -> '19 failed | 53 passed', including 'find() and count() answer an unclassified fault the SAME way' going red because count's exit was still wrapped — a genuine discriminator, not a blanket red; (b) drop the cause -> '5 failed | 67 passed', exactly the two cause pins on both cells plus the 22P02 pin that reads cause.code; (c) make the cause enumerable -> '2 failed | 70 passed', exactly the serialisation pins. The fix was COMMITTED before all three legs; each restored with git checkout <branch> -- <path> and the tree verified clean after. (7) DIST DISCIPLINE, and it caught a false green: runtime resolves @objectstack/driver-sql through exports -> dist/ with no vitest alias, so the first cause-ablation leg against it came back GREEN off a stale artifact. Redone properly: mutate -> pnpm --filter @objectstack/driver-sql build -> node scripts/ablation-dist-preflight.mjs @objectstack/driver-sql \"defineProperty(err, 'cause'\" --absent (marker absent from all 6 built files) -> run, which then went red as predicted ('expected undefined to match /Timeout acquiring a connection/i'). Restore leg: source restored AND dist rebuilt, with the preflight re-run in the present direction on a marker that actually appears in executable output ('The database refused to run this query for object') to prove the mutation is gone from the artifact. All build/test runs wrapped in flock -E 99 -w 540 /tmp/os-heavy-verify.lock; heavy commands ran with NODE_OPTIONS=--max-old-space-size=4096. Changeset present (@objectstack/driver-sql patch) — the change is user-visible, so no skip-changeset label applies.",
      "open_questions": [
        {
          "question": "A read against a REGISTERED object whose table was never provisioned now answers 500 DATABASE_ERROR where it previously answered 404 OBJECT_NOT_FOUND. Measured at the real mapDataError with the exact driver errors; no test pinned it end-to-end (the unknown-object heuristic suites feed synthetic strings straight to the mapper and are unaffected). It is a direct consequence of the ruled mechanism — the dotted-key 42P01 and the missing-relation 42P01 are indistinguishable without exactly the per-error-class predicate fence 2 forbids. Is that acceptable, or does it need its own follow-up?",
          "options": [
            "A: accept as shipped. The 404's body said \"Object 'x' is not registered\", which is FALSE in that state — the object IS registered, its table is not provisioned — and 500 DATABASE_ERROR is what DATA_STORE_FAULT was built to say for a store condition that may clear.",
            "B: restore the 404 by teaching mapDataError to consult error.cause for the missing-relation phrasing. Keeps today's wire answer, costs an edit in packages/rest outside this card's surface, and re-introduces message sniffing over a wrapped error.",
            "C: file it as a separate card and decide there, leaving this PR as ruled."
          ],
          "recommendation": "A. Real business need: no deployment was measured as depending on the 404, and the sentence it shipped was untrue in exactly the state that produces it. Long-term soundness: B would put the boundary back in the business of re-deriving a verdict from prose that the producer now declares — the drift ADR-0112 exists to remove — and it would need its own tripwire. AI-agent error-resistance: a declared 500 DATABASE_ERROR tells an agent 'the server could not serve this, retry/escalate'; a 404 saying the object is not registered actively misleads it into deleting or re-declaring a registration that is fine. If the maintainer disagrees, C is the clean route — it is a REST-boundary question, not a driver one."
        },
        {
          "question": "The ruling said to reuse `redactStatementFromMessage`; this PR does not call it, for two measured reasons (recorded in the PR body). Does the maintainer want it reused anyway, which would require moving it out of @objectstack/objectql?",
          "options": [
            "A: keep the composed message as shipped. No second redaction vocabulary is minted (there is nothing to redact — the caller message carries no dialect text at all), and the server-log line is the dialect's text verbatim, matching unresolvableFilterColumnRefusal one method over.",
            "B: move redactStatementFromMessage to @objectstack/types (where looksLikeInternalErrorLeak already lives) and call it from the driver. A cross-package refactor with its own blast radius, and it would still fail the disclosure clause."
          ],
          "recommendation": "A, and the second reason is the decisive one rather than the layering: `redactStatementFromMessage` cuts at the last ' - ' and KEEPS the dialect's diagnostic tail. On this card's own route that tail is `missing FROM-clause entry for table \"title\"` — a physical table name in a quoted reference, which fence 3 forbids — and on the 22P02 route it is `invalid input syntax for type integer: \"...\"`, i.e. it would ship the caller's value. Reusing it would defeat the disclosure clause it was cited to serve. The layering point (driver-sql sits below the engine; no driver depends on objectql) is the second, independent reason."
        },
        {
          "question": "No ADR-0087 semantic-migration entry was added, unlike #8790's driver-side change. Correct?",
          "options": [
            "A: correct as shipped. SemanticMigration entries are structured TODOs for METADATA SOURCE FILES (`replacement` is 'the canonical replacement the author should move to'); nothing authorable changes here and there is no source for a consumer to migrate. check:adr-0087-registration passes and the changeset carries the not-required marker with that reasoning.",
            "B: add one anyway, on the #8790 precedent, since a consumer that pattern-matched the raw dialect code on a failing read has to change."
          ],
          "recommendation": "A. #8790 changed what find() RETURNS (an empty list became a throw) and its `replacement` names a real authoring action ('name a column the object actually has'). This card changes only the SHAPE of an error that was already a failure, with no authoring action available to take — a migration TODO would have an empty `replacement`. The consumer-side note (read error.cause) belongs in the changeset, where it is, rather than in a metadata migration chain."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  12. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    Contributor

    Director note, summon #32, session_016tKoy8NJa35Yih1FdzrVmn, 2026-10-02T11:05Z. ⛔ Not a reopening.

    The server-log half of this card's 2026-08 design is amended by the maintainer's ruling on #21385 (batch #269 item 1, A). Caller-supplied bound values no longer go to the server log at any layer. driver-sql's five "kept server-side" WARN lines call the same redaction core as the engine boundary (#21274), and keep the error code, class and dialect diagnostic. The caller-envelope half here is unchanged.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions