diff --git a/.changeset/signature-qrcode-bounded-text-family.md b/.changeset/signature-qrcode-bounded-text-family.md new file mode 100644 index 0000000000..42ae919de5 --- /dev/null +++ b/.changeset/signature-qrcode-bounded-text-family.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/driver-sql": minor +--- + +`signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + +- **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. +- **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). +- **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + +Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 634977cbfc..b70d67b397 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -65,7 +65,7 @@ const result = CurrencyConfigSchema.parse(data); | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | -| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code. | +| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `number` | optional | Min character length | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | diff --git a/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts b/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts index b3f609f23f..da2ac357b1 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts @@ -18,22 +18,32 @@ * * ## Which types moved, and the test that decided it * - * `code` moved with `richtext`. `signature` and `qrcode` did NOT, and that is - * the load-bearing half of this file rather than an omission. + * `code` moved with `richtext`. `signature` and `qrcode` did NOT move in + * #11794, and this file asserted that half out loud as an OPEN defect. * * An unbounded TEXT column is correct for a type exactly when the WRITE SEAM * enforces that type's declared `maxLength` — the invariant `schema-drift.ts` * already states ("A TEXT column refuses nothing a `maxLength` allows … the - * bound is enforced at the write seam"). objectql's record-validator applies - * its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / - * `password` / `markdown` / `html` / `richtext` / `code` — and to no other - * type. Measured: a `maxLength: 64` field of each of those refuses a - * 100-character value; the same field declared `signature` or `qrcode` - * ACCEPTS it. So for those two an unbounded column would accept values the - * declaration forbids — a physical surface WIDER than the contract, where - * `richtext` and `code` are a restoration of it. Their own defect (a data-URI - * signature capped at 255) is real and is asserted here as an open one, so - * this file records the state rather than hiding it. + * bound is enforced at the write seam"). At #11794 objectql's record-validator + * applied its `max_length` branch to `text` / `textarea` / `email` / `url` / + * `phone` / `password` / `markdown` / `html` / `richtext` / `code` — and to no + * other type — so for `signature` / `qrcode` an unbounded column would have + * accepted values the declaration forbids: a physical surface WIDER than the + * contract, where `richtext` and `code` were a restoration of it. + * + * ## #11875 closed that half (maintainer ruling 2026-08-25, option 1) + * + * `signature` and `qrcode` joined the spec's BOUNDED_STRING_FIELD_TYPES, the + * authoring seam admits `maxLength` on them, and the record-validator's + * `max_length` branch reads that same set — so the write seam now enforces + * their declared bound and the invariant above licenses their TEXT column. + * The former "STILL-OPEN half" cases below are the same measurements in their + * CLOSED shape: the data-URI that was refused `22001` / `ER_DATA_TOO_LONG` at + * varchar(255) is accepted and round-trips byte-identically, and the #11374 + * keyed-and-bounded rule applies to them the way it applies to every other + * text-family member (keyed + bounded ⇒ varchar(maxLength), physically + * enforced at exactly the declared bound; otherwise TEXT, bound enforced at + * the write seam). * * ## What each block is worth * @@ -54,7 +64,7 @@ import { MYSQL_CELL, PG_CELL, dialectCell, declareDialectCell } from './live-dia const T = 'os11794_text_family'; -/** The two this card moves, their siblings, and the stay-put controls. */ +/** The types the two cards move, their siblings, and the stay-put controls. */ const FIELDS = { // Moved by #11794: varchar(255) → TEXT. body_rich: { type: 'richtext' }, @@ -63,11 +73,13 @@ const FIELDS = { // already honoured for two of the three Rich Content members. body_md: { type: 'markdown' }, body_html: { type: 'html' }, - // Measured and deliberately NOT moved: no write seam enforces their - // `maxLength`, so TEXT would accept what the declaration forbids. + // Moved by #11875, once the write seam gained their declared bound + // (BOUNDED_STRING_FIELD_TYPES): varchar(255) → TEXT. body_sig: { type: 'signature' }, body_qr: { type: 'qrcode' }, - // Negative controls: the catch-all and the string family. + // Negative controls: the catch-all and the string family. `color` and + // `secret` are the #11875 ruling's explicit carve-outs (short by + // construction; opaque `sys_secret` ref per ADR-0100). c_string: { type: 'string' }, c_select: { type: 'select' }, c_color: { type: 'color' }, @@ -79,9 +91,13 @@ const OPTS = { bypassTenantAudit: true } as any; /** A rich-text body nobody would call exotic — four times the old cap. */ const LONG_BODY = `
${'a rich-text body well past the old varchar(255) cap — '.repeat(20)}
`; +/** The value the #11875 half is about: an ordinary data-URI signature. */ +const DATA_URI = `data:image/png;base64,${'A'.repeat(1000)}`; + const MOVED = ['body_rich', 'body_code'] as const; const SIBLINGS = ['body_md', 'body_html'] as const; -const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_secret'] as const; +const MOVED_11875 = ['body_sig', 'body_qr'] as const; +const NOT_MOVED = ['c_string', 'c_select', 'c_color', 'c_secret'] as const; /** * Every FieldType that takes an UNBOUNDED column when no index keys it — @@ -95,8 +111,10 @@ const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_ */ const UNBOUNDED_UNKEYED = [ // text family (`createColumn`) — every member must satisfy the write-seam - // invariant in this file's header. + // invariant in this file's header. `signature`/`qrcode` joined at #11875, + // when the write seam gained their declared bound. 'text', 'textarea', 'html', 'markdown', 'richtext', 'code', + 'signature', 'qrcode', // JSON columns and the virtual/non-varchar types: not a varchar either, for // reasons that have nothing to do with this card. 'multiselect', 'checkboxes', 'tags', 'composite', 'repeater', 'record', 'json', @@ -117,13 +135,13 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', await driver?.disconnect().catch(() => {}); }); - it('lands richtext/code as TEXT beside markdown/html — and moves nothing else', async () => { + it('lands richtext/code and signature/qrcode as TEXT beside markdown/html — and moves nothing else', async () => { driver = new SqlDriver(dialectCell('sqlite').config()); await driver.initObjects([{ name: T, fields: FIELDS }]); // The PRAGMA, not the emitter: knex's columnInfo() reads table_info. const info: ColumnInfo = await (driver as any).knex(T).columnInfo(); - for (const moved of MOVED) { + for (const moved of [...MOVED, ...MOVED_11875]) { expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe(true); } for (const sibling of SIBLINGS) { @@ -150,6 +168,16 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', expect(row.body_md).toBe(LONG_BODY); // the sibling that always worked }); + it('round-trips a >255-character data-URI signature/qrcode byte-identically (#11875)', async () => { + driver = new SqlDriver(dialectCell('sqlite').config()); + await driver.initObjects([{ name: T, fields: FIELDS }]); + expect(DATA_URI.length).toBeGreaterThan(255); + await driver.create(T, { id: 's1', body_sig: DATA_URI, body_qr: DATA_URI }, OPTS); + const [row] = await driver.find(T, { where: { id: 's1' } }, OPTS); + expect(row.body_sig).toBe(DATA_URI); + expect(row.body_qr).toBe(DATA_URI); + }); + it('pins the whole unbounded-when-unkeyed SET, so the case list cannot drift again', () => { driver = new SqlDriver(dialectCell('sqlite').config()); const mirror = (type: string) => @@ -162,8 +190,11 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', // The card's minimum, spelled out: the spec's three-member "Rich Content" // group is whole again. for (const t of ['markdown', 'html', 'richtext']) expect(mirror(t)).toBeNull(); - // And the two that measured as wideners stay bounded. - for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBe(255); + // #11875: the two former wideners moved once the write seam gained their + // bound — TEXT when unkeyed, like every other text-family member. + for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBeNull(); + // The ruling's explicit carve-outs stay bounded in the catch-all. + for (const t of ['color', 'secret']) expect(mirror(t)).toBe(255); }); it('keeps #11374 keyed-and-bounded semantics for the new members', () => { @@ -180,6 +211,16 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', // Keyed and unbounded: still TEXT — MySQL then refuses the key BY NAME // (explainUnkeyableTextColumn), never a silently weaker constraint. expect(mirror({ type: 'richtext' }, { unique: true })).toBeNull(); + // #11875: the same four corners for the two new members. The declared + // bound's ENFORCEMENT never depends on the index — the write seam holds it + // in every corner — the index only decides whether the COLUMN also + // enforces it (varchar(n) keyed, TEXT otherwise), exactly as for `code`. + for (const type of ['signature', 'qrcode']) { + expect(mirror({ type })).toBeNull(); + expect(mirror({ type, maxLength: 64 })).toBeNull(); + expect(mirror({ type, maxLength: 64 }, { unique: true })).toBe(64); + expect(mirror({ type }, { unique: true })).toBeNull(); + } }); }); @@ -203,7 +244,7 @@ for (const liveCell of [PG_CELL, MYSQL_CELL]) { // information_schema.columns, not the emitter: that is what knex's // columnInfo() reads on both of these dialects. const info: ColumnInfo = await (driver as any).knex(T).columnInfo(); - for (const moved of MOVED) { + for (const moved of [...MOVED, ...MOVED_11875]) { expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe( true, ); @@ -241,24 +282,72 @@ for (const liveCell of [PG_CELL, MYSQL_CELL]) { expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i); }); - it('records the STILL-OPEN half: an oversized signature is refused by the server', async () => { - // ⛔ Not a wish and not a quarantine — the current, deliberate state. - // `signature` stays varchar(255) because nothing enforces its declared - // `maxLength` at the write seam, so TEXT would accept what the - // declaration forbids. This asserts the cost of that choice out loud: - // a data-URI signature IS refused today. When the write seam gains a - // bound for it, this test is what turns red and gets updated. + it('closes the formerly-open half (#11875): an oversized data-URI signature/qrcode is accepted and round-trips', async () => { + // The #11794 version of this case asserted the COST of leaving + // `signature`/`qrcode` at varchar(255) out loud: the data-URI below + // was refused BY THE SERVER (`ER_DATA_TOO_LONG` / `22001`). The write + // seam has since gained their declared bound (#11875, + // BOUNDED_STRING_FIELD_TYPES), the column is TEXT, and the same value + // is accepted — this is that red turned green, not a deleted control. + // Non-vacuity for this cell is carried by the c_color refusal in the + // test above: the same session refuses an oversized write into a + // column that stayed varchar(255). driver = new SqlDriver(cell.config()); await driver.execute(`drop table if exists ${T}`).catch(() => {}); await driver.initObjects([{ name: T, fields: FIELDS }]); - const dataUri = `data:image/png;base64,${'A'.repeat(1000)}`; + await driver.create(T, { id: 's1', body_sig: DATA_URI, body_qr: DATA_URI }, OPTS); + const [row] = await driver.find(T, { where: { id: 's1' } }, OPTS); + expect(row.body_sig).toBe(DATA_URI); + expect(row.body_qr).toBe(DATA_URI); + }); + + it('keeps #11374 keyed-and-bounded semantics live for the new members (#11875)', async () => { + // A KEYED bounded signature/qrcode column is varchar(maxLength) — the + // physical catalog says so — and the server enforces EXACTLY the + // declared bound: the boundary value fits, one past it is refused. + // The declared bound's enforcement therefore never diverges by shape: + // unkeyed columns are TEXT with the same bound enforced at the write + // seam (record-validator, pinned in objectql), keyed columns enforce + // it physically too. Both directions measured, boundary included. + const KT = `${T}_keyed`; + // Hoisted (not an inline literal) the way #11374's `boundedObject()` + // is: `indexes` rides through `initObjects` beyond its narrow + // parameter type, exactly as the platform objects declare it. + const keyedObject = { + name: KT, + fields: { + sig: { type: 'signature', maxLength: 64 }, + qr: { type: 'qrcode', maxLength: 64 }, + }, + indexes: [ + { fields: ['sig'], unique: true }, + { fields: ['qr'], unique: false }, + ], + }; + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${KT}`).catch(() => {}); + await driver.initObjects([keyedObject]); + const info: ColumnInfo = await (driver as any).knex(KT).columnInfo(); + for (const col of ['sig', 'qr']) { + expect( + /varchar|character varying/i.test(String(info[col]?.type)), + `${col} landed ${String(info[col]?.type)}`, + ).toBe(true); + expect(Number(info[col]?.maxLength)).toBe(64); + } + // Boundary value: exactly maxLength characters is ACCEPTED. + await driver.create(KT, { id: 'b1', sig: 'x'.repeat(64), qr: 'y'.repeat(64) }, OPTS); + const [row] = await driver.find(KT, { where: { id: 'b1' } }, OPTS); + expect(row.sig).toBe('x'.repeat(64)); + // One past the boundary: refused by the SERVER at the declared bound. const refusal = await driver - .create(T, { id: 's1', body_sig: dataUri }, OPTS) + .create(KT, { id: 'b2', qr: 'y'.repeat(65) }, OPTS) .then(() => null) .catch((e: unknown) => e); expect(refusal).toBeInstanceOf(Error); const said = `${String((refusal as { code?: string })?.code ?? '')} ${String((refusal as Error).message)}`; expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i); + await driver.execute(`drop table if exists ${KT}`).catch(() => {}); }); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 6c12539f68..19e2241350 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -13483,6 +13483,10 @@ export class SqlDriver implements IDataDriver { case 'markdown': case 'richtext': case 'code': + // #11875 — moved out of the catch-all together with `createColumn`'s + // text-family case: the write seam now enforces their declared bound. + case 'signature': + case 'qrcode': return keyed ? this.keyableTextLength(field) : null; // Virtual — `createColumn` returns without emitting anything. case 'formula': @@ -13792,13 +13796,11 @@ export class SqlDriver implements IDataDriver { // declaration still binds once the column stops binding. // // objectql's record-validator applies its `max_length` / `min_length` - // branch to exactly `text` / `textarea` / `email` / `url` / `phone` / - // `password` / `markdown` / `html` / `richtext` / `code`. Both new - // members are inside that list — measured, not read off it: a - // `maxLength: 64` field of each type refuses a 100-character value with - // a field-named ADR-0112 envelope, before any column is reached. So - // moving them here RESTORES the declared contract (any string, as - // `valueSchemaFor` says) instead of widening past it. + // branch to exactly the spec's BOUNDED_STRING_FIELD_TYPES — measured, + // not read off it: a `maxLength: 64` field of each member refuses a + // 100-character value with a field-named ADR-0112 envelope, before any + // column is reached. So membership here RESTORES the declared contract + // (any string, as `valueSchemaFor` says) instead of widening past it. // // `richtext` is the headline member: the spec groups `markdown` / `html` // / `richtext` together as "Rich Content" (`field.zod.ts`) and two of @@ -13810,17 +13812,21 @@ export class SqlDriver implements IDataDriver { // (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`) and Postgres 16 // (`22001`). `code` is the same defect on the same evidence — a code // editor's contents, refused identically on both dialects. - // - // ⛔ `signature` and `qrcode` are STRING_VALUE_TYPES members whose stored - // value is also the author's own and also routinely far past 255 - // characters (field-zoo writes a data-URI PNG for `signature`), and they - // are deliberately NOT here — see the catch-all's note. The validator - // branch above does not list them, so nothing enforces their declared - // `maxLength` anywhere: for them an unbounded TEXT column would accept - // values the declaration forbids, which is a widening of the physical - // surface past the contract rather than a restoration of it. case 'richtext': - case 'code': { + case 'code': + // #11875 (maintainer ruling 2026-08-25, option 1): `signature` and + // `qrcode` join under exactly the invariant above. #11794 measured them + // (same live refusal: a 1000-char data-URI landed varchar(255) and was + // refused `22001` on PG, `ER_DATA_TOO_LONG` on MySQL) and deliberately + // left them in the catch-all, because at that point NOTHING enforced + // their declared `maxLength` — an unbounded TEXT would have accepted + // values the declaration forbids. That gap is now closed at both seams: + // `FieldSchema` admits `maxLength` on them (BOUNDED_STRING_FIELD_TYPES) + // and the record-validator's `max_length` branch reads the same set, so + // a TEXT column here refuses nothing the declaration allows — the bound + // is enforced at the write seam, and the data-URI refusal goes away. + case 'signature': + case 'qrcode': { // #11374: a text-family column that some declared index KEYS ON is // emitted as `varchar(maxLength)` rather than TEXT, whenever the field // declared a bound this dialect can key on. @@ -13977,20 +13983,11 @@ export class SqlDriver implements IDataDriver { // any of those from the author's `maxLength` would size the wrong // string. (`code` used to be mis-listed here among the option-valued // ones — measured in field-zoo it stores the editor's contents - // verbatim, which is why #11794 moved it to the text family above.) - // - // ⚠️ `signature` and `qrcode` are here for a DIFFERENT reason, and it is - // an OPEN DEFECT rather than a design. Their stored value IS the - // declared value and it is routinely far past 255 characters — a - // data-URI PNG for `signature` — so varchar(255) refuses ordinary - // authored values on both enforcing dialects, exactly the way it did - // for `richtext`. #11794 measured them and left them here anyway, - // because NOTHING enforces their declared `maxLength`: the - // record-validator's `max_length` branch does not list them, so an - // unbounded TEXT column would trade an under-accepting column for an - // over-accepting one — a physical surface wider than the contract. - // They need an enforced bound before they can move; see the text-family - // case above for the invariant that decides it. + // verbatim, which is why #11794 moved it to the text family above. + // `signature` / `qrcode` sat here as a measured OPEN DEFECT — their + // stored value IS the declared value, routinely a data-URI far past + // 255 chars, refused by both enforcing dialects — until #11875 gave + // the write seam their bound and moved them to the text family too.) // // A type that genuinely wants the bound belongs in the string-family // case above, named — never acquired by falling through to here. diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index a9af1a7b25..a958cca040 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -936,3 +936,100 @@ describe('validateRecord — number `scale` is enforced by rejection (#7501)', ( expect(err.message).toBe('Max hours per shift的小数位数不能超过 0 位(当前 1 位)'); }); }); + +/** + * #11875 (maintainer ruling 2026-08-25, option 1) — `signature` / `qrcode` + * join the bounded-string branch, so their declared `maxLength` / `minLength` + * finally BINDS at the write seam. This is the enforcement half of the ruling; + * the storage half (their column becomes TEXT, licensed by exactly this + * branch) is pinned in driver-sql's + * `sql-driver-11794-richtext-text-family.test.ts`. + * + * The branch now reads the spec's BOUNDED_STRING_FIELD_TYPES instead of a + * hand-copied type list, so the authoring seam (`FieldSchema` refuses the key + * outside the set) and this seam can no longer drift apart — the drift is how + * these two types spent a release with a declarable-nowhere/enforced-nowhere + * bound. + */ +describe('validateRecord — signature/qrcode maxLength binds at the write seam (#11875)', () => { + const schema = { + fields: { + sig: { type: 'signature', maxLength: 64 }, + qr: { type: 'qrcode', maxLength: 64 }, + }, + }; + + const fieldsOf = (data: Record