|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) |
| 6 | + |
| 7 | +<!-- adr-0087: registered system-cache-durations-unit-in-key, system-collaboration-durations-unit-in-key, system-failover-health-check-interval-unit-in-key, system-metrics-window-durations-unit-in-key, system-object-storage-durations-unit-in-key, system-registry-config-durations-unit-in-key, system-tracing-span-duration-unit-in-key, system-worker-queue-rate-limit-duration-unit-in-key --> |
| 8 | + |
| 9 | +**BREAKING** — fifteen published `system/` duration keys are renamed and |
| 10 | +tombstoned. Shipped as `minor` under the repo's launch-window convention for |
| 11 | +breaking changes; the hand-migration prescriptions are registered under protocol |
| 12 | +major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, |
| 13 | +「同意」). |
| 14 | + |
| 15 | +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit |
| 16 | +in the key NAME, never only in its `.describe()` prose, and grandfathers no |
| 17 | +existing offender. Stack card 1/6 (#15676) landed the rule's two structural |
| 18 | +exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared |
| 19 | +`kernel/`; this card clears `system/`. Measured with the gate itself: |
| 20 | +`src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls |
| 21 | +**22 → 7**. |
| 22 | + |
| 23 | +## FROM → TO |
| 24 | + |
| 25 | +| key | replacement | unit | |
| 26 | +|:--|:--|:--| |
| 27 | +| `CacheTier.ttl` | `ttlSeconds` | seconds | |
| 28 | +| `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | |
| 29 | +| `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | |
| 30 | +| `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | |
| 31 | +| `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | |
| 32 | +| `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | |
| 33 | +| `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | |
| 34 | +| `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | |
| 35 | +| `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | |
| 36 | +| `StorageConnection.timeout` | `timeoutMs` | milliseconds | |
| 37 | +| `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | |
| 38 | +| `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | |
| 39 | +| `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | |
| 40 | +| `Span.duration` | `durationMs` | milliseconds | |
| 41 | +| `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | |
| 42 | + |
| 43 | +**Every value is unchanged** — only key names move, and every default moves with |
| 44 | +its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to |
| 45 | +300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, |
| 46 | +`RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so |
| 47 | +`syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything |
| 48 | +under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` |
| 49 | +at the authoring site (input type `never`) and fails the parse with the rename |
| 50 | +prescription rather than a bare unrecognized-key error. |
| 51 | + |
| 52 | +## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them |
| 53 | + |
| 54 | +`AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. |
| 55 | +Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare |
| 56 | +name under an `externalVocabulary` marker. |
| 57 | + |
| 58 | +The asymmetry is the whole point. Every bucket-CORS standard the first value is |
| 59 | +forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, |
| 60 | +Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation |
| 61 | +from* the cited standard rather than a mirror of it. The Fetch response header |
| 62 | +the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. |
| 63 | +A find-and-replace across both leaves no gate red: the marker exempts the twin |
| 64 | +either way. A pin test in `object-storage.test.ts` is the only guard. |
| 65 | + |
| 66 | +## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` |
| 67 | + |
| 68 | +The gate prints `sizeSeconds` for the two `window.size` keys, and that name is |
| 69 | +wrong on its face. `size` means a byte or row count everywhere else in this spec |
| 70 | +— `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and |
| 71 | +`MetricExportConfig.batch.size` on the very same file is a record count — so |
| 72 | +`sizeSeconds` would have kept the misleading half of the name and bolted a unit |
| 73 | +onto it. `windowSeconds` was rejected for a plainer reason: the parent key is |
| 74 | +already `window`, so it would read `window.windowSeconds`. |
| 75 | + |
| 76 | +`durationSeconds` names what the number is, and the file supplied its own |
| 77 | +precedent: `ServiceLevelObjective.period.duration` already called a period |
| 78 | +length a duration. After the rename all three read alike. The prescription says |
| 79 | +so explicitly, so the next author does not read the departure as a slip and |
| 80 | +"correct" it back to the mechanical name. |
| 81 | + |
| 82 | +## Dispositions — eight semantic entries, no D2 conversion |
| 83 | + |
| 84 | +Justified per key rather than defaulted, and this card's answer is uniform: |
| 85 | +**none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs |
| 86 | +over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, |
| 87 | +`disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or |
| 88 | +`worker` root — none of these twelve defs is a stack collection member or a |
| 89 | +registered metadata kind stored as a `sys_metadata` row, so the conversion chain |
| 90 | +has no seam that would see one. They are host configuration (`CacheTier`, |
| 91 | +`FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, |
| 92 | +`QueueConfig`), call arguments (`CollaborationSessionConfig`) and |
| 93 | +runtime-emitted measurements (`Span`). Each therefore carries a **semantic** |
| 94 | +entry, which is what ruling B prescribes for a key that is not authorable stack |
| 95 | +metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, |
| 96 | +nested spellings included. |
| 97 | + |
| 98 | +## Keys deliberately left alone |
| 99 | + |
| 100 | +`FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS |
| 101 | +resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. |
| 102 | +`CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is |
| 103 | +milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must |
| 104 | +not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a |
| 105 | +record count and `QueueConfig.rateLimit.max` is a task count: neither is a |
| 106 | +duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s |
| 107 | +burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit |
| 108 | +anywhere in their prose, so both are outside the gate's population entirely. |
| 109 | +Pin tests assert each of these, so a later sweep cannot read this card as |
| 110 | +"every duration-shaped number on these files". |
0 commit comments