diff --git a/.changeset/identifier-ceiling-storage-owned.md b/.changeset/identifier-ceiling-storage-owned.md new file mode 100644 index 0000000000..e21ba23c4f --- /dev/null +++ b/.changeset/identifier-ceiling-storage-owned.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): state that identifier length ceilings are storage-owned (#12144) + +The shared identifier schemas (`SystemIdentifierSchema`, +`SnakeCaseIdentifierSchema`, `EventNameSchema`) declare `.min()` plus a +grammar and no `.max()`. That absence is now a documented contract decision +rather than an accident: the enforced ceiling on an identifier is the +`maxLength` of the column that stores it (refused at the write seam by +ObjectQL's record validator), and the storing columns disagree — 100 for +`sys_permission_set.name` / `sys_position.name` / `sys_capability.name`, +255 for `sys_metadata.name` — so no single shared `.max()` can equal every +consumer's enforced ceiling. No accepted value changes. + +A pin test in `@objectstack/plugin-security` +(`identifier-storage-ceiling-pin.test.ts`) links the spec schemas to the +storage columns that bound them, reading the widths off the registration +surface (the PR #12143 idiom), so the two cannot drift silently: a `.max()` +landing below a storing column's width, or a column width change, turns a +named test red with re-derivation instructions. diff --git a/content/docs/references/shared/identifiers.mdx b/content/docs/references/shared/identifiers.mdx index c598d2630e..5227ff7c39 100644 --- a/content/docs/references/shared/identifiers.mdx +++ b/content/docs/references/shared/identifiers.mdx @@ -34,6 +34,20 @@ Enforces lowercase with underscores or dots to ensure: | Event keys | dot.notation | `user.login`, `order.created` | | Labels | Any case | `Client Account`, `Submit Form` | +**Length ceiling — storage-owned, deliberately not declared here (#12144).** +The identifier schemas in this file declare a floor and a grammar but no +`.max()`: the enforced ceiling on an identifier is the `maxLength` of the +column that stores it (refused at the write seam by ObjectQL's record +validator), and the storing columns disagree — the config-object name +columns (`sys_permission_set.name`, `sys_position.name`, +`sys_capability.name`) enforce 100 while `sys_metadata.name` enforces 255 — +so no single `.max()` here can equal every consumer's enforced ceiling. +Do not add one unless every consuming column agrees on one width: a +`.max()` below the widest storing column refuses names that are legal +stored rows today. The schema↔column link is pinned in +`@objectstack/plugin-security`'s `identifier-storage-ceiling-pin.test.ts`, +which reads the column widths off the registration surface. + @example Valid identifiers - 'account' - 'crm_account' diff --git a/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts b/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts new file mode 100644 index 0000000000..ef7b875394 --- /dev/null +++ b/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + SnakeCaseIdentifierSchema, + SystemIdentifierSchema, + MetadataItemNameSchema, +} from '@objectstack/spec/shared'; +import { SysMetadataObject } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; + +/** + * #12144 — the shared identifier schemas ↔ the storage columns that bound them. + * + * `packages/spec/src/shared/identifiers.zod.ts` declares the platform's + * identifier schemas with a floor and a grammar and **no `.max()`** — on + * purpose: every enforced ceiling on an identifier is the `maxLength` of the + * column that stores it (refused at the write seam by ObjectQL's + * record-validator `max_length` check), and the storing columns DISAGREE — + * 100 for the three config-object name columns this plugin registers + * (`sys_permission_set.name`, `sys_position.name`, `sys_capability.name`), + * 255 for `sys_metadata.name`. A single shared `.max()` therefore cannot + * equal every consumer's enforced ceiling: `.max(100)` would newly refuse + * `sys_metadata` names in (100, 255] that are legal stored rows today — + * accept-set narrowing beyond enforced reality, fenced out of #12144's + * dispatch by triage. + * + * This pin links the two surfaces so they cannot drift apart silently, the + * PR #12143 idiom: the widths are READ off the registration surface + * (`SecurityPlugin.init()` → the manifest `register({ objects })` call, and + * the `SysMetadataObject` declaration), never restated inside the assertions + * that use them. Only the one deliberate value pin restates them, so a width + * change turns exactly one test red with re-derivation instructions. + * + * ## What a red on this file means + * + * - The **value pin** red: a storing column's width moved. Re-derive #12144's + * table before accepting: does the spec schema still accept everything the + * column stores? Do the columns now AGREE on one width? If they all agree, + * the long-fenced declared-=-enforced `.max()` may finally be derivable — + * that is a spec accept-set change; escalate it, never rider it. + * - The **width-acceptance pin** red: a `.max()` landed on a shared identifier + * schema below the width of a column that stores identifier-class values — + * names that are legal stored rows today would now be refused at parse. + * That is the exact drift this file exists to catch; fix the spec, not this + * pin. + * - The **storage-owned-ceiling pin** red: a `.max()` landed on a shared + * identifier schema at or above the widest storing column. Correct ONLY if + * every consuming surface's enforced ceiling equals it — which the value + * pin above will already be contradicting while the columns disagree. Prove + * the per-surface measurement (#12144's triage fence spells out the burden) + * before touching this pin. + */ + +type AnyObject = { + name: string; + fields: Record; +}; + +/** + * The objects `SecurityPlugin` really contributes to a kernel, read off the + * manifest registration it performs in `init()` — same harness as the + * sibling `plugin-keyed-text-bounds.test.ts`. + */ +async function registeredObjects(): Promise { + const captured: AnyObject[] = []; + const noop = () => {}; + await new SecurityPlugin().init({ + logger: { info: noop, warn: noop, error: noop, debug: noop }, + registerService: noop, + getService(name: string) { + if (name === 'manifest') { + return { + register(m: { objects?: AnyObject[] }) { + for (const o of m?.objects ?? []) captured.push(o); + }, + }; + } + return undefined; + }, + } as never); + return captured; +} + +/** The three config-object name columns this plugin registers. */ +const PLUGIN_NAME_COLUMNS = ['sys_permission_set', 'sys_position', 'sys_capability'] as const; + +/** Read `.name`'s declared width, refusing to answer from absence. */ +function nameWidth(o: AnyObject | undefined, label: string): number { + const w = o?.fields?.name?.maxLength; + expect( + typeof w === 'number' && Number.isInteger(w) && w > 0, + `${label}.name must declare a positive integer maxLength (got ${String(w)}) — ` + + `without it this pin has no width to link the spec schema against`, + ).toBe(true); + return w as number; +} + +/** Every measured storing column, width read off its registration surface. */ +async function measuredColumns(): Promise> { + const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); + const cols = PLUGIN_NAME_COLUMNS.map((n) => ({ + column: `${n}.name`, + width: nameWidth(byName.get(n), n), + })); + cols.push({ + column: 'sys_metadata.name', + width: nameWidth(SysMetadataObject as unknown as AnyObject, 'sys_metadata'), + }); + return cols; +} + +/** + * A grammar-valid identifier of exactly `n` characters — a lowercase-letter + * run satisfies all three schemas' regexes and floors (min 2 / min none). + */ +const validIdentifier = (n: number): string => 'a'.repeat(n); + +const IDENTIFIER_SCHEMAS = { + SnakeCaseIdentifierSchema, + SystemIdentifierSchema, + MetadataItemNameSchema, +} as const; + +describe('shared identifier schemas ↔ the storage columns that bound them (#12144)', () => { + it('enumerates a real surface and the probes really measure the schemas — the pin is not vacuous', async () => { + const objects = await registeredObjects(); + expect(objects.map((o) => o.name)).toEqual( + expect.arrayContaining([...PLUGIN_NAME_COLUMNS]), + ); + + // Instrument honesty: a string of the same width that VIOLATES the + // grammar must be refused by every schema, so the acceptance probes + // below are measuring the schema rather than passing vacuously. + for (const [label, schema] of Object.entries(IDENTIFIER_SCHEMAS)) { + expect( + schema.safeParse('A'.repeat(100)).success, + `${label} accepted an uppercase 100-char string — the probe instrument is broken`, + ).toBe(false); + } + }); + + it('the enforced ceilings, pinned by value — a width change is a #12144 re-derivation moment', async () => { + // Pinned by VALUE, deliberately (the #12143 idiom's one restatement): + // these widths are the ENFORCED ceilings on identifier-class values — + // enforcement lives at the write seam (ObjectQL record-validator, + // `max_length`), never in the spec schemas, which declare no `.max()`. + // If one of these reds, a column width moved: re-derive #12144 before + // accepting — and if the columns now all AGREE on one width, the + // declared-=-enforced `.max()` that triage fenced may finally be + // derivable; that is a spec accept-set change to escalate, not a value + // to tidy here. + const widths = new Map((await measuredColumns()).map((c) => [c.column, c.width])); + expect(widths.get('sys_permission_set.name')).toBe(100); + expect(widths.get('sys_position.name')).toBe(100); + expect(widths.get('sys_capability.name')).toBe(100); + expect(widths.get('sys_metadata.name')).toBe(255); + }); + + it('every spec identifier schema accepts a name exactly as wide as each storing column — the #12144 fence, mechanized', async () => { + // Width read LIVE off the registration surface: a `.max()` on a shared + // identifier schema below the width of a column that stores this value + // class would newly refuse names that are legal stored rows today — + // accept-set narrowing beyond enforced reality. `sys_metadata.name` + // (255) is the widest and therefore the binding case: a blanket + // `.max(100)` matching the config-object columns reds here by name. + for (const { column, width } of await measuredColumns()) { + for (const [label, schema] of Object.entries(IDENTIFIER_SCHEMAS)) { + expect( + schema.safeParse(validIdentifier(width)).success, + `${label} refused a ${width}-char identifier, the declared width of ${column} — ` + + `a legal stored row's name no longer parses (#12144: never narrow the spec ` + + `below a storing column; fix the spec, not this pin)`, + ).toBe(true); + } + } + }); + + it('the ceiling is storage-owned: the spec schemas declare no ceiling of their own', async () => { + // One character above the WIDEST storing column, width read live. Red + // here means a `.max()` landed on a shared identifier schema. That is + // correct ONLY when every consuming surface's enforced ceiling equals + // it (declared = enforced, per surface, measured) — while the storing + // columns disagree (see the value pin) no shared `.max()` can be, and + // the change is the narrowing #12144's triage explicitly fenced. + // Escalate with the per-surface measurement; do not edit this pin to + // absorb the red. + const widest = Math.max(...(await measuredColumns()).map((c) => c.width)); + for (const [label, schema] of Object.entries(IDENTIFIER_SCHEMAS)) { + expect( + schema.safeParse(validIdentifier(widest + 1)).success, + `${label} refused a ${widest + 1}-char identifier — it now declares its own ` + + `ceiling. The identifier ceiling is storage-owned (#12144); a spec .max() ` + + `is only correct when every consuming column's enforced width equals it.`, + ).toBe(true); + } + }); +}); diff --git a/packages/spec/src/shared/identifiers.zod.ts b/packages/spec/src/shared/identifiers.zod.ts index 035cfe6f50..badf06416f 100644 --- a/packages/spec/src/shared/identifiers.zod.ts +++ b/packages/spec/src/shared/identifiers.zod.ts @@ -31,6 +31,20 @@ import { z } from 'zod'; * | Machine ID | snake_case | `crm_account`, `btn_submit`, `role_admin` | * | Event keys | dot.notation | `user.login`, `order.created` | * | Labels | Any case | `Client Account`, `Submit Form` | + * + * **Length ceiling — storage-owned, deliberately not declared here (#12144).** + * The identifier schemas in this file declare a floor and a grammar but no + * `.max()`: the enforced ceiling on an identifier is the `maxLength` of the + * column that stores it (refused at the write seam by ObjectQL's record + * validator), and the storing columns disagree — the config-object name + * columns (`sys_permission_set.name`, `sys_position.name`, + * `sys_capability.name`) enforce 100 while `sys_metadata.name` enforces 255 — + * so no single `.max()` here can equal every consumer's enforced ceiling. + * Do not add one unless every consuming column agrees on one width: a + * `.max()` below the widest storing column refuses names that are legal + * stored rows today. The schema↔column link is pinned in + * `@objectstack/plugin-security`'s `identifier-storage-ceiling-pin.test.ts`, + * which reads the column widths off the registration surface. * * @example Valid identifiers * - 'account' @@ -69,6 +83,10 @@ export const SystemIdentifierSchema = lazySchema(() => z * @example Invalid * - 'user.profile' (dots not allowed) * - 'UserProfile' (uppercase) + * + * No `.max()` is declared, deliberately — identifier length ceilings are + * storage-owned and the storing columns disagree; see the length-ceiling note + * on {@link SystemIdentifierSchema} and issue #12144. */ export const SnakeCaseIdentifierSchema = lazySchema(() => z .string() @@ -170,6 +188,12 @@ export const MetadataItemNameSchema = lazySchema(() => z * @example Invalid * - 'UserCreated' (camelCase) * - 'user_created' (should use dots for namespacing) + * + * No `.max()` is declared, deliberately — identifier length ceilings are + * storage-owned; see the length-ceiling note on + * {@link SystemIdentifierSchema} and issue #12144. (No bounded storage column + * among #12144's measured set stores an event name, so no ceiling has been + * measured for this schema at all.) */ export const EventNameSchema = lazySchema(() => z .string()