Skip to content

Commit ac0fe6f

Browse files
committed
fix(spec,core): tombstone the startTime alias the duration-unit rule refuses
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
1 parent 9faaefa commit ac0fe6f

12 files changed

Lines changed: 157 additions & 69 deletions

File tree

‎.changeset/16059-startup-orchestrator-shipped-shape.md‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,24 @@ cannot drift again.
4747
| `pluginName` | absent | `string`, required |
4848
| `success` | `boolean`, required | unchanged |
4949
| `durationMs` | `number`, **required** | `number`, **optional** (absent when the plugin declares no `start()`) |
50-
| `startTime` | absent | `number`, optional — deprecated alias of `durationMs`, mirrored because the kernel still populates it |
50+
| `startTime` | absent (it was core's own deprecated alias) | **removed** — read `durationMs`, which always carried the same value |
5151
| `error` | serializable projection | unchanged (a thrown `Error` satisfies it) |
5252
| `timedOut` | absent | `boolean`, optional — set when the failure was the timeout |
5353
| `health: HealthStatus` | optional | **removed** — no probe ever filled it |
5454

55-
**The one-line fix:** rename `plugin: { name }` to `pluginName`, and delete `health`. Both
56-
old spellings are `retiredKey()` tombstones on the surviving schema, so each is a `tsc`
57-
error at the construction site and a parse error carrying the prescription.
55+
**The one-line fix:** rename `plugin: { name }` to `pluginName`, delete `health`, and read
56+
`durationMs` wherever you read `startTime`. All three old spellings are `retiredKey()`
57+
tombstones on the surviving schema, so each is a `tsc` error at the construction site and a
58+
parse error carrying the prescription.
59+
60+
`startTime` is the one member whose removal a reader can OBSERVE: `@objectstack/core`
61+
populated it beside `durationMs` with the identical elapsed value, under its own ADR-0087
62+
L1 deprecation, and `ObjectKernel.startPluginWithTimeout()` stops setting it here. Mirroring
63+
it on the contract was the alternative and the tree refuses it — `check:duration-unit-keys`
64+
(ruling B on #14478) fails an elapsed number whose key name carries no unit, and neither of
65+
that rule's two schema-declared exemptions fits: it is not an `EpochMs` instant and it
66+
mirrors no external standard. Renaming it to `startTimeMs` would mint a spelling nothing has
67+
ever produced, for a member already documented as slated for removal.
5868

5969
For `@objectstack/core` consumers the members are unchanged; the one narrowing is that
6070
`PluginStartupResult.error` is now typed as the serializable projection

‎content/docs/references/kernel/startup-orchestrator.mdx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ What each member means, and when it is present:
3434
TIMEOUT rather than a throw from inside `start()`: the kernel sets it from
3535
the raced rejection's message. Absent on the success path; absent, not
3636
`false`, when a plugin's own `start()` threw.
37-
- `startTime` — deprecated; see its own note below.
37+
38+
The deprecated `startTime` alias the kernel used to set beside `durationMs`
39+
ends here: it never held an instant, and a member whose name promises one
40+
while carrying an elapsed duration is the confusion the duration-unit rule
41+
exists to stop. It is a tombstone on this schema and the kernel no longer
42+
populates it.
3843

3944
── [#16059] What this module used to declare, and why it no longer does ────
4045

@@ -98,9 +103,9 @@ const result = PluginStartupResultSchema.parse(data);
98103
| **pluginName** | `string` | ✅ | Name of the plugin that was started |
99104
| **success** | `boolean` | ✅ | Whether the plugin started successfully |
100105
| **durationMs** | `number` | optional | Time taken to start the plugin in milliseconds; absent when the plugin declares no start() |
101-
| **startTime** | `number` | optional | [DEPRECATED] The same elapsed milliseconds as durationMs — read durationMs instead |
102106
| **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed |
103107
| **timedOut** | `boolean` | optional | Whether startup failed because the startup timeout fired, rather than start() throwing |
108+
| **startTime** | `never` | optional | [REMOVED] `PluginStartupResult.startTime` was removed in @objectstack/spec 18 (ADR-0049) — it never held an instant: the kernel filled it with the SAME elapsed milliseconds as `durationMs`, so a reader who took the name at its word and computed `Date.now() - startTime` got an age near the epoch instead of a wait. Delete the key and read `durationMs`, which has always carried the same value. |
104109
| **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` 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 `durationMs`; the value (milliseconds) is unchanged. |
105110
| **plugin** | `never` | optional | [REMOVED] `PluginStartupResult.plugin` was removed in @objectstack/spec 18 (ADR-0049) — the kernel has never put a plugin OBJECT in this result, so the nested `{ name, version }` shape described a value nothing ever built. Replace the key with `pluginName` and carry the plugin name string. |
106111
| **health** | `never` | optional | [REMOVED] `PluginStartupResult.health` was removed in @objectstack/spec 18 (ADR-0049) — it carried a `HealthStatus`, and that vocabulary is retired with the startup orchestrator that declared it: no probe system ever ran a health check at startup, so nothing ever filled the key. Delete the key; a plugin that reports health does it through a service it registers, not through this result. |

‎packages/core/src/kernel.test.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -585,10 +585,12 @@ describe('ObjectKernel', () => {
585585
});
586586

587587
// These two pin the MEANING of the number, not merely that one is
588-
// present. The result member carrying it was spelled `startTime` while
589-
// holding `Date.now() - start`, so a reader who correctly took it for an
590-
// instant and wrote `Date.now() - result.startTime` got an age near the
591-
// epoch. `toBeGreaterThan(0)` cannot tell the two readings apart -- an
588+
// present. The result member carrying it was once ALSO spelled
589+
// `startTime` while holding `Date.now() - start`, so a reader who
590+
// correctly took that name for an instant and wrote
591+
// `Date.now() - result.startTime` got an age near the epoch. #16059
592+
// removed that alias; `durationMs` is the only spelling left, and
593+
// `toBeGreaterThan(0)` cannot tell the two readings apart -- an
592594
// epoch-millisecond instant passes it too. A ceiling can: any instant
593595
// today is ~1.7e12, orders of magnitude above any plugin's start().
594596
const INSTANT_FLOOR_MS = 1_000_000_000; // ~11.5 days as a duration; well below any real epoch-ms instant
@@ -636,8 +638,13 @@ describe('ObjectKernel', () => {
636638
expect(ok.success).toBe(true);
637639
expect(ok.durationMs).toBeGreaterThan(0);
638640
expect(ok.durationMs).toBeLessThan(INSTANT_FLOOR_MS);
639-
// The deprecated alias carries the same elapsed value, not an instant.
640-
expect(ok.startTime).toBe(ok.durationMs);
641+
// [#16059] The `startTime` alias that used to carry the same value is
642+
// GONE, not merely deprecated: a member whose name promises an
643+
// instant while holding an elapsed duration is the confusion this
644+
// case exists to pin, and the spec now tombstones it. Asserting its
645+
// absence here is what keeps a later "restore the alias" from
646+
// passing quietly.
647+
expect(ok).not.toHaveProperty('startTime');
641648

642649
const failingMeta: PluginMetadata = {
643650
name: 'failing-plugin',
@@ -652,7 +659,7 @@ describe('ObjectKernel', () => {
652659
expect(failed.success).toBe(false);
653660
expect(failed.durationMs).toBeGreaterThanOrEqual(0);
654661
expect(failed.durationMs).toBeLessThan(INSTANT_FLOOR_MS);
655-
expect(failed.startTime).toBe(failed.durationMs);
662+
expect(failed).not.toHaveProperty('startTime');
656663
});
657664
});
658665

‎packages/core/src/kernel.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -792,9 +792,6 @@ export class ObjectKernel {
792792
success: true,
793793
pluginName: plugin.name,
794794
durationMs: duration,
795-
// Deprecated alias carrying the same elapsed value; see
796-
// PluginStartupResult.startTime.
797-
startTime: duration,
798795
};
799796
} catch (error) {
800797
const duration = Date.now() - startTime;
@@ -805,9 +802,6 @@ export class ObjectKernel {
805802
pluginName: plugin.name,
806803
error: error as Error,
807804
durationMs: duration,
808-
// Deprecated alias carrying the same elapsed value; see
809-
// PluginStartupResult.startTime.
810-
startTime: duration,
811805
timedOut: isTimeout,
812806
};
813807
}

‎packages/core/src/plugin-loader.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,18 @@ export interface PluginLoadResult {
9999
* SCHEMA against what this package actually ships and made the type flow one
100100
* way — spec to core — so the two cannot drift apart again.
101101
*
102-
* The shape is unchanged for every producer and reader here: `pluginName`,
103-
* `success`, optional `durationMs`, the deprecated `startTime` alias,
104-
* `timedOut`, and `error` — declared by the spec as the serializable projection
105-
* (`name` / `message` / `stack` / `code`) that a thrown `Error` satisfies
106-
* structurally, so `ObjectKernel` keeps handing the live instance through and
107-
* `instanceof Error` keeps narrowing at the read site.
102+
* The members carry over unchanged — `pluginName`, `success`, optional
103+
* `durationMs`, `timedOut`, and `error`, which the spec declares as the
104+
* serializable projection (`name` / `message` / `stack` / `code`) that a thrown
105+
* `Error` satisfies structurally, so `ObjectKernel` keeps handing the live
106+
* instance through and `instanceof Error` keeps narrowing at the read site.
107+
*
108+
* One member does NOT carry over: the deprecated `startTime` alias, which held
109+
* the same elapsed milliseconds as `durationMs` under a name that promises an
110+
* instant. The spec tombstones it rather than mirroring it —
111+
* `check:duration-unit-keys` refuses an elapsed number whose key name carries
112+
* no unit, and neither of that rule's schema-declared exemptions fits — so the
113+
* L1 window closes here and `ObjectKernel` no longer populates it.
108114
*/
109115
export type { PluginStartupResult } from '@objectstack/spec/kernel';
110116

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@
583583
"kernel/PluginStartupResult:health [RETIRED]",
584584
"kernel/PluginStartupResult:plugin [RETIRED]",
585585
"kernel/PluginStartupResult:pluginName",
586-
"kernel/PluginStartupResult:startTime",
586+
"kernel/PluginStartupResult:startTime [RETIRED]",
587587
"kernel/PluginStartupResult:success",
588588
"kernel/PluginStartupResult:timedOut",
589589
"kernel/PluginStateSnapshot:metadata",

‎packages/spec/src/kernel/startup-orchestrator-retirement.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,14 @@ describe('[#16059] startup orchestrator retirement', () => {
117117
// asserts the shape here too, so the reason is named at the spec end.
118118
const { PluginStartupResultSchema } = await import('./startup-orchestrator.zod');
119119
const shape = (PluginStartupResultSchema as unknown as { shape: Record<string, unknown> }).shape;
120-
for (const member of ['pluginName', 'success', 'durationMs', 'startTime', 'error', 'timedOut']) {
120+
for (const member of ['pluginName', 'success', 'durationMs', 'error', 'timedOut']) {
121121
expect(Object.keys(shape), `${member} must be declared`).toContain(member);
122122
}
123-
// The two members that left the surviving def are tombstones, not
124-
// deletions — present in the shape, refusing with a prescription.
125-
for (const member of ['plugin', 'health', 'duration']) {
123+
// The members that left the surviving def are tombstones, not deletions —
124+
// present in the shape, refusing with a prescription. `startTime` is here
125+
// rather than above on purpose: mirroring the deprecated alias is what
126+
// `check:duration-unit-keys` refuses, so the contract ends it instead.
127+
for (const member of ['plugin', 'health', 'startTime', 'duration']) {
126128
expect(Object.keys(shape), `${member} must remain as a tombstone`).toContain(member);
127129
}
128130
});

‎packages/spec/src/kernel/startup-orchestrator.test.ts‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,14 @@ describe('PluginStartupResultSchema — the shape the kernel produces', () => {
1717
expect(result.success).toBe(true);
1818
});
1919

20-
it('accepts the success path: durationMs plus the deprecated startTime alias', () => {
20+
it('accepts the success path: durationMs alone', () => {
2121
const result = PluginStartupResultSchema.safeParse({
2222
pluginName: 'crm-plugin',
2323
success: true,
2424
durationMs: 1250,
25-
startTime: 1250,
2625
});
2726
expect(result.success).toBe(true);
28-
if (result.success) {
29-
expect(result.data.durationMs).toBe(1250);
30-
// The kernel populates both with the SAME elapsed value under its
31-
// ADR-0087 L1 disposition; the contract mirrors that rather than
32-
// declaring `never` for a member the kernel emits.
33-
expect(result.data.startTime).toBe(result.data.durationMs);
34-
}
27+
if (result.success) expect(result.data.durationMs).toBe(1250);
3528
});
3629

3730
it('accepts the failure path: the serializable error projection', () => {
@@ -112,6 +105,22 @@ describe('[#16059] the re-declared result refuses the members it dropped', () =>
112105
expect(issue!.message).toContain('Replace the key with `pluginName`');
113106
});
114107

108+
it('REFUSES the deprecated `startTime` alias and prescribes `durationMs`', () => {
109+
const result = PluginStartupResultSchema.safeParse({
110+
pluginName: 'crm-plugin',
111+
success: true,
112+
durationMs: 1250,
113+
startTime: 1250,
114+
});
115+
expect(result.success).toBe(false);
116+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'startTime');
117+
expect(issue).toBeDefined();
118+
expect(issue!.code).not.toBe('unrecognized_keys');
119+
// The prescription is the payload: the name promised an instant and the
120+
// value was always an elapsed duration.
121+
expect(issue!.message).toMatch(/startTime.*removed.*read `durationMs`/s);
122+
});
123+
115124
it('REFUSES `health` and says no startup probe system exists', () => {
116125
const result = PluginStartupResultSchema.safeParse({
117126
pluginName: 'crm-plugin',

‎packages/spec/src/kernel/startup-orchestrator.zod.ts‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ import { retiredKey } from '../shared/retired-key';
3535
* TIMEOUT rather than a throw from inside `start()`: the kernel sets it from
3636
* the raced rejection's message. Absent on the success path; absent, not
3737
* `false`, when a plugin's own `start()` threw.
38-
* - `startTime` — deprecated; see its own note below.
38+
*
39+
* The deprecated `startTime` alias the kernel used to set beside `durationMs`
40+
* ends here: it never held an instant, and a member whose name promises one
41+
* while carrying an elapsed duration is the confusion the duration-unit rule
42+
* exists to stop. It is a tombstone on this schema and the kernel no longer
43+
* populates it.
3944
*
4045
* ── [#16059] What this module used to declare, and why it no longer does ────
4146
*
@@ -121,21 +126,6 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({
121126
durationMs: z.number().min(0).optional()
122127
.describe('Time taken to start the plugin in milliseconds; absent when the plugin declares no start()'),
123128

124-
/**
125-
* The same elapsed milliseconds as `durationMs`.
126-
*
127-
* @deprecated Misnamed: it has never held an instant, so a reader who
128-
* correctly takes `startTime` for one and computes `Date.now() - startTime`
129-
* gets an age near the epoch instead of a wait. Read `durationMs` instead.
130-
* Mirrored here because `@objectstack/core` still POPULATES it under its
131-
* ADR-0087 L1 disposition — the old shape keeps working while the fleet
132-
* moves — and a contract that declared `never` for a member the kernel emits
133-
* would be the very drift #16059 closed. It leaves this schema in the same
134-
* change that stops the kernel emitting it.
135-
*/
136-
startTime: z.number().min(0).optional()
137-
.describe('[DEPRECATED] The same elapsed milliseconds as durationMs — read durationMs instead'),
138-
139129
/**
140130
* Error if startup failed
141131
*/
@@ -153,6 +143,25 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({
153143
timedOut: z.boolean().optional()
154144
.describe('Whether startup failed because the startup timeout fired, rather than start() throwing'),
155145

146+
/**
147+
* Tombstone for the deprecated `startTime` alias core carried (#16059).
148+
*
149+
* Mirroring it was the other candidate and the tree refuses it: the member
150+
* holds elapsed milliseconds under a name that carries no unit, which is
151+
* exactly what `check:duration-unit-keys` (ruling B on #14478) fails, and
152+
* neither of that rule's two schema-declared exemptions applies — it is not
153+
* an `EpochMs` instant and it mirrors no external standard. Renaming it to
154+
* `startTimeMs` would mint a spelling nothing has ever produced, for a member
155+
* already slated for removal. So the L1 alias ends here, audibly.
156+
*/
157+
startTime: retiredKey(
158+
'`PluginStartupResult.startTime` was removed in @objectstack/spec 18 (ADR-0049) — '
159+
+ 'it never held an instant: the kernel filled it with the SAME elapsed milliseconds as '
160+
+ '`durationMs`, so a reader who took the name at its word and computed '
161+
+ '`Date.now() - startTime` got an age near the epoch instead of a wait. Delete the key '
162+
+ 'and read `durationMs`, which has always carried the same value.',
163+
),
164+
156165
/** Tombstone for the `duration` → `durationMs` rename (#15678, ruling B on #14478). */
157166
duration: retiredKey(
158167
'`PluginStartupResult.duration` was renamed to `durationMs` in @objectstack/spec 17 — '
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// #16059 — `PluginStartupResult.startTime` was `@objectstack/core`'s own
4+
// ADR-0087 L1 alias: the kernel set it to the SAME elapsed milliseconds as
5+
// `durationMs`, under a name that promises an instant. It arrives on the spec's
6+
// surface only to leave it, because the re-declaration of this schema against
7+
// the shipped shape had to choose between mirroring the member and tombstoning
8+
// it, and mirroring is refused by `check:duration-unit-keys` (ruling B on
9+
// #14478): an elapsed number whose key name carries no unit, matching neither
10+
// of that rule's two schema-declared exemptions — not an `EpochMs` instant, not
11+
// an external-standard mirror. Renaming it to `startTimeMs` would mint a
12+
// spelling nothing ever produced for a member already slated for removal, so
13+
// the alias ends here and the kernel stops populating it in the same change.
14+
export const entry = 'kernel/PluginStartupResult:startTime';

0 commit comments

Comments
 (0)