Skip to content

Commit 945e91a

Browse files
Elon Muskclaude
andauthored
feat(devx): class-level keyed-text-bounds gate over every *.object.ts, superseding the three per-package pins (#12991)
* feat(devx): class-level keyed-text-bounds gate over every *.object.ts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw * refactor(devx): retire the duplicated keyed-text-bounds rule from the three per-package pins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw * feat(devx): declare the gate's watch hints so a dispatch brief can name it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52954c0 commit 945e91a

6 files changed

Lines changed: 1438 additions & 420 deletions

File tree

.github/workflows/lint.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,32 @@ jobs:
411411
node scripts/check-undeclared-dep-imports.mjs --self-test
412412
node scripts/check-undeclared-dep-imports.mjs
413413
414+
# A text-family column a DECLARED INDEX keys on must declare a `maxLength`
415+
# (#12147, route A of #11374). Without one `driver-sql` emits it TEXT, MySQL
416+
# refuses `ALTER TABLE ... ADD INDEX` with ER_BLOB_KEY_WITHOUT_LENGTH, and the
417+
# object lands REGISTERED-BUT-BROKEN with its declared index silently absent
418+
# (measured live on MySQL 8.0.46, #12058: 12 of 44 platform objects, sys_session
419+
# among them). Enforcement used to be per-package pins, and each one was widened
420+
# by a column that had escaped the previous scope -- objects keep moving across
421+
# package boundaries under ADR-0029 K2, so a boundary-scoped pin re-opens the
422+
# hole every time one moves. A central importing pin is NOT available: measured
423+
# on PR #12143, it would invert the dependency graph. So this is a class-level
424+
# source scan over every `*.object.ts`.
425+
# Node builtins plus the shared comment mask only -- no node_modules, so a
426+
# reviewer can run it in place. Its `--self-test` runs FIRST, and that leg is
427+
# the load-bearing one: the production run over a fixed tree is green by
428+
# construction, so it cannot tell a working matcher from a dead one. The other
429+
# half is the FLOORS -- a sweep that finds nothing because it swept nothing
430+
# reports exactly what a clean tree reports, so an empty population is `exit 2`
431+
# rather than a pass. Unclassifiable shapes refuse for the same reason.
432+
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
433+
# GATE INVOCATION IDIOM note at the top of this file.
434+
# Scans 113 *.object.ts files, no spawns; ~0.3s.
435+
- name: Keyed text-family columns declare their bound (#12147)
436+
run: |
437+
node scripts/check-keyed-text-bounds.mjs --self-test
438+
node scripts/check-keyed-text-bounds.mjs
439+
414440
# The bash-3.2 floor, over every shell file the repo ships (#12221).
415441
# `/usr/bin/env bash` is bash 3.2.57 on macOS -- Apple ships no bash 4+,
416442
# for licensing reasons -- and THIS RUNNER IS BASH 5, where every construct

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"check:refd-timer-probe": "node scripts/check-refd-timer-probe.mjs --self-test && node scripts/check-refd-timer-probe.mjs",
126126
"check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.mjs",
127127
"check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs",
128+
"check:keyed-text-bounds": "node scripts/check-keyed-text-bounds.mjs --self-test && node scripts/check-keyed-text-bounds.mjs",
128129
"check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs",
129130
"check:published-readme-exports": "node scripts/check-published-readme-exports.mjs --self-test && node scripts/check-published-readme-exports.mjs",
130131
"check:published-readme-links": "node scripts/check-published-readme-links.mjs --self-test && node scripts/check-published-readme-links.mjs",

packages/platform-objects/src/platform-keyed-text-bounds.test.ts

Lines changed: 37 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -4,120 +4,47 @@ import { describe, it, expect } from 'vitest';
44
import * as PlatformObjects from './index';
55

66
/**
7-
* #11374 — every text-family column a declared index keys on must declare a
8-
* `maxLength`, because a bound is what lets the column be a key at all.
9-
*
10-
* ## Why this pin exists
11-
*
12-
* `driver-sql` emits a KEYED text-family column as `varchar(maxLength)` when
13-
* the field declares a bound the dialect can key on, and leaves it `TEXT`
14-
* otherwise. MySQL refuses a TEXT/BLOB column in a key without a prefix length
15-
* (`ER_BLOB_KEY_WITHOUT_LENGTH`), so an unbounded keyed text column means:
16-
* `CREATE TABLE` succeeds, `ALTER TABLE … ADD [UNIQUE] INDEX` fails, and the
17-
* object lands registered-but-broken with its declared uniqueness silently
18-
* absent. Measured on live MySQL 8.0.46: 12 of 44 platform objects failed
19-
* schema-sync this way — sys_session and sys_account among them, so a MySQL
20-
* stack could not sign anyone in.
21-
*
22-
* The driver deliberately does NOT substitute a prefix index: measured on the
23-
* same server, a prefix-UNIQUE index is stricter-and-different — it refused a
24-
* second, genuinely distinct token that shared its first 191 characters
25-
* (`ER_DUP_ENTRY`), i.e. a valid sign-in refused as a duplicate. So the bound
26-
* has to live HERE, in the field declaration (maintainer ruling on #11374,
27-
* 2026-08-24: route A).
28-
*
29-
* ## Why this file enumerates the WHOLE package, not just `identity/`
30-
*
31-
* It used to be `identity/identity-keyed-text-bounds.test.ts`, importing
32-
* `./index` from `identity/`. That scoping is precisely how
33-
* `sys_import_job.created_by` — a keyed, unbounded text column in `audit/` —
34-
* survived route A's first pass: the pin could not see it, so nothing failed by
35-
* name and the column was left for a follow-up card to find by hand. A pin that
36-
* polices one directory does not police the defect class; it polices a
37-
* directory. The enumeration now walks every object the package exports, and
38-
* the vacuity control below asserts a column from OUTSIDE `identity/` is in
39-
* the enumerated set, so the same narrowing cannot silently come back.
40-
*
41-
* ## What a red on this file means
42-
*
43-
* A new keyed text-family field arrived without a `maxLength`. Do not silence
44-
* the assertion — derive a bound from the value's producer (upstream
45-
* better-auth schema/constraints, IdP norms, or the in-repo producer) and
46-
* declare it. If the value source genuinely cannot be bounded, extend
47-
* `UNBOUNDABLE` WITH a comment naming why — but read the #11701 block below
48-
* first: an unboundable column may only be keyed by a UNIQUE index, because a
49-
* UNIQUE index is the only kind #11627's hash shadow can carry.
50-
*
51-
* A bound may legitimately exceed 768 chars (the utf8mb4 index-key ceiling —
52-
* e.g. `sys_account.issuer` at 2048, the oauth TOKEN columns at 1024 —
53-
* `sys_oauth_resource.identifier` is no longer among them, see #12313): the
54-
* column then stays TEXT and its index still cannot exist on MySQL directly.
55-
* That debt was #11627's, and #11627 discharged it for the UNIQUE half — such
56-
* an index is now carried on a hash-shadow column. The first `describe` below
57-
* still polices only "keyed text declares its bound".
58-
*
59-
* ## #11701 — the NON-UNIQUE half, which a hash shadow cannot serve
60-
*
61-
* The second `describe` polices the case #11627 deliberately left refused. A
62-
* UNIQUE constraint is an equality-only predicate, so hashing the value
63-
* preserves it exactly; a NON-UNIQUE index exists for an ACCESS PATH, and an
64-
* index over a digest accelerates no `WHERE col = ?` the planner can reach
65-
* without rewriting the read side. So for a non-unique index there is no
66-
* shadow to fall back on: the column must be KEYABLE — bounded, and bounded at
67-
* or under 768 — or the index cannot exist on MySQL at all and the object's
68-
* whole schema-sync is refused.
69-
*
70-
* That left exactly two platform members, and the maintainer ruled them
71-
* separately on 2026-08-25 because they are different problems:
72-
*
73-
* • `sys_verification.value` — unboundable AND unread. The declared index was
74-
* REMOVED, on measured liveness (better-auth keys verification lookups on
75-
* `identifier`; no in-repo query filters by `value`). Removing it is what
76-
* emptied `UNBOUNDABLE` below.
77-
* • `sys_oauth_client_resource.resource_id` — a LIVE access path (the FK side
78-
* of `sys_oauth_resource.identifier`), so its bound was narrowed
79-
* 1024 → 768 instead. See the field's own comment for the evidence that
80-
* nothing legitimate lives in the discarded band.
81-
*
82-
* ⚠️ UPDATED by #12313: that bound is now **255**, not 768. #11701 picked
83-
* 768 as the smallest narrowing that made the index expressible and left
84-
* the number unsourced on purpose; #12313 sourced the REFERENT
85-
* (`sys_oauth_resource.identifier`, 1024 → 255, from better-auth 1.7.1's
86-
* own varchar(255) emission) and this column follows it, as a referencing
87-
* column takes the referenced column's bound. 255 ≤ 768, so the #11701
88-
* rule below is still satisfied — it is the same disposition at a sourced
89-
* number, not a different one.
90-
*
91-
* The pin below is the executable form of "the class is closed": it does not
92-
* name those two, it enumerates the whole package, so a THIRD member arriving
93-
* later fails here rather than being found on a live MySQL months on.
7+
* #11701 — a NON-UNIQUE declared index over a text column MySQL cannot key.
8+
*
9+
* ## What used to be here, and where it went (#12147)
10+
*
11+
* This file also carried route A's own rule — "every text-family column a
12+
* declared index keys on declares a `maxLength`" (#11374) — enumerated over
13+
* this package's exports, with a vacuity control, an `UNBOUNDABLE` allowlist
14+
* and a synthetic control driving that allowlist's two branches. All of it is
15+
* now `scripts/check-keyed-text-bounds.mjs`, which walks EVERY `*.object.ts` in
16+
* the repository rather than one package's export surface.
17+
*
18+
* That is not a like-for-like move, and the difference is the reason for it.
19+
* This pin enumerated `Object.values(PlatformObjects)`, so its population was
20+
* whatever the barrel re-exports — 95 keyed text columns, measured. The gate's
21+
* population over the same objects is 97: `sys_metadata_commit.package_id` and
22+
* `sys_metadata_commit.parent_commit_id` were invisible here, because
23+
* `metadata/index.ts` is a HAND-WRITTEN back-compat re-export naming four
24+
* objects and `sys_metadata_commit` was never added to it. Both columns are
25+
* bounded today, so nothing was broken — but nothing in the tree was watching
26+
* them either, which is the same escape-by-boundary this pin was itself widened
27+
* to close once before (`identity/` → the package, after
28+
* `sys_import_job.created_by` slipped through).
29+
*
30+
* ## Why THIS half stays
31+
*
32+
* It is a different rule with a different disposition, not a narrower copy of
33+
* the one that moved. Route A asks "is there a bound?"; this asks "is the
34+
* declared bound small enough to be a key?" — and answers it only for
35+
* NON-UNIQUE indexes, because a UNIQUE index over an unkeyable column is
36+
* EXPRESSIBLE after #11627 (it moves onto a SHA-256 hash-shadow column) while a
37+
* non-unique one is not: hashing destroys the ordering and prefix structure an
38+
* access path is for, so there is no fallback and the column itself must be
39+
* keyable. `sys_account.issuer` (bounded at 2048) is the live illustration that
40+
* the two rules are independent — it passes the gate and is out of this
41+
* describe's scope because its index is unique.
42+
*
43+
* The gate deliberately does not fold this in; its header says so.
9444
*/
9545

9646
const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']);
9747

98-
/**
99-
* Keyed text-family columns with NO defensible bound. Every entry must name
100-
* why. Entries that stop matching a real keyed unbounded column fail the
101-
* fourth test, so the list cannot rot.
102-
*
103-
* ⚠️ EMPTY since #11701 — and empty here is a RESULT, not a default. The list
104-
* held exactly one entry, `sys_verification.value`, allowlisted because
105-
* better-auth's oauth-provider writes OIDC authorization-code payloads there as
106-
* a JSON blob and no bound provably admits all of them. That entry was written
107-
* to explain why the column could not be BOUNDED, and the maintainer's
108-
* 2026-08-25 ruling did not bound it — it removed the column's declared INDEX,
109-
* on measured liveness. An unindexed column is not a keyed column, so the entry
110-
* stopped describing anything real and moved with the change rather than being
111-
* left to rot. (The fourth test enforces exactly that: it is what would have
112-
* gone red had the entry been left behind.)
113-
*
114-
* ⚠️ Before adding an entry: an unboundable column may only be keyed by a
115-
* UNIQUE index, which #11627 carries on a hash shadow. A NON-UNIQUE index over
116-
* an unboundable column is not "debt" — it is unfixable, and the #11701
117-
* `describe` below rejects it.
118-
*/
119-
const UNBOUNDABLE: ReadonlySet<string> = new Set<string>([]);
120-
12148
/**
12249
* MySQL's utf8mb4 key-part ceiling, in CHARACTERS: 768 × 4 = 3072 bytes, the
12350
* whole key-part budget. A declared bound at or under this makes `driver-sql`
@@ -143,106 +70,6 @@ const platformObjects: AnyObject[] = Object.values(PlatformObjects)
14370
!!v.fields,
14471
);
14572

146-
function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unknown }> {
147-
const keyed = new Set<string>();
148-
for (const ix of o.indexes ?? []) for (const f of ix.fields ?? []) keyed.add(f);
149-
return Object.entries(o.fields)
150-
.filter(([name, def]) => keyed.has(name) && TEXT_FAMILY.has(def?.type ?? ''))
151-
.map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength }));
152-
}
153-
154-
/**
155-
* The rule the third test enforces, as a pure function of (objects, allowlist).
156-
*
157-
* Extracted rather than inlined because #11701 emptied `UNBOUNDABLE`: with the
158-
* allowlist empty, the `allowlist.has(column)` branch is never taken against the
159-
* real objects, so it would sit unexecuted and free to rot until the next agent
160-
* needed it. The synthetic control below drives both of its outcomes.
161-
*/
162-
function unboundedKeyedColumns(objects: AnyObject[], allowlist: ReadonlySet<string>): string[] {
163-
const offenders: string[] = [];
164-
for (const o of objects) {
165-
for (const { column, maxLength } of keyedTextColumns(o)) {
166-
if (allowlist.has(column)) continue;
167-
const bounded = typeof maxLength === 'number' && Number.isInteger(maxLength) && maxLength > 0;
168-
if (!bounded) offenders.push(`${column} (maxLength: ${String(maxLength)})`);
169-
}
170-
}
171-
return offenders;
172-
}
173-
174-
describe('platform keyed text-family columns declare their bound (#11374)', () => {
175-
it('enumerates a real surface — the probe itself is not vacuous', () => {
176-
// Positive control: if the export shape or field/index spelling changes so
177-
// this file stops seeing columns, fail loudly instead of passing empty.
178-
const all = platformObjects.flatMap(keyedTextColumns);
179-
expect(platformObjects.length).toBeGreaterThanOrEqual(40);
180-
expect(all.length).toBeGreaterThanOrEqual(70);
181-
expect(all.map((c) => c.column)).toContain('sys_session.token');
182-
});
183-
184-
it('reaches beyond identity/ — the scoping that let a keyed column escape', () => {
185-
// The specific regression control for this file's own history: while it
186-
// lived in `identity/` it enumerated only that directory, and
187-
// `sys_import_job.created_by` (audit/) went unbounded through route A's
188-
// first pass. These two names are in DIFFERENT source directories, so a
189-
// future re-narrowing of the import fails here by name rather than by
190-
// quietly enumerating less.
191-
const columns = platformObjects.flatMap(keyedTextColumns).map((c) => c.column);
192-
expect(columns).toContain('sys_import_job.created_by'); // audit/
193-
expect(columns).toContain('sys_metadata.name'); // metadata/
194-
expect(columns).toContain('sys_setting.key'); // system/
195-
});
196-
197-
it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', () => {
198-
const offenders = unboundedKeyedColumns(platformObjects, UNBOUNDABLE);
199-
expect(
200-
offenders,
201-
`keyed text-family column(s) without a declared maxLength — on MySQL their ` +
202-
`declared index cannot be created and the object lands registered-but-broken. ` +
203-
`Declare a sourced bound or extend UNBOUNDABLE with a named reason: ` +
204-
offenders.join(', '),
205-
).toEqual([]);
206-
});
207-
208-
it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => {
209-
const real = new Map(
210-
platformObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]),
211-
);
212-
for (const entry of UNBOUNDABLE) {
213-
expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true);
214-
expect(
215-
real.get(entry),
216-
`allowlist entry ${entry} now declares a bound — remove it from UNBOUNDABLE`,
217-
).toBeUndefined();
218-
}
219-
});
220-
221-
/**
222-
* ⚠️ The control that keeps the test above honest now that #11701 emptied the
223-
* allowlist. An empty `for` loop passes, so with a real-objects-only check the
224-
* excusing branch of the rule would be dead code that nobody notices rotting.
225-
* This drives BOTH outcomes on a synthetic object, so the mechanism a future
226-
* unboundable column will rely on is proven to work while the list is empty.
227-
*/
228-
it('the allowlist mechanism still excuses and still accuses — driven on a synthetic object', () => {
229-
const synthetic: AnyObject[] = [
230-
{
231-
name: 'sys_probe',
232-
fields: { blob: { type: 'text' } },
233-
indexes: [{ fields: ['blob'], unique: true }],
234-
},
235-
];
236-
// Keyed + unbounded, excused by nothing → an offender, named with its value.
237-
expect(unboundedKeyedColumns(synthetic, new Set<string>())).toEqual([
238-
'sys_probe.blob (maxLength: undefined)',
239-
]);
240-
// …and named in the allowlist → excused. The branch the real objects no
241-
// longer reach.
242-
expect(unboundedKeyedColumns(synthetic, new Set(['sys_probe.blob']))).toEqual([]);
243-
});
244-
});
245-
24673
/**
24774
* #11701 — a NON-UNIQUE index over a text column MySQL cannot key.
24875
*

0 commit comments

Comments
 (0)