Skip to content

fix(objectql): make the statement cut template-aware so a value containing " - " keeps the diagnostic's head (#9275) - #9358

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-9275-value-bearing-cut-template-head
Aug 17, 2026
Merged

fix(objectql): make the statement cut template-aware so a value containing " - " keeps the diagnostic's head (#9275)#9358
os-zhuang merged 2 commits into
mainfrom
claude/issue-9275-value-bearing-cut-template-head

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Fixes#9275

⚠️ Read this first — the dispatch brief and the maintainer ruling disagree, and I followed the ruling

The dispatch brief for this card says: "If the close requires changing the cut, STOP AND REPORT — that is a contract change and needs a tier judgement the PM has not made."

It does require changing the cut. But the tier judgement has been made, by the maintainer, and is recorded on the card itself — comment 5315738370, 2026-08-17 12:00, three hours before the dispatch:

Ruled: Option 1 — the cut becomes template-aware, and #8682's contract is amended honestly. […] The file's contract note is rewritten, not deleted […] Label flipped needs-user-decisionpm:queue in the same stroke.

The brief's stop-condition rests on the factual claim "a tier judgement I have not made", and that claim is falsified by the record — the card is in the dispatch pool because of that ruling, which also prescribes the test ("extend the #9274 probe with a canary containing -"). Stopping would have burned a round on a decision already taken in writing, so I implemented exactly what was ruled and nothing wider. Flagging it here rather than picking a side silently. If the PM intended the fence to outrank the ruling, close this and say so — the work is one commit plus its correction and costs nothing to drop.

Which contract this touches, stated plainly

#8682's cut contract — where the statement ends — not #8823/#9160's value list (which spans are values). That is the distinction the card was filed on, and it is the one this PR changes. The value list gains one row's tail, but that row is the #8823 mechanism, not the cut.

What was measured

Both live servers stood up locally with CI's own configuration (PostgreSQL 16.13 @ Asia/Shanghai, MySQL 8.0.46 @ +08:00) — the same method PR #9274 used, re-run at HEAD rather than inherited.

Non-vacuity control first. A canary without- (SENSITIVE-CANARY-9275) was driven through all 12 families before anything was asserted about the broken case. All 12 redact correctly and reproduce #9274's recorded phrasings verbatim — so the instrument answers on the known-good case, and a difference in the broken one is a measurement rather than an artifact.

Then the broken case, canary SENSITIVE-CANARY-9275 - 2026 - Q3, at HEAD, before any change:

pg 22P02 raised: insert into "t" ("age") values ($1)
- invalid input syntax for type integer: "…CANARY… - 2026 - Q3"
logged: Q3" [statement and bound values redacted]
pg 22007 logged: Q3" [statement and bound values redacted]
mysql 1292 logged: Q3' [statement and bound values redacted]

Q3 is the caller's data. The residue reproduces at HEAD; the card's premise stands.

⚠️ The population is THREE families, not two — a PM assumption falsified

The brief asked me to verify that the two anchor-less families are the whole population, and to move the count if a third had appeared. A third is there, and it was already in the file — it just is not anchor-less, so neither the card nor #9274 looked at it.

pg 22003 was left without a head-gone recovery on the reasoning that an out-of-range value is a NUMBER, and a number cannot contain -. Measured through the driver's own bind path — no raw SQL, no cast — that reasoning is false. Postgres' integer parser detects the OVERFLOW while scanning digits, before it rejects the trailing junk, so it echoes the caller's whole string back:

insert({ age: '99999999999 - 2026 - Q3' })
raised: … values ($1) - value "99999999999 - 2026 - Q3" is out of range for type integer
logged: Q3" is out of range for type integer ← same leak, different remedy

It keeps its right anchor, so it takes the #8823tail recovery and needs no cut change at all. Included here under the same-defect-class exemption (mechanical, form pinned by two sibling tail rows, same file, same gates) and named rather than slipped in.

What changed

  1. The cut is template-aware. When a separator stands immediately before a diagnostic head this file has measured, that separator is the true cut point whatever its position — the head survives and the value after it, - and all, is dropped whole by the template that owns it. Operators now get more diagnostic than before: invalid input syntax for type integer: [value redacted] […] instead of Q3" […].
  2. A tail row for pg 22003, per the measurement above.
  3. The contract note is amended, not deleted — as the ruling required. The old "never match a template before the cut" prohibition now states the steering it was avoiding, and the bound on it.

The trade, stated rather than buried: matching a template before the cut lets a hostile value steer where the cut lands. It is bounded to over-redaction, never exposure, by one invariant — a template may declare a head only if its value runs to end of message, so a cut landing inside a statement is swallowed whole by that template. A crafted value can suppress a real diagnostic; that cost has its own asserted case rather than being left to be discovered later.

Over-matching stayed the expensive direction. The six identifier-only families #9274 pinned are byte-identical before and after, on live servers and in fixtures.

Verification

All at 481c7e71d, the final commit, with both live servers wired as CI wires them.

  • Full objectql suite: 215 files, 3807 tests, all pass (50 in driver-fault-redaction.test.ts, up from 37).
  • Full driver-sql suite against both live servers (TZ=America/New_York, both URLs, OS_EXPECT_LIVE_DIALECT_MATRIX=1): 105 files, 2284 tests, all pass. The probe's live cells ran (verified by name, not by exit code): 17/17, positive control included.
  • Typecheck: both packages clean.
  • Gate union re-derived from the actual changed paths via scripts/pm/dispatch-gates.mjs, all pass at 481c7e71d: check:changeset-gate-self-tests, check:durability-log-level, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher, check:nul-bytes, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-engine-split-ratio, docs-audit/check-affected-docs, and the ratchet check:type-check-debt (--re-measure, 33 entries, 1926 raw errors, none above its recorded number) run on a built closure.

Reverse verification — four legs, each committed then ablated then restored

  1. Head-anchored cut removed → exactly the 6 head-anchored cases go red; all 44 pre-existing cases stay green, so Undeclared fields still reach the driver: beforeInsert hooks run and an auto-number is consumed before the request is refused, and the whole INSERT with its values is logged at ERROR (17.0.0 GA) #8682/The #8682 write-path redaction keeps a caller's VALUE in the log on MySQL — Duplicate entry 'acme@example.com' for key … survives the cut, measured through the shipped function #8823/objectql: the value-bearing-diagnostic list in driver-fault-redaction.ts has one entry and no way to notice a second is missing (observation) #9160 behaviour is provably unchanged.
  2. The 22003 tail row removed → exactly 1 red, quoting the live residue: expected 'Q3" is out of range for type integer …' not to contain 'Q3'.
  3. Cut takes the FIRST head instead of the LASTGREEN, against my prediction. See below.
  4. Probe canaries stripped of their separator → all 4 new probe cases go red against live servers, each quoting the server's real output; the other 13 stay green. So the new cases measure the separator effect specifically, not merely the presence of a canary.

Leg 3 came back green, and a claim in the file was wrong

I had written that the safety argument rested on two properties: the end-of-message invariant and taking the LAST matching head ("taking the first would leak"). Leg 3 ablated the second and all 50 cases stayed green.

The reason is mechanical: an end-of-message pattern matches only ONCE, from its earliest position, so lastMatch returns that first match and the output is identical wherever between two heads the cut landed. The invariant is the whole argument. I corrected the file and the misleading case name (second commit) rather than inventing a test that would make the claim look pinned — a plausible second line of defence that does not exist is worse than none, because the next reader will lean on it.

redactBoundStatement still has exactly 3 call sites. No TEST_DEBT entry raised, no domain:* label touched, no content/docs/releases/ edit.


Generated by Claude Code

…ining " - " keeps the diagnostic's head (#9275)
redactStatementFromMessage cuts at the LAST " - " because a bound value may
contain the separator and cutting at the first would leave a fragment standing.
When the value the DATABASE inlines into its own diagnostic also contains it,
that reasoning inverts: the cut lands inside the value, discards the template
head, and keeps a suffix of the caller's data.
Re-measured at HEAD on live PostgreSQL 16.13 / MySQL 8.0.46 with the canary
"SENSITIVE-CANARY-9275 - 2026 - Q3": pg 22P02/22007 logged `Q3"` and mysql
1292's column-less spelling logged `Q3'`.
The cut now asks first whether a separator stands immediately before a MEASURED
diagnostic head; if one does, that separator is the true cut point whatever its
position. Ruled by the maintainer on 2026-08-17 (Option 1), and the file's
"never match a template before the cut" note is amended rather than deleted: the
steering a hostile value can exert is stated, and bounded to over-redaction by
two properties -- only an end-of-message template may declare a head, and the
LAST matching head wins.
The re-measurement also moved the count from two families to three. pg 22003 was
left without a head-gone recovery on the reasoning that an out-of-range value is
a number and cannot contain " - ". Through the driver's own bind path that is
false: Postgres detects the overflow while scanning digits, before it rejects the
trailing junk, so it echoes the caller's whole string. It keeps its right anchor,
so it takes the #8823 tail recovery, not the new cut.
The six identifier-only families the live probe pins are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…at the ablation actually showed (#9275)
The note claimed two properties bounded the steering a hostile value can exert:
the end-of-message invariant, and taking the LAST matching head rather than the
first. Ablating the second -- cut changed to take the FIRST head -- left all 50
cases in the suite GREEN, so it is not load-bearing and the claim was wrong.
An end-of-message pattern matches only once, from its earliest position, so
lastMatch() returns that first match and the output is identical wherever
between two heads the cut landed. The invariant is the whole argument; last-head
is retained as a tie-break and is now labelled as one, in the file and in the
case whose name previously advertised the ordering it does not discriminate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 14 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 origin/mainpackageMentionDocs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 17, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 17, 2026 16:31
@os-zhuang
os-zhuang added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit 682b86bAug 17, 2026
30 of 31 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9275-value-bearing-cut-template-head branch August 17, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude