diff --git a/.changeset/kernel-duration-keys-unit-in-key-name.md b/.changeset/kernel-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..c8053a60c2 --- /dev/null +++ b/.changeset/kernel-duration-keys-unit-in-key-name.md @@ -0,0 +1,114 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": patch +--- + +feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) + + + +**BREAKING** — fourteen published `kernel/` duration keys are renamed and +tombstoned. Shipped as `minor` under the repo's launch-window convention for +breaking changes; the hand-migration prescriptions are registered under protocol +major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, +「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, never only in its `.describe()` prose, and grandfathers no +existing offender. Stack card 1/6 (#15676) landed the rule's two structural +exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. +Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, +and the whole-tree count falls **36 → 22**. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `EventPersistence.retention` | `retentionDays` | days | +| `EventSourcingConfig.retention` | `retentionDays` | days | +| `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | +| `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | +| `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | +| `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | +| `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | +| `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | +| `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | +| `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | +| `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | +| `StartupOptions.timeout` | `timeoutMs` | milliseconds | +| `PluginStartupResult.duration` | `durationMs` | milliseconds | +| `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | + +**Every value is unchanged** — only key names move, and every default moves with +its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to +365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the +authoring site (input type `never`) and fails the parse with the rename +prescription rather than a bare unrecognized-key error. + +## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace + +**`responseTime` meant two different units on two kernel shapes.** On +`PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a +publisher promises to answer a vulnerability report); on +`PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So +`responseTime: 24` was a day on one shape and a fortieth of a second on the +other, with nothing at the authoring site to tell them apart. They land on +`responseTimeHours` and `responseTimeMs` respectively — do not let one +find-and-replace rewrite both. + +**`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision +was already costing prose: the protocol lifecycle page carried a standing +paragraph whose only job was telling the two apart. `metrics.uptime` becomes +`metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a +separate, unchanged surface and must not be renamed with it. + +A third split worth reading before you migrate: `estimatedDurationSeconds: 120` +is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent +measurements of the same package install carried two different units, and no +parse can catch a value moved between them — both bounds accept any +non-negative integer. + +## Dispositions — five semantic entries, no D2 conversion + +Justified per key rather than defaulted, and this card's answer is uniform: +**none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs +over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, +`upgrade` or plugin-security root — none of these twelve defs is a stack +collection member or a registered metadata kind stored as a `sys_metadata` row, +so the conversion chain has no seam that would see one. They are host +construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, +`MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and +runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, +`StartupOrchestrationResult`, `UpgradePlan`, +`PackageDependencyResolutionResult`). Each therefore carries a **semantic** +entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on +one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling +B prescribes for a key that is not authorable metadata. All fourteen are +registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + +## Keys deliberately left alone + +`EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and +`MultiVersionSupport.rollout.percentage` is a proportion — neither is a +duration, so neither has a unit to carry and both keep their names. +`RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above +the key ("Execution timeout in milliseconds"), a channel +`check:duration-unit-keys` does not read: it reads `.describe()` and +`.meta({ description })`, and this key's describe ("Maximum execution time") +names none. The gate therefore lists it among the duration-shaped keys but +deliberately does not judge it — neither an offender nor an exemption — so it is +outside this rename; that JSDoc-channel gap is filed as #15939. A pin test +asserts the key still parses bare, so a later sweep cannot read the four +security renames as "every timeout on that file". + +## Readers moved in the same PR, at the same magnitude + +`@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - +startTime`), the kernel and contracts test suites, and the hand-written +`content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now +states the collision the rename removes. + +⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local +`PluginStartupResult` interface — a different type, carrying `startTime` rather +than any duration key. It is not a reader of this schema, it is untouched by +this rename, and the divergence between the two shapes is tracked separately. diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index ff3e155f8a..0499e2aaae 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -770,7 +770,7 @@ this shape over HTTP — it is an in-process model, not a wire body. "status": "healthy", "timestamp": "2024-01-15T11:00:00.000Z", "metrics": { - "uptime": 3600000 + "uptimeMs": 3600000 }, "checks": [ { "name": "healthCheck", "status": "passed" } @@ -789,12 +789,13 @@ this shape over HTTP — it is an in-process model, not a wire body. | `"plugin-loaded"` | no `checkMethod` is configured, **or** the configured name does not resolve to a function on the plugin | | `"health-check"` | the check **threw** — a `timeout` overrun included, since the race surfaces it as a rejection. A fixed name, neither the method's nor the default's, and always `status: "failed"` | -`metrics.uptime` is in **milliseconds** (`Date.now() - startTime`), unlike -the seconds-valued `uptime` of `GET /health` above, and the report carries no +`metrics.uptimeMs` is in **milliseconds** (`Date.now() - startTime`), unlike +the seconds-valued `uptime` of `GET /health` above — which is the very +collision the unit-in-the-key-name rule exists to remove — and the report carries no `version` field — it identifies its plugin by the key it is stored under. The optional `message` is set only when a check fails; the schema's remaining `metrics` fields (`memoryUsage`, `cpuUsage`, `activeConnections`, `errorRate`, -`responseTime`) and its `dependencies` array are declared but left unset by the +`responseTimeMs`) and its `dependencies` array are declared but left unset by the monitor today. ## Shutdown Sequence diff --git a/content/docs/references/kernel/events-bus.mdx b/content/docs/references/kernel/events-bus.mdx index 03b4c077e4..e476b9284c 100644 --- a/content/docs/references/kernel/events-bus.mdx +++ b/content/docs/references/kernel/events-bus.mdx @@ -27,9 +27,9 @@ const result = EventBusConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **persistence** | `{ enabled: boolean; retention: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | +| **persistence** | `{ enabled: boolean; retentionDays: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | | **queue** | `{ name: string; concurrency: integer; retryPolicy?: object; deadLetterQueue?: string; … }` | optional | Event queue configuration | -| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retention: integer; … }` | optional | Event sourcing configuration | +| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retentionDays: integer; … }` | optional | Event sourcing configuration | | **replay** | `{ enabled: boolean }` | optional | Event replay configuration | | **webhooks** | `{ id?: string; eventPattern: string; url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>; … }[]` | optional | Webhook configurations | | **messageQueue** | `{ provider: Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>; topic: string; eventPattern: string; partitionKey?: string; … }` | optional | Message queue integration | @@ -42,7 +42,8 @@ const result = EventBusConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable event persistence | -| **retention** | `integer` | ✅ | Days to retain persisted events | +| **retentionDays** | `integer` | ✅ | Days to retain persisted events | +| **retention** | `never` | optional | [REMOVED] `EventPersistence.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged. | | **filter** | `any` | optional | Optional filter function to select which events to persist | | **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | optional (default: `"database"`) | Storage backend for persisted events | @@ -63,7 +64,8 @@ const result = EventBusConfigSchema.parse(data); | **enabled** | `boolean` | optional (default: `false`) | Enable event sourcing | | **snapshotInterval** | `integer` | optional (default: `100`) | Create snapshot every N events | | **snapshotRetention** | `integer` | optional (default: `10`) | Number of snapshots to retain | -| **retention** | `integer` | optional (default: `365`) | Days to retain events | +| **retentionDays** | `integer` | optional (default: `365`) | Days to retain events | +| **retention** | `never` | optional | [REMOVED] `EventSourcingConfig.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged. The neighbouring `snapshotRetention` is a COUNT of snapshots, not a duration, so it keeps its name. | | **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | | **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | diff --git a/content/docs/references/kernel/events-handlers.mdx b/content/docs/references/kernel/events-handlers.mdx index b1f1377621..5761bbef89 100644 --- a/content/docs/references/kernel/events-handlers.mdx +++ b/content/docs/references/kernel/events-handlers.mdx @@ -54,7 +54,8 @@ const result = EventHandlerSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable event persistence | -| **retention** | `integer` | ✅ | Days to retain persisted events | +| **retentionDays** | `integer` | ✅ | Days to retain persisted events | +| **retention** | `never` | optional | [REMOVED] `EventPersistence.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged. | | **filter** | `any` | optional | Optional filter function to select which events to persist | | **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | optional (default: `"database"`) | Storage backend for persisted events | diff --git a/content/docs/references/kernel/events-queue.mdx b/content/docs/references/kernel/events-queue.mdx index 29aecd6f32..ebdf5816c5 100644 --- a/content/docs/references/kernel/events-queue.mdx +++ b/content/docs/references/kernel/events-queue.mdx @@ -70,7 +70,8 @@ const result = EventQueueConfigSchema.parse(data); | **enabled** | `boolean` | optional (default: `false`) | Enable event sourcing | | **snapshotInterval** | `integer` | optional (default: `100`) | Create snapshot every N events | | **snapshotRetention** | `integer` | optional (default: `10`) | Number of snapshots to retain | -| **retention** | `integer` | optional (default: `365`) | Days to retain events | +| **retentionDays** | `integer` | optional (default: `365`) | Days to retain events | +| **retention** | `never` | optional | [REMOVED] `EventSourcingConfig.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged. The neighbouring `snapshotRetention` is a COUNT of snapshots, not a duration, so it keeps its name. | | **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | | **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 387886614b..b396ad4217 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -198,7 +198,8 @@ Upgrade package response | **requiresMigration** | `boolean` | optional (default: `false`) | Whether data migration scripts are needed | | **migrationScripts** | `string[]` | optional | Paths to migration scripts | | **dependencyUpgrades** | `{ packageId: string; fromVersion: string; toVersion: string }[]` | optional | Dependent packages that also need upgrading | -| **estimatedDuration** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDurationSeconds** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDuration** | `never` | optional | [REMOVED] `UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds` 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 `estimatedDurationSeconds`; the value (seconds) is unchanged. | | **summary** | `string` | optional | Human-readable upgrade summary | @@ -241,7 +242,8 @@ Upgrade analysis plan generated before execution | **requiresMigration** | `boolean` | optional (default: `false`) | Whether data migration scripts are needed | | **migrationScripts** | `string[]` | optional | Paths to migration scripts | | **dependencyUpgrades** | `{ packageId: string; fromVersion: string; toVersion: string }[]` | optional | Dependent packages that also need upgrading | -| **estimatedDuration** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDurationSeconds** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDuration** | `never` | optional | [REMOVED] `UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds` 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 `estimatedDurationSeconds`; the value (seconds) is unchanged. | | **summary** | `string` | optional | Human-readable upgrade summary | ### Nested Shape: `UpgradePlan.changes[number]` diff --git a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx index ee0e588bbc..8874201504 100644 --- a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx +++ b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx @@ -79,7 +79,7 @@ const result = HotReloadConfigSchema.parse(data); | **status** | `Enum<'healthy' \| 'degraded' \| 'unhealthy' \| 'failed' \| 'recovering' \| 'unknown'>` | ✅ | Current health status of the plugin | | **timestamp** | `string` | ✅ | | | **message** | `string` | optional | | -| **metrics** | `{ uptime?: number; memoryUsage?: number; cpuUsage?: number; activeConnections?: number; … }` | optional | | +| **metrics** | `{ uptimeMs?: number; memoryUsage?: number; cpuUsage?: number; activeConnections?: number; … }` | optional | | | **checks** | `{ name: string; status: Enum<'passed' \| 'failed' \| 'warning'>; message?: string; data?: Record }[]` | optional | | | **dependencies** | `{ pluginId: string; status: Enum<'healthy' \| 'degraded' \| 'unhealthy' \| 'failed' \| 'recovering' \| 'unknown'>; message?: string }[]` | optional | | @@ -87,12 +87,14 @@ const result = HotReloadConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **uptime** | `number` | optional | Plugin uptime in milliseconds | +| **uptimeMs** | `number` | optional | Plugin uptime in milliseconds | | **memoryUsage** | `number` | optional | Memory usage in bytes | | **cpuUsage** | `number` | optional | CPU usage percentage | | **activeConnections** | `number` | optional | Number of active connections | | **errorRate** | `number` | optional | Error rate (errors per minute) | -| **responseTime** | `number` | optional | Average response time in ms | +| **responseTimeMs** | `number` | optional | Average response time in ms | +| **uptime** | `never` | optional | [REMOVED] `PluginHealthReport.metrics.uptime` was renamed to `uptimeMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and this platform already spells a SECONDS-valued uptime with the same bare name on GET /health. Rename the key to `uptimeMs`; the value (milliseconds, `Date.now() - startTime`) is unchanged. | +| **responseTime** | `never` | optional | [REMOVED] `PluginHealthReport.metrics.responseTime` was renamed to `responseTimeMs` 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 `responseTimeMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `PluginHealthReport.checks[number]` diff --git a/content/docs/references/kernel/plugin-security-advanced.mdx b/content/docs/references/kernel/plugin-security-advanced.mdx index 7f244f83de..10a4294e67 100644 --- a/content/docs/references/kernel/plugin-security-advanced.mdx +++ b/content/docs/references/kernel/plugin-security-advanced.mdx @@ -42,9 +42,9 @@ const result = KernelSecurityPolicySchema.parse(data); | **csp** | `{ directives?: Record; reportOnly: boolean }` | optional | | | **cors** | `{ allowedOrigins: string[]; allowedMethods: string[]; allowedHeaders: string[]; allowCredentials: boolean; … }` | optional | | | **rateLimit** | `{ enabled: boolean; maxRequests: integer; windowMs: integer; strategy: Enum<'fixed' \| 'sliding' \| 'token-bucket'> }` | optional | | -| **authentication** | `{ required: boolean; methods: Enum<'jwt' \| 'oauth2' \| 'api-key' \| 'session' \| 'certificate'>[]; tokenExpiration?: integer }` | optional | | +| **authentication** | `{ required: boolean; methods: Enum<'jwt' \| 'oauth2' \| 'api-key' \| 'session' \| 'certificate'>[]; tokenExpirationSeconds?: integer }` | optional | | | **encryption** | `{ dataAtRest: boolean; dataInTransit: boolean; algorithm?: string; minKeyLength?: integer }` | optional | | -| **auditLog** | `{ enabled: boolean; events?: string[]; retention?: integer }` | optional | | +| **auditLog** | `{ enabled: boolean; events?: string[]; retentionDays?: integer }` | optional | | ### Nested Shape: `KernelSecurityPolicy.rateLimit` @@ -61,7 +61,8 @@ const result = KernelSecurityPolicySchema.parse(data); | :--- | :--- | :--- | :--- | | **required** | `boolean` | optional (default: `true`) | | | **methods** | `Enum<'jwt' \| 'oauth2' \| 'api-key' \| 'session' \| 'certificate'>[]` | ✅ | | -| **tokenExpiration** | `integer` | optional | Token expiration in seconds | +| **tokenExpirationSeconds** | `integer` | optional | Token expiration in seconds | +| **tokenExpiration** | `never` | optional | [REMOVED] `KernelSecurityPolicy.authentication.tokenExpiration` was renamed to `tokenExpirationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling rate-limit window on this same policy is already `windowMs`. Rename the key to `tokenExpirationSeconds`; the value (seconds) is unchanged. | ### Nested Shape: `KernelSecurityPolicy.encryption` @@ -78,7 +79,8 @@ const result = KernelSecurityPolicySchema.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | | | **events** | `string[]` | optional | Events to log | -| **retention** | `integer` | optional | Log retention in days | +| **retentionDays** | `integer` | optional | Log retention in days | +| **retention** | `never` | optional | [REMOVED] `KernelSecurityPolicy.auditLog.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged. | --- @@ -270,7 +272,7 @@ Scope of permission application | **codeSigning** | `{ signed: boolean; signature?: string; certificate?: string; algorithm?: string; … }` | optional | | | **certifications** | `{ name: string; issuer: string; issuedDate: string; expiryDate?: string; … }[]` | optional | | | **securityContact** | `{ email?: string; url?: string; pgpKey?: string }` | optional | | -| **vulnerabilityDisclosure** | `{ policyUrl?: string; responseTime?: integer; bugBounty?: boolean }` | optional | | +| **vulnerabilityDisclosure** | `{ policyUrl?: string; responseTimeHours?: integer; bugBounty?: boolean }` | optional | | ### Nested Shape: `PluginSecurityManifest.sandbox` @@ -281,7 +283,7 @@ Scope of permission application | **runtime** | `{ engine?: Enum<'v8-isolate' \| 'wasm' \| 'container' \| 'process'>; engineConfig?: object; resourceLimits?: object }` | optional | Execution environment and isolation settings | | **filesystem** | `{ mode?: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedPaths?: string[]; deniedPaths?: string[]; maxFileSize?: integer }` | optional | | | **network** | `{ mode?: Enum<'none' \| 'local' \| 'restricted' \| 'full'>; allowedHosts?: string[]; deniedHosts?: string[]; allowedPorts?: number[]; … }` | optional | | -| **process** | `{ allowSpawn?: boolean; allowedCommands?: string[]; timeout?: integer }` | optional | | +| **process** | `{ allowSpawn?: boolean; allowedCommands?: string[]; timeoutMs?: integer }` | optional | | | **memory** | `{ maxHeap?: integer; maxStack?: integer }` | optional | | | **cpu** | `{ maxCpuPercent?: number; maxThreads?: integer }` | optional | | | **environment** | `{ mode?: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedVars?: string[]; deniedVars?: string[] }` | optional | | @@ -301,7 +303,8 @@ Scope of permission application | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **policyUrl** | `string` | optional | | -| **responseTime** | `integer` | optional | Expected response time in hours | +| **responseTimeHours** | `integer` | optional | Expected response time in hours | +| **responseTime** | `never` | optional | [REMOVED] `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` was renamed to `responseTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. This one is HOURS, while the same bare name on `PluginHealthReport.metrics` was milliseconds — which is the confusion the rule exists to remove. Rename the key to `responseTimeHours`; the value (hours) is unchanged. | | **bugBounty** | `boolean` | optional (default: `false`) | | @@ -380,7 +383,7 @@ Type of resource being accessed | **runtime** | `{ engine: Enum<'v8-isolate' \| 'wasm' \| 'container' \| 'process'>; engineConfig?: object; resourceLimits?: object }` | optional | Execution environment and isolation settings | | **filesystem** | `{ mode: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedPaths?: string[]; deniedPaths?: string[]; maxFileSize?: integer }` | optional | | | **network** | `{ mode: Enum<'none' \| 'local' \| 'restricted' \| 'full'>; allowedHosts?: string[]; deniedHosts?: string[]; allowedPorts?: number[]; … }` | optional | | -| **process** | `{ allowSpawn: boolean; allowedCommands?: string[]; timeout?: integer }` | optional | | +| **process** | `{ allowSpawn: boolean; allowedCommands?: string[]; timeoutMs?: integer }` | optional | | | **memory** | `{ maxHeap?: integer; maxStack?: integer }` | optional | | | **cpu** | `{ maxCpuPercent?: number; maxThreads?: integer }` | optional | | | **environment** | `{ mode: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedVars?: string[]; deniedVars?: string[] }` | optional | | @@ -418,7 +421,8 @@ Type of resource being accessed | :--- | :--- | :--- | :--- | | **allowSpawn** | `boolean` | optional (default: `false`) | Allow spawning child processes | | **allowedCommands** | `string[]` | optional | Whitelisted commands | -| **timeout** | `integer` | optional | Process timeout in ms | +| **timeoutMs** | `integer` | optional | Process timeout in ms | +| **timeout** | `never` | optional | [REMOVED] `SandboxConfig.process.timeout` was renamed to `timeoutMs` 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 `timeoutMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `SandboxConfig.memory` diff --git a/content/docs/references/kernel/plugin-security.mdx b/content/docs/references/kernel/plugin-security.mdx index 568528cc5d..6ad7b823c9 100644 --- a/content/docs/references/kernel/plugin-security.mdx +++ b/content/docs/references/kernel/plugin-security.mdx @@ -169,7 +169,8 @@ Result of a dependency resolution process | **conflicts** | `{ package: string; conflicts: object[]; resolution?: object; severity: Enum<'error' \| 'warning' \| 'info'> }[]` | optional (default: `[]`) | List of dependency conflicts detected during resolution | | **errors** | `{ package: string; error: string }[]` | optional (default: `[]`) | Errors encountered during dependency resolution | | **installOrder** | `string[]` | optional (default: `[]`) | Topologically sorted list of package IDs for installation | -| **resolvedIn** | `integer` | optional | Time taken to resolve dependencies in milliseconds | +| **resolvedInMs** | `integer` | optional | Time taken to resolve dependencies in milliseconds | +| **resolvedIn** | `never` | optional | [REMOVED] `PackageDependencyResolutionResult.resolvedIn` was renamed to `resolvedInMs` 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 `resolvedInMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `PackageDependencyResolutionResult.graph` diff --git a/content/docs/references/kernel/plugin-versioning.mdx b/content/docs/references/kernel/plugin-versioning.mdx index f5d4c1e277..c133075a53 100644 --- a/content/docs/references/kernel/plugin-versioning.mdx +++ b/content/docs/references/kernel/plugin-versioning.mdx @@ -146,7 +146,7 @@ Compatibility level between versions | **maxConcurrentVersions** | `integer` | optional (default: `2`) | How many versions can run at the same time | | **selectionStrategy** | `Enum<'latest' \| 'stable' \| 'compatible' \| 'pinned' \| 'canary' \| 'custom'>` | optional (default: `"latest"`) | | | **routing** | `{ condition: string \| object; version: string; priority?: integer }[]` | optional | | -| **rollout** | `{ enabled?: boolean; strategy: Enum<'percentage' \| 'blue-green' \| 'canary'>; percentage?: number; duration?: integer }` | optional | | +| **rollout** | `{ enabled?: boolean; strategy: Enum<'percentage' \| 'blue-green' \| 'canary'>; percentage?: number; durationMs?: integer }` | optional | | ### Nested Shape: `MultiVersionSupport.routing[number]` @@ -163,7 +163,8 @@ Compatibility level between versions | **enabled** | `boolean` | optional (default: `false`) | | | **strategy** | `Enum<'percentage' \| 'blue-green' \| 'canary'>` | ✅ | | | **percentage** | `number` | optional | Percentage of traffic to new version | -| **duration** | `integer` | optional | Rollout duration in milliseconds | +| **durationMs** | `integer` | optional | Rollout duration in milliseconds | +| **duration** | `never` | optional | [REMOVED] `MultiVersionSupport.rollout.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. | --- diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index 945a88ca9e..ed261a553f 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -52,7 +52,8 @@ const result = HealthStatusSchema.parse(data); | :--- | :--- | :--- | :--- | | **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | | **success** | `boolean` | ✅ | Whether the plugin started successfully | -| **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **durationMs** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **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. | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | | **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | @@ -84,7 +85,8 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **timeout** | `integer` | optional (default: `30000`) | Maximum time in milliseconds to wait for each plugin to start | +| **timeoutMs** | `integer` | optional (default: `30000`) | Maximum time in milliseconds to wait for each plugin to start | +| **timeout** | `never` | optional | [REMOVED] `StartupOptions.timeout` was renamed to `timeoutMs` 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 `timeoutMs`; the value (milliseconds) is unchanged. | | **rollbackOnFailure** | `boolean` | optional (default: `true`) | Whether to rollback already-started plugins if any plugin fails | | **healthCheck** | `boolean` | optional (default: `false`) | Whether to run health checks after plugin startup | | **parallel** | `boolean` | optional (default: `false`) | Whether to start plugins in parallel when dependencies allow | @@ -99,8 +101,9 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **results** | `{ plugin: object; success: boolean; duration: number; error?: object; … }[]` | ✅ | Startup results for each plugin | -| **totalDuration** | `number` | ✅ | Total time taken for all plugins in milliseconds | +| **results** | `{ plugin: object; success: boolean; durationMs: number; error?: object; … }[]` | ✅ | Startup results for each plugin | +| **totalDurationMs** | `number` | ✅ | Total time taken for all plugins in milliseconds | +| **totalDuration** | `never` | optional | [REMOVED] `StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs` 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 `totalDurationMs`; the value (milliseconds) is unchanged. | | **allSuccessful** | `boolean` | ✅ | Whether all plugins started successfully | | **rolledBack** | `string[]` | optional | Names of plugins that were rolled back | @@ -110,7 +113,8 @@ const result = HealthStatusSchema.parse(data); | :--- | :--- | :--- | :--- | | **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | | **success** | `boolean` | ✅ | Whether the plugin started successfully | -| **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **durationMs** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **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. | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | | **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | diff --git a/packages/core/src/health-monitor.ts b/packages/core/src/health-monitor.ts index 42f63f9da3..66311420b3 100644 --- a/packages/core/src/health-monitor.ts +++ b/packages/core/src/health-monitor.ts @@ -345,7 +345,7 @@ export class PluginHealthMonitor { timestamp: new Date().toISOString(), message, metrics: { - uptime: Date.now() - startTime, + uptimeMs: Date.now() - startTime, }, checks: checks.length > 0 ? checks : undefined, }; diff --git a/packages/spec/authorable-defaults/kernel.json b/packages/spec/authorable-defaults/kernel.json index c162a52d84..e37bcdb4ed 100644 --- a/packages/spec/authorable-defaults/kernel.json +++ b/packages/spec/authorable-defaults/kernel.json @@ -29,7 +29,7 @@ "kernel/EventQueueConfig:priorityEnabled = true", "kernel/EventReplayConfig:speed = 1", "kernel/EventSourcingConfig:enabled = false", - "kernel/EventSourcingConfig:retention = 365", + "kernel/EventSourcingConfig:retentionDays = 365", "kernel/EventSourcingConfig:snapshotInterval = 100", "kernel/EventSourcingConfig:snapshotRetention = 10", "kernel/EventTypeDefinition:deprecated = false", @@ -141,7 +141,7 @@ "kernel/StartupOptions:healthCheck = false", "kernel/StartupOptions:parallel = false", "kernel/StartupOptions:rollbackOnFailure = true", - "kernel/StartupOptions:timeout = 30000", + "kernel/StartupOptions:timeoutMs = 30000", "kernel/TenantRuntimeContext:features = {}", "kernel/TenantRuntimeContext:mode = \"production\"", "kernel/UpgradePackageRequest:createSnapshot = true", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 016597856f..194cc55140 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -126,7 +126,8 @@ "kernel/EventMetadata:userId", "kernel/EventPersistence:enabled", "kernel/EventPersistence:filter", - "kernel/EventPersistence:retention", + "kernel/EventPersistence:retention [RETIRED]", + "kernel/EventPersistence:retentionDays", "kernel/EventPersistence:storage", "kernel/EventQueueConfig:concurrency", "kernel/EventQueueConfig:deadLetterQueue", @@ -144,7 +145,8 @@ "kernel/EventRoute:transform", "kernel/EventSourcingConfig:aggregateTypes", "kernel/EventSourcingConfig:enabled", - "kernel/EventSourcingConfig:retention", + "kernel/EventSourcingConfig:retention [RETIRED]", + "kernel/EventSourcingConfig:retentionDays", "kernel/EventSourcingConfig:snapshotInterval", "kernel/EventSourcingConfig:snapshotRetention", "kernel/EventSourcingConfig:storage", @@ -419,7 +421,8 @@ "kernel/PackageDependencyResolutionResult:errors", "kernel/PackageDependencyResolutionResult:graph", "kernel/PackageDependencyResolutionResult:installOrder", - "kernel/PackageDependencyResolutionResult:resolvedIn", + "kernel/PackageDependencyResolutionResult:resolvedIn [RETIRED]", + "kernel/PackageDependencyResolutionResult:resolvedInMs", "kernel/PackageDependencyResolutionResult:status", "kernel/Plugin:author", "kernel/Plugin:default", @@ -576,7 +579,8 @@ "kernel/PluginSecurityManifest:trustLevel", "kernel/PluginSecurityManifest:vulnerabilities", "kernel/PluginSecurityManifest:vulnerabilityDisclosure", - "kernel/PluginStartupResult:duration", + "kernel/PluginStartupResult:duration [RETIRED]", + "kernel/PluginStartupResult:durationMs", "kernel/PluginStartupResult:error", "kernel/PluginStartupResult:health", "kernel/PluginStartupResult:plugin", @@ -746,11 +750,13 @@ "kernel/StartupOptions:healthCheck", "kernel/StartupOptions:parallel", "kernel/StartupOptions:rollbackOnFailure", - "kernel/StartupOptions:timeout", + "kernel/StartupOptions:timeout [RETIRED]", + "kernel/StartupOptions:timeoutMs", "kernel/StartupOrchestrationResult:allSuccessful", "kernel/StartupOrchestrationResult:results", "kernel/StartupOrchestrationResult:rolledBack", - "kernel/StartupOrchestrationResult:totalDuration", + "kernel/StartupOrchestrationResult:totalDuration [RETIRED]", + "kernel/StartupOrchestrationResult:totalDurationMs", "kernel/TenantRuntimeContext:appName", "kernel/TenantRuntimeContext:cwd", "kernel/TenantRuntimeContext:features", @@ -787,7 +793,8 @@ "kernel/UpgradePlan:affectedCustomizations", "kernel/UpgradePlan:changes", "kernel/UpgradePlan:dependencyUpgrades", - "kernel/UpgradePlan:estimatedDuration", + "kernel/UpgradePlan:estimatedDuration [RETIRED]", + "kernel/UpgradePlan:estimatedDurationSeconds", "kernel/UpgradePlan:fromVersion", "kernel/UpgradePlan:impactLevel", "kernel/UpgradePlan:migrationScripts", diff --git a/packages/spec/src/contracts/package-service.test.ts b/packages/spec/src/contracts/package-service.test.ts index db4dc8e1e3..14dea660e6 100644 --- a/packages/spec/src/contracts/package-service.test.ts +++ b/packages/spec/src/contracts/package-service.test.ts @@ -170,7 +170,7 @@ describe('Package Service Contract', () => { requiresMigration: true, migrationScripts: ['migrations/v2_add_account_fields.ts'], dependencyUpgrades: [{ packageId: 'com.acme.core', fromVersion: '1.2.0', toVersion: '2.0.0' }], - estimatedDuration: 120, + estimatedDurationSeconds: 120, summary: 'Major upgrade with 3 metadata changes and 1 migration', }), upgrade: async () => ({ success: true, phase: 'completed' }), @@ -187,7 +187,7 @@ describe('Package Service Contract', () => { expect(plan.requiresMigration).toBe(true); expect(plan.migrationScripts).toHaveLength(1); expect(plan.dependencyUpgrades).toHaveLength(1); - expect(plan.estimatedDuration).toBe(120); + expect(plan.estimatedDurationSeconds).toBe(120); }); it('should execute upgrade and support rollback', async () => { diff --git a/packages/spec/src/contracts/startup-orchestrator.test.ts b/packages/spec/src/contracts/startup-orchestrator.test.ts index 9888f309b4..9971963a60 100644 --- a/packages/spec/src/contracts/startup-orchestrator.test.ts +++ b/packages/spec/src/contracts/startup-orchestrator.test.ts @@ -17,14 +17,14 @@ describe('Startup Orchestrator Contract', () => { const options: StartupOptions = {}; expect(options).toBeDefined(); - expect(options.timeout).toBeUndefined(); + expect(options.timeoutMs).toBeUndefined(); expect(options.rollbackOnFailure).toBeUndefined(); }); it('parses the input tier into the defaulted StartupOptions tier', () => { const parsed: StartupOptionsParsed = StartupOptionsSchema.parse({}); - expect(parsed.timeout).toBe(30000); + expect(parsed.timeoutMs).toBe(30000); expect(parsed.rollbackOnFailure).toBe(true); expect(parsed.healthCheck).toBe(false); expect(parsed.parallel).toBe(false); @@ -32,14 +32,14 @@ describe('Startup Orchestrator Contract', () => { it('should allow full options', () => { const options: StartupOptions = { - timeout: 30000, + timeoutMs: 30000, rollbackOnFailure: true, healthCheck: true, parallel: false, context: { db: 'postgres' }, }; - expect(options.timeout).toBe(30000); + expect(options.timeoutMs).toBe(30000); expect(options.rollbackOnFailure).toBe(true); expect(options.healthCheck).toBe(true); expect(options.parallel).toBe(false); @@ -78,11 +78,11 @@ describe('Startup Orchestrator Contract', () => { const result: PluginStartupResult = { plugin, success: true, - duration: 150, + durationMs: 150, }; expect(result.success).toBe(true); - expect(result.duration).toBe(150); + expect(result.durationMs).toBe(150); expect(result.error).toBeUndefined(); }); @@ -91,7 +91,7 @@ describe('Startup Orchestrator Contract', () => { const result: PluginStartupResult = { plugin, success: false, - duration: 30000, + durationMs: 30000, // The kernel schema declares the serializable projection, not a live // Error instance — what a wire/log consumer of the result can carry. error: { name: 'Error', message: 'Timeout' }, @@ -106,7 +106,7 @@ describe('Startup Orchestrator Contract', () => { const result: PluginStartupResult = { plugin, success: true, - duration: 50, + durationMs: 50, health: { healthy: true, checkedAt: Date.now(), @@ -125,7 +125,7 @@ describe('Startup Orchestrator Contract', () => { return plugins.map((p) => ({ plugin: p, success: true, - duration: 10, + durationMs: 10, })); }, rollback: async (_startedPlugins) => {}, @@ -151,14 +151,14 @@ describe('Startup Orchestrator Contract', () => { return pluginList.map((p) => ({ plugin: p, success: true, - duration: options.timeout ? 10 : 20, + durationMs: options.timeoutMs ? 10 : 20, })); }, rollback: async () => {}, checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), }; - const results = await orchestrator.orchestrateStartup(plugins, { timeout: 5000 }); + const results = await orchestrator.orchestrateStartup(plugins, { timeoutMs: 5000 }); expect(results).toHaveLength(2); expect(results[0].success).toBe(true); expect(results[1].plugin.name).toBe('auth'); diff --git a/packages/spec/src/kernel/events.test.ts b/packages/spec/src/kernel/events.test.ts index 3c91808ea6..06538a7549 100644 --- a/packages/spec/src/kernel/events.test.ts +++ b/packages/spec/src/kernel/events.test.ts @@ -358,7 +358,7 @@ describe('EventPersistenceSchema', () => { it('should accept valid minimal persistence config', () => { const config: EventPersistence = { enabled: true, - retention: 30, + retentionDays: 30, }; expect(() => EventPersistenceSchema.parse(config)).not.toThrow(); @@ -366,7 +366,7 @@ describe('EventPersistenceSchema', () => { it('should apply default values', () => { const config = EventPersistenceSchema.parse({ - retention: 30, + retentionDays: 30, }); expect(config.enabled).toBe(false); @@ -375,42 +375,42 @@ describe('EventPersistenceSchema', () => { it('should accept config with all fields', () => { const config = { enabled: true, - retention: 90, + retentionDays: 90, filter: (event: Event) => event.name.startsWith('audit.'), }; const parsed = EventPersistenceSchema.parse(config); expect(parsed.enabled).toBe(true); - expect(parsed.retention).toBe(90); + expect(parsed.retentionDays).toBe(90); expect(parsed.filter).toBeDefined(); }); it('should accept different retention periods', () => { const retentions = [1, 7, 30, 90, 365]; - retentions.forEach(retention => { - const config = { enabled: true, retention }; + retentions.forEach(retentionDays => { + const config = { enabled: true, retentionDays }; const parsed = EventPersistenceSchema.parse(config); - expect(parsed.retention).toBe(retention); + expect(parsed.retentionDays).toBe(retentionDays); }); }); it('should reject negative retention', () => { expect(() => EventPersistenceSchema.parse({ enabled: true, - retention: -1, + retentionDays: -1, })).toThrow(); expect(() => EventPersistenceSchema.parse({ enabled: true, - retention: 0, + retentionDays: 0, })).toThrow(); }); it('should accept filter function', () => { const config = { enabled: true, - retention: 60, + retentionDays: 60, filter: (event: Event) => { return event.name.startsWith('critical.') || event.metadata.source === 'security.plugin'; @@ -423,7 +423,7 @@ describe('EventPersistenceSchema', () => { it('should handle disabled persistence', () => { const config = { enabled: false, - retention: 30, + retentionDays: 30, }; const parsed = EventPersistenceSchema.parse(config); @@ -479,7 +479,7 @@ describe('Event System Integration', () => { // Configure persistence const persistence: EventPersistence = { enabled: true, - retention: 90, + retentionDays: 90, filter: (e: Event) => e.name.startsWith('user.'), }; @@ -567,7 +567,7 @@ describe('EventSourcingConfigSchema', () => { const config = { enabled: true, snapshotInterval: 100, - retention: 365, + retentionDays: 365, aggregateTypes: ['order', 'customer'], }; @@ -580,7 +580,7 @@ describe('EventSourcingConfigSchema', () => { expect(config.enabled).toBe(false); expect(config.snapshotInterval).toBe(100); expect(config.snapshotRetention).toBe(10); - expect(config.retention).toBe(365); + expect(config.retentionDays).toBe(365); }); }); @@ -751,7 +751,7 @@ describe('EventBusConfigSchema', () => { const config: EventBusConfig = { persistence: { enabled: true, - retention: 365, + retentionDays: 365, }, queue: { concurrency: 20, @@ -816,3 +816,34 @@ describe('Enhanced Event Handler', () => { expect(() => EventHandlerSchema.parse(handler)).not.toThrow(); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. Both old spellings are `retiredKey()` +// tombstones, so the refusal carries the RENAME (the prescription IS the +// payload) rather than a bare unrecognized-key error, and the value survives at +// the same magnitude. Asserting the message, not just `.toThrow()`: a bare +// throw stays green when the schema throws for some unrelated reason. +describe('Event bus retention windows carry their unit (#15678)', () => { + it.each([ + ['EventPersistence', EventPersistenceSchema, { enabled: true }], + ['EventSourcingConfig', EventSourcingConfigSchema, {}], + ])('%s REFUSES the retired `retention` with the rename in the message', (def, schema, base) => { + const result = schema.safeParse({ ...base, retention: 90 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retention'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`${def}.retention\` was renamed to \`retentionDays\``); + }); + + it('accepts `retentionDays` at the same magnitude on both defs', () => { + expect(EventPersistenceSchema.parse({ enabled: true, retentionDays: 90 }).retentionDays).toBe(90); + expect(EventSourcingConfigSchema.parse({ retentionDays: 90 }).retentionDays).toBe(90); + }); + + it('keeps the count-valued `snapshotRetention` bare — a count has no unit to carry', () => { + const parsed = EventSourcingConfigSchema.parse({ snapshotRetention: 10, retentionDays: 365 }); + expect(parsed.snapshotRetention).toBe(10); + expect(parsed.retentionDays).toBe(365); + }); +}); diff --git a/packages/spec/src/kernel/events/bus.zod.ts b/packages/spec/src/kernel/events/bus.zod.ts index a398304afe..d58713342c 100644 --- a/packages/spec/src/kernel/events/bus.zod.ts +++ b/packages/spec/src/kernel/events/bus.zod.ts @@ -16,7 +16,7 @@ import { EventWebhookConfigSchema, EventMessageQueueConfigSchema, RealTimeNotifi * * @example * { - * "persistence": { "enabled": true, "retention": 365 }, + * "persistence": { "enabled": true, "retentionDays": 365 }, * "queue": { "concurrency": 20 }, * "eventSourcing": { "enabled": true }, * "webhooks": [], diff --git a/packages/spec/src/kernel/events/handlers.zod.ts b/packages/spec/src/kernel/events/handlers.zod.ts index 0873251b40..6dfd8e2836 100644 --- a/packages/spec/src/kernel/events/handlers.zod.ts +++ b/packages/spec/src/kernel/events/handlers.zod.ts @@ -11,6 +11,7 @@ import { z } from 'zod'; * Defines how to handle a specific event */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const EventHandlerSchema = lazySchema(() => z.object({ /** * Handler identifier @@ -82,7 +83,16 @@ export type EventRoute = z.input; */ export const EventPersistenceSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable event persistence'), - retention: z.number().int().positive().describe('Days to retain persisted events'), + // Renamed from `retention` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + retentionDays: z.number().int().positive().describe('Days to retain persisted events'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + retention: retiredKey( + '`EventPersistence.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged.', + ), filter: z.unknown().optional().describe('Optional filter function to select which events to persist'), storage: z.enum(['database', 'file', 's3', 'custom']).default('database') .describe('Storage backend for persisted events'), diff --git a/packages/spec/src/kernel/events/queue.zod.ts b/packages/spec/src/kernel/events/queue.zod.ts index 6d389cb9a5..0cf8210808 100644 --- a/packages/spec/src/kernel/events/queue.zod.ts +++ b/packages/spec/src/kernel/events/queue.zod.ts @@ -21,6 +21,7 @@ import { z } from 'zod'; * } */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const EventQueueConfigSchema = lazySchema(() => z.object({ /** * Queue name @@ -125,7 +126,7 @@ export type EventReplayConfigParsed = z.infer; * { * "enabled": true, * "snapshotInterval": 100, - * "retention": 365 + * "retentionDays": 365 * } */ export const EventSourcingConfigSchema = lazySchema(() => z.object({ @@ -149,8 +150,18 @@ export const EventSourcingConfigSchema = lazySchema(() => z.object({ /** * Event retention */ - retention: z.number().int().positive().default(365) + // Renamed from `retention` (#15678, #14478 ruling B): the unit lived only in + // the describe prose, one key below the count-valued `snapshotRetention`. + retentionDays: z.number().int().positive().default(365) .describe('Days to retain events'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + retention: retiredKey( + '`EventSourcingConfig.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is unchanged.' + + ' The neighbouring `snapshotRetention` is a COUNT of snapshots, not a duration, so it keeps its name.', + ), /** * Aggregate types diff --git a/packages/spec/src/kernel/package-upgrade.test.ts b/packages/spec/src/kernel/package-upgrade.test.ts index 15081e4e4e..b5dd7bb082 100644 --- a/packages/spec/src/kernel/package-upgrade.test.ts +++ b/packages/spec/src/kernel/package-upgrade.test.ts @@ -107,7 +107,7 @@ describe('UpgradePlanSchema', () => { dependencyUpgrades: [ { packageId: 'com.acme.base', fromVersion: '1.0.0', toVersion: '1.1.0' }, ], - estimatedDuration: 120, + estimatedDurationSeconds: 120, summary: 'Major upgrade adding Deals module and restructuring Account object', }; const parsed = UpgradePlanSchema.parse(plan); @@ -280,3 +280,35 @@ describe('RollbackPackageResponseSchema', () => { expect(parsed.restoredVersion).toBe('1.0.0'); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone, +// so the refusal carries the RENAME rather than a bare unrecognized-key error. +describe('UpgradePlan.estimatedDuration carries its unit (#15678)', () => { + const basePlan = { + packageId: 'com.acme.crm', + fromVersion: '1.0.0', + toVersion: '2.0.0', + impactLevel: 'low' as const, + changes: [], + }; + + it('REFUSES the retired `estimatedDuration` with the rename in the message', () => { + const result = UpgradePlanSchema.safeParse({ ...basePlan, estimatedDuration: 120 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'estimatedDuration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds`', + ); + // The prescription must name the unit, because SECONDS is the minority unit + // in this package and the sibling rename on this card is milliseconds. + expect(issue!.message).toContain('the value (seconds) is unchanged'); + }); + + it('accepts `estimatedDurationSeconds` at the same magnitude', () => { + const parsed = UpgradePlanSchema.parse({ ...basePlan, estimatedDurationSeconds: 120 }); + expect(parsed.estimatedDurationSeconds).toBe(120); + }); +}); diff --git a/packages/spec/src/kernel/package-upgrade.zod.ts b/packages/spec/src/kernel/package-upgrade.zod.ts index e724b5adf9..5cbc747556 100644 --- a/packages/spec/src/kernel/package-upgrade.zod.ts +++ b/packages/spec/src/kernel/package-upgrade.zod.ts @@ -36,6 +36,7 @@ import { ManifestSchema } from './manifest.zod'; * Type of change detected between package versions. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MetadataChangeTypeSchema = lazySchema(() => z.enum([ 'added', // New metadata item added in new version 'modified', // Existing metadata item modified @@ -121,9 +122,18 @@ export const UpgradePlanSchema = lazySchema(() => z.object({ })).optional().describe('Dependent packages that also need upgrading'), /** Estimated upgrade duration in seconds */ - estimatedDuration: z.number().int().min(0).optional() + // Renamed from `estimatedDuration` (#15678, #14478 ruling B): the unit lived + // only in the describe prose. + estimatedDurationSeconds: z.number().int().min(0).optional() .describe('Estimated upgrade duration in seconds'), + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + estimatedDuration: retiredKey( + '`UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds` 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 `estimatedDurationSeconds`; the value (seconds) is unchanged.', + ), + /** Human-readable summary */ summary: z.string().optional().describe('Human-readable upgrade summary'), }).describe('Upgrade analysis plan generated before execution')); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts index 84aa2e5657..0cb85f7e59 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts @@ -126,12 +126,12 @@ describe('Plugin Lifecycle Advanced Schemas', () => { timestamp: new Date().toISOString(), message: 'Plugin is operating normally', metrics: { - uptime: 3600000, + uptimeMs: 3600000, memoryUsage: 52428800, cpuUsage: 15.5, activeConnections: 10, errorRate: 0.1, - responseTime: 150, + responseTimeMs: 150, }, checks: [ { @@ -153,7 +153,7 @@ describe('Plugin Lifecycle Advanced Schemas', () => { }; const result = PluginHealthReportSchema.parse(report); expect(result.status).toBe('healthy'); - expect(result.metrics?.uptime).toBe(3600000); + expect(result.metrics?.uptimeMs).toBe(3600000); expect(result.checks).toHaveLength(2); }); @@ -301,3 +301,45 @@ describe('Plugin Lifecycle Advanced Schemas', () => { }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. Both old spellings are `retiredKey()` +// tombstones inside the live `metrics` block, so the refusal carries the RENAME +// and the block's other members must keep parsing beside it. +describe('PluginHealthReport metrics durations carry their unit (#15678)', () => { + const base = { status: 'healthy' as const, timestamp: new Date().toISOString() }; + + it.each([ + ['uptime', 'uptimeMs', 3600000], + ['responseTime', 'responseTimeMs', 150], + ])('REFUSES the retired `metrics.%s` with the rename to `%s` in the message', (old, next, value) => { + const result = PluginHealthReportSchema.safeParse({ ...base, metrics: { [old]: value } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === `metrics.${old}`); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + `\`PluginHealthReport.metrics.${old}\` was renamed to \`${next}\``, + ); + }); + + it('accepts the suffixed metrics beside their unchanged non-duration siblings', () => { + const parsed = PluginHealthReportSchema.parse({ + ...base, + metrics: { + uptimeMs: 3600000, + responseTimeMs: 150, + // Not durations, so this rule does not reach them and they keep their + // bare names: bytes, a percentage, a count and a rate. + memoryUsage: 52428800, + cpuUsage: 15.5, + activeConnections: 10, + errorRate: 0.1, + }, + }); + expect(parsed.metrics?.uptimeMs).toBe(3600000); + expect(parsed.metrics?.responseTimeMs).toBe(150); + expect(parsed.metrics?.memoryUsage).toBe(52428800); + expect(parsed.metrics?.activeConnections).toBe(10); + }); +}); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts index 6d9a4c0a7e..f559e8b1b7 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts @@ -154,6 +154,19 @@ export const PluginHealthCheckSchema = lazySchema(() => z.object({ restartBackoff: retiredKey(RESTART_BACKOFF_RETIRED), })); +const UPTIME_RETIRED = + '`PluginHealthReport.metrics.uptime` was renamed to `uptimeMs` in @objectstack/spec 17 ' + + '— the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose, and this platform already spells a SECONDS-valued uptime with the same bare name ' + + 'on GET /health. Rename the key to `uptimeMs`; the value (milliseconds, `Date.now() - ' + + 'startTime`) is unchanged.'; + +const HEALTH_RESPONSE_TIME_RETIRED = + '`PluginHealthReport.metrics.responseTime` was renamed to `responseTimeMs` 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 `responseTimeMs`; the value ' + + '(milliseconds) is unchanged.'; + /** * Plugin Health Report * Detailed health information from a plugin @@ -178,12 +191,20 @@ export const PluginHealthReportSchema = lazySchema(() => z.object({ * Detailed metrics */ metrics: z.object({ - uptime: z.number().describe('Plugin uptime in milliseconds'), + // Renamed from `uptime` (#15678, #14478 ruling B): the unit lived only in the + // describe prose, and the neighbouring HTTP /health `uptime` is SECONDS. + uptimeMs: z.number().describe('Plugin uptime in milliseconds'), memoryUsage: z.number().optional().describe('Memory usage in bytes'), cpuUsage: z.number().optional().describe('CPU usage percentage'), activeConnections: z.number().optional().describe('Number of active connections'), errorRate: z.number().optional().describe('Error rate (errors per minute)'), - responseTime: z.number().optional().describe('Average response time in ms'), + // Renamed from `responseTime` (#15678, #14478 ruling B): the unit lived only + // in the describe prose. + responseTimeMs: z.number().optional().describe('Average response time in ms'), + + /** Tombstones for the two renames above (#15678, ruling B on #14478). */ + uptime: retiredKey(UPTIME_RETIRED), + responseTime: retiredKey(HEALTH_RESPONSE_TIME_RETIRED), }).partial().optional(), /** diff --git a/packages/spec/src/kernel/plugin-security-advanced.test.ts b/packages/spec/src/kernel/plugin-security-advanced.test.ts index f3555f4daf..b38eb98fd3 100644 --- a/packages/spec/src/kernel/plugin-security-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-security-advanced.test.ts @@ -243,7 +243,7 @@ describe('Plugin Security Advanced Schemas', () => { authentication: { required: true, methods: ['jwt' as const, 'api-key' as const], - tokenExpiration: 3600, + tokenExpirationSeconds: 3600, }, encryption: { dataAtRest: true, @@ -254,7 +254,7 @@ describe('Plugin Security Advanced Schemas', () => { auditLog: { enabled: true, events: ['auth', 'data-access', 'config-change'], - retention: 90, + retentionDays: 90, }, }; const result = KernelSecurityPolicySchema.parse(policy); @@ -306,3 +306,103 @@ describe('Plugin Security Advanced Schemas', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. This file is the rule's sharpest case in the +// spec — FOUR durations on one security manifest carried FOUR DIFFERENT units +// (ms, seconds, days, hours) and none said so in its name. All four old +// spellings are `retiredKey()` tombstones inside live blocks, so the refusal +// carries the RENAME and each block's other members keep parsing beside it. +describe('Plugin security durations carry their unit (#15678)', () => { + it('SandboxConfig REFUSES the retired `process.timeout` with the rename in the message', () => { + const result = SandboxConfigSchema.safeParse({ process: { timeout: 30000 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'process.timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`SandboxConfig.process.timeout` was renamed to `timeoutMs`'); + }); + + it.each([ + ['authentication.tokenExpiration', 'tokenExpirationSeconds', 3600, + { authentication: { methods: ['jwt' as const], tokenExpiration: 3600 } }], + ['auditLog.retention', 'retentionDays', 90, + { auditLog: { retention: 90 } }], + ])('KernelSecurityPolicy REFUSES the retired `%s` with the rename to `%s`', (old, next, _v, policy) => { + const result = KernelSecurityPolicySchema.safeParse(policy); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === old); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`KernelSecurityPolicy.${old}\` was renamed to \`${next}\``); + }); + + it('accepts the suffixed policy keys beside the already-suffixed `windowMs`', () => { + const parsed = KernelSecurityPolicySchema.parse({ + rateLimit: { maxRequests: 100, windowMs: 60000 }, + authentication: { methods: ['jwt' as const], tokenExpirationSeconds: 3600 }, + auditLog: { retentionDays: 90 }, + }); + expect(parsed.rateLimit?.windowMs).toBe(60000); + expect(parsed.authentication?.tokenExpirationSeconds).toBe(3600); + expect(parsed.auditLog?.retentionDays).toBe(90); + }); + + it('accepts the suffixed sandbox key beside its non-duration siblings', () => { + const parsed = SandboxConfigSchema.parse({ + process: { allowSpawn: false, allowedCommands: ['git'], timeoutMs: 30000 }, + }); + expect(parsed.process?.timeoutMs).toBe(30000); + expect(parsed.process?.allowSpawn).toBe(false); + }); + + // The pair the rule exists for: ONE bare name, TWO units, two kernel shapes. + it('PluginSecurityManifest REFUSES `vulnerabilityDisclosure.responseTime` and names HOURS', () => { + const result = PluginSecurityManifestSchema.safeParse({ + pluginId: 'com.acme.analytics', + trustLevel: 'trusted' as const, + permissions: { permissions: [] }, + sandbox: { level: 'strict' as const }, + vulnerabilityDisclosure: { responseTime: 24 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find( + (i) => i.path.join('.') === 'vulnerabilityDisclosure.responseTime', + ); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`PluginSecurityManifest.vulnerabilityDisclosure.responseTime` was renamed to ' + + '`responseTimeHours`', + ); + // Not `responseTimeMs`: the identically-named health-report key IS + // milliseconds, and confusing the two is a 3,600,000x error. + expect(issue!.message).not.toContain('`responseTimeMs`'); + expect(issue!.message).toContain('the value (hours) is unchanged'); + }); + + it('accepts `responseTimeHours` beside the unchanged `bugBounty`', () => { + const parsed = PluginSecurityManifestSchema.parse({ + pluginId: 'com.acme.analytics', + trustLevel: 'trusted' as const, + permissions: { permissions: [] }, + sandbox: { level: 'strict' as const }, + vulnerabilityDisclosure: { responseTimeHours: 24, bugBounty: true }, + }); + expect(parsed.vulnerabilityDisclosure?.responseTimeHours).toBe(24); + expect(parsed.vulnerabilityDisclosure?.bugBounty).toBe(true); + }); + + // A NEGATIVE control on the same file: this key names its unit only in the + // JSDoc above it, a channel the gate does not read (it reads `.describe()` and + // `.meta({ description })`) — so the gate lists it without judging it, and it + // is outside this rename. The JSDoc-channel gap is #15939. Without this test, a + // later sweep reads the four renames above as "every timeout on this file". + it('leaves `RuntimeConfig.resourceLimits.timeout` bare — its describe names no unit', () => { + const parsed = RuntimeConfigSchema.parse({ + engine: 'process' as const, + resourceLimits: { maxMemory: 1073741824, timeout: 60000 }, + }); + expect(parsed.resourceLimits?.timeout).toBe(60000); + }); +}); diff --git a/packages/spec/src/kernel/plugin-security-advanced.zod.ts b/packages/spec/src/kernel/plugin-security-advanced.zod.ts index 1b699b909a..af629b3427 100644 --- a/packages/spec/src/kernel/plugin-security-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-security-advanced.zod.ts @@ -22,6 +22,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Defines the scope of a permission */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const PermissionScopeSchema = lazySchema(() => z.enum([ 'global', // Applies to entire system 'tenant', // Applies to specific tenant @@ -295,6 +296,31 @@ export const RuntimeConfigSchema = lazySchema(() => z.object({ }).optional(), })); +const SANDBOX_PROCESS_TIMEOUT_RETIRED = + '`SandboxConfig.process.timeout` was renamed to `timeoutMs` 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 `timeoutMs`; the value (milliseconds) is unchanged.'; + +const TOKEN_EXPIRATION_RETIRED = + '`KernelSecurityPolicy.authentication.tokenExpiration` was renamed to ' + + '`tokenExpirationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number ' + + 'lives in the key name, not only in the describe prose, and the sibling rate-limit window ' + + 'on this same policy is already `windowMs`. Rename the key to `tokenExpirationSeconds`; ' + + 'the value (seconds) is unchanged.'; + +const AUDIT_LOG_RETENTION_RETIRED = + '`KernelSecurityPolicy.auditLog.retention` was renamed to `retentionDays` 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 `retentionDays`; the value (days) is ' + + 'unchanged.'; + +const DISCLOSURE_RESPONSE_TIME_RETIRED = + '`PluginSecurityManifest.vulnerabilityDisclosure.responseTime` was renamed to ' + + '`responseTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives ' + + 'in the key name, not only in the describe prose. This one is HOURS, while the same bare ' + + 'name on `PluginHealthReport.metrics` was milliseconds — which is the confusion the rule ' + + 'exists to remove. Rename the key to `responseTimeHours`; the value (hours) is unchanged.'; + /** * Sandbox Configuration * Defines how plugin is isolated @@ -349,7 +375,12 @@ export const SandboxConfigSchema = lazySchema(() => z.object({ process: z.object({ allowSpawn: z.boolean().default(false).describe('Allow spawning child processes'), allowedCommands: z.array(z.string()).optional().describe('Whitelisted commands'), - timeout: z.number().int().optional().describe('Process timeout in ms'), + // Renamed from `timeout` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + timeoutMs: z.number().int().optional().describe('Process timeout in ms'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + timeout: retiredKey(SANDBOX_PROCESS_TIMEOUT_RETIRED), }).optional(), /** @@ -583,7 +614,12 @@ export const KernelSecurityPolicySchema = lazySchema(() => z.object({ authentication: z.object({ required: z.boolean().default(true), methods: z.array(z.enum(['jwt', 'oauth2', 'api-key', 'session', 'certificate'])), - tokenExpiration: z.number().int().optional().describe('Token expiration in seconds'), + // Renamed from `tokenExpiration` (#15678, #14478 ruling B): the unit lived + // only in the describe prose, beside the already-suffixed `windowMs`. + tokenExpirationSeconds: z.number().int().optional().describe('Token expiration in seconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + tokenExpiration: retiredKey(TOKEN_EXPIRATION_RETIRED), }).optional(), /** @@ -602,7 +638,12 @@ export const KernelSecurityPolicySchema = lazySchema(() => z.object({ auditLog: z.object({ enabled: z.boolean().default(true), events: z.array(z.string()).optional().describe('Events to log'), - retention: z.number().int().optional().describe('Log retention in days'), + // Renamed from `retention` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + retentionDays: z.number().int().optional().describe('Log retention in days'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + retention: retiredKey(AUDIT_LOG_RETENTION_RETIRED), }).optional(), })); @@ -694,7 +735,13 @@ export const PluginSecurityManifestSchema = lazySchema(() => z.object({ */ vulnerabilityDisclosure: z.object({ policyUrl: z.string().url().optional(), - responseTime: z.number().int().optional().describe('Expected response time in hours'), + // Renamed from `responseTime` (#15678, #14478 ruling B): the unit lived only + // in the describe prose — and it is HOURS here, while the same bare name on + // PluginHealthReport.metrics was milliseconds. + responseTimeHours: z.number().int().optional().describe('Expected response time in hours'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + responseTime: retiredKey(DISCLOSURE_RESPONSE_TIME_RETIRED), bugBounty: z.boolean().default(false), }).optional(), })); diff --git a/packages/spec/src/kernel/plugin-security.test.ts b/packages/spec/src/kernel/plugin-security.test.ts index 5c01471616..6b60b876a3 100644 --- a/packages/spec/src/kernel/plugin-security.test.ts +++ b/packages/spec/src/kernel/plugin-security.test.ts @@ -184,7 +184,7 @@ describe('Plugin Security Protocol', () => { conflicts: [], errors: [], installOrder: ['com.acme.app'], - resolvedIn: 150, + resolvedInMs: 150, }; const result = PackageDependencyResolutionResultSchema.safeParse(validResult); @@ -340,3 +340,26 @@ describe('Plugin Security Protocol', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone, +// so the refusal carries the RENAME rather than a bare unrecognized-key error. +describe('PackageDependencyResolutionResult.resolvedIn carries its unit (#15678)', () => { + const base = { status: 'success' as const, installOrder: ['com.acme.app'] }; + + it('REFUSES the retired `resolvedIn` with the rename in the message', () => { + const result = PackageDependencyResolutionResultSchema.safeParse({ ...base, resolvedIn: 150 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'resolvedIn'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`PackageDependencyResolutionResult.resolvedIn` was renamed to `resolvedInMs`', + ); + }); + + it('accepts `resolvedInMs` at the same magnitude', () => { + const parsed = PackageDependencyResolutionResultSchema.parse({ ...base, resolvedInMs: 150 }); + expect(parsed.resolvedInMs).toBe(150); + }); +}); diff --git a/packages/spec/src/kernel/plugin-security.zod.ts b/packages/spec/src/kernel/plugin-security.zod.ts index 0721054b5e..d4812c01b5 100644 --- a/packages/spec/src/kernel/plugin-security.zod.ts +++ b/packages/spec/src/kernel/plugin-security.zod.ts @@ -25,6 +25,7 @@ import { z } from 'zod'; * Vulnerability Severity */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const VulnerabilitySeverity = z.enum([ 'critical', 'high', @@ -512,7 +513,16 @@ export const PackageDependencyResolutionResultSchema = lazySchema(() => z.object /** * Resolution time (ms) */ - resolvedIn: z.number().int().min(0).optional().describe('Time taken to resolve dependencies in milliseconds'), + // Renamed from `resolvedIn` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + resolvedInMs: z.number().int().min(0).optional().describe('Time taken to resolve dependencies in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + resolvedIn: retiredKey( + '`PackageDependencyResolutionResult.resolvedIn` was renamed to `resolvedInMs` 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 `resolvedInMs`; the value (milliseconds) is unchanged.', + ), }).describe('Result of a dependency resolution process')); export type PackageDependencyResolutionResult = z.input; diff --git a/packages/spec/src/kernel/plugin-versioning.test.ts b/packages/spec/src/kernel/plugin-versioning.test.ts index 2357a6ff0d..cfec1e1d7b 100644 --- a/packages/spec/src/kernel/plugin-versioning.test.ts +++ b/packages/spec/src/kernel/plugin-versioning.test.ts @@ -350,7 +350,7 @@ describe('Plugin Versioning Schemas', () => { enabled: true, strategy: 'canary' as const, percentage: 10, - duration: 3600000, + durationMs: 3600000, }, }; const result = MultiVersionSupportSchema.parse(config); @@ -454,3 +454,30 @@ describe('Plugin Versioning Schemas', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone +// inside the live `rollout` block, so the refusal carries the RENAME and the +// block's unit-less `percentage` must keep parsing beside it. +describe('MultiVersionSupport rollout duration carries its unit (#15678)', () => { + it('REFUSES the retired `rollout.duration` with the rename in the message', () => { + const result = MultiVersionSupportSchema.safeParse({ + rollout: { strategy: 'canary' as const, duration: 3600000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'rollout.duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`MultiVersionSupport.rollout.duration` was renamed to `durationMs`', + ); + }); + + it('accepts `durationMs` beside the unit-less `percentage`, which keeps its name', () => { + const parsed = MultiVersionSupportSchema.parse({ + rollout: { enabled: true, strategy: 'canary' as const, percentage: 10, durationMs: 3600000 }, + }); + expect(parsed.rollout?.durationMs).toBe(3600000); + expect(parsed.rollout?.percentage).toBe(10); + }); +}); diff --git a/packages/spec/src/kernel/plugin-versioning.zod.ts b/packages/spec/src/kernel/plugin-versioning.zod.ts index 172263cbf7..a8ef8ede29 100644 --- a/packages/spec/src/kernel/plugin-versioning.zod.ts +++ b/packages/spec/src/kernel/plugin-versioning.zod.ts @@ -21,6 +21,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Standard SemVer format with optional pre-release and build metadata */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const SemanticVersionSchema = lazySchema(() => z.object({ major: z.number().int().min(0).describe('Major version (breaking changes)'), minor: z.number().int().min(0).describe('Minor version (backward compatible features)'), @@ -336,6 +337,11 @@ export const PluginDependencyResolutionResultSchema = lazySchema(() => z.object( .describe('Map of plugin ID to its dependencies'), })); +const ROLLOUT_DURATION_RETIRED = + '`MultiVersionSupport.rollout.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.'; + /** * Multi-Version Support Configuration * Allows running multiple versions of a plugin simultaneously @@ -381,8 +387,13 @@ export const MultiVersionSupportSchema = lazySchema(() => z.object({ strategy: z.enum(['percentage', 'blue-green', 'canary']), percentage: z.number().min(0).max(100).optional() .describe('Percentage of traffic to new version'), - duration: z.number().int().optional() + // Renamed from `duration` (#15678, #14478 ruling B): the unit lived only in + // the describe prose, beside the unit-less `percentage`. + durationMs: z.number().int().optional() .describe('Rollout duration in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + duration: retiredKey(ROLLOUT_DURATION_RETIRED), }).optional(), })); diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index 461a113c3d..d1f64ba101 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -12,7 +12,7 @@ describe('Startup Orchestrator Protocol', () => { const options = {}; const result = StartupOptionsSchema.parse(options); - expect(result.timeout).toBe(30000); + expect(result.timeoutMs).toBe(30000); expect(result.rollbackOnFailure).toBe(true); expect(result.healthCheck).toBe(false); expect(result.parallel).toBe(false); @@ -20,7 +20,7 @@ describe('Startup Orchestrator Protocol', () => { it('should validate custom options', () => { const options = { - timeout: 60000, + timeoutMs: 60000, rollbackOnFailure: false, healthCheck: true, parallel: true, @@ -30,14 +30,14 @@ describe('Startup Orchestrator Protocol', () => { const result = StartupOptionsSchema.safeParse(options); expect(result.success).toBe(true); if (result.success) { - expect(result.data.timeout).toBe(60000); + expect(result.data.timeoutMs).toBe(60000); expect(result.data.context).toEqual({ custom: 'data' }); } }); it('should reject negative timeout', () => { const options = { - timeout: -1000, + timeoutMs: -1000, }; const result = StartupOptionsSchema.safeParse(options); @@ -80,7 +80,7 @@ describe('Startup Orchestrator Protocol', () => { version: '1.0.0', }, success: true, - duration: 1250, + durationMs: 1250, }; const result = PluginStartupResultSchema.safeParse(successResult); @@ -94,7 +94,7 @@ describe('Startup Orchestrator Protocol', () => { version: '1.0.0', }, success: false, - duration: 500, + durationMs: 500, error: { name: 'Error', message: 'Connection failed' }, }; @@ -108,7 +108,7 @@ describe('Startup Orchestrator Protocol', () => { name: 'crm-plugin', }, success: true, - duration: 1250, + durationMs: 1250, health: { healthy: true, checkedAt: Date.now(), @@ -123,7 +123,7 @@ describe('Startup Orchestrator Protocol', () => { const invalidResult = { plugin: { name: 'test' }, success: true, - duration: -100, + durationMs: -100, }; const result = PluginStartupResultSchema.safeParse(invalidResult); @@ -138,15 +138,15 @@ describe('Startup Orchestrator Protocol', () => { { plugin: { name: 'plugin1', version: '1.0.0' }, success: true, - duration: 1200, + durationMs: 1200, }, { plugin: { name: 'plugin2', version: '2.0.0' }, success: true, - duration: 850, + durationMs: 850, }, ], - totalDuration: 2050, + totalDurationMs: 2050, allSuccessful: true, }; @@ -160,16 +160,16 @@ describe('Startup Orchestrator Protocol', () => { { plugin: { name: 'plugin1' }, success: true, - duration: 1200, + durationMs: 1200, }, { plugin: { name: 'plugin2' }, success: false, - duration: 850, + durationMs: 850, error: { name: 'Error', message: 'Startup failed' }, }, ], - totalDuration: 2050, + totalDurationMs: 2050, allSuccessful: false, rolledBack: ['plugin1'], }; @@ -179,3 +179,66 @@ describe('Startup Orchestrator Protocol', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. All three old spellings are `retiredKey()` +// tombstones, so the refusal carries the RENAME (the prescription IS the +// payload) rather than a bare unrecognized-key error. This contract already +// contained its own counter-example: `startWithTimeout(plugin, ctx, timeoutMs)` +// named its parameter correctly while the options object beside it did not. +describe('Startup orchestration durations carry their unit (#15678)', () => { + it('StartupOptions REFUSES the retired `timeout` with the rename in the message', () => { + const result = StartupOptionsSchema.safeParse({ timeout: 60000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`StartupOptions.timeout` was renamed to `timeoutMs`'); + }); + + it('PluginStartupResult REFUSES the retired `duration` with the rename in the message', () => { + const result = PluginStartupResultSchema.safeParse({ + plugin: { name: 'crm-plugin' }, + success: true, + duration: 1250, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`PluginStartupResult.duration` was renamed to `durationMs`'); + }); + + it('StartupOrchestrationResult REFUSES the retired `totalDuration` with the rename', () => { + const result = StartupOrchestrationResultSchema.safeParse({ + results: [], + totalDuration: 2050, + allSuccessful: true, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'totalDuration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs`', + ); + }); + + it('the aggregate and its parts now agree: totalDurationMs sums durationMs', () => { + const parsed = StartupOrchestrationResultSchema.parse({ + results: [ + { plugin: { name: 'plugin1' }, success: true, durationMs: 1200 }, + { plugin: { name: 'plugin2' }, success: true, durationMs: 850 }, + ], + totalDurationMs: 2050, + allSuccessful: true, + }); + expect(parsed.totalDurationMs).toBe(2050); + expect(parsed.results.reduce((sum, r) => sum + r.durationMs, 0)).toBe(2050); + }); + + it('keeps the 30000 default under the renamed key', () => { + expect(StartupOptionsSchema.parse({}).timeoutMs).toBe(30000); + expect(StartupOptionsSchema.parse({ timeoutMs: 5000 }).timeoutMs).toBe(5000); + }); +}); diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index 767b048ffe..3516956699 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -22,7 +22,7 @@ import { z } from 'zod'; * * @example * { - * "timeout": 30000, + * "timeoutMs": 30000, * "rollbackOnFailure": true, * "healthCheck": false, * "parallel": false @@ -36,8 +36,18 @@ export const StartupOptionsSchema = lazySchema(() => z.object({ * Maximum time (ms) to wait for each plugin to start * @default 30000 (30 seconds) */ - timeout: z.number().int().min(0).optional().default(30000) + // Renamed from `timeout` (#15678, #14478 ruling B): the unit lived only in the + // describe prose. The contract's own `startWithTimeout(plugin, ctx, timeoutMs)` + // parameter already spelled it this way. + timeoutMs: z.number().int().min(0).optional().default(30000) .describe('Maximum time in milliseconds to wait for each plugin to start'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + timeout: retiredKey( + '`StartupOptions.timeout` was renamed to `timeoutMs` 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 `timeoutMs`; the value (milliseconds) is unchanged.', + ), /** * Whether to rollback (destroy) already-started plugins on failure @@ -134,7 +144,7 @@ export type HealthStatus = z.input; * { * "plugin": { "name": "crm-plugin", "version": "1.0.0" }, * "success": true, - * "duration": 1250, + * "durationMs": 1250, * "health": { * "healthy": true, * "checkedAt": 1706659200000 @@ -158,7 +168,16 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({ /** * Time taken to start (milliseconds) */ - duration: z.number().min(0).describe('Time taken to start the plugin in milliseconds'), + // Renamed from `duration` (#15678, #14478 ruling B): the unit lived only in the + // describe prose. + durationMs: z.number().min(0).describe('Time taken to start the plugin in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + duration: retiredKey( + '`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.', + ), /** * Error if startup failed @@ -189,10 +208,10 @@ export type PluginStartupResult = z.input; * @example * { * "results": [ - * { "plugin": { "name": "plugin1" }, "success": true, "duration": 1200 }, - * { "plugin": { "name": "plugin2" }, "success": true, "duration": 850 } + * { "plugin": { "name": "plugin1" }, "success": true, "durationMs": 1200 }, + * { "plugin": { "name": "plugin2" }, "success": true, "durationMs": 850 } * ], - * "totalDuration": 2050, + * "totalDurationMs": 2050, * "allSuccessful": true * } */ @@ -205,7 +224,16 @@ export const StartupOrchestrationResultSchema = lazySchema(() => z.object({ /** * Total time taken for all plugins (milliseconds) */ - totalDuration: z.number().min(0).describe('Total time taken for all plugins in milliseconds'), + // Renamed from `totalDuration` (#15678, #14478 ruling B): the unit lived only + // in the describe prose. + totalDurationMs: z.number().min(0).describe('Total time taken for all plugins in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + totalDuration: retiredKey( + '`StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs` 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 `totalDurationMs`; the value (milliseconds) is unchanged.', + ), /** * Whether all plugins started successfully diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts new file mode 100644 index 0000000000..c235962efb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `EventPersistence.retention` +// said "Days to retain persisted events" in prose and nothing else. Renamed to +// `retentionDays`; the value is unchanged. Tombstoned with `retiredKey()`. No +// D2 conversion: an `EventPersistence` hangs off `EventBusConfig`, the event +// bus's construction argument — never a stack collection member (`stack.zod.ts` +// declares no `eventBus` key) and never a stored sys_metadata row, so the +// conversion chain has no seam that would see one. The semantic entry +// `kernel-event-bus-retention-unit-in-key` carries the prescription. +export const entry = 'kernel/EventPersistence:retention'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts new file mode 100644 index 0000000000..806d93bc86 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `EventSourcingConfig.retention` +// said "Days to retain events" in prose and nothing else — two keys above the +// count-valued `snapshotRetention`, so `retention: 365` and +// `snapshotRetention: 10` read as the same kind of number and are not. Renamed +// to `retentionDays`; the value is unchanged, and `snapshotRetention` keeps its +// name because a count has no unit to carry. Tombstoned with `retiredKey()`. +// No D2 conversion, for the reason the sibling `EventPersistence:retention` +// entry records; `kernel-event-bus-retention-unit-in-key` is the prescription. +export const entry = 'kernel/EventSourcingConfig:retention'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts new file mode 100644 index 0000000000..b2dfd34de6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `KernelSecurityPolicy.auditLog.retention` said "Log retention in days" in +// prose and nothing else. Renamed to `retentionDays`; the value is unchanged. +// Tombstoned with `retiredKey()`. This is the THIRD bare `retention` this card +// renames and the second unit-bearing one to land on `retentionDays` — the +// spelling is now uniform across the kernel. No D2 conversion; see +// `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/KernelSecurityPolicy:auditLog.retention'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts new file mode 100644 index 0000000000..8a572cf130 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `KernelSecurityPolicy.authentication.tokenExpiration` said "Token expiration +// in seconds" in prose and nothing else — on a policy whose rate-limit window +// two blocks above was ALREADY spelled `windowMs`, so one policy document +// carried both conventions. Renamed to `tokenExpirationSeconds`; the value is +// unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a +// `KernelSecurityPolicy` is a plugin security manifest's policy block, never a +// stack collection member. See `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/KernelSecurityPolicy:authentication.tokenExpiration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts new file mode 100644 index 0000000000..63b1f9b42c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `MultiVersionSupport.rollout.duration` said "Rollout duration in +// milliseconds" in prose and nothing else, directly beside the unit-less +// `percentage` — two bare numbers on one block, one a proportion and one a +// span. Renamed to `durationMs`; the value is unchanged, and `percentage` +// keeps its name because a proportion has no time unit to carry. Tombstoned +// with `retiredKey()`. No D2 conversion: `MultiVersionSupport` is a plugin +// version-routing configuration a host constructs, never a stack collection +// member. See `kernel-package-lifecycle-durations-unit-in-key`. +export const entry = 'kernel/MultiVersionSupport:rollout.duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts new file mode 100644 index 0000000000..9d1d31e11d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `PackageDependencyResolutionResult.resolvedIn` said "Time taken to resolve +// dependencies in milliseconds" in prose and nothing else. Renamed to +// `resolvedInMs`; the value is unchanged. Tombstoned with `retiredKey()`. No +// D2 conversion: the result is EMITTED by a dependency resolution run, never +// authored into a metadata document. See +// `kernel-package-lifecycle-durations-unit-in-key`. +export const entry = 'kernel/PackageDependencyResolutionResult:resolvedIn'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts new file mode 100644 index 0000000000..13ac09eb66 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.responseTime` +// said "Average response time in ms" in prose and nothing else. Renamed to +// `responseTimeMs`; the value is unchanged. Tombstoned with `retiredKey()`. +// ⚠️ Not to be confused with `PluginSecurityManifest.vulnerabilityDisclosure.responseTime`, +// the identically-named key this same card renames to `responseTimeHours` — +// same bare name, different unit, which is the confusion ruling B removes. No +// D2 conversion; `kernel-plugin-health-report-durations-unit-in-key` carries +// the prescription. +export const entry = 'kernel/PluginHealthReport:metrics.responseTime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts new file mode 100644 index 0000000000..de4dfeac42 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.uptime` +// said "Plugin uptime in milliseconds" in prose and nothing else, while this +// same platform serves a SECONDS-valued `uptime` on `GET /health` (the protocol +// lifecycle page had to spend a paragraph telling the two apart). Renamed to +// `uptimeMs`; the value is unchanged. Tombstoned with `retiredKey()` inside the +// live `metrics` block — a tombstone whose siblings must keep parsing. No D2 +// conversion: a health report is emitted by the monitor at runtime +// (`packages/core/src/health-monitor.ts`), never authored. See +// `kernel-plugin-health-report-durations-unit-in-key`. +export const entry = 'kernel/PluginHealthReport:metrics.uptime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts new file mode 100644 index 0000000000..e66cb4e21f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` said "Expected +// response time in hours" in prose and nothing else. Renamed to +// `responseTimeHours`; the value is unchanged. This is the card's sharpest +// case: `PluginHealthReport.metrics.responseTime` carried the SAME bare name +// for a MILLISECOND value, so `responseTime: 24` meant a day on one kernel +// shape and 24ms on another. Tombstoned with `retiredKey()`. No D2 conversion: +// a security manifest is a package artifact a publisher ships, never a stack +// collection member. See `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts new file mode 100644 index 0000000000..7b23713e3b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `PluginStartupResult.duration` +// said "Time taken to start the plugin in milliseconds" in prose and nothing +// else. Renamed to `durationMs`; the value is unchanged. Tombstoned with +// `retiredKey()`. No D2 conversion: the result is EMITTED by the orchestrator +// per plugin at boot, never authored. +// +// ⚠️ Note for anyone grepping: `packages/core/src/plugin-loader.ts` declares +// its OWN local `PluginStartupResult` interface — a DIFFERENT type +// (`{ success, pluginName, startTime?, error?, timedOut? }`) with no +// `duration` key at all. It is not a reader of this schema, it is untouched by +// this rename, and the divergence between the two shapes is filed separately. +// See `kernel-startup-orchestrator-durations-unit-in-key`. +export const entry = 'kernel/PluginStartupResult:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts new file mode 100644 index 0000000000..8d780e8b3a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `SandboxConfig.process.timeout` +// said "Process timeout in ms" in prose and nothing else. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()` inside +// the live `process` block. ⚠️ Note for anyone grepping this file: the +// neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key whose +// describe names no unit at all, so it is outside the gate's population and is +// untouched here. No D2 conversion: a `SandboxConfig` is the isolation +// argument a host or a plugin security manifest constructs, never a stack +// collection member or a stored row. See +// `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/SandboxConfig:process.timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts new file mode 100644 index 0000000000..429fbaad76 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `StartupOptions.timeout` said +// "Maximum time in milliseconds to wait for each plugin to start" in prose and +// nothing else — while the very contract that consumes it, +// `IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs)`, already +// named its own parameter `timeoutMs`. One boundary, two spellings. Renamed to +// `timeoutMs`; the value and the 30000 default are unchanged. Tombstoned with +// `retiredKey()`. No D2 conversion: `StartupOptions` is the argument a host +// passes to `orchestrateStartup()` at boot, never a stack collection member or +// a stored row. See `kernel-startup-orchestrator-durations-unit-in-key`. +export const entry = 'kernel/StartupOptions:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts new file mode 100644 index 0000000000..1a59d97e09 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `StartupOrchestrationResult.totalDuration` said "Total time taken for all +// plugins in milliseconds" in prose and nothing else. Renamed to +// `totalDurationMs`; the value is unchanged, and it now agrees with the +// per-plugin `durationMs` it sums. Tombstoned with `retiredKey()`. No D2 +// conversion: the result is EMITTED at the end of a boot, never authored. See +// `kernel-startup-orchestrator-durations-unit-in-key`. +export const entry = 'kernel/StartupOrchestrationResult:totalDuration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts new file mode 100644 index 0000000000..a4b2ea5c73 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `UpgradePlan.estimatedDuration` +// said "Estimated upgrade duration in seconds" in prose and nothing else — and +// SECONDS is the minority unit in this package, which is exactly why the bare +// name misleads. Renamed to `estimatedDurationSeconds`; the value is unchanged. +// Tombstoned with `retiredKey()`. No D2 conversion: an `UpgradePlan` is +// GENERATED by `IPackageService.planUpgrade()` before an upgrade runs and +// carried on the `UpgradeResult`, never authored into a metadata document. The +// semantic entry `kernel-package-lifecycle-durations-unit-in-key` carries it. +export const entry = 'kernel/UpgradePlan:estimatedDuration'; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts new file mode 100644 index 0000000000..6e5b245bf6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-event-bus-retention-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two event-bus retention windows whose name carried no unit: ' + + 'EventPersistence.retention (kernel/events/handlers.zod.ts) and ' + + 'EventSourcingConfig.retention (kernel/events/queue.zod.ts)', + replacement: 'retentionDays on both — rename each key; both values are unchanged, and so is ' + + 'the 365 default on EventSourcingConfig', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'What makes these two one entry rather than two is the neighbour they share and the one ' + + 'they do not. Both hang off EventBusConfig, so an author configuring a bus met the same ' + + 'bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare ' + + 'retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, ' + + 'not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind ' + + 'of number and are not. Suffixing the duration separates the families at the authoring ' + + 'site; snapshotRetention keeps its name, because a count has no unit to carry. Both are ' + + 'retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in ' + + 'silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event ' + + 'bus construction argument a host builds in code (stack.zod.ts declares no eventBus key ' + + 'and no metadata kind is bound to one), so it is never a stack collection member and ' + + 'never a stored sys_metadata row, and the conversion chain has no seam that would see ' + + 'one. That is what ruling B prescribes for a key that is not authorable metadata, and ' + + 'the disposition the epoch-instant renames on this same kernel took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every ' + + 'literal handed to an event bus spells retentionDays; authoring either old spelling fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps ' + + 'events for ninety days exactly as `retention: 90` did, and a config that omits the key ' + + 'still gets the 365 default on EventSourcingConfig. The positive-integer bound rides ' + + 'along with the renamed key, so a zero or negative window is still refused — the pin ' + + 'covering that in kernel/events.test.ts was moved onto the new spelling rather than ' + + 'dropped.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts new file mode 100644 index 0000000000..8fa7a5ebd3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-package-lifecycle-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three package and version lifecycle durations whose name carried no unit: ' + + 'UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), ' + + 'PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and ' + + 'MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)', + replacement: 'estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every ' + + 'value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one story told to one audience — a package ' + + 'being planned, resolved and rolled out — and because the group is precisely where the ' + + 'unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are ' + + 'MILLISECONDS, three adjacent measurements of the same install, two units, none of them ' + + 'named. A reader who learned the unit from one of these three learned it wrongly for the ' + + 'other two. The rollout case adds a second confusion of its own: duration sat directly ' + + 'beside the unit-less percentage, so one block carried a proportion and a span as ' + + 'indistinguishable bare numbers; percentage keeps its name, because a proportion has no ' + + 'time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so ' + + 'a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a ' + + 'PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport ' + + 'is a version-routing argument a host constructs — none is a stack collection member or ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see one. That ' + + 'is what ruling B prescribes for a key that is not authorable metadata. #15678, #14478, ' + + 'ADR-0087.', + acceptanceCriteria: + 'Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and ' + + 'every caller reads it under that name; every dependency-resolution producer returns ' + + 'resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old ' + + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription. Behaviour is unchanged in every case, and the unit split is the thing to ' + + 'check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two ' + + 'MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value ' + + 'between the two would be a thousand-fold error the parse cannot catch, since both ' + + 'bounds accept any non-negative integer.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts new file mode 100644 index 0000000000..9c957a2a31 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-plugin-health-report-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two plugin health-report metrics whose name carried no unit: ' + + 'PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime ' + + '(kernel/plugin-lifecycle-advanced.zod.ts)', + replacement: 'uptimeMs and responseTimeMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'uptime is the case this rule was written for, and this repo had already paid for it in ' + + 'documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a ' + + 'MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a ' + + 'standing paragraph whose whole job was telling the two apart ("metrics.uptime is in ' + + 'milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning ' + + 'that has to exist is the symptom; the key name is where the fix belongs. responseTime ' + + 'moves with it because it is a sibling in the same metrics block and because the ' + + 'identical bare name means HOURS on ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. ' + + 'The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a ' + + 'percentage, activeConnections is a count and errorRate is a rate — none is a duration, ' + + 'and this rule reaches durations only. Both are retiredKey() tombstones inside the live ' + + 'metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 ' + + 'conversion: a health report is EMITTED by the monitor each round ' + + '(packages/core/src/health-monitor.ts) and kept in memory — never authored into a ' + + 'metadata document, never a stored sys_metadata row — so the conversion chain has no ' + + 'seam that would see one, the same disposition HealthStatus.timestamp took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely ' + + 'packages/core/src/health-monitor.ts, the one production writer, whose metrics block now ' + + 'reads `uptimeMs: Date.now() - startTime`. Every consumer reading result.metrics?.uptime ' + + 'moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: the value is still Date.now() - startTime in milliseconds, and a report that ' + + 'omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not ' + + 'part of this and must not be renamed with it: the seconds-valued uptime of the ' + + 'GET /health response body, and the free-form HealthStatus.details record, which is a ' + + 'z.record whose contents this rule does not reach.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts new file mode 100644 index 0000000000..1f1828aed6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-plugin-security-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the four plugin-security durations whose name carried no unit: ' + + 'SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, ' + + 'KernelSecurityPolicy.auditLog.retention and ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime ' + + '(kernel/plugin-security-advanced.zod.ts)', + replacement: 'timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These four are one entry because they are one document — everything here hangs off a ' + + 'PluginSecurityManifest — and because together they are this rule\'s clearest case in ' + + 'the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units ' + + '(milliseconds, seconds, days, hours) and not one of them said so in its name. The ' + + 'sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher ' + + 'promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by ' + + 'the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a ' + + 'day on one kernel shape and a fortieth of a second on another, with nothing at the ' + + 'authoring site to tell them apart. The policy was already inconsistent with itself, ' + + 'too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled ' + + 'windowMs, so one security policy carried both conventions. All four are retiredKey() ' + + 'tombstones inside live blocks whose siblings must keep parsing; no shape here is ' + + 'strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 ' + + 'conversion: a PluginSecurityManifest is a package artifact a publisher ships and a ' + + 'SandboxConfig is the isolation argument a host constructs, so neither is a stack ' + + 'collection member or a stored sys_metadata row and the conversion chain has no seam ' + + 'that would see one. That is what ruling B prescribes for a key that is not authorable ' + + 'metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this ' + + 'same file names its unit only in the JSDoc above it ("Execution timeout in ' + + 'milliseconds"), a channel the gate does not read: it reads `.describe()` and ' + + '`.meta({ description })`, and that key\'s describe ("Maximum execution time") names ' + + 'none. So the gate lists it among the duration-shaped keys without judging it — neither ' + + 'an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap is ' + + '#15939. #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and ' + + 'PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin ' + + 'sandbox or security manifest, spells the suffixed keys; authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned ' + + 'process after thirty seconds exactly as `timeout: 30000` did, a policy with ' + + '`tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still ' + + 'keeps ninety days of audit log, and `responseTimeHours: 24` still promises a ' + + 'twenty-four-hour disclosure response. Every integer bound rides along with its renamed ' + + 'key. Verify the sharp pair explicitly: a manifest and a health report in the same ' + + 'codebase must now read responseTimeHours and responseTimeMs respectively, and neither ' + + 'accepts the bare name.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts new file mode 100644 index 0000000000..d89a1fd9a6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-startup-orchestrator-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three startup-orchestration durations whose name carried no unit: ' + + 'StartupOptions.timeout, PluginStartupResult.duration and ' + + 'StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)', + replacement: 'timeoutMs, durationMs and totalDurationMs — rename each key; every value is ' + + 'unchanged, and so is the 30000 default on StartupOptions', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one boundary: a host passes StartupOptions ' + + 'in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back ' + + 'from the same call. The file already contained its own counter-example — ' + + 'IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter ' + + 'timeoutMs while the options object beside it said timeout, so one contract carried both ' + + 'conventions and the suffixed one was already the honest half. totalDuration is the sum ' + + 'of the per-plugin durations, so the two had to move together or the aggregate would ' + + 'have been spelled unlike its parts. All three are retiredKey() tombstones; none of ' + + 'these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry ' + + 'and not a D2 conversion: StartupOptions is a boot-time call argument and the two result ' + + 'shapes are emitted measurements, so none is ever a stack collection member or a stored ' + + 'sys_metadata row and the conversion chain has no seam that would see one — the same ' + + 'disposition HealthStatus.timestamp took on this very file ' + + '(epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ' + + '#15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every ' + + 'implementation that BUILDS a PluginStartupResult spells durationMs and every one that ' + + 'builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: an orchestrator given `timeoutMs: 5000` waits ' + + 'five seconds per plugin exactly as `timeout: 5000` did, an omitted key still defaults ' + + 'to 30000, and the non-negative bounds ride along with the renamed keys so a negative ' + + 'timeout or a negative duration is still refused. One thing this rename deliberately ' + + 'does NOT touch: packages/core/src/plugin-loader.ts declares its own local ' + + 'PluginStartupResult interface — a different type, carrying startTime rather than any ' + + 'duration key — which is not a reader of this schema and is unchanged.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d9853ea10d..03e246a539 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7408,6 +7408,212 @@ const step18: MigrationStep = { + 'OS_PREVIEW_BASE_DOMAINS keep working exactly as documented ' + '(deployment routing, never identity).', }, + { + id: 'kernel-event-bus-retention-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two event-bus retention windows whose name carried no unit: ' + + 'EventPersistence.retention (kernel/events/handlers.zod.ts) and ' + + 'EventSourcingConfig.retention (kernel/events/queue.zod.ts)', + replacement: 'retentionDays on both — rename each key; both values are unchanged, and so is ' + + 'the 365 default on EventSourcingConfig', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'What makes these two one entry rather than two is the neighbour they share and the one ' + + 'they do not. Both hang off EventBusConfig, so an author configuring a bus met the same ' + + 'bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare ' + + 'retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, ' + + 'not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind ' + + 'of number and are not. Suffixing the duration separates the families at the authoring ' + + 'site; snapshotRetention keeps its name, because a count has no unit to carry. Both are ' + + 'retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in ' + + 'silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event ' + + 'bus construction argument a host builds in code (stack.zod.ts declares no eventBus key ' + + 'and no metadata kind is bound to one), so it is never a stack collection member and ' + + 'never a stored sys_metadata row, and the conversion chain has no seam that would see ' + + 'one. That is what ruling B prescribes for a key that is not authorable metadata, and ' + + 'the disposition the epoch-instant renames on this same kernel took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every ' + + 'literal handed to an event bus spells retentionDays; authoring either old spelling fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps ' + + 'events for ninety days exactly as `retention: 90` did, and a config that omits the key ' + + 'still gets the 365 default on EventSourcingConfig. The positive-integer bound rides ' + + 'along with the renamed key, so a zero or negative window is still refused — the pin ' + + 'covering that in kernel/events.test.ts was moved onto the new spelling rather than ' + + 'dropped.', + }, + { + id: 'kernel-package-lifecycle-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three package and version lifecycle durations whose name carried no unit: ' + + 'UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), ' + + 'PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and ' + + 'MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)', + replacement: 'estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every ' + + 'value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one story told to one audience — a package ' + + 'being planned, resolved and rolled out — and because the group is precisely where the ' + + 'unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are ' + + 'MILLISECONDS, three adjacent measurements of the same install, two units, none of them ' + + 'named. A reader who learned the unit from one of these three learned it wrongly for the ' + + 'other two. The rollout case adds a second confusion of its own: duration sat directly ' + + 'beside the unit-less percentage, so one block carried a proportion and a span as ' + + 'indistinguishable bare numbers; percentage keeps its name, because a proportion has no ' + + 'time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so ' + + 'a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a ' + + 'PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport ' + + 'is a version-routing argument a host constructs — none is a stack collection member or ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see one. That ' + + 'is what ruling B prescribes for a key that is not authorable metadata. #15678, #14478, ' + + 'ADR-0087.', + acceptanceCriteria: + 'Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and ' + + 'every caller reads it under that name; every dependency-resolution producer returns ' + + 'resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old ' + + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription. Behaviour is unchanged in every case, and the unit split is the thing to ' + + 'check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two ' + + 'MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value ' + + 'between the two would be a thousand-fold error the parse cannot catch, since both ' + + 'bounds accept any non-negative integer.', + }, + { + id: 'kernel-plugin-health-report-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two plugin health-report metrics whose name carried no unit: ' + + 'PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime ' + + '(kernel/plugin-lifecycle-advanced.zod.ts)', + replacement: 'uptimeMs and responseTimeMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'uptime is the case this rule was written for, and this repo had already paid for it in ' + + 'documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a ' + + 'MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a ' + + 'standing paragraph whose whole job was telling the two apart ("metrics.uptime is in ' + + 'milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning ' + + 'that has to exist is the symptom; the key name is where the fix belongs. responseTime ' + + 'moves with it because it is a sibling in the same metrics block and because the ' + + 'identical bare name means HOURS on ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. ' + + 'The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a ' + + 'percentage, activeConnections is a count and errorRate is a rate — none is a duration, ' + + 'and this rule reaches durations only. Both are retiredKey() tombstones inside the live ' + + 'metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 ' + + 'conversion: a health report is EMITTED by the monitor each round ' + + '(packages/core/src/health-monitor.ts) and kept in memory — never authored into a ' + + 'metadata document, never a stored sys_metadata row — so the conversion chain has no ' + + 'seam that would see one, the same disposition HealthStatus.timestamp took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely ' + + 'packages/core/src/health-monitor.ts, the one production writer, whose metrics block now ' + + 'reads `uptimeMs: Date.now() - startTime`. Every consumer reading result.metrics?.uptime ' + + 'moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: the value is still Date.now() - startTime in milliseconds, and a report that ' + + 'omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not ' + + 'part of this and must not be renamed with it: the seconds-valued uptime of the ' + + 'GET /health response body, and the free-form HealthStatus.details record, which is a ' + + 'z.record whose contents this rule does not reach.', + }, + { + id: 'kernel-plugin-security-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the four plugin-security durations whose name carried no unit: ' + + 'SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, ' + + 'KernelSecurityPolicy.auditLog.retention and ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime ' + + '(kernel/plugin-security-advanced.zod.ts)', + replacement: 'timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These four are one entry because they are one document — everything here hangs off a ' + + 'PluginSecurityManifest — and because together they are this rule\'s clearest case in ' + + 'the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units ' + + '(milliseconds, seconds, days, hours) and not one of them said so in its name. The ' + + 'sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher ' + + 'promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by ' + + 'the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a ' + + 'day on one kernel shape and a fortieth of a second on another, with nothing at the ' + + 'authoring site to tell them apart. The policy was already inconsistent with itself, ' + + 'too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled ' + + 'windowMs, so one security policy carried both conventions. All four are retiredKey() ' + + 'tombstones inside live blocks whose siblings must keep parsing; no shape here is ' + + 'strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 ' + + 'conversion: a PluginSecurityManifest is a package artifact a publisher ships and a ' + + 'SandboxConfig is the isolation argument a host constructs, so neither is a stack ' + + 'collection member or a stored sys_metadata row and the conversion chain has no seam ' + + 'that would see one. That is what ruling B prescribes for a key that is not authorable ' + + 'metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this ' + + 'same file names its unit only in the JSDoc above it ("Execution timeout in ' + + 'milliseconds"), a channel the gate does not read: it reads `.describe()` and ' + + '`.meta({ description })`, and that key\'s describe ("Maximum execution time") names ' + + 'none. So the gate lists it among the duration-shaped keys without judging it — neither ' + + 'an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap is ' + + '#15939. #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and ' + + 'PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin ' + + 'sandbox or security manifest, spells the suffixed keys; authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned ' + + 'process after thirty seconds exactly as `timeout: 30000` did, a policy with ' + + '`tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still ' + + 'keeps ninety days of audit log, and `responseTimeHours: 24` still promises a ' + + 'twenty-four-hour disclosure response. Every integer bound rides along with its renamed ' + + 'key. Verify the sharp pair explicitly: a manifest and a health report in the same ' + + 'codebase must now read responseTimeHours and responseTimeMs respectively, and neither ' + + 'accepts the bare name.', + }, + { + id: 'kernel-startup-orchestrator-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three startup-orchestration durations whose name carried no unit: ' + + 'StartupOptions.timeout, PluginStartupResult.duration and ' + + 'StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)', + replacement: 'timeoutMs, durationMs and totalDurationMs — rename each key; every value is ' + + 'unchanged, and so is the 30000 default on StartupOptions', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one boundary: a host passes StartupOptions ' + + 'in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back ' + + 'from the same call. The file already contained its own counter-example — ' + + 'IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter ' + + 'timeoutMs while the options object beside it said timeout, so one contract carried both ' + + 'conventions and the suffixed one was already the honest half. totalDuration is the sum ' + + 'of the per-plugin durations, so the two had to move together or the aggregate would ' + + 'have been spelled unlike its parts. All three are retiredKey() tombstones; none of ' + + 'these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry ' + + 'and not a D2 conversion: StartupOptions is a boot-time call argument and the two result ' + + 'shapes are emitted measurements, so none is ever a stack collection member or a stored ' + + 'sys_metadata row and the conversion chain has no seam that would see one — the same ' + + 'disposition HealthStatus.timestamp took on this very file ' + + '(epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ' + + '#15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every ' + + 'implementation that BUILDS a PluginStartupResult spells durationMs and every one that ' + + 'builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: an orchestrator given `timeoutMs: 5000` waits ' + + 'five seconds per plugin exactly as `timeout: 5000` did, an omitted key still defaults ' + + 'to 30000, and the non-negative bounds ride along with the renamed keys so a negative ' + + 'timeout or a negative duration is still refused. One thing this rename deliberately ' + + 'does NOT touch: packages/core/src/plugin-loader.ts declares its own local ' + + 'PluginStartupResult interface — a different type, carrying startTime rather than any ' + + 'duration key — which is not a reader of this schema and is unchanged.', + }, { id: 'memory-persistence-placeholder-refused', surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + @@ -9710,6 +9916,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `${defKey}:${name}` membership per def, never by radiating from a neighbour. // See `18.integration__Connector__errorMapping.ts` for the retirement record. 'integration/DeclarativeConnectorEntry:errorMapping', + // #15678 (stack card 3/6 of #14478) — ruling B. `EventPersistence.retention` + // said "Days to retain persisted events" in prose and nothing else. Renamed to + // `retentionDays`; the value is unchanged. Tombstoned with `retiredKey()`. No + // D2 conversion: an `EventPersistence` hangs off `EventBusConfig`, the event + // bus's construction argument — never a stack collection member (`stack.zod.ts` + // declares no `eventBus` key) and never a stored sys_metadata row, so the + // conversion chain has no seam that would see one. The semantic entry + // `kernel-event-bus-retention-unit-in-key` carries the prescription. + 'kernel/EventPersistence:retention', + // #15678 (stack card 3/6 of #14478) — ruling B. `EventSourcingConfig.retention` + // said "Days to retain events" in prose and nothing else — two keys above the + // count-valued `snapshotRetention`, so `retention: 365` and + // `snapshotRetention: 10` read as the same kind of number and are not. Renamed + // to `retentionDays`; the value is unchanged, and `snapshotRetention` keeps its + // name because a count has no unit to carry. Tombstoned with `retiredKey()`. + // No D2 conversion, for the reason the sibling `EventPersistence:retention` + // entry records; `kernel-event-bus-retention-unit-in-key` is the prescription. + 'kernel/EventSourcingConfig:retention', // #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` // is the instant the health check RAN: it moved onto the shared `EpochMs` schema // and was renamed `checkedAt`, which also states what the instant marks. @@ -9794,6 +10018,23 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // // Registered under 18, not 17, for the reason that sibling entry records. 'kernel/KernelContext:startTime', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `KernelSecurityPolicy.auditLog.retention` said "Log retention in days" in + // prose and nothing else. Renamed to `retentionDays`; the value is unchanged. + // Tombstoned with `retiredKey()`. This is the THIRD bare `retention` this card + // renames and the second unit-bearing one to land on `retentionDays` — the + // spelling is now uniform across the kernel. No D2 conversion; see + // `kernel-plugin-security-durations-unit-in-key`. + 'kernel/KernelSecurityPolicy:auditLog.retention', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `KernelSecurityPolicy.authentication.tokenExpiration` said "Token expiration + // in seconds" in prose and nothing else — on a policy whose rate-limit window + // two blocks above was ALREADY spelled `windowMs`, so one policy document + // carried both conventions. Renamed to `tokenExpirationSeconds`; the value is + // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a + // `KernelSecurityPolicy` is a plugin security manifest's policy block, never a + // stack collection member. See `kernel-plugin-security-durations-unit-in-key`. + 'kernel/KernelSecurityPolicy:authentication.tokenExpiration', // #11332 — ADR-0049 enforce-or-remove on the plugin manifest's three dead // top-level containers (triage graded 2026-08-23; cloud leg measured clean // 2026-08-29 on #12400 with positive controls). The census found ZERO reads @@ -10123,6 +10364,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // through the tombstone (`tsc` + the parse) and the D3 semantic entry // `metadata-customization-protocol-retired`. 'kernel/MetadataPluginConfig:mergeStrategy', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `MultiVersionSupport.rollout.duration` said "Rollout duration in + // milliseconds" in prose and nothing else, directly beside the unit-less + // `percentage` — two bare numbers on one block, one a proportion and one a + // span. Renamed to `durationMs`; the value is unchanged, and `percentage` + // keeps its name because a proportion has no time unit to carry. Tombstoned + // with `retiredKey()`. No D2 conversion: `MultiVersionSupport` is a plugin + // version-routing configuration a host constructs, never a stack collection + // member. See `kernel-package-lifecycle-durations-unit-in-key`. + 'kernel/MultiVersionSupport:rollout.duration', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `PackageDependencyResolutionResult.resolvedIn` said "Time taken to resolve + // dependencies in milliseconds" in prose and nothing else. Renamed to + // `resolvedInMs`; the value is unchanged. Tombstoned with `retiredKey()`. No + // D2 conversion: the result is EMITTED by a dependency resolution run, never + // authored into a metadata document. See + // `kernel-package-lifecycle-durations-unit-in-key`. + 'kernel/PackageDependencyResolutionResult:resolvedIn', // #12032 — ADR-0049 enforce-or-remove, one class over from #12428 (PR #12571) // and #12340 (PR #12425) in the same host-driven lifecycle library, and for a // sharper reason than either: this key HAD a reader that acted, and what it did @@ -10282,6 +10541,77 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door // for the audience that exists. 'kernel/PluginHealthCheck:restartBackoff', + // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.responseTime` + // said "Average response time in ms" in prose and nothing else. Renamed to + // `responseTimeMs`; the value is unchanged. Tombstoned with `retiredKey()`. + // ⚠️ Not to be confused with `PluginSecurityManifest.vulnerabilityDisclosure.responseTime`, + // the identically-named key this same card renames to `responseTimeHours` — + // same bare name, different unit, which is the confusion ruling B removes. No + // D2 conversion; `kernel-plugin-health-report-durations-unit-in-key` carries + // the prescription. + 'kernel/PluginHealthReport:metrics.responseTime', + // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.uptime` + // said "Plugin uptime in milliseconds" in prose and nothing else, while this + // same platform serves a SECONDS-valued `uptime` on `GET /health` (the protocol + // lifecycle page had to spend a paragraph telling the two apart). Renamed to + // `uptimeMs`; the value is unchanged. Tombstoned with `retiredKey()` inside the + // live `metrics` block — a tombstone whose siblings must keep parsing. No D2 + // conversion: a health report is emitted by the monitor at runtime + // (`packages/core/src/health-monitor.ts`), never authored. See + // `kernel-plugin-health-report-durations-unit-in-key`. + 'kernel/PluginHealthReport:metrics.uptime', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` said "Expected + // response time in hours" in prose and nothing else. Renamed to + // `responseTimeHours`; the value is unchanged. This is the card's sharpest + // case: `PluginHealthReport.metrics.responseTime` carried the SAME bare name + // for a MILLISECOND value, so `responseTime: 24` meant a day on one kernel + // shape and 24ms on another. Tombstoned with `retiredKey()`. No D2 conversion: + // a security manifest is a package artifact a publisher ships, never a stack + // collection member. See `kernel-plugin-security-durations-unit-in-key`. + 'kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime', + // #15678 (stack card 3/6 of #14478) — ruling B. `PluginStartupResult.duration` + // said "Time taken to start the plugin in milliseconds" in prose and nothing + // else. Renamed to `durationMs`; the value is unchanged. Tombstoned with + // `retiredKey()`. No D2 conversion: the result is EMITTED by the orchestrator + // per plugin at boot, never authored. + // + // ⚠️ Note for anyone grepping: `packages/core/src/plugin-loader.ts` declares + // its OWN local `PluginStartupResult` interface — a DIFFERENT type + // (`{ success, pluginName, startTime?, error?, timedOut? }`) with no + // `duration` key at all. It is not a reader of this schema, it is untouched by + // this rename, and the divergence between the two shapes is filed separately. + // See `kernel-startup-orchestrator-durations-unit-in-key`. + 'kernel/PluginStartupResult:duration', + // #15678 (stack card 3/6 of #14478) — ruling B. `SandboxConfig.process.timeout` + // said "Process timeout in ms" in prose and nothing else. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()` inside + // the live `process` block. ⚠️ Note for anyone grepping this file: the + // neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key whose + // describe names no unit at all, so it is outside the gate's population and is + // untouched here. No D2 conversion: a `SandboxConfig` is the isolation + // argument a host or a plugin security manifest constructs, never a stack + // collection member or a stored row. See + // `kernel-plugin-security-durations-unit-in-key`. + 'kernel/SandboxConfig:process.timeout', + // #15678 (stack card 3/6 of #14478) — ruling B. `StartupOptions.timeout` said + // "Maximum time in milliseconds to wait for each plugin to start" in prose and + // nothing else — while the very contract that consumes it, + // `IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs)`, already + // named its own parameter `timeoutMs`. One boundary, two spellings. Renamed to + // `timeoutMs`; the value and the 30000 default are unchanged. Tombstoned with + // `retiredKey()`. No D2 conversion: `StartupOptions` is the argument a host + // passes to `orchestrateStartup()` at boot, never a stack collection member or + // a stored row. See `kernel-startup-orchestrator-durations-unit-in-key`. + 'kernel/StartupOptions:timeout', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `StartupOrchestrationResult.totalDuration` said "Total time taken for all + // plugins in milliseconds" in prose and nothing else. Renamed to + // `totalDurationMs`; the value is unchanged, and it now agrees with the + // per-plugin `durationMs` it sums. Tombstoned with `retiredKey()`. No D2 + // conversion: the result is EMITTED at the end of a boot, never authored. See + // `kernel-startup-orchestrator-durations-unit-in-key`. + 'kernel/StartupOrchestrationResult:totalDuration', // #11846 — the `TenantRuntimeContextSchema` copy of // `kernel/KernelContext:previewMode`: the def is `KernelContextSchema.extend(…)`, // so the tombstone lands in this walked shape too and `authorable-surface/` @@ -10296,6 +10626,15 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // ratchet records the two copies separately, so both are declared here. The // `previewMode` retirement registered its two copies the same way. 'kernel/TenantRuntimeContext:startTime', + // #15678 (stack card 3/6 of #14478) — ruling B. `UpgradePlan.estimatedDuration` + // said "Estimated upgrade duration in seconds" in prose and nothing else — and + // SECONDS is the minority unit in this package, which is exactly why the bare + // name misleads. Renamed to `estimatedDurationSeconds`; the value is unchanged. + // Tombstoned with `retiredKey()`. No D2 conversion: an `UpgradePlan` is + // GENERATED by `IPackageService.planUpgrade()` before an upgrade runs and + // carried on the `UpgradeResult`, never authored into a metadata document. The + // semantic entry `kernel-package-lifecycle-durations-unit-in-key` carries it. + 'kernel/UpgradePlan:estimatedDuration', // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge` // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key