Skip to content

Commit 21995d7

Browse files
Elon Muskclaude
andauthored
fix(objectql): assert the redactor's end-of-message head invariant at module load (#9359) (#9986)
The invariant that bounds #9275's head-anchored statement cut to over-redaction -- only an end-of-message template may declare a head -- was held by prose plus one behavioural case over the current rows. It is now held by the type (head XOR tail) and by assertHeadBearingTemplatesAreEndAnchored(), called at module load over VALUE_BEARING_TEMPLATES. No redaction behaviour changes. Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM Co-authored-by: Claude <noreply@anthropic.com>
1 parent b735507 commit 21995d7

3 files changed

Lines changed: 299 additions & 11 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): the redactor's end-of-message head invariant becomes a load-time guard and a type, not a doc comment (#9359)
6+
7+
#9275 made the driver-fault statement cut **template-aware**: a separator standing
8+
immediately before a *measured* diagnostic head is the true cut point wherever it falls,
9+
so the head survives and the caller's value is dropped whole. That amendment is safe
10+
because of exactly one property:
11+
12+
> **Only an end-of-message template may declare a head.**
13+
14+
That property is what bounds a hostile value's influence to **over-redaction** — a value
15+
spelling a known head can suppress a real diagnostic and show a forged one, but it cannot
16+
make a value leak. Give a `head` to a family with a **right anchor** and the same cut keeps
17+
everything after that anchor, which on such a cut is statement, which is caller values.
18+
19+
Until now the invariant was held by **prose plus one behavioural case** that forges the
20+
heads the table declares *today*. Nothing stopped a future author adding a head-bearing
21+
row whose `whole` is not end-anchored — the single shape that turns the amendment into a
22+
leak surface.
23+
24+
**The argument for closing it structurally comes from this file's own history.** The head
25+
note once claimed leak-freedom rested on taking the LAST matching head as well as on
26+
end-of-message. Ablated: with the cut changed to take the FIRST head, all 50 cases in the
27+
suite stayed green — an end-of-message pattern matches only once, from its earliest
28+
position. A documented property about this very mechanism was wrong for weeks of reading
29+
and fell only to an ablation. A doc comment is not a guard.
30+
31+
The invariant is now held in two places a future author cannot write past:
32+
33+
- **The type.** `ValueBearingTemplate` is a union of `AnchoredTemplate` (`tail?`, and
34+
`head?: never`) and `EndOfMessageTemplate` (`head`, and `tail?: never`), so a row
35+
carrying both no longer compiles.
36+
- **`assertHeadBearingTemplatesAreEndAnchored()`**, called at module load over
37+
`VALUE_BEARING_TEMPLATES` — the `assertMetaUrlSpellingsAgree()` shape. For every row
38+
that declares a `head` it requires the `whole` to end with `$()`, to carry no `m` flag
39+
(under `m`, `$` is end of LINE, so an "end-anchored" template would stop at the first
40+
newline of a multi-line dump and leave the rest standing) and to have exactly two
41+
capture groups (`redactDiagnosticValues` reads `whole[1]` and `whole[2]` by index).
42+
43+
**No redaction behaviour changes.** The statement cut, the head set and the templates
44+
themselves are untouched; the guard only refuses tables that could not have been correct.
45+
The existing behavioural case that forges each head is kept — it is evidence, and the
46+
guard is additional rather than a replacement.
47+
48+
The guard is proved to FIRE rather than merely to exist: eight new cases drive each shape
49+
it rejects (right-anchored `whole` with a head, the `m` flag, a wrong group count, a bad
50+
row behind a good one) and pin that it does **not** fire on the shipped rows or on
51+
right-anchored rows that correctly take a `tail`. Reverse-verified both legs — a
52+
head-bearing right-anchored row added to the shipped table makes every test in the package
53+
fail at module load, and a row carrying `head` and `tail` together fails `tsc`.

packages/objectql/src/driver-fault-redaction.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@
2424

2525
import { describe, it, expect } from 'vitest';
2626
import { ObjectQL } from './engine.js';
27-
import { redactBoundStatement, redactStatementFromMessage } from './driver-fault-redaction.js';
27+
import {
28+
VALUE_BEARING_TEMPLATES,
29+
assertHeadBearingTemplatesAreEndAnchored,
30+
redactBoundStatement,
31+
redactStatementFromMessage,
32+
} from './driver-fault-redaction.js';
2833

2934
/** The canaries the card planted, kept verbatim so a leak is unmistakable. */
3035
const SECRET = 'SENSITIVE-CANARY-9f3a2b';
@@ -594,6 +599,97 @@ describe('#9275 — a value containing " - " no longer eats the template head',
594599
});
595600
});
596601

602+
describe('#9359 — the head invariant is a GUARD, not a note', () => {
603+
// ⭐ Why this block exists at all. The invariant "only an end-of-message
604+
// template may declare a head" is the WHOLE of the argument that #9275's
605+
// head-anchored cut over-redacts rather than leaks. Until this card it was
606+
// held by prose plus the behavioural case above, which forges the heads the
607+
// table declares TODAY — nothing stopped a future author adding a
608+
// head-bearing row with a right anchor, which is the single shape that turns
609+
// the amendment into a leak surface.
610+
//
611+
// ⛔ The cautionary precedent is in this file's own history: the head note
612+
// once claimed leak-freedom also rested on taking the LAST matching head, and
613+
// that claim was FALSE — ablating to first-match left all 50 cases green. A
614+
// documented property about this very mechanism read as true for weeks and
615+
// fell only to an ablation. So these cases do not assert that the guard is
616+
// present; they assert that it FIRES, on each shape it claims to reject.
617+
618+
/** A right-anchored `whole` — the shape that must never carry a `head`. */
619+
const RIGHT_ANCHORED = /(incorrect \w+ value:\s+)'[\s\S]*'(\s+for column\s+'[^']*')/gi;
620+
/** A well-formed end-of-message `whole`, for the cases isolating one fault. */
621+
const END_ANCHORED = /(truncated incorrect \w+ value:\s+)'[\s\S]*$()/gi;
622+
const SOME_HEAD = /truncated incorrect \w+ value:\s+'/gi;
623+
624+
it('does NOT fire on the shipped table — it must not be a false alarm on `main`', () => {
625+
// The zero. Its positive control is every case below: the same function,
626+
// called the same way, throws on a row the shipped table does not contain.
627+
// The module-load call is the same assertion, so importing this file at all
628+
// has already exercised it — this case only says so out loud.
629+
expect(() => assertHeadBearingTemplatesAreEndAnchored(VALUE_BEARING_TEMPLATES)).not.toThrow();
630+
});
631+
632+
it('the shipped table really does contain head-bearing rows — the guard has something to check', () => {
633+
// ⛔ Anti-vacuity of the case above: a guard that loops over zero
634+
// head-bearing rows also "does not throw". Two rows carry a `head` today
635+
// (mysql/1292, pg/22P02) and the rest take a `tail`.
636+
const headBearing = VALUE_BEARING_TEMPLATES.filter((t) => t.head !== undefined);
637+
expect(headBearing.map((t) => t.id)).toEqual([
638+
'mysql/1292 ER_TRUNCATED_WRONG_VALUE',
639+
'pg/22P02 invalid_text_representation',
640+
]);
641+
});
642+
643+
it('FIRES on a head-bearing row whose `whole` is right-anchored — the leak shape itself', () => {
644+
// This is the one way #9275's amendment can leak: the head-anchored cut
645+
// lands inside the statement, and a right anchor keeps everything after it.
646+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
647+
{ id: 'test/right-anchored-with-head', whole: RIGHT_ANCHORED, head: SOME_HEAD },
648+
])).toThrow(/does not end with/);
649+
});
650+
651+
it('FIRES on an end-anchored `whole` that carries the `m` flag — `$` would mean end of LINE', () => {
652+
// Spelled end-anchored, not end-anchored. Under `m` a multi-line dump stops
653+
// at the first newline and the remainder — statement — survives.
654+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
655+
{ id: 'test/multiline-flag', whole: /(truncated incorrect \w+ value:\s+)'[\s\S]*$()/gim, head: SOME_HEAD },
656+
])).toThrow(/end of LINE/);
657+
});
658+
659+
it('FIRES when the capture groups do not number exactly two', () => {
660+
// `redactDiagnosticValues` reads `whole[1]` and `whole[2]` by index, so a
661+
// third group silently reassigns what is kept after the value.
662+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
663+
{ id: 'test/three-groups', whole: /(a)(b)'[\s\S]*$()/gi, head: SOME_HEAD },
664+
])).toThrow(/capture group/);
665+
});
666+
667+
it('names the offending row, so the throw is actionable rather than a riddle', () => {
668+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
669+
{ id: 'test/right-anchored-with-head', whole: RIGHT_ANCHORED, head: SOME_HEAD },
670+
])).toThrow(/test\/right-anchored-with-head/);
671+
});
672+
673+
it('leaves ANCHORED rows alone — a right-anchored `whole` with no head is exactly correct', () => {
674+
// ⛔ The control that stops this guard from being "throw on anything that
675+
// is not end-anchored". Three of the five shipped families are right-
676+
// anchored on purpose; the invariant is about rows that declare a HEAD.
677+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
678+
{ id: 'test/right-anchored-no-head', whole: RIGHT_ANCHORED, tail: /'(\s+for column\s+'[^']*')/gi },
679+
])).not.toThrow();
680+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
681+
{ id: 'test/end-anchored-with-head', whole: END_ANCHORED, head: SOME_HEAD },
682+
])).not.toThrow();
683+
});
684+
685+
it('checks EVERY row, not just the first — a bad row cannot hide behind a good one', () => {
686+
expect(() => assertHeadBearingTemplatesAreEndAnchored([
687+
{ id: 'test/end-anchored-with-head', whole: END_ANCHORED, head: SOME_HEAD },
688+
{ id: 'test/right-anchored-with-head', whole: RIGHT_ANCHORED, head: SOME_HEAD },
689+
])).toThrow(/test\/right-anchored-with-head/);
690+
});
691+
});
692+
597693
describe('redactBoundStatement', () => {
598694
it('redacts `stack` too — the statement opened it a second time', () => {
599695
const original = new Error(BOUND_INSERT);

packages/objectql/src/driver-fault-redaction.ts

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@
190190
* that spells a known head makes the cut land inside the statement, at that
191191
* head — and ONE property, not several, makes what follows unleakable rather
192192
* than exposed: only a template whose value runs to END OF MESSAGE may declare
193-
* a `head` (the invariant on {@link ValueBearingTemplate.head}). Whatever the
193+
* a `head` (the invariant on {@link EndOfMessageTemplate.head}). Whatever the
194194
* head-anchored cut leaves — the rest of the statement it cut into included —
195195
* is consumed whole by that template's own `whole` pattern.
196196
*
@@ -224,6 +224,14 @@
224224
* values. That is the one way this amendment could leak, and the invariant
225225
* above is what forecloses it.
226226
*
227+
* [#9359] ⭐ And since the ablation recorded just above proves that a claim in
228+
* this very note about this very mechanism can read as true for weeks, the
229+
* invariant is no longer left to the note. It is held in two places that a
230+
* future author cannot write past: the TYPE ({@link AnchoredTemplate.head} is
231+
* `never`, so no row can carry a `head` and a `tail` at once) and
232+
* {@link assertHeadBearingTemplatesAreEndAnchored}, which throws at module load
233+
* on a head-bearing row whose `whole` is not end-anchored.
234+
*
227235
* A driver dump with no separator carries no statement to cut (`UNIQUE
228236
* constraint failed: sys_user.email`, `SQLITE_CONSTRAINT_NOTNULL: …`) and is
229237
* returned untouched — there is nothing there but the diagnostic already.
@@ -444,13 +452,38 @@ const MYSQL_TRUNCATED_INCORRECT_VALUE_HEAD = /truncated incorrect \w+ value:\s+'
444452
* drops everything before the anchor, because an anchor is evidence about the
445453
* value, not licence to assert which template printed it.
446454
*/
447-
interface ValueBearingTemplate {
455+
interface ValueBearingTemplateBase {
448456
/** Dialect and the server's own error code, as the probe raises it. */
449457
readonly id: string;
450-
/** Head + value + anchor. */
458+
/**
459+
* Head + value + anchor. Group 1 is kept before the value, group 2 after —
460+
* {@link redactDiagnosticValues} reads both by index, so every row needs
461+
* exactly two.
462+
*/
451463
readonly whole: RegExp;
452-
/** The head-gone residue, when the template has a right anchor to recover it. */
464+
}
465+
466+
/**
467+
* A family whose diagnostic continues PAST the value, through a right anchor.
468+
*
469+
* ⛔ Such a family may NOT declare a `head`, and `head?: never` is what makes
470+
* that unrepresentable instead of merely forbidden: the head-anchored cut can
471+
* land inside the STATEMENT (a hostile value may spell a head), and a right
472+
* anchor would then keep whatever follows it — which on such a cut is
473+
* statement, which is caller values. It takes {@link tail} instead, which
474+
* recovers the same residue with no cut change at all.
475+
*/
476+
interface AnchoredTemplate extends ValueBearingTemplateBase {
477+
/** The head-gone residue, recovered through the family's right anchor. */
453478
readonly tail?: RegExp;
479+
readonly head?: never;
480+
}
481+
482+
/**
483+
* A family whose value runs to END OF MESSAGE, so it has no right anchor and no
484+
* residue to recover — it takes a {@link head} instead.
485+
*/
486+
interface EndOfMessageTemplate extends ValueBearingTemplateBase {
454487
/**
455488
* [#9275] The template's head through the value's OPENING QUOTE — what the
456489
* statement cut looks for so a value containing ` - ` cannot eat it.
@@ -464,21 +497,127 @@ interface ValueBearingTemplate {
464497
* template with a right anchor would instead keep whatever follows that
465498
* anchor, which on such a cut is statement, which is caller values.
466499
*
467-
* Families that DO have a right anchor take {@link tail} instead; it recovers
468-
* the same residue with no cut change at all. Both are asserted structurally
469-
* by `driver-fault-redaction.test.ts` rather than trusted from this note.
500+
* [#9359] That invariant is no longer carried by this note. The `tail` half is
501+
* held by the type ({@link AnchoredTemplate.head} is `never`); the anchoring
502+
* half is held by
503+
* {@link assertHeadBearingTemplatesAreEndAnchored}, which runs at module load
504+
* over the table below. A doc comment is not a guard — a claim this very note
505+
* once made about this very mechanism (that last-match was a second line of
506+
* defence) read as true for weeks and fell only to an ablation.
470507
*/
471-
readonly head?: RegExp;
508+
readonly head: RegExp;
509+
readonly tail?: never;
472510
}
473511

474-
const VALUE_BEARING_TEMPLATES: readonly ValueBearingTemplate[] = [
512+
type ValueBearingTemplate = AnchoredTemplate | EndOfMessageTemplate;
513+
514+
export const VALUE_BEARING_TEMPLATES: readonly ValueBearingTemplate[] = [
475515
{ id: 'mysql/1062 ER_DUP_ENTRY', whole: DUPLICATE_ENTRY, tail: DUPLICATE_ENTRY_TAIL },
476516
{ id: 'mysql/1366 ER_TRUNCATED_WRONG_VALUE_FOR_FIELD', whole: MYSQL_INCORRECT_VALUE, tail: MYSQL_INCORRECT_VALUE_TAIL },
477517
{ id: 'mysql/1292 ER_TRUNCATED_WRONG_VALUE', whole: MYSQL_TRUNCATED_INCORRECT_VALUE, head: MYSQL_TRUNCATED_INCORRECT_VALUE_HEAD },
478518
{ id: 'pg/22P02 invalid_text_representation', whole: PG_INVALID_INPUT_SYNTAX, head: PG_INVALID_INPUT_SYNTAX_HEAD },
479519
{ id: 'pg/22003 numeric_value_out_of_range', whole: PG_VALUE_OUT_OF_RANGE, tail: PG_VALUE_OUT_OF_RANGE_TAIL },
480520
];
481521

522+
/**
523+
* [#9359] What a head-bearing `whole` must END with: `$` — end of INPUT, since
524+
* JavaScript's `$` means end-of-line only under `m`, which is why the guard
525+
* rejects that flag — immediately followed by the EMPTY group 2 that
526+
* {@link redactDiagnosticValues} reads as "nothing is kept after the value".
527+
*/
528+
const END_OF_MESSAGE_ANCHOR = '$()';
529+
530+
/**
531+
* [#9359] The head invariant, ENFORCED at module load rather than described.
532+
*
533+
* ## What it forecloses
534+
*
535+
* Only an end-of-message template may declare a `head`. That single property is
536+
* what bounds a hostile value's influence over the statement cut to
537+
* OVER-REDACTION: a value that spells a known head makes the cut land inside
538+
* the statement, and `whole` then swallows the remainder whole. Give a `head`
539+
* to a family with a RIGHT ANCHOR and the same cut keeps everything after that
540+
* anchor — statement, which is caller values. That is the one way #9275's
541+
* amendment can leak, and this function is what stops it being written.
542+
*
543+
* ## Why a load-time throw is the right shape here
544+
*
545+
* The precedent is `assertMetaUrlSpellingsAgree()` in `packages/spec`, and the
546+
* property that makes a boot-path throw safe is the same in both: this reads
547+
* NOTHING but literals declared in this file. It cannot depend on install
548+
* state, environment or import order, so it is deterministic per build — it
549+
* fires on the author's first import, never at a deployment that imported the
550+
* same bytes successfully yesterday. ⛔ If a future row is ever computed from
551+
* anything outside this module, that reasoning lapses and this belongs in a
552+
* gate script instead.
553+
*
554+
* ## The checks, and why each is the invariant rather than a proxy for it
555+
*
556+
* 1. **No `m` flag.** Under `m`, `$` is end-of-LINE, so an end-anchored
557+
* template would stop at the first newline of a multi-line dump and leave
558+
* the rest standing. Then `whole` is spelled end-anchored while not being
559+
* end-anchored — the exact silent shape this card exists to end.
560+
* 2. **`whole.source` ends with {@link END_OF_MESSAGE_ANCHOR}.** This is the
561+
* invariant's own wording ("`$`-anchored and group 2 empty") in one token.
562+
* 3. **Exactly two capture groups.** {@link redactDiagnosticValues} reads
563+
* `whole[1]` and `whole[2]` by index; a third group would silently reassign
564+
* what is kept after the value.
565+
*
566+
* Checks 2 and 3 are SYNTACTIC — they read the pattern, not its behaviour — and
567+
* so they reject spellings they cannot prove end-anchored even where a human
568+
* can see the pattern is fine. That is the safe direction: a novel spelling
569+
* gets a loud, named throw and its author amends this guard deliberately,
570+
* rather than the invariant quietly acquiring an exception.
571+
*
572+
* Exported for `driver-fault-redaction.test.ts`, which calls it with synthetic
573+
* rows to prove it FIRES — a guard nobody has watched fail is the same prose
574+
* this replaced.
575+
*
576+
* @param templates - the table to check; the shipped one is checked at load.
577+
* @throws when a row declares a `head` whose `whole` is not end-anchored.
578+
*/
579+
export function assertHeadBearingTemplatesAreEndAnchored(
580+
templates: readonly ValueBearingTemplate[],
581+
): void {
582+
for (const template of templates) {
583+
if (template.head === undefined) continue;
584+
const { source, flags } = template.whole;
585+
586+
if (flags.includes('m')) {
587+
throw new Error(
588+
"[driver-fault-redaction] Template '" + template.id + "' declares a head, but its 'whole' carries the "
589+
+ "'m' flag, under which '$' matches end of LINE rather than end of MESSAGE. Only a template whose "
590+
+ 'value runs to end of message may declare a head.',
591+
);
592+
}
593+
594+
if (!source.endsWith(END_OF_MESSAGE_ANCHOR)) {
595+
throw new Error(
596+
"[driver-fault-redaction] Template '" + template.id + "' declares a head, but its 'whole' does not end "
597+
+ "with '" + END_OF_MESSAGE_ANCHOR + "', so its value does not run to end of message. Only an "
598+
+ 'end-of-message template may declare a head: the head-anchored statement cut can land inside the '
599+
+ "statement, and what bounds that to over-redaction is that 'whole' then swallows the remainder. A "
600+
+ 'template with a right anchor keeps whatever follows that anchor, which on such a cut is statement, '
601+
+ "which is caller values. Give this row a 'tail' instead.",
602+
);
603+
}
604+
605+
// `new RegExp(source + '|')` always matches the empty string through its
606+
// empty alternative, so the result is the group count without needing an
607+
// input the pattern accepts.
608+
const probe = new RegExp(source + '|').exec('');
609+
const groupCount = probe === null ? 0 : probe.length - 1;
610+
if (groupCount !== 2) {
611+
throw new Error(
612+
"[driver-fault-redaction] Template '" + template.id + "' has " + groupCount + " capture group(s); "
613+
+ "exactly 2 are required, because redactDiagnosticValues reads 'whole[1]' and 'whole[2]' by index.",
614+
);
615+
}
616+
}
617+
}
618+
619+
assertHeadBearingTemplatesAreEndAnchored(VALUE_BEARING_TEMPLATES);
620+
482621
/**
483622
* [#9275] Every known diagnostic head, as the separator that stands before it.
484623
*
@@ -488,7 +627,7 @@ const VALUE_BEARING_TEMPLATES: readonly ValueBearingTemplate[] = [
488627
* hand.
489628
*/
490629
const HEAD_ANCHORED_CUTS: readonly RegExp[] = VALUE_BEARING_TEMPLATES
491-
.filter((template): template is ValueBearingTemplate & { head: RegExp } => template.head !== undefined)
630+
.filter((template): template is EndOfMessageTemplate => template.head !== undefined)
492631
.map((template) => new RegExp(`${STATEMENT_SEPARATOR}(?=${template.head.source})`, 'gi'));
493632

494633
/**

0 commit comments

Comments
 (0)