Skip to content

Commit 55083e5

Browse files
committed
feat(spec): ADR-0087 disposition for a zone-suffixed time default
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a9853d0 commit 55083e5

3 files changed

Lines changed: 218 additions & 0 deletions

File tree

‎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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The D3 entry of the `time-default-utc-suffix-dropped` family: the stored form
6+
// of a `time` value (`ClockTimeValueSchema`) no longer admits a zone, so the
7+
// literal defaults it judges move with it.
8+
export const entry: SemanticMigration = {
9+
id: 'time-default-zone-refused',
10+
surface: 'a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an '
11+
+ 'action param typed `time`',
12+
replacement: 'the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field '
13+
+ 'when the value is an instant. The conversion drops a `Z` or a zero offset, which names the '
14+
+ 'same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant '
15+
+ '08:00 or the UTC 00:00 only the author knows, so rewrite it by hand',
16+
reason:
17+
'A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already '
18+
+ 'refuses a zone-suffixed time of day on write. The stored form still admitted one, so a '
19+
+ 'field default such as `10:00Z` parsed clean and every insert that fell back to it was then '
20+
+ 'refused `invalid_time` on a field the caller never sent, and an action param default or '
21+
+ 'submitted value passed the dispatcher. The stored form now refuses the zone, so the field '
22+
+ 'and action-param default gates refuse it when it is authored and the dispatcher refuses it '
23+
+ 'at submit.',
24+
acceptanceCriteria:
25+
'No `time` field or `time` action param declares a literal default with a zone. Zone-less '
26+
+ 'defaults, the `NOW()` token and expression defaults parse as before. A stored `sys_metadata` '
27+
+ 'row whose default carried a `Z` or a zero offset loads with the zone dropped; one with a '
28+
+ 'non-zero offset keeps loading as stored, is listed by `os migrate meta --stored` as a TODO '
29+
+ 'naming the field or param, and fails the schema wherever it is parsed until it is rewritten.',
30+
conversionIds: ['time-default-utc-suffix-dropped'],
31+
};

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5808,6 +5808,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
58085808
+ 'M apps is a judgment), so the entry prescribes the hand move instead of deleting '
58095809
+ 'authored content silently.',
58105810
},
5811+
{
5812+
id: 'time-default-zone-refused',
5813+
order: 50,
5814+
text:
5815+
'It also narrows the `time` stored form to the zone-less wall clock the record validator '
5816+
+ 'already enforces (ADR-0053 D-C1), so a field default or an action param default or value '
5817+
+ 'with a `Z` or a UTC offset is refused when it is authored or submitted rather than on '
5818+
+ 'every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` '
5819+
+ 'drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero '
5820+
+ 'offset as stored for its author to rewrite; its D3 record is the semantic entry '
5821+
+ '`time-default-zone-refused`.',
5822+
},
58115823
{
58125824
id: 'translation-component-submit-label-retired',
58135825
order: 14,
@@ -16884,6 +16896,33 @@ const step18: MigrationStep = {
1688416896
+ 'fails to parse with the rename prescription naming the suffixed key; the parsed defaults '
1688516897
+ 'are 300 and 3600 as before.',
1688616898
},
16899+
// The D3 entry of the `time-default-utc-suffix-dropped` family: the stored form
16900+
// of a `time` value (`ClockTimeValueSchema`) no longer admits a zone, so the
16901+
// literal defaults it judges move with it.
16902+
{
16903+
id: 'time-default-zone-refused',
16904+
surface: 'a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an '
16905+
+ 'action param typed `time`',
16906+
replacement: 'the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field '
16907+
+ 'when the value is an instant. The conversion drops a `Z` or a zero offset, which names the '
16908+
+ 'same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant '
16909+
+ '08:00 or the UTC 00:00 only the author knows, so rewrite it by hand',
16910+
reason:
16911+
'A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already '
16912+
+ 'refuses a zone-suffixed time of day on write. The stored form still admitted one, so a '
16913+
+ 'field default such as `10:00Z` parsed clean and every insert that fell back to it was then '
16914+
+ 'refused `invalid_time` on a field the caller never sent, and an action param default or '
16915+
+ 'submitted value passed the dispatcher. The stored form now refuses the zone, so the field '
16916+
+ 'and action-param default gates refuse it when it is authored and the dispatcher refuses it '
16917+
+ 'at submit.',
16918+
acceptanceCriteria:
16919+
'No `time` field or `time` action param declares a literal default with a zone. Zone-less '
16920+
+ 'defaults, the `NOW()` token and expression defaults parse as before. A stored `sys_metadata` '
16921+
+ 'row whose default carried a `Z` or a zero offset loads with the zone dropped; one with a '
16922+
+ 'non-zero offset keeps loading as stored, is listed by `os migrate meta --stored` as a TODO '
16923+
+ 'naming the field or param, and fails the schema wherever it is parsed until it is rewritten.',
16924+
conversionIds: ['time-default-utc-suffix-dropped'],
16925+
},
1688716926
{
1688816927
id: 'time-update-interval-sub-day-retired',
1688916928
surface:

0 commit comments

Comments
 (0)