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
88 changes: 88 additions & 0 deletions .changeset/18669-duration-key-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
"@objectstack/spec": minor
---

feat(spec)!: `FileValue.duration` and `CompatibilityMatrixEntry.estimatedMigrationTime` carry their unit in the key name (#18669, ruling A)

<!-- adr-0087: registered data-file-value-duration-unit-in-key, kernel-compatibility-matrix-estimated-migration-time-unit-in-key -->

**BREAKING** — two duration-shaped `z.number()` keys are renamed. No value type moves, no key is
removed from the contract, and nothing already stored is narrowed.

| def | before | after |
|:--|:--|:--|
| `data/FileValue` | `duration: 12` | `durationSeconds: 12` |
| `kernel/CompatibilityMatrixEntry` | `estimatedMigrationTime: 8` | `estimatedMigrationTimeHours: 8` |

Maintainer ruling A on #18669 (2026-09-17, decision batch #151 item 4): rename each key, with an
ADR-0087 conversion-layer entry each — ⛔ no new closed type, ⛔ no narrowing of stored data.

## Why each bare name was worth a rename

`FileValue.duration` declared its unit in **no channel at all** — no `.describe()`, no JSDoc, no
unit token in the key — so the published reference page printed a bare number and the authoring
site printed nothing. The company it kept is what makes it a trap rather than an omission: the
only other number on `FileValue` is `size`, a **byte** count, so the one member that measured
time was indistinguishable from a count at the site an author (very often a model, ADR-0033)
writes it.

`CompatibilityMatrixEntry.estimatedMigrationTime` said *"Estimated migration time in hours"* in a
source **JSDoc** and carried no `.describe()` — the #15939 shape, one def over. The JSDoc stops at
the source file; `.describe()` is what `content/docs/references/**` renders, so the published page
printed a bare number directly beside `migrationComplexity`, whose scale *is* named
(`trivial`/`simple`/`moderate`/`complex`/`major`). A reader comparing `major` with `40` could not
tell minutes from hours from days.

## What an author must change

Rename the key. **Nothing else** — both values keep the type they had.

```diff
// an expanded file/image/avatar/video/audio value
{
url: 'https://cdn.example.com/files/clip.mp4',
- duration: 12.34,
+ durationSeconds: 12.34,
}

// a plugin compatibility-matrix entry
{
from: '1.9.0', to: '2.0.0', compatibility: 'breaking-changes',
- estimatedMigrationTime: 8,
+ estimatedMigrationTimeHours: 8,
}
```

## Deliberately NOT narrowed

Both keys stay `z.number().optional()`. `durationSeconds: 12.34` still parses — a fractional
second is the ordinary shape of a media length — so the closed `DurationSeconds` type
(`z.number().int().nonnegative()`, #18122) was **refused** by the ruling, and so was a bare
`.int()`. `FileValue` is one of the six rows #18122 derived its unit set from; it is the one that
takes a **name** instead of a type. `estimatedMigrationTimeHours` keeps `hours` rather than
converting to seconds, for the same reason: the value does not move.

The hours key also gains `.describe('Estimated migration time in hours')`, and that half is not
cosmetic — renaming alone would leave the key name and a source comment agreeing about a unit the
published page does not print, which `check:duration-unit-keys` refuses as
`unit-in-jsdoc-not-in-describe` (ruled an offence 2026-09-18, decision batch #158 item 5, letter
A). `FileValue.durationSeconds` gains `.describe('Media duration in seconds')` for the same
reader.

## The kit

- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching
the parse raises the **rename prescription** instead of vanishing. Neither enclosing shape is
`.strict()`: `CompatibilityMatrixEntrySchema` is a plain `z.object` and would have stripped the
old spelling in silence, and `FileValueSchema` is the one deliberate `z.looseObject` in
`field-value.zod.ts` and would have waved it through as an unrecognised extra
- two ADR-0087 D3 semantic entries and two `RETIRED_KEYS_BY_MAJOR[18]` rows. **No D2 conversion**
for either: `FileValueSchema` is the ADR-0104 D3 wave-2 *expanded read* form, derived at read
time from a `sys_file` id (the stored form is `FileReferenceIdValueSchema`, an opaque string),
and a plugin compatibility matrix is a published version manifest that `stack.zod.ts` declares
no collection of — neither is ever a stored `sys_metadata` row, so the chain has no seam that
would see one
- both authorable-surface rows move: each becomes `<key> [RETIRED]` beside its renamed row, since
both are **top-level** properties of their def

Clause-②: yes
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1161,7 +1161,7 @@ media metadata object, whose only required member is `url`:
}
```

`alt` and `duration` are the other optional members. The keys are `name` and
`alt` and `durationSeconds` are the other optional members. The keys are `name` and
`mimeType` — not `filename` / `content_type`.

<Callout>
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/data/field-value.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ Type: `string`
| **size** | `number` | optional | |
| **mimeType** | `string` | optional | |
| **alt** | `string` | optional | |
| **duration** | `number` | optional | |
| **durationSeconds** | `number` | optional | Media duration in seconds |
| **duration** | `never` | optional | [REMOVED] `FileValue.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged, and a fractional second is still legal. |

---

Expand All @@ -145,7 +146,8 @@ Type: `string`
| **size** | `number` | optional | |
| **mimeType** | `string` | optional | |
| **alt** | `string` | optional | |
| **duration** | `number` | optional | |
| **durationSeconds** | `number` | optional | Media duration in seconds |
| **duration** | `never` | optional | [REMOVED] `FileValue.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged, and a fractional second is still legal. |


---
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/kernel/plugin-versioning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ Compatibility level between versions
| **breakingChanges** | `{ introducedIn: string; type: Enum<'api-removed' \| 'api-renamed' \| 'api-signature-changed' \| 'behavior-changed' \| …>; description: string; migrationGuide?: string; … }[]` | optional | |
| **migrationRequired** | `boolean` | optional (default: `false`) | |
| **migrationComplexity** | `Enum<'trivial' \| 'simple' \| 'moderate' \| 'complex' \| 'major'>` | optional | |
| **estimatedMigrationTime** | `number` | optional | |
| **estimatedMigrationTimeHours** | `number` | optional | Estimated migration time in hours |
| **estimatedMigrationTime** | `never` | optional | [REMOVED] `CompatibilityMatrixEntry.estimatedMigrationTime` was renamed to `estimatedMigrationTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in prose. Rename the key to `estimatedMigrationTimeHours`; the value (hours) is unchanged. |
| **migrationScript** | `string` | optional | Path to migration script |
| **testCoverage** | `number` | optional | Percentage of migration covered by tests |

Expand Down Expand Up @@ -191,7 +192,8 @@ Compatibility level between versions
| **breakingChanges** | `{ introducedIn: string; type: Enum<'api-removed' \| 'api-renamed' \| 'api-signature-changed' \| 'behavior-changed' \| …>; description: string; migrationGuide?: string; … }[]` | optional | |
| **migrationRequired** | `boolean` | optional (default: `false`) | |
| **migrationComplexity** | `Enum<'trivial' \| 'simple' \| 'moderate' \| 'complex' \| 'major'>` | optional | |
| **estimatedMigrationTime** | `number` | optional | |
| **estimatedMigrationTimeHours** | `number` | optional | Estimated migration time in hours |
| **estimatedMigrationTime** | `never` | optional | [REMOVED] `CompatibilityMatrixEntry.estimatedMigrationTime` was renamed to `estimatedMigrationTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in prose. Rename the key to `estimatedMigrationTimeHours`; the value (hours) is unchanged. |
| **migrationScript** | `string` | optional | Path to migration script |
| **testCoverage** | `number` | optional | Percentage of migration covered by tests |

Expand Down Expand Up @@ -285,7 +287,8 @@ Compatibility level between versions
| **breakingChanges** | `{ introducedIn: string; type: Enum<'api-removed' \| 'api-renamed' \| 'api-signature-changed' \| 'behavior-changed' \| …>; description: string; migrationGuide?: string; … }[]` | optional | |
| **migrationRequired** | `boolean` | optional (default: `false`) | |
| **migrationComplexity** | `Enum<'trivial' \| 'simple' \| 'moderate' \| 'complex' \| 'major'>` | optional | |
| **estimatedMigrationTime** | `number` | optional | |
| **estimatedMigrationTimeHours** | `number` | optional | Estimated migration time in hours |
| **estimatedMigrationTime** | `never` | optional | [REMOVED] `CompatibilityMatrixEntry.estimatedMigrationTime` was renamed to `estimatedMigrationTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in prose. Rename the key to `estimatedMigrationTimeHours`; the value (hours) is unchanged. |
| **migrationScript** | `string` | optional | Path to migration script |
| **testCoverage** | `number` | optional | Percentage of migration covered by tests |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,17 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () =>
id: 'z4',
name: 'D',
f_record: { home: '+1', work: '+2' },
f_video: { url: 'https://cdn/v.mp4', duration: 12 },
f_audio: { url: 'https://cdn/a.mp3', duration: 30 },
f_video: { url: 'https://cdn/v.mp4', durationSeconds: 12 },
f_audio: { url: 'https://cdn/a.mp3', durationSeconds: 30 },
},
{ bypassTenantAudit: true },
);
const row = await driver.findOne('zoo', { where: { id: 'z4' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');

expect(row.f_record).toEqual({ home: '+1', work: '+2' });
expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', duration: 12 });
expect(row.f_audio).toEqual({ url: 'https://cdn/a.mp3', duration: 30 });
expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', durationSeconds: 12 });
expect(row.f_audio).toEqual({ url: 'https://cdn/a.mp3', durationSeconds: 30 });
});
});

Expand Down
3 changes: 2 additions & 1 deletion packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,8 @@
"data/FilePersistenceConfig:path",
"data/FilePersistenceConfig:type",
"data/FileValue:alt",
"data/FileValue:duration",
"data/FileValue:duration [RETIRED]",
"data/FileValue:durationSeconds",
"data/FileValue:mimeType",
"data/FileValue:name",
"data/FileValue:size",
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/authorable-surface/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"kernel/ClusterCapabilityConfig:useExistingPool",
"kernel/CompatibilityMatrixEntry:breakingChanges",
"kernel/CompatibilityMatrixEntry:compatibility",
"kernel/CompatibilityMatrixEntry:estimatedMigrationTime",
"kernel/CompatibilityMatrixEntry:estimatedMigrationTime [RETIRED]",
"kernel/CompatibilityMatrixEntry:estimatedMigrationTimeHours",
"kernel/CompatibilityMatrixEntry:from",
"kernel/CompatibilityMatrixEntry:migrationComplexity",
"kernel/CompatibilityMatrixEntry:migrationRequired",
Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/data/field-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,20 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
bad({ type }, { name: 'x' }, 'expanded'); // object without url — the tightening
bad({ type }, { size: 10 }, 'expanded'); // ditto
}
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', duration: 12 }, 'expanded');
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', durationSeconds: 12 }, 'expanded');
// #18669 ruling A: the rename carries NO narrowing, so a fractional second
// — the ordinary shape of a media length — is still legal. A closed
// `DurationSeconds` (`.int().nonnegative()`) would have refused this, which
// is why the ruling declined one.
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', durationSeconds: 12.34 }, 'expanded');
// The retired spelling is refused with the rename prescription, not with a
// bare unrecognized-key error — and on a `looseObject` a bare deletion
// would have waved it through as an extra key instead.
const retiredDuration = valueSchemaFor({ type: 'video' }, 'expanded')
.safeParse({ url: 'https://cdn/v.mp4', duration: 12 });
expect(retiredDuration.success).toBe(false);
expect(JSON.stringify((retiredDuration as { error: unknown }).error))
.toContain('Rename the key to `durationSeconds`');
});

it('structured JSON types', () => {
Expand Down
21 changes: 20 additions & 1 deletion packages/spec/src/data/field-value.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { lazySchema } from '../shared/lazy-schema';
// `strictObjectError`, `declarationStore`), and the error map builds its
// suggester lazily on the first ISSUE, never at module scope. Measured with
// this file as the entry module under eager evaluation (#13802).
import { retiredKey } from '../shared/retired-key';
import { strictObject } from '../shared/strict-object';
import { SystemObjectName } from '../system/constants/system-names';
import type { FieldType } from './field.zod';
Expand Down Expand Up @@ -496,7 +497,25 @@ export const FileValueSchema = lazySchema(() => z.looseObject({
size: z.number().optional(),
mimeType: z.string().optional(),
alt: z.string().optional(),
duration: z.number().optional(),

// Renamed from `duration` (#18669, ruling A, decision batch #151 item 4): the
// unit of a duration-shaped number lives in the key NAME, not only in prose.
// The company it kept is what made the bare name worth a rename — the only
// other number on this shape is `size`, a BYTE count, so the one member that
// measured time was indistinguishable from a count at the authoring site, and
// a media length is exactly the value an author guesses at 1000x stakes.
// ⛔ The value type is deliberately unchanged: a fractional second is a legal
// media length (`12.34`), so no closed `DurationSeconds` and no `.int()`
// floor rides along with the rename.
durationSeconds: z.number().optional().describe('Media duration in seconds'),

/** Tombstone for the rename above (#18669, ruling A, decision batch #151 item 4). */
duration: retiredKey(
'`FileValue.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — '
+ 'the unit of a duration-shaped number lives in the key name, not only in the describe '
+ 'prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged, and a '
+ 'fractional second is still legal.',
),
}));
export type FileValue = z.input<typeof FileValueSchema>;

Expand Down
45 changes: 42 additions & 3 deletions packages/spec/src/kernel/plugin-versioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,53 @@ describe('Plugin Versioning Schemas', () => {
],
migrationRequired: true,
migrationComplexity: 'moderate' as const,
estimatedMigrationTime: 8,
estimatedMigrationTimeHours: 8,
migrationScript: './scripts/migrate-v1-to-v2.ts',
testCoverage: 95,
};
const result = CompatibilityMatrixEntrySchema.parse(entry);
expect(result.compatibility).toBe('breaking-changes');
expect(result.migrationRequired).toBe(true);
expect(result.estimatedMigrationTime).toBe(8);
expect(result.estimatedMigrationTimeHours).toBe(8);
});

// #18669 ruling A. The rename above is only half the contract; this is the
// other half. Without it the retirement is pinned by nothing: this shape is
// a plain `z.object`, so deleting the key rather than tombstoning it would
// strip the old spelling in SILENCE and every assertion above would stay
// green. The pin is on the PRESCRIPTION, not on "it throws" — a bare
// unrecognized-key error would also throw, and would carry no rename.
// Asserted the way `rollout.duration`'s pin below already asserts it: the
// issue is looked up BY PATH and its `code` is read, so a refusal that
// arrived as a bare `unrecognized_keys` cannot satisfy this by happening to
// mention the key name somewhere in the envelope.
it('REFUSES the retired `estimatedMigrationTime` with the rename in the message', () => {
const result = CompatibilityMatrixEntrySchema.safeParse({
from: '1.0.0',
to: '2.0.0',
compatibility: 'breaking-changes',
estimatedMigrationTime: 8,
});
expect(result.success).toBe(false);
const issue = result.error!.issues.find((i) => i.path.join('.') === 'estimatedMigrationTime');
expect(issue).toBeDefined();
expect(issue!.code).not.toBe('unrecognized_keys');
expect(issue!.message).toContain(
'`CompatibilityMatrixEntry.estimatedMigrationTime` was renamed to `estimatedMigrationTimeHours`',
);
});

// The no-narrowing half of the same ruling: the value type did not move, so
// a fractional estimate that parsed before still parses. A sweep that added
// `.int()` or adopted a closed duration type turns this red.
it('keeps the value type: a fractional hours estimate still parses', () => {
const parsed = CompatibilityMatrixEntrySchema.parse({
from: '1.0.0',
to: '2.0.0',
compatibility: 'breaking-changes',
estimatedMigrationTimeHours: 8.5,
});
expect(parsed.estimatedMigrationTimeHours).toBe(8.5);
});
});

Expand Down Expand Up @@ -421,7 +460,7 @@ describe('Plugin Versioning Schemas', () => {
],
migrationRequired: true,
migrationComplexity: 'major',
estimatedMigrationTime: 40,
estimatedMigrationTimeHours: 40,
});
expect(matrixEntry.migrationRequired).toBe(true);
expect(matrixEntry.migrationComplexity).toBe('major');
Expand Down
23 changes: 19 additions & 4 deletions packages/spec/src/kernel/plugin-versioning.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,25 @@ export const CompatibilityMatrixEntrySchema = lazySchema(() => z.object({
migrationComplexity: z.enum(['trivial', 'simple', 'moderate', 'complex', 'major']).optional(),

/**
* Estimated migration time in hours
*/
estimatedMigrationTime: z.number().optional(),

* Estimated migration time
*
* Renamed from `estimatedMigrationTime` (#18669, ruling A, decision batch #151
* item 4). The unit of a duration-shaped number lives in the key NAME, and it
* is restated in the `.describe()` the published reference page renders — this
* key used to name it in a source JSDoc alone, a channel that stops at this
* file, so the page published a bare number beside `migrationComplexity`'s
* named scale. The value type is unchanged.
*/
estimatedMigrationTimeHours: z.number().optional().describe('Estimated migration time in hours'),

/** Tombstone for the rename above (#18669, ruling A, decision batch #151 item 4). */
estimatedMigrationTime: retiredKey(
'`CompatibilityMatrixEntry.estimatedMigrationTime` was renamed to '
+ '`estimatedMigrationTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped '
+ 'number lives in the key name, not only in prose. Rename the key to '
+ '`estimatedMigrationTimeHours`; the value (hours) is unchanged.',
),

/**
* Migration script available
*/
Expand Down
Loading
Loading