From a9853d0d47f33c9d45274a26c3767486b2e12fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:22:42 +0000 Subject: [PATCH 1/4] fix(spec)!: the time stored-form value contract carries no zone Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/field-value.zod.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 0f93fd355c5..2c8b0302b0b 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -80,7 +80,7 @@ export const INSTANT_TYPES: ReadonlySet = new Set([ 'datetime', ] as const satisfies readonly FieldType[]); -/** Wall-clock time of day, `HH:MM[:SS[.fff]]` (+ optional zone) — not `Date.parse`-able (#2004). */ +/** Wall-clock time of day, `HH:MM[:SS[.fff]]` with no zone (ADR-0053 D-C1) — not `Date.parse`-able (#2004). */ export const CLOCK_TIME_TYPES: ReadonlySet = new Set([ 'time', ] as const satisfies readonly FieldType[]); @@ -372,10 +372,10 @@ export const InstantValueSchema = lazySchema(() => 'expected an ISO-8601 instant with explicit zone (e.g. 2026-03-15T14:30:00.000Z)')); export type InstantValue = z.input; -/** `HH:MM[:SS[.fff]]` with optional zone — the `time` stored form (#2004). */ +/** `HH:MM[:SS[.fff]]` with no zone — the `time` stored form (#2004, ADR-0053 D-C1). */ export const ClockTimeValueSchema = lazySchema(() => - z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/, - 'expected HH:MM or HH:MM:SS (wall-clock time of day)')); + z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?$/, + 'expected HH:MM or HH:MM:SS (wall-clock time of day, no time zone)')); export type ClockTimeValue = z.input; /** From 55083e5022dc851a7c19c4f2b00b9e30b98e5a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:24:13 +0000 Subject: [PATCH 2/4] feat(spec): ADR-0087 disposition for a zone-suffixed time default Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 148 ++++++++++++++++++ .../semantic/18.time-default-zone-refused.ts | 31 ++++ packages/spec/src/migrations/registry.ts | 39 +++++ 3 files changed, 218 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.time-default-zone-refused.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 03e4d0395fd..25b45aad6e8 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -28,6 +28,7 @@ import { } from './walk.js'; import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js'; import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js'; +import { ClockTimeValueSchema } from '../data/field-value.zod.js'; import { FILTER_ARRAY_LOGIC_KEYWORDS, FILTER_OPERATORS, @@ -7341,6 +7342,152 @@ const translationPerAppSettingsRemoved: MetadataConversion = { }, }; +/** + * A `time` literal default drops a `Z` or zero-offset suffix (protocol 18). + * + * A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the stored form + * (`ClockTimeValueSchema`) no longer admits a zone. A `Z` or a zero offset + * (`+00:00`, `+0000`, `-00:00`, `-0000`) names the same wall clock without it, + * so the suffix is dropped. A non-zero offset is left as stored and reported as + * a TODO: whether it meant its own digits or the UTC time is the author's call, + * and the parse refuses it where it lands. Only the suffixes the old stored + * form admitted are recognised, and `ClockTimeValueSchema` judges the rest. + * + * Reach: the literal defaults the narrowed gates judge — a `time` field's + * (`objects[]`, `objectExtensions[]`) and an action param typed `time` + * (`actions[]`, `objects[].actions[]`, an `element:button`'s inline action). + * Retired from the load path: an author is refused at parse, and data at rest + * and `os migrate meta` replay it. + */ +const timeDefaultUtcSuffixDropped: MetadataConversion = { + id: 'time-default-utc-suffix-dropped', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.5.0', + surface: + 'object.fields.*.defaultValue / action.params[].defaultValue / ' + + 'page.component.element:button.action.params[].defaultValue (type time)', + summary: + "a `time` literal default's `Z` or zero-offset suffix is dropped, which names the same wall " + + 'clock; a default with a non-zero offset is left as stored and reported as a TODO, because ' + + 'a `time` value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock ' + + 'it meant', + apply(stack, emit, context) { + const convert = (holder: Dict, path: string, subject: string): Dict => { + const value = holder.defaultValue; + if (typeof value !== 'string') return holder; + const suffix = /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/.exec(value); + if (!suffix) return holder; + const wallClock = value.slice(0, suffix.index); + if (!ClockTimeValueSchema.safeParse(wallClock).success) return holder; + const at = `${path}.defaultValue`; + if (/[1-9]/.test(suffix[0])) { + context?.reportTodo?.({ + path: at, + from: JSON.stringify(value), + reason: `${subject} is a time with no time zone, and this default carries a non-zero UTC ` + + 'offset. Left as stored, it is refused where it is parsed. Rewrite it by hand as the wall ' + + 'clock it meant, HH:MM or HH:MM:SS with no zone, or use a datetime field for an instant.', + }); + return holder; + } + emit({ from: JSON.stringify(value), to: JSON.stringify(wallClock), path: at }); + return { ...holder, defaultValue: wallClock }; + }; + const params = (action: Dict, path: string): Dict => { + const list = action.params; + if (!Array.isArray(list)) return action; + let changed = false; + const next = list.map((p, i) => { + if (!isDict(p) || p.type !== 'time') return p; + const name = typeof p.name === 'string' ? p.name : String(p.field ?? ''); + const mapped = convert(p, `${path}.params[${i}]`, `Action param "${name}"`); + if (mapped !== p) changed = true; + return mapped; + }); + return changed ? { ...action, params: next } : action; + }; + const fields = (owner: Dict, path: string): Dict => { + const map = owner.fields; + if (!isDict(map)) return owner; + let changed = false; + const next: Dict = {}; + for (const [name, def] of Object.entries(map)) { + next[name] = isDict(def) && def.type === 'time' + ? convert(def, `${path}.fields.${name}`, `Field "${name}"`) + : def; + if (next[name] !== def) changed = true; + } + return changed ? { ...owner, fields: next } : owner; + }; + let next = mapCollection(stack, 'objects', (obj, path) => + mapCollection(fields(obj, path), 'actions', (action, actionPath) => params(action, `${path}.${actionPath}`))); + next = mapCollection(next, 'objectExtensions', fields); + next = mapCollection(next, 'actions', params); + return mapPageComponents(next, (component, path) => { + if (component.type !== 'element:button') return component; + const properties = component.properties; + if (!isDict(properties) || !isDict(properties.action)) return component; + const action = params(properties.action, `${path}.properties.action`); + return action === properties.action ? component : { ...component, properties: { ...properties, action } }; + }); + }, + fixture: { + before: { + objects: [{ + name: 'shift', + fields: { + starts_at: { type: 'time', defaultValue: '09:00Z' }, + ends_at: { type: 'time', defaultValue: '17:30:00+00:00' }, + // A non-zero offset: left as stored (a TODO, not a notice). + handover_at: { type: 'time', defaultValue: '08:00+08:00' }, + label: { type: 'text', defaultValue: '09:00Z' }, + }, + actions: [{ name: 'reschedule', params: [{ name: 'at', type: 'time', defaultValue: '10:00-0000' }] }], + }], + objectExtensions: [{ extend: 'shift', fields: { breaks_at: { type: 'time', defaultValue: '12:00Z' } } }], + actions: [{ name: 'clock_in', params: [{ name: 'at', type: 'time', defaultValue: '07:45:00.500Z' }] }], + pages: [{ + name: 'shift_board', + regions: [{ + name: 'main', + components: [{ + type: 'element:button', + properties: { action: { type: 'script', target: 'clockOut', params: [{ name: 'at', type: 'time', defaultValue: '18:00Z' }] } }, + }], + }], + }], + }, + after: { + objects: [{ + name: 'shift', + fields: { + starts_at: { type: 'time', defaultValue: '09:00' }, + ends_at: { type: 'time', defaultValue: '17:30:00' }, + handover_at: { type: 'time', defaultValue: '08:00+08:00' }, + label: { type: 'text', defaultValue: '09:00Z' }, + }, + actions: [{ name: 'reschedule', params: [{ name: 'at', type: 'time', defaultValue: '10:00' }] }], + }], + objectExtensions: [{ extend: 'shift', fields: { breaks_at: { type: 'time', defaultValue: '12:00' } } }], + actions: [{ name: 'clock_in', params: [{ name: 'at', type: 'time', defaultValue: '07:45:00.500' }] }], + pages: [{ + name: 'shift_board', + regions: [{ + name: 'main', + components: [{ + type: 'element:button', + properties: { action: { type: 'script', target: 'clockOut', params: [{ name: 'at', type: 'time', defaultValue: '18:00' }] } }, + }], + }], + }], + }, + // One per dropped suffix: two object fields, the object-nested action, the + // extension field, the stack action and the inline action. + expectedNotices: 6, + }, +}; + /** * `translation.pages..components..submitLabel` — the component-copy * key retired with its only declarer (protocol 18, commit d173125fb, ADR-0049). @@ -12712,6 +12859,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: recordChatterPositionVocabulary, order: 2 }, { conversion: recordHighlightsFieldIconRemoved, order: 10 }, { conversion: reportJoinedChartRemoved, order: 38 }, + { conversion: timeDefaultUtcSuffixDropped, order: 48 }, { conversion: translationComponentSubmitLabelRemoved, order: 12 }, { conversion: translationPerAppSettingsRemoved, order: 34 }, { conversion: tursoConfigTimeoutToTimeoutMs, order: 28 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.time-default-zone-refused.ts b/packages/spec/src/migrations/entries/semantic/18.time-default-zone-refused.ts new file mode 100644 index 00000000000..98a48c428d8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.time-default-zone-refused.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 entry of the `time-default-utc-suffix-dropped` family: the stored form +// of a `time` value (`ClockTimeValueSchema`) no longer admits a zone, so the +// literal defaults it judges move with it. +export const entry: SemanticMigration = { + id: 'time-default-zone-refused', + surface: 'a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an ' + + 'action param typed `time`', + replacement: 'the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field ' + + 'when the value is an instant. The conversion drops a `Z` or a zero offset, which names the ' + + 'same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant ' + + '08:00 or the UTC 00:00 only the author knows, so rewrite it by hand', + reason: + 'A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already ' + + 'refuses a zone-suffixed time of day on write. The stored form still admitted one, so a ' + + 'field default such as `10:00Z` parsed clean and every insert that fell back to it was then ' + + 'refused `invalid_time` on a field the caller never sent, and an action param default or ' + + 'submitted value passed the dispatcher. The stored form now refuses the zone, so the field ' + + 'and action-param default gates refuse it when it is authored and the dispatcher refuses it ' + + 'at submit.', + acceptanceCriteria: + 'No `time` field or `time` action param declares a literal default with a zone. Zone-less ' + + 'defaults, the `NOW()` token and expression defaults parse as before. A stored `sys_metadata` ' + + 'row whose default carried a `Z` or a zero offset loads with the zone dropped; one with a ' + + 'non-zero offset keeps loading as stored, is listed by `os migrate meta --stored` as a TODO ' + + 'naming the field or param, and fails the schema wherever it is parsed until it is rewritten.', + conversionIds: ['time-default-utc-suffix-dropped'], +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 41e36656e5d..8fc677f6d30 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5808,6 +5808,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' + 'authored content silently.', }, + { + id: 'time-default-zone-refused', + order: 50, + text: + 'It also narrows the `time` stored form to the zone-less wall clock the record validator ' + + 'already enforces (ADR-0053 D-C1), so a field default or an action param default or value ' + + 'with a `Z` or a UTC offset is refused when it is authored or submitted rather than on ' + + 'every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` ' + + 'drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero ' + + 'offset as stored for its author to rewrite; its D3 record is the semantic entry ' + + '`time-default-zone-refused`.', + }, { id: 'translation-component-submit-label-retired', order: 14, @@ -16884,6 +16896,33 @@ const step18: MigrationStep = { + 'fails to parse with the rename prescription naming the suffixed key; the parsed defaults ' + 'are 300 and 3600 as before.', }, + // The D3 entry of the `time-default-utc-suffix-dropped` family: the stored form + // of a `time` value (`ClockTimeValueSchema`) no longer admits a zone, so the + // literal defaults it judges move with it. + { + id: 'time-default-zone-refused', + surface: 'a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an ' + + 'action param typed `time`', + replacement: 'the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field ' + + 'when the value is an instant. The conversion drops a `Z` or a zero offset, which names the ' + + 'same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant ' + + '08:00 or the UTC 00:00 only the author knows, so rewrite it by hand', + reason: + 'A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already ' + + 'refuses a zone-suffixed time of day on write. The stored form still admitted one, so a ' + + 'field default such as `10:00Z` parsed clean and every insert that fell back to it was then ' + + 'refused `invalid_time` on a field the caller never sent, and an action param default or ' + + 'submitted value passed the dispatcher. The stored form now refuses the zone, so the field ' + + 'and action-param default gates refuse it when it is authored and the dispatcher refuses it ' + + 'at submit.', + acceptanceCriteria: + 'No `time` field or `time` action param declares a literal default with a zone. Zone-less ' + + 'defaults, the `NOW()` token and expression defaults parse as before. A stored `sys_metadata` ' + + 'row whose default carried a `Z` or a zero offset loads with the zone dropped; one with a ' + + 'non-zero offset keeps loading as stored, is listed by `os migrate meta --stored` as a TODO ' + + 'naming the field or param, and fails the schema wherever it is parsed until it is rewritten.', + conversionIds: ['time-default-utc-suffix-dropped'], + }, { id: 'time-update-interval-sub-day-retired', surface: From 14add0aa0a67b5070410b637199d0616cdae4952 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:25:25 +0000 Subject: [PATCH 3/4] test(spec): pin the zone-less time value contract and its stored-default disposition Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../time-default-utc-suffix-dropped.test.ts | 130 ++++++++++++++++++ .../spec/src/data/field-default-value.test.ts | 16 +++ packages/spec/src/data/field-value.test.ts | 8 ++ .../src/ui/action-param-default-value.test.ts | 20 +++ 4 files changed, 174 insertions(+) create mode 100644 packages/spec/src/conversions/time-default-utc-suffix-dropped.test.ts diff --git a/packages/spec/src/conversions/time-default-utc-suffix-dropped.test.ts b/packages/spec/src/conversions/time-default-utc-suffix-dropped.test.ts new file mode 100644 index 00000000000..719bf796221 --- /dev/null +++ b/packages/spec/src/conversions/time-default-utc-suffix-dropped.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ObjectSchema } from '../data/object.zod.js'; +import { ActionSchema } from '../ui/action.zod.js'; +import { applyConversions } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import { CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from './types.js'; + +/** + * [#20740] `time-default-utc-suffix-dropped` — the ADR-0087 disposition of a + * stored `time` default with a zone, now that the stored form refuses one. + * + * The fixture pair in `conversions.test.ts` proves before → after over the + * whole table. This file pins it where it matters, on a stored row: a `Z` or a + * zero offset is dropped and the row then parses; a non-zero offset is left + * byte-identical, reported as a TODO naming the field, and still refused by the + * schema at that field. + */ + +const ID = 'time-default-utc-suffix-dropped'; + +function storedObject(defaultValue: unknown, type = 'time') { + return { name: 'shift', label: 'Shift', fields: { starts_at: { type, label: 'Starts', defaultValue } } }; +} + +function convertStored(type: string, row: Record) { + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const item = applyConversionsToStoredItem(type, row, { + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }); + return { item, notices: notices.filter((n) => n.conversionId === ID), todos }; +} + +function defaultValueIssuePaths(row: unknown): string[] { + const r = ObjectSchema.safeParse(row); + return r.success ? [] : r.error.issues.map((i) => i.path.join('.')).filter((p) => p.endsWith('defaultValue')); +} + +describe('[#20740] time-default-utc-suffix-dropped (ADR-0087 D2)', () => { + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + }); + + it('a stored field default with a `Z` or a zero offset loses the suffix, and the row then parses', () => { + for (const [stored, wallClock] of [ + ['10:00Z', '10:00'], + ['10:00:00+00:00', '10:00:00'], + ['10:00:00.250-0000', '10:00:00.250'], + ] as const) { + const row = storedObject(stored); + expect(defaultValueIssuePaths(row), `${stored} is refused before the replay`).toEqual(['fields.starts_at.defaultValue']); + const { item, notices, todos } = convertStored('object', row); + expect((item as typeof row).fields.starts_at.defaultValue).toBe(wallClock); + expect(notices.map((n) => [n.path, n.from, n.to])).toEqual([ + ['objects[0].fields.starts_at.defaultValue', JSON.stringify(stored), JSON.stringify(wallClock)], + ]); + expect(todos).toEqual([]); + expect(defaultValueIssuePaths(item), `${stored} parses after the replay`).toEqual([]); + } + }); + + it('a non-zero offset is left as stored, reported as a TODO naming the field, and still refused at that field', () => { + const row = storedObject('10:00+08:00'); + const { item, notices, todos } = convertStored('object', row); + expect(item, 'nothing is rewritten').toBe(row); + expect(notices).toEqual([]); + expect(todos).toHaveLength(1); + expect(todos[0]).toMatchObject({ + code: CONVERSION_TODO_CODE, + conversionId: ID, + path: 'objects[0].fields.starts_at.defaultValue', + from: '"10:00+08:00"', + }); + expect(todos[0]!.reason).toContain('"starts_at"'); + expect(defaultValueIssuePaths(item)).toEqual(['fields.starts_at.defaultValue']); + }); + + it('a stored action row: a `time` param default loses a `Z`; other params are untouched', () => { + const row = { + name: 'clock_in', + label: 'Clock in', + type: 'script', + target: 'clockIn', + params: [ + { name: 'at', type: 'time', defaultValue: '07:45Z' }, + { name: 'note', type: 'text', defaultValue: '07:45Z' }, + { field: 'starts_at', defaultValue: '07:45Z' }, + ], + }; + expect(ActionSchema.safeParse(row).success).toBe(false); + const { item, notices } = convertStored('action', row); + const params = (item as typeof row).params; + expect(params[0]!.defaultValue).toBe('07:45'); + expect(params[1]).toBe(row.params[1]); + expect(params[2]).toBe(row.params[2]); + expect(notices.map((n) => n.path)).toEqual(['actions[0].params[0].defaultValue']); + expect(ActionSchema.safeParse(item).success).toBe(true); + }); + + it('control: what the old stored form refused, a zone-less default and a non-`time` field are the same reference', () => { + for (const row of [ + storedObject('10:00'), + storedObject('10:00z'), + storedObject('25:00Z'), + storedObject('10:00Z', 'text'), + storedObject({ dialect: 'cel', source: 'now()' }), + ]) { + const { item, notices, todos } = convertStored('object', row); + expect(item).toBe(row); + expect(notices).toEqual([]); + expect(todos).toEqual([]); + } + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions({ objects: [storedObject('10:00Z')] }, { includeRetired: true }); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/field-default-value.test.ts b/packages/spec/src/data/field-default-value.test.ts index e2b89c7188e..9746dd6a637 100644 --- a/packages/spec/src/data/field-default-value.test.ts +++ b/packages/spec/src/data/field-default-value.test.ts @@ -55,6 +55,21 @@ const CASES: Case[] = [ accepted: false, contains: ['ISO-8601 instant'], }, + // [#20740] A time is a zone-less wall clock (ADR-0053 D-C1). These parsed + // clean before, and every insert that fell back to them was then refused + // `invalid_time` on a field the caller never sent. + { + label: 'time + a wall clock with a Z', + field: { type: 'time', defaultValue: '10:00Z' }, + accepted: false, + contains: ['(time)', '"10:00Z"'], + }, + { + label: 'time + a wall clock with a non-zero offset', + field: { type: 'time', defaultValue: '10:00+08:00' }, + accepted: false, + contains: ['(time)', '"10:00+08:00"'], + }, { label: 'select + a non-member of its own options', field: { @@ -116,6 +131,7 @@ const CASES: Case[] = [ { label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true }, { label: 'VALID boolean', field: { type: 'boolean', defaultValue: false }, accepted: true }, { label: 'VALID date (calendar day)', field: { type: 'date', defaultValue: '2026-08-10' }, accepted: true }, + { label: 'VALID time (a zone-less wall clock)', field: { type: 'time', defaultValue: '10:00' }, accepted: true }, { label: 'VALID select member', field: { type: 'select', options: [{ label: 'Gold', value: 'gold' }], defaultValue: 'gold' }, diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index d2f2b35a3f1..63067a7bc62 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -274,6 +274,14 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => { bad({ type: 'time' }, 'not-a-time'); }); + it('[#20740] time carries no zone: a `Z` or an offset is refused, as the record validator refuses it', () => { + ok({ type: 'time' }, '14:30:00.500'); + bad({ type: 'time' }, '14:30:00Z'); + bad({ type: 'time' }, '14:30+08:00'); + bad({ type: 'time' }, '08:15:00-0530'); + bad({ type: 'time' }, '14:30+00:00'); + }); + it('option types enforce declared option codes; free-form without options', () => { const options = [{ value: 'high' }, { value: 'low' }]; ok({ type: 'select', options }, 'high'); diff --git a/packages/spec/src/ui/action-param-default-value.test.ts b/packages/spec/src/ui/action-param-default-value.test.ts index 0802e9341d7..979693788fa 100644 --- a/packages/spec/src/ui/action-param-default-value.test.ts +++ b/packages/spec/src/ui/action-param-default-value.test.ts @@ -71,6 +71,13 @@ const CASES: Array<{ label: string; param: Record; accepted: bo accepted: false, }, { label: 'boolean + a string', param: { name: 'notify', type: 'boolean', defaultValue: 'yes' }, accepted: false }, + // [#20740] A time is a zone-less wall clock (ADR-0053 D-C1). + { label: 'time + a wall clock with a Z', param: { name: 'at', type: 'time', defaultValue: '10:00Z' }, accepted: false }, + { + label: 'time + a wall clock with a non-zero offset', + param: { name: 'at', type: 'time', defaultValue: '10:00+08:00' }, + accepted: false, + }, { label: 'lookup + an embedded record object instead of an id', param: { name: 'owner', type: 'lookup', reference: 'sys_user', defaultValue: { id: 'usr_1', name: 'Ada' } }, @@ -95,6 +102,7 @@ const CASES: Array<{ label: string; param: Record; accepted: bo accepted: true, }, { label: 'VALID date', param: { name: 'due', type: 'date', defaultValue: '2026-08-10' }, accepted: true }, + { label: 'VALID time (a zone-less wall clock)', param: { name: 'at', type: 'time', defaultValue: '10:00' }, accepted: true }, { label: 'VALID boolean', param: { name: 'notify', type: 'boolean', defaultValue: true }, accepted: true }, { label: 'json — an explicitly OPEN value contract, so any default rides', @@ -238,3 +246,15 @@ describe('#6970 ActionParamSchema.defaultValue — authored defaults meet the pa } }); }); + +describe('[#20740] the submit door refuses a zone-suffixed `time` value', () => { + it('`validateActionParams` answers `invalid_shape` naming the param; a zone-less wall clock passes', () => { + for (const value of ['10:00Z', '10:00+08:00', '10:00:00-0530']) { + const issue = submitIssue({ name: 'at', type: 'time', defaultValue: value }); + expect(issue, value).not.toBeNull(); + expect(issue!.param).toBe('at'); + expect(issue!.code).toBe('invalid_shape'); + } + expect(submitIssue({ name: 'at', type: 'time', defaultValue: '10:00' })).toBeNull(); + }); +}); From 674893bd03233a4701d8f6c97177aace8f2eef2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:26:29 +0000 Subject: [PATCH 4/4] chore(changeset): the time value contract carries no zone Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/20740-time-value-zone-less.md | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/20740-time-value-zone-less.md diff --git a/.changeset/20740-time-value-zone-less.md b/.changeset/20740-time-value-zone-less.md new file mode 100644 index 00000000000..41868de0ec5 --- /dev/null +++ b/.changeset/20740-time-value-zone-less.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: a `time` value carries no zone — `ClockTimeValueSchema` refuses a `Z` or a UTC offset, so a `time` field default, an action param default or a submitted `time` action param with one is refused when it is authored or submitted, not on every insert that falls back to it + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows the `time` stored form (`valueSchemaFor({ type: 'time' })`) to the zone-less wall clock `HH:MM[:SS[.fraction]]` that the record validator already enforces on write (ADR-0053 D-C1). It ships as `minor` under the launch-window convention for accept-set narrowings; the breaking-ness is carried by this banner and the ADR-0087 disposition above. + +Refused now, where they parsed before: + +- `FieldSchema`: a `time` field's literal `defaultValue` with a zone (`'10:00Z'`, `'10:00+08:00'`). Before, it parsed and each insert that fell back to it was refused `400 VALIDATION_FAILED` / `invalid_time` on a field the caller never sent. +- `ActionParamSchema`: a `time` param's literal `defaultValue` with a zone. +- `validateActionParams` (the action dispatcher, ADR-0104 D2): a submitted `time` param value with a zone, now `invalid_shape`. + +## FROM → TO + +| you wrote | write instead | +| --- | --- | +| `defaultValue: '10:00Z'` or `'10:00+00:00'` | `defaultValue: '10:00'` | +| `defaultValue: '10:00+08:00'` | the wall clock you meant, `'10:00'` or `'02:00'`, or a `datetime` field for an instant | + +**The one-line fix:** drop the `Z` or offset from every `time` value, or use a `datetime` field. + +**Stored metadata.** The D2 conversion `time-default-utc-suffix-dropped` (retired from the load path) drops a `Z` or a zero offset from a stored `time` default on a field or on an action param typed `time`, so such a row loads canonical. It leaves a non-zero offset as stored and reports it as a TODO naming the field or param, which `os migrate meta --stored` lists; the row keeps loading, fails the schema wherever it is parsed, and needs the rewrite by hand. The D3 entry `time-default-zone-refused` carries that judgement. + +**Unchanged:** a zone-less wall clock, the `NOW()` token and expression defaults on a `time` field, and every `date` and `datetime` value. The repo census found no shipped `time` default with a zone.