Skip to content

Commit 1f28699

Browse files
os-zhuangclaude
andauthored
docs(spec,objectql,driver-sql): declare the unanchored readback boundary (#7287) (#7405)
Maintainer ruling on #7287 (2026-08-10, treatment (2) 「宣告边界」): on an UNANCHORED autonumber format (neither prefix nor suffix), a stored value carrying non-digit content is OUT OF CONTRACT for counter readback, and `readAutonumberCounter`'s `undefined` for that slot is the contract rather than a gap. Zero behavior movement. The two consumers keep their divergent legacy readings — the engine's `readStoredAutonumberCounter` takes the last digit run, driver-sql's `scanMaxNumericTail` concatenates every digit — each now labelled implementation detail outside the declared boundary, pointing at spec's TSDoc. Hoisting either reading was rejected: it would move live behavior on the other side over record numbers already issued (the reason #7247 refused the hoist as a rider). The boundary is drawn by CONTENT and deliberately wider than the observed divergence: pure-digit values (what `renderAutonumber` emits for an unanchored format) are in contract and read the same on both sides, while every mixed-content value is outside — including ones the two readings happen to agree on, since divergence needs two digit runs. Narrowing it to "values the sides actually disagree on" would make membership undecidable from the value alone. Reachability: #6555's Route-3 ruling (PR #7265) made `{0000}` the declared default for format-less autonumber fields, so the default authoring shape now lands in this unanchored slot. New pins in `autonumber-unanchored-boundary.test.ts` assert the `undefined` for eight mixed-content shapes, show the two readings diverging on the inputs the boundary excludes and agreeing on the ones it admits, and pin that `{0000}` really renders an unanchored pair. No existing test is edited. Diff is comments and one new test file only — no executable line is added, removed or moved outside tests. Closes #7287 Claude-Session: https://claude.ai/code/session_0184Hrx9PcaQ2KMMt88DRZ2c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42cc219 commit 1f28699

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3629,6 +3629,28 @@ export class SqlDriver implements IDataDriver {
36293629
* the last digit run), so there is nothing shared to hoist — spec answers
36303630
* `undefined` for an unanchored slot rather than pick one of the two.
36313631
*
3632+
* ## The unanchored arm is IMPLEMENTATION DETAIL, outside the declared contract (#7287)
3633+
*
3634+
* That refusal is now a DECLARED boundary, not merely an absent agreement: the
3635+
* #7287 ruling (2026-08-10, 「宣告边界」) states that a stored value carrying
3636+
* non-digit content on an unanchored format is out of contract for counter
3637+
* readback, and that `readAutonumberCounter`'s `undefined` for that slot is the
3638+
* contract rather than a gap. The boundary and its rationale live in that
3639+
* function's TSDoc in `packages/spec/src/data/autonumber-format.ts`; the pins
3640+
* are `packages/spec/src/data/autonumber-unanchored-boundary.test.ts`.
3641+
*
3642+
* So the concatenate-every-digit reading below is THIS DRIVER'S behavior, not a
3643+
* promise the platform makes. On the inputs the contract admits — pure-digit
3644+
* values, which is what `renderAutonumber` emits for an unanchored format — it
3645+
* answers the same number the engine does. On mixed-content values it may
3646+
* answer differently (`'SO-2024-0007'` → `20240007` here, `7` in the engine's
3647+
* `readStoredAutonumberCounter`), and that difference is out of contract on
3648+
* both sides, not a defect on either.
3649+
*
3650+
* Consequently the ruling moves nothing here: neither this reading nor the
3651+
* engine's was hoisted into the shared helper, precisely because doing so would
3652+
* move live behavior on the other side over record numbers already issued.
3653+
*
36323654
* ## Why the suffix is NOT pushed into the LIKE
36333655
*
36343656
* `like 'prefix%suffix'` looks tempting and is wrong: the counter scope is the

packages/objectql/src/engine.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,27 @@ interface IssuedAutonumber {
372372
* concatenates every digit), which is exactly why spec refuses to answer for
373373
* an unanchored slot instead of picking one of the two.
374374
*
375+
* # The unanchored arm is IMPLEMENTATION DETAIL, outside the declared contract (#7287)
376+
*
377+
* Spec's refusal above is no longer just an absence of agreement: the #7287
378+
* ruling (2026-08-10, 「宣告边界」) DECLARES that a stored value carrying
379+
* non-digit content on an unanchored format is out of contract for counter
380+
* readback, and that `readAutonumberCounter`'s `undefined` for that slot is the
381+
* contract rather than a gap. The boundary and its rationale live in that
382+
* function's TSDoc in `packages/spec/src/data/autonumber-format.ts`; the pins
383+
* are `packages/spec/src/data/autonumber-unanchored-boundary.test.ts`.
384+
*
385+
* So the last-digit-run reading below is THIS ENGINE'S behavior, not a promise
386+
* the platform makes. On the inputs the contract admits — pure-digit values,
387+
* which is what `renderAutonumber` emits for an unanchored format — it answers
388+
* the same number the SQL driver does. On mixed-content values it may answer
389+
* differently (`'SO-2024-0007'` → `7` here, `20240007` there), and that
390+
* difference is out of contract on both sides, not a defect on either.
391+
*
392+
* Consequently the ruling moves nothing here: neither this reading nor the
393+
* driver's was hoisted into the shared helper, precisely because doing so would
394+
* move live behavior on the other side over record numbers already issued.
395+
*
375396
* The unanchored branch uses the linear `/\d+/g` — a backtracking lookahead here
376397
* is a polynomial-ReDoS sink on stored values full of zeros (CodeQL
377398
* js/polynomial-redos).

packages/spec/src/data/autonumber-format.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,57 @@ export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumb
336336
* Hoisting either one here would make this contract claim an agreement that does
337337
* not exist. Each side documents its own fallback at its own call site.
338338
*
339+
* # The declared boundary: mixed-content unanchored values are OUT OF CONTRACT (#7287)
340+
*
341+
* That refusal is not a gap waiting to be filled — as of the maintainer's ruling
342+
* on #7287 (2026-08-10, 「宣告边界」, treatment (2)) it IS the contract:
343+
*
344+
* > **On an UNANCHORED format (`prefix === '' && suffix === ''`), a stored value
345+
* > carrying any non-digit content is out of contract for counter readback.**
346+
* > `undefined` is this function's answer for that slot, permanently.
347+
*
348+
* Why declare it rather than pick a reading. The two consumers have read such a
349+
* value differently since before this helper existed, and both readings are
350+
* pinned by their own tests:
351+
*
352+
* - the engine's `readStoredAutonumberCounter`
353+
* (`packages/objectql/src/engine.ts`) takes the **last digit run** —
354+
* `'SO-2024-0007'` → `7`;
355+
* - the SQL driver's `scanMaxNumericTail`
356+
* (`packages/drivers/driver-sql/src/sql-driver.ts`) **concatenates every
357+
* digit** — the same value → `20240007`.
358+
*
359+
* Ruling either one to be the contract would move live behavior on the other
360+
* side, over stored business identifiers that are not reclaimable once issued —
361+
* which is why #7247 refused the hoist as a rider, and why #7287 rejects it
362+
* outright rather than picking a side. Declaring the input out of contract costs
363+
* nothing that was ever promised: no reading was ever specified for it.
364+
*
365+
* The boundary is drawn by CONTENT, and deliberately wider than the observed
366+
* divergence. A **pure-digit** value — which is exactly what `renderAutonumber`
367+
* emits for an unanchored format — is inside the contract, and both sides answer
368+
* the same number for it: `'0007'` reads as `7` either way. Everything else is
369+
* outside, including mixed-content values the two readings happen to agree on
370+
* (`'CASE-12'` is `12` both ways: divergence needs TWO digit runs, and that one
371+
* has one). Narrowing the boundary to "values the sides actually disagree on"
372+
* would make it undecidable from the value alone — a caller would have to know
373+
* which consumer is running to know whether its input is in contract, which is
374+
* the very "same metadata, different driver, different number" property this
375+
* boundary exists to bound. So the out-of-contract face is simply
376+
* legacy/migrated data: a dash-separated string in a column later declared
377+
* autonumber.
378+
*
379+
* Reachability, and why the boundary is worth stating now: #6555's Route-3
380+
* ruling (PR #7265) made `{0000}` the declared default for format-less
381+
* autonumber fields, and `{0000}` renders neither prefix nor suffix — so the
382+
* DEFAULT authoring shape resolves to this unanchored slot on both sides.
383+
*
384+
* Consequence for the consumers, also recorded by the ruling: neither moves.
385+
* Each keeps its current unanchored reading as **implementation detail outside
386+
* this boundary**, not as a contract this file underwrites. See the pins in
387+
* `autonumber-unanchored-boundary.test.ts`, and the ruling itself at
388+
* https://github.com/objectstack-ai/objectstack/issues/7287#issuecomment-5238560707.
389+
*
339390
* The digit match is the linear `/^\d+/` — a backtracking lookahead here is a
340391
* polynomial-ReDoS sink on stored values full of zeros (CodeQL
341392
* js/polynomial-redos).
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pins for the contract boundary declared on #7287 (maintainer ruling
5+
* 2026-08-10, 「宣告边界」, treatment (2)):
6+
*
7+
* On an UNANCHORED format (`prefix === '' && suffix === ''`), a stored value
8+
* carrying non-digit content is OUT OF CONTRACT for counter readback.
9+
* `readAutonumberCounter`'s `undefined` for that slot is the contract, not a
10+
* gap — permanently.
11+
*
12+
* These cases exist to make that boundary fail loudly if a later change tries to
13+
* fill the slot in: hoisting the engine's reading (last digit run) or the SQL
14+
* driver's (concatenate every digit) into the shared helper would move live
15+
* behavior on the other side, over record numbers that are not reclaimable once
16+
* issued. #7247 refused that hoist as a rider; #7287 rejects it outright.
17+
*
18+
* The tests below assert only `readAutonumberCounter` — spec's declared surface.
19+
* The two consumers' readings are modelled here ONLY to show WHY the slot is out
20+
* of contract (they disagree on exactly the inputs the boundary excludes, and
21+
* agree on the ones it admits); neither model is a contract, and the real
22+
* implementations live at their own call sites.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { parseAutonumberFormat, renderAutonumber, readAutonumberCounter } from './autonumber-format';
27+
28+
const NOW = new Date('2026-06-17T21:30:00.000Z');
29+
30+
/**
31+
* The engine's unanchored reading, mirrored from `readStoredAutonumberCounter`
32+
* in `packages/objectql/src/engine.ts` — the LAST digit run of the value.
33+
* Illustration only: this file pins nothing about the engine.
34+
*/
35+
function engineUnanchoredReading(value: string): number | undefined {
36+
const runs = value.match(/\d+/g);
37+
const digits = runs ? runs[runs.length - 1] : undefined;
38+
return digits ? parseInt(digits, 10) : undefined;
39+
}
40+
41+
/**
42+
* The SQL driver's unanchored reading, mirrored from `scanMaxNumericTail` in
43+
* `packages/drivers/driver-sql/src/sql-driver.ts` — EVERY digit, concatenated.
44+
* Illustration only: this file pins nothing about the driver.
45+
*/
46+
function driverSqlUnanchoredReading(value: string): number | undefined {
47+
const n = parseInt(value.replace(/[^0-9]/g, ''), 10);
48+
return Number.isFinite(n) ? n : undefined;
49+
}
50+
51+
describe('the unanchored readback boundary (#7287 ruling: mixed content is out of contract)', () => {
52+
it('the declared default format {0000} (#6555, PR #7265) really does render an UNANCHORED pair', () => {
53+
// The premise the boundary hangs on: the default authoring shape is the one
54+
// that lands in this slot, which is why the boundary is worth declaring.
55+
const r = renderAutonumber({ tokens: parseAutonumberFormat('{0000}'), seq: 7, now: NOW });
56+
expect(r.prefix).toBe('');
57+
expect(r.suffix).toBe('');
58+
expect(r.value).toBe('0007');
59+
});
60+
61+
describe('OUT OF CONTRACT — non-digit content on an unanchored format reads as undefined', () => {
62+
it.each([
63+
['A1B2', 'digits split by letters — the shape the two readings disagree on most sharply'],
64+
['CASE-12', 'a dash-separated legacy value against a bare {0000}'],
65+
['SO-2024-0007', 'the issue-body example: engine reads 7, driver-sql reads 20240007'],
66+
['2026-06-17', 'a date-looking value in a column later declared autonumber'],
67+
['INV/0042', 'a separator other than a dash — the boundary is content, not punctuation'],
68+
['0007 (void)', 'digits first, non-digit content trailing'],
69+
['v2.1', 'digits inside an unrelated version-shaped identifier'],
70+
['DRAFT', 'no digits at all — still out of contract, and still undefined'],
71+
])(
72+
'out of contract: %s reads as undefined — the declared boundary, not a gap (#7287)',
73+
(value) => {
74+
expect(readAutonumberCounter(value, '', '')).toBeUndefined();
75+
},
76+
);
77+
78+
it.each([
79+
['A1B2'],
80+
['SO-2024-0007'],
81+
['2026-06-17'],
82+
['v2.1'],
83+
])(
84+
'why %s is out of contract: the two consumers read it as DIFFERENT numbers',
85+
(value) => {
86+
expect(engineUnanchoredReading(value)).not.toBe(driverSqlUnanchoredReading(value));
87+
// And spec declines to break the tie — that refusal IS the contract.
88+
expect(readAutonumberCounter(value, '', '')).toBeUndefined();
89+
},
90+
);
91+
92+
it.each([['CASE-12', 12], ['INV/0042', 42], ['DRAFT-7', 7]])(
93+
'out of contract even though the two consumers happen to agree on %s (%i)',
94+
(value, agreed) => {
95+
// The boundary is drawn by CONTENT, deliberately wider than the observed
96+
// divergence: divergence needs TWO digit runs, so a mixed-content value
97+
// carrying only one reads the same number either way. Narrowing the
98+
// boundary to "values the two sides actually disagree on" would make it
99+
// undecidable from the value alone — a caller would have to know which
100+
// consumer is running to know whether it is in contract, which is the
101+
// very "same metadata, different driver, different number" property
102+
// #7287 exists to bound. So: still out of contract, still undefined.
103+
expect(engineUnanchoredReading(value)).toBe(agreed);
104+
expect(driverSqlUnanchoredReading(value)).toBe(agreed);
105+
expect(readAutonumberCounter(value, '', '')).toBeUndefined();
106+
},
107+
);
108+
});
109+
110+
describe('INSIDE the contract — pure-digit unanchored values, where both sides agree', () => {
111+
it.each([['0007', 7], ['10', 10], ['0000', 0], ['123456', 123456]])(
112+
'in contract: %s carries no non-digit content, so both consumers read %i',
113+
(value, expected) => {
114+
expect(engineUnanchoredReading(value)).toBe(expected);
115+
expect(driverSqlUnanchoredReading(value)).toBe(expected);
116+
},
117+
);
118+
119+
it('what renderAutonumber itself emits for {0000} stays inside the boundary', () => {
120+
// The in-contract face is the one the platform generates: an unanchored
121+
// format renders pure digits, so a store the platform filled reads the
122+
// same number on either side. The out-of-contract face is legacy/migrated
123+
// data that arrived by some other path.
124+
const tokens = parseAutonumberFormat('{0000}');
125+
for (const seq of [0, 7, 42, 9999, 123456]) {
126+
const { value } = renderAutonumber({ tokens, seq, now: NOW });
127+
expect(engineUnanchoredReading(value)).toBe(seq);
128+
expect(driverSqlUnanchoredReading(value)).toBe(seq);
129+
}
130+
});
131+
132+
it('agreement on the digits does NOT make the shared helper answer for the slot', () => {
133+
// The boundary is about which INPUTS are in contract; the shared helper's
134+
// declared job stays the ANCHORED inverse of renderAutonumber. Even where
135+
// the two consumers agree, spec does not underwrite an unanchored reading
136+
// — each side answers from its own implementation detail.
137+
expect(readAutonumberCounter('0007', '', '')).toBeUndefined();
138+
});
139+
});
140+
141+
it('an anchored format is untouched by this boundary — non-digit content still reads normally there', () => {
142+
// Guard against the boundary being read too widely: it is scoped to the
143+
// unanchored slot. `CASE-0007` against the pair `{prefix: 'CASE-'}` is in
144+
// contract and answers 7, mixed content and all.
145+
expect(readAutonumberCounter('CASE-0007', 'CASE-', '')).toBe(7);
146+
});
147+
});

0 commit comments

Comments
 (0)