Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/20740-time-value-zone-less.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered time-default-utc-suffix-dropped, time-default-zone-refused -->

**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.
148 changes: 148 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.<name>.components.<id>.submitLabel` — the component-copy
* key retired with its only declarer (protocol 18, commit d173125fb, ADR-0049).
Expand Down Expand Up @@ -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 },
Expand Down
130 changes: 130 additions & 0 deletions packages/spec/src/conversions/time-default-utc-suffix-dropped.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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([]);
});
});
16 changes: 16 additions & 0 deletions packages/spec/src/data/field-default-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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' },
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/src/data/field-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions packages/spec/src/data/field-value.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const INSTANT_TYPES: ReadonlySet<string> = 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<string> = new Set([
'time',
] as const satisfies readonly FieldType[]);
Expand Down Expand Up @@ -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<typeof InstantValueSchema>;

/** `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<typeof ClockTimeValueSchema>;

/**
Expand Down
Loading
Loading