Skip to content

Commit c9c182e

Browse files
fix(spec)!: a time value carries no zone — ClockTimeValueSchema refuses a Z or an offset, with an ADR-0087 disposition for stored defaults (#20740) (#20763)
Closes #20740 Clause-②: no (narrowing) `ClockTimeValueSchema` refuses a `Z` or a UTC offset. A `time` field default, a `time` action-param default or a submitted `time` action param that carries a zone is now refused when it is authored or submitted, and no longer only when an insert falls back to it. The record validator already refuses such a value on write. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset from a stored `time` default. A non-zero offset stays as stored and is reported as a TODO. D3 entry: `time-default-zone-refused`. `@objectstack/spec` minor, BREAKING. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f284ab2 commit c9c182e

9 files changed

Lines changed: 426 additions & 4 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered time-default-utc-suffix-dropped, time-default-zone-refused -->
10+
11+
**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.
12+
13+
Refused now, where they parsed before:
14+
15+
- `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.
16+
- `ActionParamSchema`: a `time` param's literal `defaultValue` with a zone.
17+
- `validateActionParams` (the action dispatcher, ADR-0104 D2): a submitted `time` param value with a zone, now `invalid_shape`.
18+
19+
## FROM → TO
20+
21+
| you wrote | write instead |
22+
| --- | --- |
23+
| `defaultValue: '10:00Z'` or `'10:00+00:00'` | `defaultValue: '10:00'` |
24+
| `defaultValue: '10:00+08:00'` | the wall clock you meant, `'10:00'` or `'02:00'`, or a `datetime` field for an instant |
25+
26+
**The one-line fix:** drop the `Z` or offset from every `time` value, or use a `datetime` field.
27+
28+
**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.
29+
30+
**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.

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from './walk.js';
2929
import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js';
3030
import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js';
31+
import { ClockTimeValueSchema } from '../data/field-value.zod.js';
3132
import {
3233
FILTER_ARRAY_LOGIC_KEYWORDS,
3334
FILTER_OPERATORS,
@@ -7341,6 +7342,152 @@ const translationPerAppSettingsRemoved: MetadataConversion = {
73417342
},
73427343
};
73437344

7345+
/**
7346+
* A `time` literal default drops a `Z` or zero-offset suffix (protocol 18).
7347+
*
7348+
* A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the stored form
7349+
* (`ClockTimeValueSchema`) no longer admits a zone. A `Z` or a zero offset
7350+
* (`+00:00`, `+0000`, `-00:00`, `-0000`) names the same wall clock without it,
7351+
* so the suffix is dropped. A non-zero offset is left as stored and reported as
7352+
* a TODO: whether it meant its own digits or the UTC time is the author's call,
7353+
* and the parse refuses it where it lands. Only the suffixes the old stored
7354+
* form admitted are recognised, and `ClockTimeValueSchema` judges the rest.
7355+
*
7356+
* Reach: the literal defaults the narrowed gates judge — a `time` field's
7357+
* (`objects[]`, `objectExtensions[]`) and an action param typed `time`
7358+
* (`actions[]`, `objects[].actions[]`, an `element:button`'s inline action).
7359+
* Retired from the load path: an author is refused at parse, and data at rest
7360+
* and `os migrate meta` replay it.
7361+
*/
7362+
const timeDefaultUtcSuffixDropped: MetadataConversion = {
7363+
id: 'time-default-utc-suffix-dropped',
7364+
toMajor: 18,
7365+
retiredFromLoadPath: true,
7366+
retiredAfter: '17.5.0',
7367+
surface:
7368+
'object.fields.*.defaultValue / action.params[].defaultValue / '
7369+
+ 'page.component.element:button.action.params[].defaultValue (type time)',
7370+
summary:
7371+
"a `time` literal default's `Z` or zero-offset suffix is dropped, which names the same wall "
7372+
+ 'clock; a default with a non-zero offset is left as stored and reported as a TODO, because '
7373+
+ 'a `time` value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock '
7374+
+ 'it meant',
7375+
apply(stack, emit, context) {
7376+
const convert = (holder: Dict, path: string, subject: string): Dict => {
7377+
const value = holder.defaultValue;
7378+
if (typeof value !== 'string') return holder;
7379+
const suffix = /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/.exec(value);
7380+
if (!suffix) return holder;
7381+
const wallClock = value.slice(0, suffix.index);
7382+
if (!ClockTimeValueSchema.safeParse(wallClock).success) return holder;
7383+
const at = `${path}.defaultValue`;
7384+
if (/[1-9]/.test(suffix[0])) {
7385+
context?.reportTodo?.({
7386+
path: at,
7387+
from: JSON.stringify(value),
7388+
reason: `${subject} is a time with no time zone, and this default carries a non-zero UTC `
7389+
+ 'offset. Left as stored, it is refused where it is parsed. Rewrite it by hand as the wall '
7390+
+ 'clock it meant, HH:MM or HH:MM:SS with no zone, or use a datetime field for an instant.',
7391+
});
7392+
return holder;
7393+
}
7394+
emit({ from: JSON.stringify(value), to: JSON.stringify(wallClock), path: at });
7395+
return { ...holder, defaultValue: wallClock };
7396+
};
7397+
const params = (action: Dict, path: string): Dict => {
7398+
const list = action.params;
7399+
if (!Array.isArray(list)) return action;
7400+
let changed = false;
7401+
const next = list.map((p, i) => {
7402+
if (!isDict(p) || p.type !== 'time') return p;
7403+
const name = typeof p.name === 'string' ? p.name : String(p.field ?? '');
7404+
const mapped = convert(p, `${path}.params[${i}]`, `Action param "${name}"`);
7405+
if (mapped !== p) changed = true;
7406+
return mapped;
7407+
});
7408+
return changed ? { ...action, params: next } : action;
7409+
};
7410+
const fields = (owner: Dict, path: string): Dict => {
7411+
const map = owner.fields;
7412+
if (!isDict(map)) return owner;
7413+
let changed = false;
7414+
const next: Dict = {};
7415+
for (const [name, def] of Object.entries(map)) {
7416+
next[name] = isDict(def) && def.type === 'time'
7417+
? convert(def, `${path}.fields.${name}`, `Field "${name}"`)
7418+
: def;
7419+
if (next[name] !== def) changed = true;
7420+
}
7421+
return changed ? { ...owner, fields: next } : owner;
7422+
};
7423+
let next = mapCollection(stack, 'objects', (obj, path) =>
7424+
mapCollection(fields(obj, path), 'actions', (action, actionPath) => params(action, `${path}.${actionPath}`)));
7425+
next = mapCollection(next, 'objectExtensions', fields);
7426+
next = mapCollection(next, 'actions', params);
7427+
return mapPageComponents(next, (component, path) => {
7428+
if (component.type !== 'element:button') return component;
7429+
const properties = component.properties;
7430+
if (!isDict(properties) || !isDict(properties.action)) return component;
7431+
const action = params(properties.action, `${path}.properties.action`);
7432+
return action === properties.action ? component : { ...component, properties: { ...properties, action } };
7433+
});
7434+
},
7435+
fixture: {
7436+
before: {
7437+
objects: [{
7438+
name: 'shift',
7439+
fields: {
7440+
starts_at: { type: 'time', defaultValue: '09:00Z' },
7441+
ends_at: { type: 'time', defaultValue: '17:30:00+00:00' },
7442+
// A non-zero offset: left as stored (a TODO, not a notice).
7443+
handover_at: { type: 'time', defaultValue: '08:00+08:00' },
7444+
label: { type: 'text', defaultValue: '09:00Z' },
7445+
},
7446+
actions: [{ name: 'reschedule', params: [{ name: 'at', type: 'time', defaultValue: '10:00-0000' }] }],
7447+
}],
7448+
objectExtensions: [{ extend: 'shift', fields: { breaks_at: { type: 'time', defaultValue: '12:00Z' } } }],
7449+
actions: [{ name: 'clock_in', params: [{ name: 'at', type: 'time', defaultValue: '07:45:00.500Z' }] }],
7450+
pages: [{
7451+
name: 'shift_board',
7452+
regions: [{
7453+
name: 'main',
7454+
components: [{
7455+
type: 'element:button',
7456+
properties: { action: { type: 'script', target: 'clockOut', params: [{ name: 'at', type: 'time', defaultValue: '18:00Z' }] } },
7457+
}],
7458+
}],
7459+
}],
7460+
},
7461+
after: {
7462+
objects: [{
7463+
name: 'shift',
7464+
fields: {
7465+
starts_at: { type: 'time', defaultValue: '09:00' },
7466+
ends_at: { type: 'time', defaultValue: '17:30:00' },
7467+
handover_at: { type: 'time', defaultValue: '08:00+08:00' },
7468+
label: { type: 'text', defaultValue: '09:00Z' },
7469+
},
7470+
actions: [{ name: 'reschedule', params: [{ name: 'at', type: 'time', defaultValue: '10:00' }] }],
7471+
}],
7472+
objectExtensions: [{ extend: 'shift', fields: { breaks_at: { type: 'time', defaultValue: '12:00' } } }],
7473+
actions: [{ name: 'clock_in', params: [{ name: 'at', type: 'time', defaultValue: '07:45:00.500' }] }],
7474+
pages: [{
7475+
name: 'shift_board',
7476+
regions: [{
7477+
name: 'main',
7478+
components: [{
7479+
type: 'element:button',
7480+
properties: { action: { type: 'script', target: 'clockOut', params: [{ name: 'at', type: 'time', defaultValue: '18:00' }] } },
7481+
}],
7482+
}],
7483+
}],
7484+
},
7485+
// One per dropped suffix: two object fields, the object-nested action, the
7486+
// extension field, the stack action and the inline action.
7487+
expectedNotices: 6,
7488+
},
7489+
};
7490+
73447491
/**
73457492
* `translation.pages.<name>.components.<id>.submitLabel` — the component-copy
73467493
* key retired with its only declarer (protocol 18, commit d173125fb, ADR-0049).
@@ -12712,6 +12859,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
1271212859
{ conversion: recordChatterPositionVocabulary, order: 2 },
1271312860
{ conversion: recordHighlightsFieldIconRemoved, order: 10 },
1271412861
{ conversion: reportJoinedChartRemoved, order: 38 },
12862+
{ conversion: timeDefaultUtcSuffixDropped, order: 48 },
1271512863
{ conversion: translationComponentSubmitLabelRemoved, order: 12 },
1271612864
{ conversion: translationPerAppSettingsRemoved, order: 34 },
1271712865
{ conversion: tursoConfigTimeoutToTimeoutMs, order: 28 },
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { ObjectSchema } from '../data/object.zod.js';
6+
import { ActionSchema } from '../ui/action.zod.js';
7+
import { applyConversions } from './apply.js';
8+
import { ALL_CONVERSIONS } from './registry.js';
9+
import { applyConversionsToStoredItem } from './stored.js';
10+
import { CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from './types.js';
11+
12+
/**
13+
* [#20740] `time-default-utc-suffix-dropped` — the ADR-0087 disposition of a
14+
* stored `time` default with a zone, now that the stored form refuses one.
15+
*
16+
* The fixture pair in `conversions.test.ts` proves before → after over the
17+
* whole table. This file pins it where it matters, on a stored row: a `Z` or a
18+
* zero offset is dropped and the row then parses; a non-zero offset is left
19+
* byte-identical, reported as a TODO naming the field, and still refused by the
20+
* schema at that field.
21+
*/
22+
23+
const ID = 'time-default-utc-suffix-dropped';
24+
25+
function storedObject(defaultValue: unknown, type = 'time') {
26+
return { name: 'shift', label: 'Shift', fields: { starts_at: { type, label: 'Starts', defaultValue } } };
27+
}
28+
29+
function convertStored(type: string, row: Record<string, unknown>) {
30+
const notices: ConversionNotice[] = [];
31+
const todos: ConversionTodoNotice[] = [];
32+
const item = applyConversionsToStoredItem(type, row, {
33+
onNotice: (n) => notices.push(n),
34+
onTodo: (t) => todos.push(t),
35+
});
36+
return { item, notices: notices.filter((n) => n.conversionId === ID), todos };
37+
}
38+
39+
function defaultValueIssuePaths(row: unknown): string[] {
40+
const r = ObjectSchema.safeParse(row);
41+
return r.success ? [] : r.error.issues.map((i) => i.path.join('.')).filter((p) => p.endsWith('defaultValue'));
42+
}
43+
44+
describe('[#20740] time-default-utc-suffix-dropped (ADR-0087 D2)', () => {
45+
it('is registered for protocol 18 and retired from the authoring load path', () => {
46+
const entry = ALL_CONVERSIONS.find((c) => c.id === ID);
47+
expect(entry, 'the conversion is registered').toBeDefined();
48+
expect(entry!.toMajor).toBe(18);
49+
expect(entry!.retiredFromLoadPath).toBe(true);
50+
});
51+
52+
it('a stored field default with a `Z` or a zero offset loses the suffix, and the row then parses', () => {
53+
for (const [stored, wallClock] of [
54+
['10:00Z', '10:00'],
55+
['10:00:00+00:00', '10:00:00'],
56+
['10:00:00.250-0000', '10:00:00.250'],
57+
] as const) {
58+
const row = storedObject(stored);
59+
expect(defaultValueIssuePaths(row), `${stored} is refused before the replay`).toEqual(['fields.starts_at.defaultValue']);
60+
const { item, notices, todos } = convertStored('object', row);
61+
expect((item as typeof row).fields.starts_at.defaultValue).toBe(wallClock);
62+
expect(notices.map((n) => [n.path, n.from, n.to])).toEqual([
63+
['objects[0].fields.starts_at.defaultValue', JSON.stringify(stored), JSON.stringify(wallClock)],
64+
]);
65+
expect(todos).toEqual([]);
66+
expect(defaultValueIssuePaths(item), `${stored} parses after the replay`).toEqual([]);
67+
}
68+
});
69+
70+
it('a non-zero offset is left as stored, reported as a TODO naming the field, and still refused at that field', () => {
71+
const row = storedObject('10:00+08:00');
72+
const { item, notices, todos } = convertStored('object', row);
73+
expect(item, 'nothing is rewritten').toBe(row);
74+
expect(notices).toEqual([]);
75+
expect(todos).toHaveLength(1);
76+
expect(todos[0]).toMatchObject({
77+
code: CONVERSION_TODO_CODE,
78+
conversionId: ID,
79+
path: 'objects[0].fields.starts_at.defaultValue',
80+
from: '"10:00+08:00"',
81+
});
82+
expect(todos[0]!.reason).toContain('"starts_at"');
83+
expect(defaultValueIssuePaths(item)).toEqual(['fields.starts_at.defaultValue']);
84+
});
85+
86+
it('a stored action row: a `time` param default loses a `Z`; other params are untouched', () => {
87+
const row = {
88+
name: 'clock_in',
89+
label: 'Clock in',
90+
type: 'script',
91+
target: 'clockIn',
92+
params: [
93+
{ name: 'at', type: 'time', defaultValue: '07:45Z' },
94+
{ name: 'note', type: 'text', defaultValue: '07:45Z' },
95+
{ field: 'starts_at', defaultValue: '07:45Z' },
96+
],
97+
};
98+
expect(ActionSchema.safeParse(row).success).toBe(false);
99+
const { item, notices } = convertStored('action', row);
100+
const params = (item as typeof row).params;
101+
expect(params[0]!.defaultValue).toBe('07:45');
102+
expect(params[1]).toBe(row.params[1]);
103+
expect(params[2]).toBe(row.params[2]);
104+
expect(notices.map((n) => n.path)).toEqual(['actions[0].params[0].defaultValue']);
105+
expect(ActionSchema.safeParse(item).success).toBe(true);
106+
});
107+
108+
it('control: what the old stored form refused, a zone-less default and a non-`time` field are the same reference', () => {
109+
for (const row of [
110+
storedObject('10:00'),
111+
storedObject('10:00z'),
112+
storedObject('25:00Z'),
113+
storedObject('10:00Z', 'text'),
114+
storedObject({ dialect: 'cel', source: 'now()' }),
115+
]) {
116+
const { item, notices, todos } = convertStored('object', row);
117+
expect(item).toBe(row);
118+
expect(notices).toEqual([]);
119+
expect(todos).toEqual([]);
120+
}
121+
});
122+
123+
it('is idempotent — the converted result replays to itself with no second notice', () => {
124+
const once = applyConversions({ objects: [storedObject('10:00Z')] }, { includeRetired: true });
125+
const notices: ConversionNotice[] = [];
126+
const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) });
127+
expect(twice).toBe(once);
128+
expect(notices).toEqual([]);
129+
});
130+
});

‎packages/spec/src/data/field-default-value.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ const CASES: Case[] = [
5555
accepted: false,
5656
contains: ['ISO-8601 instant'],
5757
},
58+
// [#20740] A time is a zone-less wall clock (ADR-0053 D-C1). These parsed
59+
// clean before, and every insert that fell back to them was then refused
60+
// `invalid_time` on a field the caller never sent.
61+
{
62+
label: 'time + a wall clock with a Z',
63+
field: { type: 'time', defaultValue: '10:00Z' },
64+
accepted: false,
65+
contains: ['(time)', '"10:00Z"'],
66+
},
67+
{
68+
label: 'time + a wall clock with a non-zero offset',
69+
field: { type: 'time', defaultValue: '10:00+08:00' },
70+
accepted: false,
71+
contains: ['(time)', '"10:00+08:00"'],
72+
},
5873
{
5974
label: 'select + a non-member of its own options',
6075
field: {
@@ -116,6 +131,7 @@ const CASES: Case[] = [
116131
{ label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true },
117132
{ label: 'VALID boolean', field: { type: 'boolean', defaultValue: false }, accepted: true },
118133
{ label: 'VALID date (calendar day)', field: { type: 'date', defaultValue: '2026-08-10' }, accepted: true },
134+
{ label: 'VALID time (a zone-less wall clock)', field: { type: 'time', defaultValue: '10:00' }, accepted: true },
119135
{
120136
label: 'VALID select member',
121137
field: { type: 'select', options: [{ label: 'Gold', value: 'gold' }], defaultValue: 'gold' },

‎packages/spec/src/data/field-value.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,14 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
274274
bad({ type: 'time' }, 'not-a-time');
275275
});
276276

277+
it('[#20740] time carries no zone: a `Z` or an offset is refused, as the record validator refuses it', () => {
278+
ok({ type: 'time' }, '14:30:00.500');
279+
bad({ type: 'time' }, '14:30:00Z');
280+
bad({ type: 'time' }, '14:30+08:00');
281+
bad({ type: 'time' }, '08:15:00-0530');
282+
bad({ type: 'time' }, '14:30+00:00');
283+
});
284+
277285
it('option types enforce declared option codes; free-form without options', () => {
278286
const options = [{ value: 'high' }, { value: 'low' }];
279287
ok({ type: 'select', options }, 'high');

‎packages/spec/src/data/field-value.zod.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const INSTANT_TYPES: ReadonlySet<string> = new Set([
8080
'datetime',
8181
] as const satisfies readonly FieldType[]);
8282

83-
/** Wall-clock time of day, `HH:MM[:SS[.fff]]` (+ optional zone) — not `Date.parse`-able (#2004). */
83+
/** Wall-clock time of day, `HH:MM[:SS[.fff]]` with no zone (ADR-0053 D-C1) — not `Date.parse`-able (#2004). */
8484
export const CLOCK_TIME_TYPES: ReadonlySet<string> = new Set([
8585
'time',
8686
] as const satisfies readonly FieldType[]);
@@ -372,10 +372,10 @@ export const InstantValueSchema = lazySchema(() =>
372372
'expected an ISO-8601 instant with explicit zone (e.g. 2026-03-15T14:30:00.000Z)'));
373373
export type InstantValue = z.input<typeof InstantValueSchema>;
374374

375-
/** `HH:MM[:SS[.fff]]` with optional zone — the `time` stored form (#2004). */
375+
/** `HH:MM[:SS[.fff]]` with no zone — the `time` stored form (#2004, ADR-0053 D-C1). */
376376
export const ClockTimeValueSchema = lazySchema(() =>
377-
z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/,
378-
'expected HH:MM or HH:MM:SS (wall-clock time of day)'));
377+
z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?$/,
378+
'expected HH:MM or HH:MM:SS (wall-clock time of day, no time zone)'));
379379
export type ClockTimeValue = z.input<typeof ClockTimeValueSchema>;
380380

381381
/**

0 commit comments

Comments
 (0)