Skip to content

Commit 4805b56

Browse files
os-warrenclaude
andauthored
feat(platform-objects): declare a sourced maxLength on sys_import_job.created_by (#11374 route A, last column) (#12058)
Route A's remaining column. `sys_import_job.created_by` is keyed by `(created_by, created_at)` and declared no bound, so driver-sql emitted it TEXT and MySQL refused the index (`ER_BLOB_KEY_WITHOUT_LENGTH`), landing the object registered-but-broken. Per #11699's own measurement it was the only remaining such object outside the >768-character class. The bound is 255, derived by referenced-column transitivity from three converging in-repo producers rather than chosen: the column holds a `sys_user.id` (stamped from `context.userId` by the rest-server import route), and driver-sql creates every primary key as `table.string('id').primary()` = knex's varchar(255); a sibling declared as `Field.lookup('sys_user')` emits `DEFAULT_STRING_VARCHAR_CHARS` = 255; and the landed text declarations for the same value class (sys_metadata_audit.actor, sys_metadata_commit.actor, sys_view_definition.owner) are all 255. A minted platform id is 26 characters, so the floor is cleared with 229 characters of headroom, and 255 is within the 768-character utf8mb4 key ceiling so this stays out of the hash-shadow class. The route-A pin moves out of `identity/` and now enumerates every platform object the package exports. That directory scoping is exactly how this column escaped the first pass, so a new control asserts the enumeration reaches `audit/`, `metadata/` and `system/` columns by name. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude <noreply@anthropic.com>
1 parent a17da05 commit 4805b56

3 files changed

Lines changed: 102 additions & 10 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
Declare a sourced `maxLength` on `sys_import_job.created_by`, so its declared
6+
index can exist on MySQL — route A's last column
7+
8+
`driver-sql` (since #11430) honours a keyed text-family field's declared
9+
`maxLength`, emitting `varchar(maxLength)` instead of `TEXT`, and #11699
10+
declared bounds on thirteen keyed identity columns. `sys_import_job.created_by`
11+
is keyed by `(created_by, created_at)` and declared no bound at all, so on MySQL
12+
that index was refused (`ER_BLOB_KEY_WITHOUT_LENGTH`: a TEXT/BLOB column cannot
13+
be a key without a prefix length) and the object landed registered-but-broken.
14+
It was the only remaining such object outside the >768-character class that
15+
#11627 tracks.
16+
17+
The bound is **255**, derived by referenced-column transitivity rather than
18+
chosen: the column holds a `sys_user.id` stamped by the rest-server import route
19+
from `context.userId`, and `driver-sql` creates every table's primary key as
20+
`table.string('id').primary()` — knex's `varchar(255)` — so no id this column
21+
can receive exceeds 255. It agrees with what the column would get if declared
22+
like its siblings (`Field.lookup('sys_user')` emits
23+
`DEFAULT_STRING_VARCHAR_CHARS` = 255) and with the landed declarations for the
24+
same value class (`sys_metadata_audit.actor`, `sys_metadata_commit.actor`,
25+
`sys_view_definition.owner`, all 255). A minted platform id is 26 characters, so
26+
the bound clears the floor with 229 characters of headroom.
27+
28+
This is behaviour-narrowing on a published object: on a strict MySQL server a
29+
`created_by` longer than 255 is now **refused** (`ER_DATA_TOO_LONG`, 0 rows)
30+
rather than stored, where previously the column was unbounded `TEXT`. No value
31+
the producing contract can emit is affected, because the id it copies is itself
32+
capped at 255 by its own column.
33+
34+
The route-A pin moves from `identity/identity-keyed-text-bounds.test.ts` to
35+
`platform-keyed-text-bounds.test.ts` and now enumerates **every** platform
36+
object the package exports, not just `identity/`. That directory scoping is
37+
exactly how this column escaped the first pass — the pin could not see it — and
38+
a new control asserts the enumeration reaches columns in `audit/`, `metadata/`
39+
and `system/` so the narrowing cannot silently return.

packages/platform-objects/src/audit/sys-import-job.object.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,35 @@ export const SysImportJob = ObjectSchema.create({
9292
// ── lifecycle timestamps ──
9393
started_at: Field.datetime({ label: 'Started At', required: false, group: 'State' }),
9494
completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }),
95-
created_by: Field.text({ label: 'Created By', required: false, readonly: true, group: 'System' }),
95+
// [#11374 route A] The value is `context.userId`, stamped by the rest-server
96+
// import route (`String(context?.userId ?? context?.user?.id ?? '')` in
97+
// `rest-server.ts`) — i.e. a `sys_user.id`. The bound is derived by
98+
// referenced-column transitivity from three converging in-repo producers,
99+
// never guessed:
100+
// - the physical column the id itself lives in: driver-sql creates every
101+
// table's primary key as `table.string('id').primary()`, which is knex's
102+
// `varchar(255)`, so no id this column can ever receive exceeds 255;
103+
// - what this column would be if it were declared like its siblings: every
104+
// other actor column on a platform object is `Field.lookup('sys_user')`,
105+
// which driver-sql emits at `DEFAULT_STRING_VARCHAR_CHARS` = 255;
106+
// - the landed text declarations for the same value class:
107+
// `sys_metadata_audit.actor`, `sys_metadata_commit.actor` and
108+
// `sys_view_definition.owner` all declare `maxLength: 255`.
109+
// The floor is cleared with room to spare: a minted platform id is 26
110+
// characters (measured on #11431, where honouring a bound below that made a
111+
// column structurally unable to hold any id at all).
112+
// 255 is also <= the 768-character utf8mb4 key ceiling, so the
113+
// `(created_by, created_at)` index below is expressible on MySQL — which is
114+
// the whole point: unbounded, this column was emitted TEXT and MySQL refused
115+
// the index with `ER_BLOB_KEY_WITHOUT_LENGTH`, landing the object
116+
// registered-but-broken.
117+
created_by: Field.text({
118+
label: 'Created By',
119+
required: false,
120+
readonly: true,
121+
maxLength: 255,
122+
group: 'System',
123+
}),
96124
created_at: Field.datetime({
97125
label: 'Created At',
98126
required: true,

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

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import * as Identity from './index';
4+
import * as PlatformObjects from './index';
55

66
/**
77
* #11374 — every text-family column a declared index keys on must declare a
@@ -26,6 +26,18 @@ import * as Identity from './index';
2626
* has to live HERE, in the field declaration (maintainer ruling on #11374,
2727
* 2026-08-24: route A).
2828
*
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+
*
2941
* ## What a red on this file means
3042
*
3143
* A new keyed text-family field arrived without a `maxLength`. Do not silence
@@ -47,7 +59,7 @@ const TEXT_FAMILY = new Set(['text', 'textarea', 'html', 'markdown']);
4759
/**
4860
* Keyed text-family columns with NO defensible bound. Every entry must name
4961
* why. Entries that stop matching a real keyed unbounded column fail the
50-
* second test, so the list cannot rot.
62+
* third test, so the list cannot rot.
5163
*/
5264
const UNBOUNDABLE: ReadonlySet<string> = new Set([
5365
// better-auth's oauth-provider stores OIDC authorization-code payloads in
@@ -65,7 +77,7 @@ type AnyObject = {
6577
indexes?: Array<{ fields?: string[]; unique?: boolean }>;
6678
};
6779

68-
const identityObjects: AnyObject[] = Object.values(Identity)
80+
const platformObjects: AnyObject[] = Object.values(PlatformObjects)
6981
.map((v) => v as unknown as AnyObject)
7082
.filter(
7183
(v) =>
@@ -84,19 +96,32 @@ function keyedTextColumns(o: AnyObject): Array<{ column: string; maxLength: unkn
8496
.map(([column, def]) => ({ column: `${o.name}.${column}`, maxLength: def.maxLength }));
8597
}
8698

87-
describe('identity keyed text-family columns declare their bound (#11374)', () => {
99+
describe('platform keyed text-family columns declare their bound (#11374)', () => {
88100
it('enumerates a real surface — the probe itself is not vacuous', () => {
89101
// Positive control: if the export shape or field/index spelling changes so
90102
// this file stops seeing columns, fail loudly instead of passing empty.
91-
const all = identityObjects.flatMap(keyedTextColumns);
92-
expect(identityObjects.length).toBeGreaterThanOrEqual(20);
93-
expect(all.length).toBeGreaterThanOrEqual(30);
103+
const all = platformObjects.flatMap(keyedTextColumns);
104+
expect(platformObjects.length).toBeGreaterThanOrEqual(40);
105+
expect(all.length).toBeGreaterThanOrEqual(70);
94106
expect(all.map((c) => c.column)).toContain('sys_session.token');
95107
});
96108

109+
it('reaches beyond identity/ — the scoping that let a keyed column escape', () => {
110+
// The specific regression control for this file's own history: while it
111+
// lived in `identity/` it enumerated only that directory, and
112+
// `sys_import_job.created_by` (audit/) went unbounded through route A's
113+
// first pass. These two names are in DIFFERENT source directories, so a
114+
// future re-narrowing of the import fails here by name rather than by
115+
// quietly enumerating less.
116+
const columns = platformObjects.flatMap(keyedTextColumns).map((c) => c.column);
117+
expect(columns).toContain('sys_import_job.created_by'); // audit/
118+
expect(columns).toContain('sys_metadata.name'); // metadata/
119+
expect(columns).toContain('sys_setting.key'); // system/
120+
});
121+
97122
it('every keyed text-family column declares a positive integer maxLength, or is allowlisted by name', () => {
98123
const offenders: string[] = [];
99-
for (const o of identityObjects) {
124+
for (const o of platformObjects) {
100125
for (const { column, maxLength } of keyedTextColumns(o)) {
101126
if (UNBOUNDABLE.has(column)) continue;
102127
const bounded =
@@ -115,7 +140,7 @@ describe('identity keyed text-family columns declare their bound (#11374)', () =
115140

116141
it('the UNBOUNDABLE allowlist matches only real, still-unbounded keyed columns', () => {
117142
const real = new Map(
118-
identityObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]),
143+
platformObjects.flatMap(keyedTextColumns).map((c) => [c.column, c.maxLength]),
119144
);
120145
for (const entry of UNBOUNDABLE) {
121146
expect(real.has(entry), `allowlist entry ${entry} is not a keyed text column any more — remove it`).toBe(true);

0 commit comments

Comments
 (0)