Skip to content

Commit 731f020

Browse files
os-warrenclaude
andauthored
feat(spec)!: FileValue.duration and CompatibilityMatrixEntry.estimatedMigrationTime carry their unit in the key name (#18669) (#19549)
Fixes #18669 Part of #18124 Clause-②: yes Maintainer ruling **A** (comment 5717168926, decision batch #151 item 4, 「其他同意」 2026-09-17T15:42Z): > rename `FileValue.duration` → `durationSeconds` and `CompatibilityMatrixEntry.estimatedMigrationTime` → `estimatedMigrationTimeHours`, each with an ADR-0087 conversion-layer entry; ⛔ no new closed type, ⛔ no narrowing of stored data Options B (adopt a closed type), C (new closed types) and D (retire both keys) were not chosen and are not implemented here. | def | before | after | |:--|:--|:--| | `data/FileValue` | `duration: 12` | `durationSeconds: 12` | | `kernel/CompatibilityMatrixEntry` | `estimatedMigrationTime: 8` | `estimatedMigrationTimeHours: 8` | Both stay `z.number().optional()`. `durationSeconds: 12.34` still parses — the closed `DurationSeconds` (`z.number().int().nonnegative()`, #18122) and a bare `.int()` were both refused by the ruling, and the fractional case is pinned as a test. ## 1. Premise re-measured on `origin/main` before any edit Taken at tip `ef256e6a8f9bf5744e071631f4337ded9c3b3b76`, 2026-09-21T06:17Z. Every construct was found by NAME, never by the card's 2026-09-17 line numbers. | what the card asserts | answer | |:--|:--| | `FileValue.duration` is still `z.number().optional()`, no unit in the name, no `externalVocabulary` | **holds** — `field-value.zod.ts` line 499, bare, no `.describe()`, no `.meta()` | | `CompatibilityMatrixEntry.estimatedMigrationTime` is still `z.number().optional()` with the JSDoc "Estimated migration time in hours" | **holds** — `plugin-versioning.zod.ts` line 191, JSDoc verbatim at line 189, no `.describe()` | | `check-duration-unit-keys.ts` still names three admission channels and the phrase "carries it in its NAME or in its TYPE, never only in its `.describe()` prose" | **holds** — verbatim at line 6; the ADMISSION section declares TYPE, NAME and prose (describe + JSDoc as the second prose channel), with the retired name-shape list explicitly not an admission channel | | the two precedents in `migrations/registry.ts` | **both present** — `system/MetricAggregationConfig:window.size` and `system/ServiceLevelObjective:period.duration`, read in full; their entry shape is copied rather than invented | The premise holds in all four places, so the implementation was built on it. ## 2. Producer / consumer census, each zero with a lit control Instrument: `git grep` with **no pathspec** (the measured pathspec trap in this checkout), at `ef256e6a8f9bf5744e071631f4337ded9c3b3b76`, 2026-09-21T06:20Z. **`estimatedMigrationTime` — 9 lines / 5 files**, printed rather than counted: 3 generated doc rows (`content/docs/references/kernel/plugin-versioning.mdx`), 2 generated ratchet rows (`authorable-surface.base.json`, `authorable-surface/kernel.json`), 3 test lines (`plugin-versioning.test.ts` 169/176/424) and the schema declaration itself. **Zero runtime producers, zero runtime consumers.** Lit control, same instrument: `CompatibilityMatrixEntry` returns 39 rows across 13 files, so the grep is not silently answering nothing. **`FileValue.duration`** — the bare token `duration` is repo-wide noise (628 files), so the instrument is co-occurrence with `mimeType`: 25 files, of which the source hits are `packages/spec/src/data/field-value.test.ts:362` (a fixture), `content/docs/protocol/objectql/types.mdx:1164` (hand-written prose), `docs/adr/0104-...md:459` (ADR prose), `packages/spec/src/shared/duration.zod.ts:60` and `duration.test.ts:70` (the #18122 six-row census), and `.changeset/18122-closed-duration-types.md:20`. **Zero runtime producers, zero runtime consumers.** Lit control: the same instrument returns `packages/objectql/src/engine.ts`, `packages/client/src/index.ts` and three more for the pair, so it is reading real files; those files' `duration` hits are a hook-scope comment, `durationMs` and Tailwind `duration-200` classes, none of them a file value. **Reachability, measured rather than assumed:** `FileValueSchema` appears ONLY in the `expanded` arm of `valueSchemaFor` — the `stored` arm is `FileReferenceIdValueSchema`, an opaque id string — and `git grep "'expanded'"` over `packages` excluding `packages/spec` returns **zero** call sites, with the same grep inside `packages/spec` returning hits as the lit control. So no write path and no read path in this repo ever parses a value against this shape. **objectui at the pinned sha** `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` (`.objectui-sha` on `origin/main`): `readFileValue` / `readFileValues` (`packages/fields/src/widgets/file-value.ts`) read `id`, `name`, `url`, `mimeType`, `size` and **never** `duration`. Every `duration` hit in that tree is a toast timeout, a `durationMs` field or a Tailwind transition class. Lit control: `git grep FileValue` at that sha returns 19 rows, so the instrument reaches the tree. ## 3. What landed - `packages/spec/src/data/field-value.zod.ts` — `durationSeconds: z.number().optional().describe('Media duration in seconds')`, plus a `retiredKey()` tombstone on `duration` - `packages/spec/src/kernel/plugin-versioning.zod.ts` — `estimatedMigrationTimeHours: z.number().optional().describe('Estimated migration time in hours')`, plus a `retiredKey()` tombstone on `estimatedMigrationTime` - two ADR-0087 D3 semantic entries: **`data-file-value-duration-unit-in-key`** and **`kernel-compatibility-matrix-estimated-migration-time-unit-in-key`** - two `RETIRED_KEYS_BY_MAJOR[18]` rows: `data/FileValue:duration` and `kernel/CompatibilityMatrixEntry:estimatedMigrationTime` - prose sweep: `content/docs/protocol/objectql/types.mdx`, the `shared/duration.zod.ts` six-row census and its test comment - regenerated: `authorable-surface/data.json`, `authorable-surface/kernel.json`, `migrations/registry.ts`, `content/docs/references/{data/field-value,kernel/plugin-versioning}.mdx` Both authorable-surface rows are **top-level**, so the ratchet moves in both files: `data/FileValue:duration [RETIRED]` beside `data/FileValue:durationSeconds`, and the same shape on `kernel/CompatibilityMatrixEntry`. `authorable-surface.base.json` is deliberately **not** re-anchored. ### Why the describe on the hours key is not cosmetic Renaming `estimatedMigrationTime` alone would have produced a NEW gate failure, not a fix: the key name would carry `Hours`, the JSDoc would name hours, and the `.describe()` would name nothing — `check:duration-unit-keys` refuses exactly that as `unit-in-jsdoc-not-in-describe` (the agreement shape, ruled an offence 2026-09-18, decision batch #158 item 5, letter A). The remedy the gate names is to state the unit in the describe, which is what this does. `FileValue.durationSeconds` gains one for the same reader. ### Why no D2 conversion for either key `FileValueSchema` is the ADR-0104 D3 wave-2 **expanded read** form, derived at read time from a `sys_file` id; the stored form is an opaque string. A plugin compatibility matrix is a published version manifest and `stack.zod.ts` declares no collection of them. Neither is ever a stored `sys_metadata` row, so the conversion chain has no seam that would see one — the same disposition `ai-conversation-analytics-duration-unit-in-key` and the two metrics entries already took. ## 4. Verification Head under test: **`ccec095`** (`ccec0959cdd66f0b6188d33473811a37c2b5216a`), after merging `origin/main` at `61170fa38`. The merge of `migrations/registry.ts` was a clean text merge of two sorted-union insertions; `check:migration-registry` reads the result as current (228 semantic, 203 retired-key, 181 retired-def), so nothing was hand-resolved. **Reverse verification against the rebuilt `.d.ts`** — a throwaway probe in `packages/objectql/src`, type-checked through `node_modules` so it resolves `@objectstack/spec` via its `exports` into `dist/`: - red leg: `duration: 12` and `estimatedMigrationTime: 8` produce **exactly two** `TS2322 Type 'number' is not assignable to type 'undefined'` errors, one per old spelling, and nothing else - green control leg: the same file with only `durationSeconds: 12.34` and `estimatedMigrationTimeHours: 8` exits **0** - the probe was removed and the removal proved by observation: `git diff HEAD` empty and `git status --porcelain` empty, not by the delete command's exit code **Package tests** (each a real reading, not a summary): | package | files | tests | |:--|:--|:--| | `@objectstack/spec` (the changed package) | 509 passed (509) | 14887 passed, 1 todo (14888) | | `@objectstack/objectql` | 303 passed (303) | 5050 passed | | `@objectstack/lint` | 106 passed (106) | 4039 passed | | `@objectstack/service-analytics` | 113 passed (113) | 2411 passed | | `@objectstack/driver-sql` | 178 passed, 11 skipped (189) | 2627 passed, 168 skipped | | `@objectstack/cli` | 271 passed, 2 failed (273) | 3535 passed, 30 skipped, **0 failed** | The consumer set is the packages that import `valueSchemaFor` from `@objectstack/spec/data` — the only route by which this contract reaches another package, since no package outside `packages/spec` imports `FileValueSchema` or `CompatibilityMatrixEntrySchema` by name. Two honest non-passes, neither a red gate: - `@objectstack/cli` — the two failing FILES are `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts`, both of which refuse their own prerequisite in words: *"packages/cli is not built (./dist/index.js is absent) … Run: pnpm --filter @objectstack/cli build"*. Zero individual tests failed. **PREREQUISITE NOT MET**, not a pass and not a failure. - `@objectstack/runtime` — **NOT MEASURED**: its suite did not finish inside this container's foreground window and was cut at 560s. Declared to CI rather than reported as green. **Gate reconciliation** — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_RECORD` with every command recorded as `command :: exit code`, exit codes captured before any pipe: ``` Run reconciliation — 109 derived, 108 run, 1 NOT-MEASURED, 0 UNRUN. ``` The one NOT-MEASURED is `node scripts/check-plugin-teardown-shape.mjs --self-test`, exit **3**: its positive-control fixture is pinned to commit `621a487607881c66b2899b7e3477115229a156b4`, unreachable in this shallow checkout. It refuses its own prerequisite rather than reporting a verdict, and it grades the checker, not this diff. Sixteen ratchet-sensitive families were re-run at the final head `ccec095` after the merge — `check:type-check-coverage`, `check:type-check-debt`, `check:authorable-surface`, `check:duration-unit-keys`, `check:api-surface`, `check:generated`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:query-options-erasure`, `check:strictness-ledger`, `check:liveness`, `check:docs`, `check:spec-changes`, `check:upgrade-guide`, `check-adr-0087-registration --base origin/main`, `check:nul-bytes` — all exit 0. `check:duration-unit-keys` reads **205 unit-declaring numeric keys across 2549 source files, zero offenders, no baseline**, and its `--list` census shows both new keys admitted and satisfied: ``` packages/spec/src/data/field-value.zod.ts:510 durationSeconds [name: seconds] [prose: seconds] "Media duration in seconds" packages/spec/src/kernel/plugin-versioning.zod.ts:198 estimatedMigrationTimeHours [name: hours] [prose: hours] "Estimated migration time in hours" ``` **Lint — a declared narrowing, with its three readings.** `pnpm lint` is `eslint . --no-inline-config`, a whole-repo scan CI owns. Run here instead over the diff's lintable files at `ccec095`: (1) the universe is read from `eslint.config.mjs` itself, whose file globs are `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`; (2) `--format json` reports **11 files linted, 0 errors, 0 warnings**, exit 0 — the same 11 lintable paths the diff touches; (3) the invariance claim is the config's own, stated in its header and measured there with a positive control: this repo *"runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file"*, so no edit in this diff can move a verdict on a file it does not touch. ## Acceptance notes Noted, not filed — each is prose about a measurement taken before this rename, not a defect: - `.changeset/18122-closed-duration-types.md` names `FileValue.duration` among the six rows the #18122 unit set was derived from. That sentence was true when it was measured and rewriting another card's unreleased changeset would rewrite their record, so it is left verbatim. Carrier if it ever matters: the release that ships both changesets. - `docs/adr/0104-field-runtime-value-shape-contract.md:459` prints the wave-1 inline shape as `{ url, name?, size?, mimeType?, alt?, duration? }`. ADR prose is a decision record, and the measured precedent is that renames do not sweep it — after `ApiEndpoint.cacheTtl` became `cacheTtlSeconds` in spec 17, `docs/adr/0121-...md` still spells `cacheTtl` twice, and a control grep for five other already-renamed keys across `docs/adr/**` returns zero. Editing it would also make this PR Tier H governed for a prose line whose rename channel is the ADR-0087 ledger this PR adds. Carrier: none — deliberately. ## Refused, and why - ⛔ Did not adopt `DurationSeconds`/`DurationMs`, add `.int()`, or narrow either value — ruling A forbids it, and the fractional-second case is pinned so a later sweep cannot quietly do it. - ⛔ Did not edit `docs/adr/**` or `.changeset/18122-...md` (above). - ⛔ Did not re-anchor `packages/spec/authorable-surface.base.json`; its `baseRev` note on a shallow checkout is informational and `check:authorable-surface` is green. - ⛔ Did not mark this ready, enqueue, merge or arm auto-merge. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77df0f6 commit 731f020

18 files changed

Lines changed: 509 additions & 23 deletions
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `FileValue.duration` and `CompatibilityMatrixEntry.estimatedMigrationTime` carry their unit in the key name (#18669, ruling A)
6+
7+
<!-- adr-0087: registered data-file-value-duration-unit-in-key, kernel-compatibility-matrix-estimated-migration-time-unit-in-key -->
8+
9+
**BREAKING** — two duration-shaped `z.number()` keys are renamed. No value type moves, no key is
10+
removed from the contract, and nothing already stored is narrowed.
11+
12+
| def | before | after |
13+
|:--|:--|:--|
14+
| `data/FileValue` | `duration: 12` | `durationSeconds: 12` |
15+
| `kernel/CompatibilityMatrixEntry` | `estimatedMigrationTime: 8` | `estimatedMigrationTimeHours: 8` |
16+
17+
Maintainer ruling A on #18669 (2026-09-17, decision batch #151 item 4): rename each key, with an
18+
ADR-0087 conversion-layer entry each — ⛔ no new closed type, ⛔ no narrowing of stored data.
19+
20+
## Why each bare name was worth a rename
21+
22+
`FileValue.duration` declared its unit in **no channel at all** — no `.describe()`, no JSDoc, no
23+
unit token in the key — so the published reference page printed a bare number and the authoring
24+
site printed nothing. The company it kept is what makes it a trap rather than an omission: the
25+
only other number on `FileValue` is `size`, a **byte** count, so the one member that measured
26+
time was indistinguishable from a count at the site an author (very often a model, ADR-0033)
27+
writes it.
28+
29+
`CompatibilityMatrixEntry.estimatedMigrationTime` said *"Estimated migration time in hours"* in a
30+
source **JSDoc** and carried no `.describe()` — the #15939 shape, one def over. The JSDoc stops at
31+
the source file; `.describe()` is what `content/docs/references/**` renders, so the published page
32+
printed a bare number directly beside `migrationComplexity`, whose scale *is* named
33+
(`trivial`/`simple`/`moderate`/`complex`/`major`). A reader comparing `major` with `40` could not
34+
tell minutes from hours from days.
35+
36+
## What an author must change
37+
38+
Rename the key. **Nothing else** — both values keep the type they had.
39+
40+
```diff
41+
// an expanded file/image/avatar/video/audio value
42+
{
43+
url: 'https://cdn.example.com/files/clip.mp4',
44+
- duration: 12.34,
45+
+ durationSeconds: 12.34,
46+
}
47+
48+
// a plugin compatibility-matrix entry
49+
{
50+
from: '1.9.0', to: '2.0.0', compatibility: 'breaking-changes',
51+
- estimatedMigrationTime: 8,
52+
+ estimatedMigrationTimeHours: 8,
53+
}
54+
```
55+
56+
## Deliberately NOT narrowed
57+
58+
Both keys stay `z.number().optional()`. `durationSeconds: 12.34` still parses — a fractional
59+
second is the ordinary shape of a media length — so the closed `DurationSeconds` type
60+
(`z.number().int().nonnegative()`, #18122) was **refused** by the ruling, and so was a bare
61+
`.int()`. `FileValue` is one of the six rows #18122 derived its unit set from; it is the one that
62+
takes a **name** instead of a type. `estimatedMigrationTimeHours` keeps `hours` rather than
63+
converting to seconds, for the same reason: the value does not move.
64+
65+
The hours key also gains `.describe('Estimated migration time in hours')`, and that half is not
66+
cosmetic — renaming alone would leave the key name and a source comment agreeing about a unit the
67+
published page does not print, which `check:duration-unit-keys` refuses as
68+
`unit-in-jsdoc-not-in-describe` (ruled an offence 2026-09-18, decision batch #158 item 5, letter
69+
A). `FileValue.durationSeconds` gains `.describe('Media duration in seconds')` for the same
70+
reader.
71+
72+
## The kit
73+
74+
- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching
75+
the parse raises the **rename prescription** instead of vanishing. Neither enclosing shape is
76+
`.strict()`: `CompatibilityMatrixEntrySchema` is a plain `z.object` and would have stripped the
77+
old spelling in silence, and `FileValueSchema` is the one deliberate `z.looseObject` in
78+
`field-value.zod.ts` and would have waved it through as an unrecognised extra
79+
- two ADR-0087 D3 semantic entries and two `RETIRED_KEYS_BY_MAJOR[18]` rows. **No D2 conversion**
80+
for either: `FileValueSchema` is the ADR-0104 D3 wave-2 *expanded read* form, derived at read
81+
time from a `sys_file` id (the stored form is `FileReferenceIdValueSchema`, an opaque string),
82+
and a plugin compatibility matrix is a published version manifest that `stack.zod.ts` declares
83+
no collection of — neither is ever a stored `sys_metadata` row, so the chain has no seam that
84+
would see one
85+
- both authorable-surface rows move: each becomes `<key> [RETIRED]` beside its renamed row, since
86+
both are **top-level** properties of their def
87+
88+
Clause-②: yes

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1161,7 +1161,7 @@ media metadata object, whose only required member is `url`:
11611161
}
11621162
```
11631163

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

11671167
<Callout>

‎content/docs/references/data/field-value.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ Type: `string`
120120
| **size** | `number` | optional | |
121121
| **mimeType** | `string` | optional | |
122122
| **alt** | `string` | optional | |
123-
| **duration** | `number` | optional | |
123+
| **durationSeconds** | `number` | optional | Media duration in seconds |
124+
| **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. |
124125

125126
---
126127

@@ -145,7 +146,8 @@ Type: `string`
145146
| **size** | `number` | optional | |
146147
| **mimeType** | `string` | optional | |
147148
| **alt** | `string` | optional | |
148-
| **duration** | `number` | optional | |
149+
| **durationSeconds** | `number` | optional | Media duration in seconds |
150+
| **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. |
149151

150152

151153
---

‎content/docs/references/kernel/plugin-versioning.mdx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ Compatibility level between versions
7777
| **breakingChanges** | `{ introducedIn: string; type: Enum<'api-removed' \| 'api-renamed' \| 'api-signature-changed' \| 'behavior-changed' \| …>; description: string; migrationGuide?: string; … }[]` | optional | |
7878
| **migrationRequired** | `boolean` | optional (default: `false`) | |
7979
| **migrationComplexity** | `Enum<'trivial' \| 'simple' \| 'moderate' \| 'complex' \| 'major'>` | optional | |
80-
| **estimatedMigrationTime** | `number` | optional | |
80+
| **estimatedMigrationTimeHours** | `number` | optional | Estimated migration time in hours |
81+
| **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. |
8182
| **migrationScript** | `string` | optional | Path to migration script |
8283
| **testCoverage** | `number` | optional | Percentage of migration covered by tests |
8384

@@ -191,7 +192,8 @@ Compatibility level between versions
191192
| **breakingChanges** | `{ introducedIn: string; type: Enum<'api-removed' \| 'api-renamed' \| 'api-signature-changed' \| 'behavior-changed' \| …>; description: string; migrationGuide?: string; … }[]` | optional | |
192193
| **migrationRequired** | `boolean` | optional (default: `false`) | |
193194
| **migrationComplexity** | `Enum<'trivial' \| 'simple' \| 'moderate' \| 'complex' \| 'major'>` | optional | |
194-
| **estimatedMigrationTime** | `number` | optional | |
195+
| **estimatedMigrationTimeHours** | `number` | optional | Estimated migration time in hours |
196+
| **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. |
195197
| **migrationScript** | `string` | optional | Path to migration script |
196198
| **testCoverage** | `number` | optional | Percentage of migration covered by tests |
197199

@@ -285,7 +287,8 @@ Compatibility level between versions
285287
| **breakingChanges** | `{ introducedIn: string; type: Enum<'api-removed' \| 'api-renamed' \| 'api-signature-changed' \| 'behavior-changed' \| …>; description: string; migrationGuide?: string; … }[]` | optional | |
286288
| **migrationRequired** | `boolean` | optional (default: `false`) | |
287289
| **migrationComplexity** | `Enum<'trivial' \| 'simple' \| 'moderate' \| 'complex' \| 'major'>` | optional | |
288-
| **estimatedMigrationTime** | `number` | optional | |
290+
| **estimatedMigrationTimeHours** | `number` | optional | Estimated migration time in hours |
291+
| **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. |
289292
| **migrationScript** | `string` | optional | Path to migration script |
290293
| **testCoverage** | `number` | optional | Percentage of migration covered by tests |
291294

‎packages/drivers/driver-sql/src/sql-driver-numeric-fidelity.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,17 @@ describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () =>
119119
id: 'z4',
120120
name: 'D',
121121
f_record: { home: '+1', work: '+2' },
122-
f_video: { url: 'https://cdn/v.mp4', duration: 12 },
123-
f_audio: { url: 'https://cdn/a.mp3', duration: 30 },
122+
f_video: { url: 'https://cdn/v.mp4', durationSeconds: 12 },
123+
f_audio: { url: 'https://cdn/a.mp3', durationSeconds: 30 },
124124
},
125125
{ bypassTenantAudit: true },
126126
);
127127
const row = await driver.findOne('zoo', { where: { id: 'z4' } }, { bypassTenantAudit: true });
128128
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
129129

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

‎packages/spec/authorable-surface/data.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,8 @@
443443
"data/FilePersistenceConfig:path",
444444
"data/FilePersistenceConfig:type",
445445
"data/FileValue:alt",
446-
"data/FileValue:duration",
446+
"data/FileValue:duration [RETIRED]",
447+
"data/FileValue:durationSeconds",
447448
"data/FileValue:mimeType",
448449
"data/FileValue:name",
449450
"data/FileValue:size",

‎packages/spec/authorable-surface/kernel.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"kernel/ClusterCapabilityConfig:useExistingPool",
3131
"kernel/CompatibilityMatrixEntry:breakingChanges",
3232
"kernel/CompatibilityMatrixEntry:compatibility",
33-
"kernel/CompatibilityMatrixEntry:estimatedMigrationTime",
33+
"kernel/CompatibilityMatrixEntry:estimatedMigrationTime [RETIRED]",
34+
"kernel/CompatibilityMatrixEntry:estimatedMigrationTimeHours",
3435
"kernel/CompatibilityMatrixEntry:from",
3536
"kernel/CompatibilityMatrixEntry:migrationComplexity",
3637
"kernel/CompatibilityMatrixEntry:migrationRequired",

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,20 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => {
359359
bad({ type }, { name: 'x' }, 'expanded'); // object without url — the tightening
360360
bad({ type }, { size: 10 }, 'expanded'); // ditto
361361
}
362-
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', duration: 12 }, 'expanded');
362+
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', durationSeconds: 12 }, 'expanded');
363+
// #18669 ruling A: the rename carries NO narrowing, so a fractional second
364+
// — the ordinary shape of a media length — is still legal. A closed
365+
// `DurationSeconds` (`.int().nonnegative()`) would have refused this, which
366+
// is why the ruling declined one.
367+
ok({ type: 'video' }, { url: 'https://cdn/v.mp4', durationSeconds: 12.34 }, 'expanded');
368+
// The retired spelling is refused with the rename prescription, not with a
369+
// bare unrecognized-key error — and on a `looseObject` a bare deletion
370+
// would have waved it through as an extra key instead.
371+
const retiredDuration = valueSchemaFor({ type: 'video' }, 'expanded')
372+
.safeParse({ url: 'https://cdn/v.mp4', duration: 12 });
373+
expect(retiredDuration.success).toBe(false);
374+
expect(JSON.stringify((retiredDuration as { error: unknown }).error))
375+
.toContain('Rename the key to `durationSeconds`');
363376
});
364377

365378
it('structured JSON types', () => {

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { lazySchema } from '../shared/lazy-schema';
4040
// `strictObjectError`, `declarationStore`), and the error map builds its
4141
// suggester lazily on the first ISSUE, never at module scope. Measured with
4242
// this file as the entry module under eager evaluation (#13802).
43+
import { retiredKey } from '../shared/retired-key';
4344
import { strictObject } from '../shared/strict-object';
4445
import { SystemObjectName } from '../system/constants/system-names';
4546
import type { FieldType } from './field.zod';
@@ -496,7 +497,25 @@ export const FileValueSchema = lazySchema(() => z.looseObject({
496497
size: z.number().optional(),
497498
mimeType: z.string().optional(),
498499
alt: z.string().optional(),
499-
duration: z.number().optional(),
500+
501+
// Renamed from `duration` (#18669, ruling A, decision batch #151 item 4): the
502+
// unit of a duration-shaped number lives in the key NAME, not only in prose.
503+
// The company it kept is what made the bare name worth a rename — the only
504+
// other number on this shape is `size`, a BYTE count, so the one member that
505+
// measured time was indistinguishable from a count at the authoring site, and
506+
// a media length is exactly the value an author guesses at 1000x stakes.
507+
// ⛔ The value type is deliberately unchanged: a fractional second is a legal
508+
// media length (`12.34`), so no closed `DurationSeconds` and no `.int()`
509+
// floor rides along with the rename.
510+
durationSeconds: z.number().optional().describe('Media duration in seconds'),
511+
512+
/** Tombstone for the rename above (#18669, ruling A, decision batch #151 item 4). */
513+
duration: retiredKey(
514+
'`FileValue.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — '
515+
+ 'the unit of a duration-shaped number lives in the key name, not only in the describe '
516+
+ 'prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged, and a '
517+
+ 'fractional second is still legal.',
518+
),
500519
}));
501520
export type FileValue = z.input<typeof FileValueSchema>;
502521

‎packages/spec/src/kernel/plugin-versioning.test.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,14 +166,53 @@ describe('Plugin Versioning Schemas', () => {
166166
],
167167
migrationRequired: true,
168168
migrationComplexity: 'moderate' as const,
169-
estimatedMigrationTime: 8,
169+
estimatedMigrationTimeHours: 8,
170170
migrationScript: './scripts/migrate-v1-to-v2.ts',
171171
testCoverage: 95,
172172
};
173173
const result = CompatibilityMatrixEntrySchema.parse(entry);
174174
expect(result.compatibility).toBe('breaking-changes');
175175
expect(result.migrationRequired).toBe(true);
176-
expect(result.estimatedMigrationTime).toBe(8);
176+
expect(result.estimatedMigrationTimeHours).toBe(8);
177+
});
178+
179+
// #18669 ruling A. The rename above is only half the contract; this is the
180+
// other half. Without it the retirement is pinned by nothing: this shape is
181+
// a plain `z.object`, so deleting the key rather than tombstoning it would
182+
// strip the old spelling in SILENCE and every assertion above would stay
183+
// green. The pin is on the PRESCRIPTION, not on "it throws" — a bare
184+
// unrecognized-key error would also throw, and would carry no rename.
185+
// Asserted the way `rollout.duration`'s pin below already asserts it: the
186+
// issue is looked up BY PATH and its `code` is read, so a refusal that
187+
// arrived as a bare `unrecognized_keys` cannot satisfy this by happening to
188+
// mention the key name somewhere in the envelope.
189+
it('REFUSES the retired `estimatedMigrationTime` with the rename in the message', () => {
190+
const result = CompatibilityMatrixEntrySchema.safeParse({
191+
from: '1.0.0',
192+
to: '2.0.0',
193+
compatibility: 'breaking-changes',
194+
estimatedMigrationTime: 8,
195+
});
196+
expect(result.success).toBe(false);
197+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'estimatedMigrationTime');
198+
expect(issue).toBeDefined();
199+
expect(issue!.code).not.toBe('unrecognized_keys');
200+
expect(issue!.message).toContain(
201+
'`CompatibilityMatrixEntry.estimatedMigrationTime` was renamed to `estimatedMigrationTimeHours`',
202+
);
203+
});
204+
205+
// The no-narrowing half of the same ruling: the value type did not move, so
206+
// a fractional estimate that parsed before still parses. A sweep that added
207+
// `.int()` or adopted a closed duration type turns this red.
208+
it('keeps the value type: a fractional hours estimate still parses', () => {
209+
const parsed = CompatibilityMatrixEntrySchema.parse({
210+
from: '1.0.0',
211+
to: '2.0.0',
212+
compatibility: 'breaking-changes',
213+
estimatedMigrationTimeHours: 8.5,
214+
});
215+
expect(parsed.estimatedMigrationTimeHours).toBe(8.5);
177216
});
178217
});
179218

@@ -421,7 +460,7 @@ describe('Plugin Versioning Schemas', () => {
421460
],
422461
migrationRequired: true,
423462
migrationComplexity: 'major',
424-
estimatedMigrationTime: 40,
463+
estimatedMigrationTimeHours: 40,
425464
});
426465
expect(matrixEntry.migrationRequired).toBe(true);
427466
expect(matrixEntry.migrationComplexity).toBe('major');

0 commit comments

Comments
 (0)