From 14ac9508741ea6cba0871507468da8bc07a2bfef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 12:49:09 +0000 Subject: [PATCH 1/6] fix(spec)!: Field.select shorthand keeps every character it does not document transforming, so a hyphenated option is refused rather than rewritten sys_metadata_audit.lock_state becomes a text column holding the MetadataLock spelling its writer stores. Claude-Session: https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ Co-authored-by: Claude --- .../src/objects/sys-metadata-audit.object.ts | 10 +++++++++- packages/spec/src/data/field.zod.ts | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts index 052b207b5f4..8bf14442aa4 100644 --- a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts @@ -186,8 +186,16 @@ export const SysMetadataAuditObject = ObjectSchema.create({ * item carried no `_lock`). Captured even on `allowed` rows so * later compliance queries can see "what was the lock state when * this write succeeded". + * + * A text column, not a select: the writer stores the `MetadataLock` + * spelling as is (`none` | `no-overlay` | `no-delete` | `full`), and a + * select option value is a system identifier, which holds no hyphen. The + * `Field.select([...])` shorthand used to strip the hyphen, declaring + * `nooverlay` / `nodelete` — values no row ever held (#25013). Like `code` + * above, the closed set lives with its writer (`MetadataLockSchema`). + * No `maxLength`: existing columns keep their width and report no drift. */ - lock_state: Field.select(['none', 'no-overlay', 'no-delete', 'full'], { + lock_state: Field.text({ label: 'Lock State', required: false, readonly: true, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 0b244e42c23..9d7a024c0fb 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -3050,13 +3050,12 @@ type UserFieldInput = Omit & { /** `Field.select` with inline options — the array or `{ options }` forms. */ function selectWithOptions(optionsOrConfig: Array | Omit & { options: Array }, config?: FieldInput) { - // Helper function to convert string to lowercase snake_case - const toSnakeCase = (str: string): string => { - return str - .toLowerCase() - .replace(/\s+/g, '_') // Replace spaces with underscores - .replace(/[^a-z0-9_]/g, ''); // Remove invalid characters (keeping underscores only) - }; + // The shorthand's whole transform: lower-case, and whitespace runs → `_`. + // Every other character stays as written, so a string that is no system + // identifier (`'no-overlay'`, `'v2-beta'`) reaches `SelectOptionSchema.value` + // and is refused there, by the same rule an explicit `{ value: 'no-overlay' }` + // meets — never rewritten into a value the author did not write (#25013). + const toSnakeCase = (str: string): string => str.toLowerCase().replace(/\s+/g, '_'); // Support both old and new signatures: // Old: Field.select(['a', 'b'], { label: 'X' }) @@ -3163,6 +3162,12 @@ export const Field = { * Field.select(['In Progress', 'Closed Won'], { label: 'Status' }) * // Results in: [{ label: 'In Progress', value: 'in_progress' }, { label: 'Closed Won', value: 'closed_won' }] * + * Lower-casing and whitespace → `_` are the shorthand's only transforms; any + * other character is kept, so `Field.select(['no-overlay'])` yields the value + * `no-overlay`, which `SelectOptionSchema.value` refuses (a system identifier + * holds letters, digits, `_` and `.`). Write such an option explicitly: + * `{ label: 'no-overlay', value: 'no_overlay' }`. + * * @example Shared picklist — the options come from the named `picklist` item * Field.select({ picklist: 'industry', label: 'Industry' }) * // Results in: { type: 'select', picklist: 'industry', label: 'Industry' } — no `options` From 9b40625673ae5716e0ad3e3c1d75ae7b0933962b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:02:45 +0000 Subject: [PATCH 2/6] test: pin the shorthand refusal at the save door and lock_state's text declaration; regenerate translation bundles Claude-Session: https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ Co-authored-by: Claude --- .../sys-metadata-audit-lock-state.test.ts | 40 +++++++ .../protocol.select-shorthand-refusal.test.ts | 103 ++++++++++++++++++ .../apps/translations/en.objects.generated.ts | 8 +- .../translations/es-ES.objects.generated.ts | 8 +- .../translations/ja-JP.objects.generated.ts | 8 +- .../translations/zh-CN.objects.generated.ts | 8 +- packages/spec/src/data/field.test.ts | 43 ++++++++ 7 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts create mode 100644 packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts diff --git a/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts b/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts new file mode 100644 index 00000000000..2f396a0481f --- /dev/null +++ b/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25013 — `sys_metadata_audit.lock_state` declares a column that holds the + * value its writer stores. + * + * The writer (`metadata-protocol` `protocol.ts`, `lock_state: entry.lockState + * ?? 'none'`) stores a `MetadataLock` as spelled — `no-overlay`, `no-delete`. + * The column used to be `Field.select([...])`, whose shorthand stripped the + * hyphen and declared `nooverlay` / `nodelete`, values no row ever held. A + * select option value is a system identifier, which holds no hyphen, so the + * column is text, and the closed set stays with `MetadataLockSchema`. + */ +import { describe, expect, it } from 'vitest'; +import { ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; +import { MetadataLockSchema } from '@objectstack/spec/kernel'; +import { SysMetadataAuditObject } from './sys-metadata-audit.object.js'; + +describe('sys_metadata_audit.lock_state (#25013)', () => { + const field = SysMetadataAuditObject.fields.lock_state as Record; + + it('is a text column with no option list of its own and no width', () => { + expect(field.type).toBe('text'); + expect(field).not.toHaveProperty('options'); + // No `maxLength`: an existing `varchar(255)` column reports no width drift. + expect(field).not.toHaveProperty('maxLength'); + expect(field.readonly).toBe(true); + }); + + it('the writer stores values a select option could not declare — which is why it is not a select', () => { + const undeclarable = MetadataLockSchema.options.filter( + (lock) => !SelectOptionSchema.safeParse({ label: lock, value: lock }).success, + ); + expect(undeclarable).toEqual(['no-overlay', 'no-delete']); + }); + + it('the object still parses as a whole', () => { + expect(ObjectSchema.safeParse(SysMetadataAuditObject).success).toBe(true); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts b/packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts new file mode 100644 index 00000000000..d1fcca24bd0 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25013 — a `Field.select` shorthand string that is no system identifier is + * refused at the object save door, by the refusal an explicit option value + * already meets. + * + * The shorthand (`packages/spec` `selectWithOptions`) used to delete every + * character outside `[a-z0-9_]`, so `Field.select(['no-overlay'])` declared + * the value `nooverlay` — a different value than the one written, accepted + * without a word. It now only lower-cases and joins whitespace with `_`, so + * the written value reaches `SelectOptionSchema.value` and `saveMetaItem` + * answers `422 INVALID_METADATA` naming the option slot, exactly as for + * `{ label: 'no-overlay', value: 'no-overlay' }`. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { Field } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** The engine surface the repository write path touches (as the container-issue-descent harness). */ +function makeProtocol() { + const rows = new Map>(); + let nextId = 0; + const engine: any = { + async findOne(object: string, query?: EngineFindOneQueryInput) { + assertEngineFindOnePredicate(object, query); return null; }, + async find() { return []; }, + async insert(table: string, data: Record) { + nextId += 1; + if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data); + return { id: `r_${nextId}` }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { registerItem: () => {}, registerObject: () => {} }, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); + return { protocol, rows }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (err) { + return err; + } + throw new Error('expected the save to be rejected, but it resolved'); +} + +const objectWith = (status: unknown) => ({ + name: 'ticket', + label: 'Ticket', + sharingModel: 'private', + fields: { status: { name: 'status', label: 'Status', ...(status as object) } }, +}); + +describe('#25013 the save door refuses a shorthand option that is no system identifier', () => { + it('`Field.select([\'open\', \'no-overlay\'])` answers 422 INVALID_METADATA at the option slot, and nothing is stored', async () => { + const { protocol, rows } = makeProtocol(); + + const err = await rejection(protocol.saveMetaItem({ + type: 'object', name: 'ticket', item: objectWith(Field.select(['open', 'no-overlay'])), + })); + + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + expect(err.issues).toEqual([ + expect.objectContaining({ path: 'fields.status.options.1.value', code: 'invalid_format' }), + ]); + expect(rows.size).toBe(0); + }); + + it('the explicit `{ value: \'no-overlay\' }` meets the identical refusal — one refusal set, not two', async () => { + const shorthand = await rejection(makeProtocol().protocol.saveMetaItem({ + type: 'object', name: 'ticket', item: objectWith(Field.select(['open', 'no-overlay'])), + })); + const explicit = await rejection(makeProtocol().protocol.saveMetaItem({ + type: 'object', name: 'ticket', + item: objectWith(Field.select({ options: ['open', { label: 'no-overlay', value: 'no-overlay' }] })), + })); + + expect([explicit.code, explicit.status]).toEqual(['INVALID_METADATA', 422]); + expect(shorthand.issues).toEqual(explicit.issues); + }); + + it('control: the documented transform still saves (`In Progress` → `in_progress`)', async () => { + const { protocol, rows } = makeProtocol(); + + const result = await protocol.saveMetaItem({ + type: 'object', name: 'ticket', item: objectWith(Field.select(['Open', 'In Progress'])), + }); + + expect(result.success).toBe(true); + expect(rows.size).toBe(1); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 019a10cebea..495f45a6171 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3245,13 +3245,7 @@ export const enObjects: NonNullable = { label: "Code" }, lock_state: { - label: "Lock State", - options: { - none: "none", - nooverlay: "no-overlay", - nodelete: "no-delete", - full: "full" - } + label: "Lock State" }, lock_overridden: { label: "Lock Overridden" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 39005bfcfc4..fea3218412e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3245,13 +3245,7 @@ export const esESObjects: NonNullable = { label: "Código" }, lock_state: { - label: "Estado de bloqueo", - options: { - none: "Ninguno", - nooverlay: "Sin superposición", - nodelete: "Sin eliminación", - full: "Bloqueo total" - } + label: "Estado de bloqueo" }, lock_overridden: { label: "Bloqueo anulado" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index e7d15534368..a82570c2582 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3245,13 +3245,7 @@ export const jaJPObjects: NonNullable = { label: "コード" }, lock_state: { - label: "ロック状態", - options: { - none: "なし", - nooverlay: "オーバーレイ禁止", - nodelete: "削除禁止", - full: "完全ロック" - } + label: "ロック状態" }, lock_overridden: { label: "ロックの上書き" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index f9367c48ec6..82c69565cc9 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3245,13 +3245,7 @@ export const zhCNObjects: NonNullable = { label: "代码" }, lock_state: { - label: "锁定状态", - options: { - none: "无", - nooverlay: "禁止叠加", - nodelete: "禁止删除", - full: "完全锁定" - } + label: "锁定状态" }, lock_overridden: { label: "锁定已被覆盖" diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 5def92dc7cb..fff70c7ab30 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1485,6 +1485,49 @@ describe('Field Factory Helpers', () => { expect(selectField.options[1]).toEqual({ label: 'Inactive', value: 'inactive' }); expect(selectField.options[2]).toEqual({ label: 'Pending', value: 'pending' }); }); + + // #25013 — the shorthand's only transforms are lower-casing and + // whitespace → `_`. It used to also DELETE every other character, so + // `'no-overlay'` was declared as `nooverlay`: a value nobody wrote. + describe('a shorthand string is kept as written beyond the documented transform', () => { + const issuesOf = (field: unknown) => { + const result = FieldSchema.safeParse({ name: 'status', ...(field as object) }); + expect(result.success).toBe(false); + return (result as { error: z.ZodError }).error.issues; + }; + + it('keeps a hyphen, so the value is the one written', () => { + expect(Field.select(['no-overlay']).options).toEqual([{ label: 'no-overlay', value: 'no-overlay' }]); + expect(Field.select({ options: ['user-agent-based'] }).options) + .toEqual([{ label: 'user-agent-based', value: 'user-agent-based' }]); + }); + + it('meets the refusal an explicit `{ value: \'no-overlay\' }` meets — the same issue, the same path', () => { + const shorthand = issuesOf(Field.select(['open', 'no-overlay'])); + const explicit = issuesOf(Field.select({ options: ['open', { label: 'no-overlay', value: 'no-overlay' }] })); + expect(shorthand).toEqual(explicit); + expect(shorthand).toHaveLength(1); + expect(shorthand[0]).toMatchObject({ code: 'invalid_format', path: ['options', 1, 'value'] }); + }); + + it('refuses at module load when an object declares it through `ObjectSchema.create`', () => { + let thrown: unknown; + try { + ObjectSchema.create({ name: 'probe_lock', label: 'Probe', fields: { lock_state: Field.select(['none', 'no-overlay']) } }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(z.ZodError); + expect((thrown as z.ZodError).issues.map((i) => [i.code, i.path.join('.')])) + .toEqual([['invalid_format', 'fields.lock_state.options.1.value']]); + }); + + it('still lower-cases and joins whitespace with `_`, and those values parse', () => { + const field = Field.select(['In Progress', 'Closed Won', 'v1.2']); + expect(field.options.map((o) => o.value)).toEqual(['in_progress', 'closed_won', 'v1.2']); + expect(FieldSchema.safeParse({ name: 'stage', ...field }).success).toBe(true); + }); + }); }); describe('Lookup and Master-Detail Fields', () => { From 88440a148ba2768de8d7a701bdb97816d0baac0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:14:03 +0000 Subject: [PATCH 3/6] fix(metadata-core): lock_state keeps the varchar(255) width the select column was created with Claude-Session: https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ Co-authored-by: Claude --- .../src/objects/sys-metadata-audit-lock-state.test.ts | 7 ++++--- .../metadata-core/src/objects/sys-metadata-audit.object.ts | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts b/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts index 2f396a0481f..86b9836ac57 100644 --- a/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts +++ b/packages/metadata-core/src/objects/sys-metadata-audit-lock-state.test.ts @@ -19,11 +19,12 @@ import { SysMetadataAuditObject } from './sys-metadata-audit.object.js'; describe('sys_metadata_audit.lock_state (#25013)', () => { const field = SysMetadataAuditObject.fields.lock_state as Record; - it('is a text column with no option list of its own and no width', () => { + it('is a text column with no option list of its own, as wide as the select column was', () => { expect(field.type).toBe('text'); expect(field).not.toHaveProperty('options'); - // No `maxLength`: an existing `varchar(255)` column reports no width drift. - expect(field).not.toHaveProperty('maxLength'); + // The `varchar(255)` a select column is created as: an existing database + // reports no drift (no `maxLength` would read as varchar → text). + expect(field.maxLength).toBe(255); expect(field.readonly).toBe(true); }); diff --git a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts index 8bf14442aa4..afe8c33b513 100644 --- a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts @@ -193,12 +193,16 @@ export const SysMetadataAuditObject = ObjectSchema.create({ * `Field.select([...])` shorthand used to strip the hyphen, declaring * `nooverlay` / `nodelete` — values no row ever held (#25013). Like `code` * above, the closed set lives with its writer (`MetadataLockSchema`). - * No `maxLength`: existing columns keep their width and report no drift. + * `maxLength: 255` is the `varchar(255)` the select column was created + * as, so an existing database reports no drift: measured with + * `diffManagedTable` on postgres / mysql / sqlite, no `maxLength` reads as a + * varchar → text type change and a narrower one as a destructive narrowing. */ lock_state: Field.text({ label: 'Lock State', required: false, readonly: true, + maxLength: 255, }), /** True when the write succeeded by bypassing a lock (Phase 3). */ From 62ebb57f3fa220e2e18d72507579958ade389eac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:16:18 +0000 Subject: [PATCH 4/6] chore(changeset): the select shorthand refusal and lock_state's text declaration Claude-Session: https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ Co-authored-by: Claude --- ...13-select-shorthand-keeps-written-value.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/25013-select-shorthand-keeps-written-value.md diff --git a/.changeset/25013-select-shorthand-keeps-written-value.md b/.changeset/25013-select-shorthand-keeps-written-value.md new file mode 100644 index 00000000000..5c28fadcc5d --- /dev/null +++ b/.changeset/25013-select-shorthand-keeps-written-value.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-core': minor +'@objectstack/platform-objects': patch +--- + +fix(spec)!: the `Field.select` string shorthand keeps every character it does not document transforming, so an option such as `'no-overlay'` is refused instead of being declared as a different value + +Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead) + +Metadata-protocol: narrowed + + + +**BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes. + +`Field.select(['a', 'b'])` and `Field.select({ options: ['a', 'b'] })` build each option as `{ label: s, value: … }`. The value used to be the string lower-cased, with whitespace turned into `_`, and then with every character outside `a-z`, `0-9` and `_` deleted. That last step was undocumented and silent: `'no-overlay'` became `nooverlay`, `'user-agent-based'` became `useragentbased`, `'v1.2'` became `v12`, and the field declared a value its author never wrote, so a record written with the value as written was refused with `invalid_option`. + +- **FROM:** a shorthand string is lower-cased, whitespace becomes `_`, and every other character outside `a-z0-9_` is deleted. `Field.select(['no-overlay'])` declares the option value `nooverlay`, and the object loads. +- **TO:** a shorthand string is lower-cased and whitespace becomes `_`, nothing else. `Field.select(['no-overlay'])` declares `no-overlay`, which is not a system identifier, so the object is refused by the rule an explicit `{ value: 'no-overlay' }` already met: `ObjectSchema.create` throws a `ZodError` at module load with `invalid_format` at `fields.FIELD.options.N.value` and the message `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots`, and the object save door answers `422 INVALID_METADATA` with the same finding. A dot is now kept (`'v1.2'` declares `v1.2`, which is a valid identifier). +- **Unchanged:** `Field.select(['In Progress', 'Closed Won'])` still declares `in_progress` and `closed_won`. An explicit option's value is still lower-cased. `SelectOptionSchema.value` accepts and refuses exactly what it did before. +- **The fix:** write such an option explicitly, with the value you will store: `Field.select({ options: [{ label: 'no-overlay', value: 'no_overlay' }] })`. If rows already hold the hyphenated spelling, the column is not a select: declare it as `Field.text({ maxLength: 255 })`. + +`@objectstack/metadata-core`: `sys_metadata_audit.lock_state` was the one in-tree column relying on the strip. It declared the options `none`, `nooverlay`, `nodelete` and `full`, while its writer stores the `MetadataLock` spelling (`none`, `no-overlay`, `no-delete`, `full`), so no `no-overlay` or `no-delete` row ever matched a declared option. It is now `Field.text({ maxLength: 255 })`, read-only as before. `GET /api/v1/meta/object/sys_metadata_audit` serves `lock_state` as `type: 'text'` with `maxLength: 255` and no `options`. No stored row is rewritten, the writer and reader are unchanged, and an existing `varchar(255)` column reports no schema drift on postgres, mysql or sqlite. + +`@objectstack/platform-objects`: the translation bundles drop the four `sys_metadata_audit.lock_state` option labels (`none`, `nooverlay`, `nodelete`, `full`), which the column no longer declares. The field label stays translated. From 91d74cabe8bee2e4efaaa49e7d6f9a72eafda6d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:17:49 +0000 Subject: [PATCH 5/6] chore(spec): register the select shorthand refusal as an ADR-0087 semantic entry Claude-Session: https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ Co-authored-by: Claude --- ...13-select-shorthand-keeps-written-value.md | 2 +- ...ld-select-shorthand-keeps-written-value.ts | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.field-select-shorthand-keeps-written-value.ts diff --git a/.changeset/25013-select-shorthand-keeps-written-value.md b/.changeset/25013-select-shorthand-keeps-written-value.md index 5c28fadcc5d..6a43256ad6a 100644 --- a/.changeset/25013-select-shorthand-keeps-written-value.md +++ b/.changeset/25013-select-shorthand-keeps-written-value.md @@ -10,7 +10,7 @@ Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to Metadata-protocol: narrowed - + **BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes. diff --git a/packages/spec/src/migrations/entries/semantic/18.field-select-shorthand-keeps-written-value.ts b/packages/spec/src/migrations/entries/semantic/18.field-select-shorthand-keeps-written-value.ts new file mode 100644 index 00000000000..79056da3680 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-select-shorthand-keeps-written-value.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A shorthand that silently declared a value nobody wrote, refused instead. The +// Field.select string shorthand used to delete every character outside a-z, 0-9 +// and _, so 'no-overlay' was declared as nooverlay. It now keeps the string as +// written beyond lower-casing and whitespace to _, and SelectOptionSchema.value +// refuses what is no system identifier, as it always did for an explicit value. +// A structured TODO, not a D2 conversion: the shorthand runs in the author's own +// TypeScript, so no stored artifact carries it, and which value the author meant +// to store (no_overlay, or the column is not a select at all) is intent no +// artifact records. +export const entry: SemanticMigration = { + id: 'field-select-shorthand-keeps-written-value', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.Field.select string shorthand (Field.select([...]) and Field.select({ options: [...] }) with ' + + 'string options) — a string holding a character outside letters, digits, whitespace, _ and . ' + + '(a hyphen, most often) is now refused at options.N.value instead of being declared with that ' + + 'character deleted', + replacement: + 'write the option explicitly with the value you store: Field.select({ options: [{ label: ' + + '\'no-overlay\', value: \'no_overlay\' }] }). Where rows already hold the hyphenated spelling, the ' + + 'column is not a select: declare it as Field.text({ maxLength: 255 })', + reason: + 'The shorthand built each option as { label: s, value: s lower-cased, whitespace runs turned into _, ' + + 'and every other character outside a-z, 0-9 and _ deleted }. The last step was undocumented and ' + + 'silent: Field.select([\'no-overlay\']) declared nooverlay, and a record written with no-overlay, the ' + + 'value as written, was refused with invalid_option on a writable field and held a value no option ' + + 'declared on a read-only one. The ruling on this defect made the shorthand stop rewriting and join ' + + 'the refusal an explicit { value: \'no-overlay\' } already met, rather than inventing a parallel one ' + + '(ADR-0049: a declaration holds what its writer writes). SelectOptionSchema.value accepts and refuses ' + + 'exactly what it did before; the shorthand only stops hiding a value from it. A dot is now kept ' + + 'rather than deleted, so \'v1.2\' declares v1.2, a valid identifier, where it declared v12. An ' + + 'object built by the old helper and stored carries explicit option values (nooverlay), which still ' + + 'parse, so no stored row or definition needs rewriting. Measured on this tree at the change: the one ' + + 'in-tree shorthand the strip altered was sys_metadata_audit.lock_state, now a text column; no ' + + 'example, template, CLI scaffold or published skill writes such a string. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Load every object module (os validate or os build): ObjectSchema.create and the object save door ' + + 'report any shorthand option that is no system identifier at fields.FIELD.options.N.value with ' + + 'invalid_format. Rewrite each as an explicit option whose value is the one your writers store, or ' + + 'declare the column as text where stored rows hold the hyphenated spelling. Done when every object ' + + 'loads and every select option value equals a value a writer stores.', +}; From 490f11d10b9342df62f9c6eb89142b1e09a39a53 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:49:41 +0000 Subject: [PATCH 6/6] chore: record the save-door test's engine double in the pinned ledger Claude-Session: https://claude.ai/code/session_0123qkvzKLQfRRRnuR8wanHJ Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index cb7c960c7ad..fc8482acbf6 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1566,6 +1566,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.served-content-hash.test.ts", "verb": "delete",