|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) |
| 6 | + |
| 7 | +<!-- adr-0087: registered dashboard-refresh-interval-to-refresh-interval-seconds, connector-health-and-trigger-durations-unit-in-key, memory-persistence-auto-save-interval-to-ms, turso-config-timeout-to-timeout-ms, ai-conversation-analytics-duration-unit-in-key, data-nosql-query-options-timeout-unit-in-key --> |
| 8 | + |
| 9 | +**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped |
| 10 | +as `minor` under the repo's launch-window convention for breaking changes; the |
| 11 | +hand-migration prescriptions are registered under protocol major 18. Maintainer |
| 12 | +ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). |
| 13 | + |
| 14 | +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in |
| 15 | +the key NAME, never only in its `.describe()` prose, and grandfathers no existing |
| 16 | +offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 |
| 17 | +(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 |
| 18 | +(#15679) cleared `system/`. This card clears the remainder, and is the first |
| 19 | +where the gate itself reads **`zero offenders`** and exits `0`. |
| 20 | + |
| 21 | +⚠️ That is green **for the gate's currently declared population** |
| 22 | +(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has |
| 23 | +already measured an offender outside this subtree, so the gate is expected to go |
| 24 | +red again by design. This changeset does not claim #14478 is finished. |
| 25 | + |
| 26 | +## FROM → TO |
| 27 | + |
| 28 | +| key | replacement | unit | |
| 29 | +|:--|:--|:--| |
| 30 | +| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | |
| 31 | +| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | |
| 32 | +| `ConnectorTrigger.interval` | `intervalSeconds` | seconds | |
| 33 | +| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | |
| 34 | +| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | |
| 35 | +| `TursoConfig.timeout` | `timeoutMs` | milliseconds | |
| 36 | +| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | |
| 37 | +| `ConversationAnalytics.duration` | `durationSeconds` | seconds | |
| 38 | + |
| 39 | +**Every value is unchanged** — only key names move. The two keys that carried a |
| 40 | +default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to |
| 41 | +60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare |
| 42 | +none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything |
| 43 | +under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and |
| 44 | +`TursoConfig.timeoutMs` still refuse a zero or negative integer, and |
| 45 | +`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old |
| 46 | +spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site |
| 47 | +(input type `never`) and fails the parse with the rename prescription rather than |
| 48 | +a bare unrecognized-key error. |
| 49 | + |
| 50 | +`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` |
| 51 | +— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming |
| 52 | +the tombstone would have prescribed a key the shape refuses, which is the one |
| 53 | +failure this rename could have introduced silently; a pin asserts all three. |
| 54 | + |
| 55 | +## ⚠️ `dashboard.refreshInterval` crosses a repository boundary |
| 56 | + |
| 57 | +This is the only rename in the whole stack whose consumer is in **another |
| 58 | +repository**, so its reader could not move in this PR the way every other reader |
| 59 | +in this card did. objectui's dashboard renderer reads the key, multiplies by |
| 60 | +1000 to drive a `setInterval`, and republishes it as an authoring input the |
| 61 | +console offers. Those sites move in a follow-up objectui card, sequenced behind |
| 62 | +a release that actually ships this rename. |
| 63 | + |
| 64 | +Until that lands the renderer sees an absent key and simply does not start its |
| 65 | +refresh timer — a dashboard still renders, and still refreshes when the user |
| 66 | +asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards |
| 67 | +and `os migrate meta` correct in the meantime. |
| 68 | + |
| 69 | +## ⚠️ An eighth key moves that the gate did not list |
| 70 | + |
| 71 | +`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` |
| 72 | +named no unit at all, and the predicate judges prose against name. |
| 73 | + |
| 74 | +It moves anyway because it is not a second key. `persistence: { type: 'auto' }` |
| 75 | +resolves to the same Node.js file adapter as `type: 'file'`, and this value is |
| 76 | +forwarded to the same `FileSystemPersistenceAdapter` field, in the same |
| 77 | +milliseconds, under the same `min(100)` bound. Renaming one arm and not the other |
| 78 | +would have left one value with two spellings across sibling arms of one union, |
| 79 | +and the driver reading both — the consumer-side dialect Prime Directive #12 |
| 80 | +forbids. Its describe now names the unit too, and a pin asserts the refusal on |
| 81 | +the arm the gate never listed, so a later reader cannot "restore" the bare |
| 82 | +spelling as an over-application of the rule. |
| 83 | + |
| 84 | +## Dispositions — four D2 conversions, two semantic entries |
| 85 | + |
| 86 | +Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and |
| 87 | +unlike card 4/6 this card's answer is split. |
| 88 | + |
| 89 | +**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` |
| 90 | +are each a stack collection whose members are stored whole as `sys_metadata` |
| 91 | +rows, so the conversion chain has a seam that sees them: |
| 92 | +`dashboard-refresh-interval-to-refresh-interval-seconds`, |
| 93 | +`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one |
| 94 | +pass, emitting separately), |
| 95 | +`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and |
| 96 | +`turso-config-timeout-to-timeout-ms`. The two datasource conversions are |
| 97 | +driver-aware for the reason `datasource-config-driver-key-aliases` records: a |
| 98 | +bare `config.timeout` under another driver is that driver's own key and must not |
| 99 | +be touched. |
| 100 | + |
| 101 | +**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime |
| 102 | +and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument |
| 103 | +reached only through `AggregationPipeline.options`. Neither is a stack collection |
| 104 | +member or a stored row, so the chain has no seam — the disposition every |
| 105 | +runtime-emitted measurement in this stack has taken. |
| 106 | + |
| 107 | +All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. |
| 108 | + |
| 109 | +## A retirement tombstone is no longer read as a secret |
| 110 | + |
| 111 | +`refusedCredentialKeys` derives a driver's refused inline credentials by finding |
| 112 | +`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a |
| 113 | +`z.never()`, and until this card no driver contract carried one — so "never ⇒ |
| 114 | +credential" held by accident of population rather than by construction. The first |
| 115 | +tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a |
| 116 | +millisecond budget was a secret: it was redacted off the datasource read path and |
| 117 | +dragged a non-credential name into the fallback list every unrecognised driver is |
| 118 | +scrubbed by. |
| 119 | + |
| 120 | +The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` |
| 121 | +itself stamps. The exclusion is deliberately **negative** — skip declared |
| 122 | +tombstones — rather than positive (keep only keys marked `format: 'password'`), |
| 123 | +even though every credential slot in every builtin contract does carry that |
| 124 | +marker today: under-redacting is the dangerous direction, so a future credential |
| 125 | +key whose author forgets the marker is still scrubbed, and only a key that has |
| 126 | +explicitly declared itself retired may drop out. Both directions are pinned. |
| 127 | + |
| 128 | +## Keys deliberately left alone |
| 129 | + |
| 130 | +`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` |
| 131 | +already carried their unit — they are the same-shape neighbours that made the |
| 132 | +bare `timeout` and `monitoringWindow` collisions visible, and pins assert they |
| 133 | +did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every |
| 134 | +number on `ConversationAnalytics` other than the duration is a count of messages, |
| 135 | +tokens or events: a count has no unit to carry. The turso schema shipped by |
| 136 | +`@objectstack/driver-turso` is a separate declaration outside this gate's |
| 137 | +declared population and is not touched here; card 6/6 owns it, so the two |
| 138 | +declarations disagree by design until that lands. |
0 commit comments