From 8583d9f1e45852cb17d8b5757080d89101fdaadc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:35:24 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec)!:=20retire=20an=20analytics=20cu?= =?UTF-8?q?be's=20refreshKey=20=E2=80=94=20every=20and=20sql,=20read=20by?= =?UTF-8?q?=20nothing=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-②: no (narrowing) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/20637-cube-refresh-key-retired.md | 41 ++ .../2026-07-unknown-key-strictness-ledger.md | 2 +- .../src/data/analytics/showcase.cube.ts | 7 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/analytics_cube.json | 15 +- packages/spec/src/conversions/registry.ts | 106 ++++ .../data/analytics-strictness-batchd.test.ts | 14 +- packages/spec/src/data/analytics.test.ts | 10 +- packages/spec/src/data/analytics.zod.ts | 53 +- .../data/cube-refresh-key-retirement.test.ts | 476 ++++++++++++++++++ .../retired-keys/18.data__Cube__refreshKey.ts | 16 + ...alytics-authorable-unknown-keys-refused.ts | 15 +- .../semantic/18.cube-refresh-key-retired.ts | 28 ++ packages/spec/src/migrations/registry.ts | 13 + packages/spec/vitest.repo-tests.json | 1 + 15 files changed, 755 insertions(+), 44 deletions(-) create mode 100644 .changeset/20637-cube-refresh-key-retired.md create mode 100644 packages/spec/src/data/cube-refresh-key-retirement.test.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.data__Cube__refreshKey.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts diff --git a/.changeset/20637-cube-refresh-key-retired.md b/.changeset/20637-cube-refresh-key-retired.md new file mode 100644 index 00000000000..76b7b85bea4 --- /dev/null +++ b/.changeset/20637-cube-refresh-key-retired.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire an analytics cube's `refreshKey` — the refresh cadence and data-change probe nothing read (#20637) + +**BREAKING** — `refreshKey` on an analytics cube (`CubeSchema`), with its `every` and `sql`, is now refused at parse: nothing ever read it, and no analytics result is cached, so a declared refresh cadence refreshed nothing. Delete the key. Every analytics query is computed when it is asked, as it always was. A refresh cadence is declared again when a result cache exists. + +Clause-②: no (narrowing) + +Measured before removal: `git grep refreshKey` over the non-test sources of `packages/services`, `packages/drivers` and `packages/rest` answered 0 lines (4 for the neighbouring `.public` in the same pathspec). `@objectstack/service-analytics` references no cache or job service; its one cache is request-scoped (dimension labels). The one in-repo author was the showcase app (`every: '1 hour'`), which no longer writes it. + +**Removed rather than enforced** (ADR-0049 enforce-or-remove; the maintainer's ruling on the card, letter C): a result cache keyed by cube, query, read scope and tenant is a subsystem with its own design, and a key that does nothing until then is the residue ADR-0049 removes. `sql` also had no safe seam: raw SQL on a schedule, outside the read scope every other cube `sql` goes through. + +## FROM → TO + +| you wrote (17.5 and earlier) | write instead | +| --- | --- | +| `refreshKey: { every: '1 hour' }` | nothing — delete the key | +| `refreshKey: { sql: 'SELECT MAX(updated_at) FROM orders' }` | nothing — delete the key | +| `refreshKey: { every: '1 hour', sql: '…' }` | nothing — delete the key | + +**The one-line fix:** delete `refreshKey` from every cube. + +**What an author who still writes it sees.** `tsc` fails at the authoring site (`Cube` types the key `never`), and the parse — `defineCube()`, `defineStack({ analyticsCubes })`, `PUT /api/v1/meta/analytics_cube/:name` — refuses it at `refreshKey` with the prescription: + +> `analytics_cube.refreshKey` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: no analytics result is cached, so neither `every` nor `sql` ever refreshed anything. Delete the key; every analytics query is computed when it is asked. A refresh cadence is declared again when a result cache exists. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + +`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +## The retirement kit + +- **A `retiredKey()` tombstone** on `CubeSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. The nested `every` / `sql` shape is gone with it. `RETIRED_KEYS_BY_MAJOR[18]`: `data/Cube:refreshKey`. The key had no default, so no retired-default residue is owed. +- **The D2 conversion `cube-refresh-key-removed`** (protocol 18, retired from the load path) deletes the whole block from every `analyticsCubes[]` entry, one notice per cube, as a lossless delete. A built artifact or a stored `analytics_cube` row that carries it loads through the rehydration seams, which replay it. +- **The D3 entry `cube-refresh-key-retired`** asks the author whether anything they built assumed cube results were cached or refreshed on a schedule. They never were. +- **Ledger:** the `refreshKey.every` / `refreshKey.sql` rows collapse into one `dead` tombstone row. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 5af7b11c56c..c3bde543ce9 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -725,7 +725,7 @@ column does not move and the `strip` column falls by the count of what left. | `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | | `driver/turso.zod.ts` | authorable | The libSQL/Turso `config` contract, added by **#6345** — and the last driver on the platform whose `config` had no gate at all. It was not an oversight of #4410 but a consequence of turso not being a BUILTIN: its driver ships in the optional `@objectstack/driver-turso` package, so `resolveDriverId('turso')` returned `undefined` and `validateDriverConfig` answered `{ known: false }` — "nothing to check against" — while both boot hosts dispatched `turso` for real. A datasource carrying `{ token: … }` (the plausible spelling; the driver reads `authToken`) was therefore accepted in silence and then connected UNAUTHENTICATED, which is #4410's own failure mode surviving in the one driver #4410 could not see. Every site strict, same error factory as the rest of the campaign, including the nested `sync` block — a bare `z.object` there would have dropped `sync: { interval: 60 }` and synced on the default while the author believed otherwise, i.e. added a strip site to this map instead of closing one. The declared keys are drawn from what `TursoDriverConfig` actually READS, not from what libSQL supports, so closing this gap does not open an ADR-0049 one: `client` (a live `@libsql/client` instance — not authorable metadata), `pool` and `schemaMode`/`readOnly` (datasource-level, like every other driver) are deliberately absent | | `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | -| `analytics.zod.ts` | authorable | **strict as of #4001 batch D — all 8 sites; the `mixed (p)` resolved to authorable on both halves.** The cube family (Metric + its `filters[]` item, Dimension, CubeJoin, Cube + `refreshKey`) has two live authoring doors, measured: `defineCube()` `.parse()`s an author literal (the showcase example authors through it) and `defineStack({ analyticsCubes })` carries every cube through `StackSchema.parse` — the whole family resolves REACHABLE in the batch-D BFS (positive/negative controls green in the same run). Pre-close probes: a cube's `publik`, a metric's `title`, a join's `relationshipp` (falling back to the `many_to_one` default — a different join shape under a successful parse), a `refreshKey.sqll` all parsed clean and vanished. The query half was subtler and is the batch's live behaviour change: `AnalyticsQuerySchema`'s TOP level was already gated at its one production door (`api/analytics.zod.ts`'s `AnalyticsQueryRequestSchema` is `.extend(…).strict()` since #3878), but **top-level strictness does not recurse** — measured on `main`, `timeDimensions: [{ dimension, granuarity: 'day' }]` rode through the strict wrapper with the typo silently stripped, bucketing the whole range as one group under an ordinary 200. Closing the base makes the posture hold at every door instead of only at the wrapper that re-applied it. ADR-0010 envelope deliberately NOT declared: no protected item re-parses here (`CubeRegistry.register` takes typed objects without a parse; `analytics_cube` resolves no `getMetadataTypeSchema` entry so `saveMetaItem` never parses it; artifact ingest parses the compiled definition BEFORE `applyProtection` stamps). Producer sweep over objectui/cloud: zero cube/analytics-vocabulary producers (objectui's `data-objectstack` sends only declared keys — `cube`/`measures`/`dimensions`/`where`) | +| `analytics.zod.ts` | authorable | **strict as of #4001 batch D — all 8 sites; the `mixed (p)` resolved to authorable on both halves.** **Two of the eight sites later left with their keys (ADR-0049 enforce-or-remove): the metric `filters[]` item (#10414) and the cube's `refreshKey` block (#20637, retired whole — nothing read `every` or `sql`, and no analytics result is cached). The batch-D verdicts for those two are superseded, not reopened; their pins in `analytics-strictness-batchd.test.ts` now assert the retirement prescriptions.** The cube family (Metric + its `filters[]` item, Dimension, CubeJoin, Cube + `refreshKey`) has two live authoring doors, measured: `defineCube()` `.parse()`s an author literal (the showcase example authors through it) and `defineStack({ analyticsCubes })` carries every cube through `StackSchema.parse` — the whole family resolves REACHABLE in the batch-D BFS (positive/negative controls green in the same run). Pre-close probes: a cube's `publik`, a metric's `title`, a join's `relationshipp` (falling back to the `many_to_one` default — a different join shape under a successful parse), a `refreshKey.sqll` all parsed clean and vanished. The query half was subtler and is the batch's live behaviour change: `AnalyticsQuerySchema`'s TOP level was already gated at its one production door (`api/analytics.zod.ts`'s `AnalyticsQueryRequestSchema` is `.extend(…).strict()` since #3878), but **top-level strictness does not recurse** — measured on `main`, `timeDimensions: [{ dimension, granuarity: 'day' }]` rode through the strict wrapper with the typo silently stripped, bucketing the whole range as one group under an ordinary 200. Closing the base makes the posture hold at every door instead of only at the wrapper that re-applied it. ADR-0010 envelope deliberately NOT declared: no protected item re-parses here (`CubeRegistry.register` takes typed objects without a parse; `analytics_cube` resolves no `getMetadataTypeSchema` entry so `saveMetaItem` never parses it; artifact ingest parses the compiled definition BEFORE `applyProtection` stamps). Producer sweep over objectui/cloud: zero cube/analytics-vocabulary producers (objectui's `data-objectstack` sends only declared keys — `cube`/`measures`/`dimensions`/`where`) | | `document.zod.ts` | wire (p) | | | `hook.zod.ts` / `hook-body.zod.ts` | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | | `mapping.zod.ts` | authorable (p) | | diff --git a/examples/app-showcase/src/data/analytics/showcase.cube.ts b/examples/app-showcase/src/data/analytics/showcase.cube.ts index 1293034af4d..0cbceb912d9 100644 --- a/examples/app-showcase/src/data/analytics/showcase.cube.ts +++ b/examples/app-showcase/src/data/analytics/showcase.cube.ts @@ -84,9 +84,10 @@ export const DeliveryCube = defineCube({ name: 'showcase_project', }, }, - refreshKey: { - every: '1 hour', - }, + // No refresh cadence: no analytics result is cached, so every query against + // this cube is computed when it is asked. The `every: '1 hour'` this file + // declared was read by nothing, and the key is retired. + // // No `public` key: the cube is VISIBLE, the default. It is this app's // demonstration of the `/api/v1/analytics/*` surface (src/coverage.ts marks // `analyticsCubes` demonstrated, and the platform checklist's dashboards item diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 7d839b114b8..fe291d1f8af 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -945,7 +945,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`), as measured at `5f5511f0` before #20288 repointed the enum at the emitted `DataEventType` + `BulkDataEventType` names, are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the two rows where they are the whole verdict (`authentication` and `actions`; `triggers` was the third until its retirement made it a tombstone both carriers refuse). **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/25 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 25 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `metadata`, `actions.description`/`.outputSchema`, and the seven top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks` and `triggers`. That sums to 25, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 30 until the connector `triggers` array was retired (ADR-0049; ADR-0041 unchanged): `triggers` counted 6 drilled rows (`key`, `label`, `description`, `type`, `intervalSeconds` and the `interval` rename tombstone — dead because nothing read a connector trigger, which the schema's own docblock said: #3197) and is now ONE leaf tombstone row, by the same gate rule as `health` below. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `triggers` and `fieldMappings.transform` — but ⛔ that eight is NOT a separate addend: the first seven ARE the top-level tombstones counted above and the last one is already inside the `fieldMappings` count, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`triggers.interval` was one of the eight until its array left whole with `triggers`, whose own leaf row took its place — the count held at eight by a swap, not by standing still.) (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | -| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 7 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere; re-measured 2026-09-29), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | +| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 6 `dead` are the `refreshKey` tombstone, the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. It was 7 until #20637 RETIRED `refreshKey` whole (ADR-0049 enforce-or-remove, maintainer letter C): the caching block's `every` and `sql` were two drilled `dead` rows — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere, re-measured 2026-09-29 — and the `retiredKey()` tombstone that replaced the block is ONE leaf row, because the gate refuses `children` on a property that is no longer a container (the connector `health` precedent). **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 8acd30ae061..2f7f7b3455f 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -124,18 +124,9 @@ } }, "refreshKey": { - "children": { - "every": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Pre-aggregation / cache refresh cadence with no engine behind it. Census over the whole repository: `refreshKey` appears in `analytics.zod.ts` (the declaration), in `examples/app-showcase/src/data/analytics/showcase.cube.ts` (an AUTHOR writing it), in the generated authorable-surface artifacts, and in the protocol-18 strictness migration note — and in no consumer. There is no refresh scheduler, no pre-aggregation table and no cache keyed on it anywhere in packages/services, packages/drivers or packages/rest. The showcase authoring it is the evidence that the trap is live rather than theoretical." - }, - "sql": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "The 'SQL to check for data changes' probe of the same unimplemented refresh mechanism — same census as `refreshKey.every`. The block's own `strictObject` history note ('a typo'd `sql` probe left the cube refreshing on nothing') describes a cadence that does not exist either way." - } - } + "status": "dead", + "verifiedAt": "2026-09-29", + "note": "REMOVED 2026-09-29 (ADR-0049 enforce-or-remove; maintainer ruling on #20637, letter C: retire whole, build no cache) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources and stored rows by the protocol-18 conversion `cube-refresh-key-removed` (the D3 entry `cube-refresh-key-retired`). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). ⚠️ It is a LEAF now: the two rows that were drilled under it — `every` and `sql`, both `dead`, verified 2026-09-17 — left with the subtree, because the gate refuses `children` on a property that is not a container (the connector `health` precedent). Their verdicts are carried here, one reason for both: nothing read the block, and there was nothing for it to key on. Re-measured 2026-09-29 at 0be898499f: `git grep refreshKey` over the non-test sources of packages/services, packages/drivers and packages/rest answers 0 lines (4 for the neighbouring `.public` in the same pathspec); packages/services/service-analytics/src references no ICacheService or IJobService, and its one cache is the request-scoped dimension-labels.ts#withLabelFetchCache — no analytics result is cached, so every query is computed when it is asked. The one author, examples/app-showcase (`every: '1 hour'`), no longer writes it. A refresh cadence is declared again when a result cache exists. The tombstone is packages/spec/src/data/analytics.zod.ts#CubeSchema." }, "public": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 4ddd2b0c920..d436e8e37df 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7710,6 +7710,111 @@ const metricFiltersRemoved: MetadataConversion = { }, }; +/** + * `refreshKey` — a cube's refresh cadence (`every`) and data-change probe + * (`sql`), retired whole (#20637, ADR-0049 enforce-or-remove; maintainer ruling + * letter C). + * + * Nothing read either key, and there was nothing for them to key on: no + * analytics result is cached, so a declared cadence refreshed nothing. The + * tombstone on `CubeSchema` refuses the key at parse (see + * `CUBE_REFRESH_KEY_REMOVED` in `analytics.zod.ts`). + * + * ## Why a D2 strip + * + * The key was optional with no default, so a persisted cube carries it only + * where an author wrote it — as the showcase did. After the tombstone the boot + * door refuses such a cube (`ObjectStackDefinitionSchema` spreads + * `analyticsCubes: z.array(CubeSchema)`), and only the D2 table is replayed at + * the rehydration seams (`applyArtifactForwardConversions`, + * `applyConversionsToStoredItem`), so a built artifact or a stored + * `analytics_cube` row that carries the key loads only through this entry. The + * strip is lossless: a key that never had an effect has none to lose. + * + * The WHOLE block leaves, whatever it holds — `every`, `sql`, both, neither, or + * a value no longer an object: the tombstone refuses every value, so a partial + * strip would leave a cube that still cannot load. The emitted path NAMES the + * cube, as `cube-join-sql-and-relationship-removed` does: an index into the + * author's `analyticsCubes[]` is a position, not a name. The D3 record is the + * semantic entry `cube-refresh-key-retired`. + */ +const cubeRefreshKeyRemoved: MetadataConversion = { + id: 'cube-refresh-key-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.5.0', + surface: 'analyticsCubes[].refreshKey', + summary: + "cube key 'refreshKey' removed, with its 'every' and 'sql' (ADR-0049 enforce-or-remove — nothing read " + + 'it: no analytics result is cached, so a declared refresh cadence refreshed nothing. Delete the key; ' + + 'a refresh cadence is declared again when a result cache exists)', + apply(stack, emit) { + return mapCollection(stack, 'analyticsCubes', (cube, path) => { + // Name the cube, not just its index: the notice is the only record an + // upgrading author gets of WHICH cube lost the key. + const where = typeof cube.name === 'string' ? `${path}(${cube.name})` : path; + return stripKeys(cube, ['refreshKey'], emit, where); + }); + }, + fixture: { + before: { + analyticsCubes: [ + { + // The showcase's shape: a cadence alone. + name: 'delivery', + sql: 'task', + measures: { count: { label: 'Tasks', type: 'count', sql: 'id' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, + refreshKey: { every: '1 hour' }, + }, + { + // A SECOND cube, so the notices have to distinguish two of them: both + // keys, the probe included. + name: 'billing', + sql: 'invoice', + measures: { amount: { label: 'Amount', type: 'sum', sql: 'amount' } }, + dimensions: { issued_on: { label: 'Issued', type: 'time', sql: 'issued_on' } }, + refreshKey: { every: '1 day', sql: 'SELECT MAX(updated_at) FROM invoice' }, + }, + { + // Already canonical — rides through untouched. The fixture's own + // control: the strip dispatches on key presence, and copy-on-write + // keeps this reference. + name: 'accounts', + sql: 'account', + measures: { count: { label: 'Accounts', type: 'count', sql: 'id' } }, + dimensions: { tier: { label: 'Tier', type: 'string', sql: 'tier' } }, + }, + ], + }, + after: { + analyticsCubes: [ + { + name: 'delivery', + sql: 'task', + measures: { count: { label: 'Tasks', type: 'count', sql: 'id' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, + }, + { + name: 'billing', + sql: 'invoice', + measures: { amount: { label: 'Amount', type: 'sum', sql: 'amount' } }, + dimensions: { issued_on: { label: 'Issued', type: 'time', sql: 'issued_on' } }, + }, + { + name: 'accounts', + sql: 'account', + measures: { count: { label: 'Accounts', type: 'count', sql: 'id' } }, + dimensions: { tier: { label: 'Tier', type: 'string', sql: 'tier' } }, + }, + ], + }, + // Two notices, one per STRIPPED KEY (the whole block is one key): `delivery` + // and `billing`, none for the canonical `accounts`. + expectedNotices: 2, + }, +}; + /** * `dimensions..granularities` — the three sub-day names `TimeUpdateInterval` * declared until protocol 18 (#17296, ADR-0049 enforce-or-remove). @@ -12548,6 +12653,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: connectorTriggersRemoved, order: 26 }, { conversion: cubeJoinSqlAndRelationshipRemoved, order: 9 }, { conversion: cubeMemberInnerNameRemoved, order: 44 }, + { conversion: cubeRefreshKeyRemoved, order: 47 }, { conversion: cubeSubDayGranularitiesRemoved, order: 8 }, { conversion: currencyConfigPrecisionRemoved, order: 41 }, { conversion: dashboardRefreshIntervalToRefreshIntervalSeconds, order: 24 }, diff --git a/packages/spec/src/data/analytics-strictness-batchd.test.ts b/packages/spec/src/data/analytics-strictness-batchd.test.ts index 6763362a57e..ef718db99a5 100644 --- a/packages/spec/src/data/analytics-strictness-batchd.test.ts +++ b/packages/spec/src/data/analytics-strictness-batchd.test.ts @@ -110,7 +110,6 @@ describe('#4001 batch D — the doors the cube family is reachable through', () ...CUBE, title: 'Probe', joins: { other: { name: 'other' } }, - refreshKey: { every: '1 hour', sql: 'SELECT max(updated_at)' }, public: true, }); accept(AnalyticsQuerySchema, { @@ -134,8 +133,17 @@ describe('#4001 batch D — closed sites reject unknown keys where they live', ( expect(reject(CubeSchema, { ...CUBE, publik: true })).toContain('publik'); }); - it('`Cube.refreshKey` — one level below an already-closed parent', () => { - expect(reject(CubeSchema, { ...CUBE, refreshKey: { every: '1 hour', sqll: 'x' } })).toContain('sqll'); + // Batch D also closed the nested `Cube.refreshKey` block ("this cube + // refreshKey block"), pinned here as `{ every, sqll }` → rejection naming + // `sqll`. #20637 retired `refreshKey` whole (ADR-0049 enforce-or-remove: + // nothing read `every` or `sql`, and no analytics result is cached), so the + // nested surface no longer exists — the batch-D verdict for it is SUPERSEDED, + // not reopened. The key itself now rejects with the retirement prescription, + // whatever it holds (the full pin set is `cube-refresh-key-retirement.test.ts`): + it('`Cube.refreshKey` — REMOVED; the key rejects with the prescription, not as a bare unknown', () => { + expect(reject(CubeSchema, { ...CUBE, refreshKey: { every: '1 hour', sqll: 'x' } })).toContain( + '`analytics_cube.refreshKey` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove)', + ); }); it('`Metric` — through the cube `measures` record', () => { diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index 8b49a915e4b..02d79533301 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -420,16 +420,13 @@ describe('CubeSchema', () => { name: 'users', }, }, - refreshKey: { - every: '1 hour', - sql: 'SELECT MAX(updated_at) FROM orders', - }, + // No `refreshKey`: retired whole (#20637), refused with its prescription — + // pinned in `cube-refresh-key-retirement.test.ts`. public: true, }); expect(cube.title).toBe('Orders Cube'); expect(cube.joins).toBeDefined(); - expect(cube.refreshKey?.every).toBe('1 hour'); expect(cube.public).toBe(true); }); @@ -446,7 +443,8 @@ describe('CubeSchema', () => { expect(cube.public).toBe(true); expect(cube.title).toBeUndefined(); expect(cube.joins).toBeUndefined(); - expect(cube.refreshKey).toBeUndefined(); + // The retired `refreshKey` tombstone materializes nothing. + expect(cube).not.toHaveProperty('refreshKey'); }); it('should reject cube with invalid name', () => { diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 1bb5a4d8349..66c47a40054 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -412,6 +412,43 @@ export const CubeJoinSchema = lazySchema(() => strictObject( }, )); +/** + * A cube's `refreshKey` — the refresh cadence (`every`) and the data-change + * probe (`sql`) of a pre-aggregation cache — RETIRED whole (#20637, ADR-0049 + * enforce-or-remove; maintainer ruling 5890724395, letter C). + * + * Measured before removal: no reader. `git grep refreshKey` over the non-test + * sources of `packages/services`, `packages/drivers` and `packages/rest` + * answered 0 lines, against 4 for the neighbouring `.public` in the same + * pathspec; repo-wide the key appeared only in this schema, its generated + * surfaces, the migration notes and one author (`examples/app-showcase`, + * `every: '1 hour'`). And nothing for it to key on: `service-analytics` + * references no cache or job service — its one cache is the request-scoped + * `dimension-labels.ts#withLabelFetchCache` — so every analytics query is + * computed when it is asked. `sql` was security-adjacent as well: raw SQL run on + * a schedule, outside the read-scope machinery every other cube `sql` goes + * through. + * + * The mainstream capability (a result cache keyed by cube, normalized query, + * read scope and tenant — Cube.dev, Looker) is not lost from the plan: the + * ruling re-declares a cadence the day such a cache exists, with its own design, + * rather than carrying a key that does nothing until then. + * + * A `retiredKey()` tombstone on the `strictObject` below rather than a strict + * deletion, so the refusal carries this prescription instead of a bare + * unknown-key verdict and a typed authoring site fails `tsc` first (the members' + * inner `name` precedent). The nested `strictObject` the key carried is gone + * with it. The ADR-0087 D2 conversion `cube-refresh-key-removed` strips the + * block wherever the chain is replayed, and the D3 entry + * `cube-refresh-key-retired` carries what the author still owes. + */ +const CUBE_REFRESH_KEY_REMOVED = + '`analytics_cube.refreshKey` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing read it: no analytics result is cached, so neither `every` nor `sql` ever refreshed ' + + 'anything. Delete the key; every analytics query is computed when it is asked. A refresh cadence ' + + 'is declared again when a result cache exists. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + /** * Cube Schema * A logical data model representing a business entity or process for analysis. @@ -468,18 +505,10 @@ export const CubeSchema = lazySchema(() => strictObject( /** Relationships */ joins: z.record(z.string(), CubeJoinSchema).optional(), - /** Pre-aggregations / Caching */ - refreshKey: strictObject( - { - surface: 'this cube refreshKey block', - history: 'Until this shape was closed, an undeclared refreshKey key was silently dropped — ' - + 'a typo\'d `sql` probe left the cube refreshing on nothing.', - }, - { - every: z.string().optional().describe('Refresh interval (e.g. "1 hour")'), - sql: z.string().optional().describe('SQL to check for data changes'), - }, - ).optional(), + // `refreshKey` REMOVED (#20637, ADR-0049 enforce-or-remove) — `every` and + // `sql` with it. No analytics result is cached, so nothing read either. See + // the note above `CUBE_REFRESH_KEY_REMOVED`. + refreshKey: retiredKey(CUBE_REFRESH_KEY_REMOVED), /** * Visibility on the analytics API (the Cube.dev `public` semantics). diff --git a/packages/spec/src/data/cube-refresh-key-retirement.test.ts b/packages/spec/src/data/cube-refresh-key-retirement.test.ts new file mode 100644 index 00000000000..4efb2c0facc --- /dev/null +++ b/packages/spec/src/data/cube-refresh-key-retirement.test.ts @@ -0,0 +1,476 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A cube's `refreshKey` RETIRED whole — the refresh cadence `every` and the + * data-change probe `sql` (#20637) — ADR-0049 enforce-or-remove, ruled letter C + * by the maintainer: retire the key, build no cache, and declare a cadence again + * the day a result cache exists. + * + * Measured before removal, recorded on the tombstone in `analytics.zod.ts` and + * on the ledger row: zero readers in the non-test sources of + * `packages/services`, `packages/drivers` and `packages/rest` (against four for + * the neighbouring `.public` in the same pathspec), and nothing to key on — + * `service-analytics` references no cache or job service, so no analytics + * result is cached. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `CubeSchema`, a `strictObject` — so the + * refusal carries the prescription instead of a bare unknown-key verdict, + * and the key's input type is `never` for `tsc`. The nested `strictObject` + * the key carried is gone with it. + * 2. D2 conversion `cube-refresh-key-removed` (step 18), a delete of the whole + * block from every `analyticsCubes[]` entry, retired from the load path: a + * live author is refused, a stored or built cube replays clean. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `data/Cube:refreshKey` — ONE row: the + * nested `every` / `sql` left with the block — and the family's D3 entry is + * `cube-refresh-key-retired`. + * 4. The liveness row STAYS, `dead`, as one leaf, because the tombstone keeps + * the key in the walked shape (`check:liveness` is the judge of that half). + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — that envelope belongs to + * the authoring door, `defineStack`, which is pinned with its `code` and + * `status` below. Everywhere else: refusal, the issue `code`, the `path` + * naming the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { CubeSchema, defineCube, type Cube } from './analytics.zod'; + +const CONVERSION_ID = 'cube-refresh-key-removed'; +const D3_ID = 'cube-refresh-key-retired'; + +/** A well-formed cube — every live key an author commonly writes, not the retired one. */ +const CUBE = { + name: 'orders', + sql: 'orders', + measures: { count: { label: 'Orders', type: 'count', sql: '*' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, +} as const; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +// The four clauses the ruling set: nothing read it, no cache exists, delete it, +// and a cadence is declared again when a cache exists. +const PRESCRIPTION = + /`analytics_cube\.refreshKey` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing read it: no analytics result is cached.*Delete the key\..*A refresh cadence is declared again when a result cache exists\. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +/** What an author could write under `refreshKey` before the removal. */ +const AUTHORED = [ + { every: '1 hour' }, + { sql: 'SELECT MAX(updated_at) FROM orders' }, + { every: '1 day', sql: 'SELECT MAX(updated_at) FROM orders' }, +] as const; + +/** A cube as a 17.x source or stored row could carry it: the showcase's cadence. */ +const persistedCube = () => ({ ...CUBE, refreshKey: { every: '1 hour' } }); + +describe('cube refreshKey retirement — the tombstone, at every door that carries a cube', () => { + it('the cube schema refuses `refreshKey` at its path, with the prescription', () => { + const r = CubeSchema.safeParse(persistedCube()); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['refreshKey']); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`analytics_cube.refreshKey` was removed')).toBe(true); + }); + + it('refuses EVERY value — each authored shape, an empty block and a non-object; the tombstone accepts only absence', () => { + for (const value of [...AUTHORED, {}, '1 hour', null, 0]) { + const r = CubeSchema.safeParse({ ...CUBE, refreshKey: value }); + expect(r.success, `refreshKey: ${JSON.stringify(value)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['refreshKey']); + expect(r.error.issues[0]!.message).toMatch(PRESCRIPTION); + } + }); + + it('the `analytics_cube` write door (the registry binding) refuses it', () => { + // `getMetadataTypeSchema('analytics_cube')` is what `saveMetaItem` validates a + // `PUT /api/v1/meta/analytics_cube` body against; a rebinding to some other + // shape would pass the pins above and still accept the key in production. + const door = getMetadataTypeSchema('analytics_cube'); + expect(door, 'no schema bound for `analytics_cube`').toBeDefined(); + expect(door).toBe(CubeSchema); + const r = door!.safeParse(persistedCube()); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['refreshKey']); + expect(r.error.issues[0]!.message).toMatch(PRESCRIPTION); + }); + + it('`defineCube()` refuses it with the prescription', () => { + expect(() => defineCube(persistedCube() as never)).toThrow(PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { + const stack = (cube: Record) => ({ + manifest: { id: 'com.example.cube-refresh-key', name: 'cube_refresh_key', version: '1.0.0', type: 'app' }, + analyticsCubes: [cube], + }); + let thrown: unknown; + try { + defineStack(stack(persistedCube()) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['analyticsCubes', 0, 'refreshKey']); + expect(refusal.issues?.[0]?.message).toMatch(PRESCRIPTION); + // CONTROL: the same stack without the key is accepted by the same door. + expect(() => defineStack(stack({ ...CUBE }) as never)).not.toThrow(); + }); + + it('CONTROL: the same cube without the key passes, every live key intact, and grows no `refreshKey`', () => { + const cube = CubeSchema.safeParse(CUBE); + expect(cube.success).toBe(true); + if (!cube.success) return; + // Absence stays absence: the tombstone materializes nothing. + expect(cube.data).not.toHaveProperty('refreshKey'); + // The live default still applies, so the empty reading above is the + // retirement and not a schema that stopped emitting. + expect(cube.data.public).toBe(true); + expect(defineCube(CUBE).measures).toEqual(CUBE.measures); + }); + + it('the walked shape keeps `refreshKey` as a key — the ledger row and the authorable-surface row stay reachable', () => { + const shape = (CubeSchema as unknown as { shape?: Record }).shape; + expect(shape, 'CubeSchema must expose a read-through shape').toBeDefined(); + expect(Object.keys(shape!)).toContain('refreshKey'); + expect(Object.keys(shape!), 'CONTROL: its live neighbour').toContain('joins'); + }); + + it('the did-you-mean never offers the tombstone: a near-miss `refreshKy` is refused as unknown, not steered onto `refreshKey`', () => { + // `strictObject` excludes a key that accepts nothing from its suggestion + // pool (`acceptsNothing`), so an author who typed `refreshKy` is not told to + // write the retired key and meet a second refusal. + const r = CubeSchema.safeParse({ ...CUBE, refreshKy: { every: '1 hour' } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('`refreshKy`'); + expect(issue.message).not.toMatch(/`refreshKy` → `refreshKey`/); + }); + + it('fails tsc at the authoring site: the input type of `refreshKey` is `never`', () => { + const cube: Cube = { + ...CUBE, + // @ts-expect-error — `refreshKey` is a retiredKey() tombstone: its input type is `never`. + refreshKey: { every: '1 hour' }, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => CubeSchema.parse(cube)).toThrow(PRESCRIPTION); + }); +}); + +describe('cube refreshKey retirement — the D2 conversion', () => { + it('a STORED `analytics_cube` row carrying the key replays clean through the rehydration seam', () => { + // The seam wraps an `analytics_cube` row as `{ analyticsCubes: [row] }` and + // replays the full chain, retired entries included. + const notices: { conversionId?: string; path?: string; from?: string; to?: string }[] = []; + const rehydrated = applyConversionsToStoredItem( + 'analytics_cube', + { ...CUBE, refreshKey: { every: '1 day', sql: 'SELECT MAX(updated_at) FROM orders' } }, + { onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }) }, + ) as Record; + + expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [CONVERSION_ID, 'analyticsCubes[0](orders).refreshKey', 'refreshKey', '(removed)'], + ]); + expect(rehydrated).not.toHaveProperty('refreshKey'); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual(CUBE); + // And the rehydrated row is exactly what the write door accepts now. + expect(CubeSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const artifact = () => ({ analyticsCubes: [persistedCube()] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('`analytics_cube.refreshKey` was removed'); + + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect( + after.success, + `expected the converted artifact to parse; got ${JSON.stringify(after.error?.issues ?? [])}`, + ).toBe(true); + }); + + it('LIT CONTROL — a cube that was always wrong is refused on BOTH sides of the conversion', () => { + // `refreshKy` is not the retired key, so the strip leaves it and the door + // still refuses — which is what makes the leg above a reading and not a + // tautology. + const artifact = () => ({ analyticsCubes: [{ ...persistedCube(), refreshKy: { every: '1 hour' } }] }); + expect(ObjectStackDefinitionSchema.safeParse(artifact()).success).toBe(false); + const healed = applyConversions(artifact(), { includeRetired: true }); + expect(ObjectStackDefinitionSchema.safeParse(healed).success).toBe(false); + }); + + it('strips the WHOLE block whatever it holds — one notice per cube — and names each cube', () => { + const input = { + analyticsCubes: [ + { ...CUBE, name: 'a', refreshKey: { every: '1 hour' } }, + { ...CUBE, name: 'b', refreshKey: { sql: 'SELECT 1' } }, + { ...CUBE, name: 'c', refreshKey: {} }, + { ...CUBE, name: 'd', refreshKey: '1 hour' }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(mine.map((n) => n.path)).toEqual([ + 'analyticsCubes[0](a).refreshKey', + 'analyticsCubes[1](b).refreshKey', + 'analyticsCubes[2](c).refreshKey', + 'analyticsCubes[3](d).refreshKey', + ]); + for (const cube of stack.analyticsCubes as Record[]) { + expect(cube).not.toHaveProperty('refreshKey'); + expect(CubeSchema.safeParse(cube).success).toBe(true); + } + }); + + it('strips only the cubes that carry the key, and is idempotent by construction', () => { + const input = { + analyticsCubes: [ + persistedCube(), + // A cube already canonical: no notice, and handed back by reference. + { ...CUBE, name: 'accounts' }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); + const cubes = stack.analyticsCubes as unknown[]; + expect(cubes[1]).toBe(input.analyticsCubes[1]); + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { analyticsCubes: [persistedCube()] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('cube refreshKey retirement — ADR-0087 registration', () => { + it('declares ONE key under major 18 — the nested `every` / `sql` left with the block', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('data/Cube:refreshKey'); + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k.startsWith('data/Cube:refreshKey'))).toEqual(['data/Cube:refreshKey']); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('analyticsCubes[].refreshKey'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete the key'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('the batch-D strictness entry no longer offers `refreshKey` as a surface an author can reach', () => { + const batchD = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'analytics-authorable-unknown-keys-refused'); + expect(batchD, 'the batch-D D3 entry').toBeDefined(); + expect(batchD!.surface).not.toContain('refreshKey'); + expect(batchD!.acceptanceCriteria).not.toContain('refreshKey'); + // Its reason records the removal by the conversion's whole id. + expect(batchD!.reason).toContain(`\`${CONVERSION_ID}\``); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on `Cube`, +// so every TYPED authoring site fails to compile (the showcase authored through +// `defineCube`). The residue is what `tsc` never judges: JSON, YAML, MD/MDX code +// fences, untyped `.js`, and TS literals typed `any` / `unknown`. This walk +// covers that residue across the five repo roots `scripts/cross-package-test-inputs.mjs` +// already declares for `@objectstack/spec#test` (mirrored in `turbo.json`), plus +// the example apps' own `src/` trees, declared there as `examples/*/src/**/*.ts` +// — where the one known author lived. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `refreshKey` in key +// position whose object value holds an `every` or `sql` key (TS / JS / JSON, and +// YAML block and flow form). `refreshKey` is also an ordinary React prop name +// elsewhere, which never takes an `every` / `sql` object. Prose mentions are +// spelled in inline code throughout this repo, and inline code is stripped +// before judging. The bound, stated: an empty `refreshKey: {}`, a block +// assembled by spread or under computed keys, and `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a cube refreshKey', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = [ + // TS / JS / JSON, block or inline: the key, then an object whose own keys + // (before any nested brace) include `every` or `sql`. + /(^|[^\w.$])["']?refreshKey["']?\s*:\s*\{[^{}]*?(^|[^\w.$])["']?(every|sql)["']?\s*:/m, + // YAML block form: the key on its own line, `every:` / `sql:` indented below it. + /^[ \t]*(-[ \t]+)?refreshKey[ \t]*:[ \t]*\r?\n[ \t]+(every|sql)[ \t]*:/m, + ]; + + /** + * Inline code spans are prose — the house style `check:doc-authoring` enforces + * — so stripping single-backtick spans separates "the retirement kit + * describing what it removed" from "a source still writing it". + * Newline-bounded: a fenced block's content is NOT stripped, so an authoring + * inside a fenced example is still caught. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of AUTHORING) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The batch-D site pin authors the key to assert the retirement refusal + // where the nested strictness pin used to stand. + 'packages/spec/src/data/analytics-strictness-batchd.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement cube on purpose. + 'packages/spec/src/conversions/', + // The liveness ledgers key one row per schema PROPERTY: the `refreshKey` + // row is a classification of the shape, not an authoring, and the tombstone + // route requires it to STAY. + 'packages/spec/liveness/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `analytics.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and a React prop (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("defineCube({ name: 'o', sql: 'o', refreshKey: { every: '1 hour' } })")).not.toBeNull(); + expect(judge(" refreshKey: {\n every: '1 hour',\n },")).not.toBeNull(); + expect(judge(" refreshKey: {\n // the probe\n sql: 'SELECT 1',\n },")).not.toBeNull(); + expect(judge('{ "refreshKey": { "every": "1 hour", "sql": "SELECT 1" } }')).not.toBeNull(); + expect(judge('cubes:\n - name: o\n refreshKey:\n every: 1 hour\n')).not.toBeNull(); + expect(judge('refreshKey: { every: 1 hour }\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefineCube({ refreshKey: { sql: 'x' } });\n```\n")).not.toBeNull(); + // ⛔ NARROWNESS of the strip: a real authoring sharing a line with inline code still counts. + expect(judge("// see `joins` — refreshKey: { every: '1 hour' },")).not.toBeNull(); + // Neighbours that must NOT match. + // Prose and inline code: the retirement kit must be able to describe what it removed. + expect(judge("the `refreshKey: { every: '1 hour' }` block leaves with it")).toBeNull(); + expect(judge('"data/Cube:refreshKey [RETIRED]",')).toBeNull(); + expect(judge("surface: 'analyticsCubes[].refreshKey (every, sql)',")).toBeNull(); + // The tombstone declaration itself, and a ledger row keyed by the property. + expect(judge('refreshKey: retiredKey(CUBE_REFRESH_KEY_REMOVED),')).toBeNull(); + expect(judge('"refreshKey": {\n "status": "dead",\n "verifiedAt": "2026-09-29"\n}')).toBeNull(); + // The React prop spelling that shares the word: a counter, never a cadence. + expect(judge('')).toBeNull(); + expect(judge('seen.push({ refreshTrigger: s?.refreshTrigger, refreshKey: props.refreshKey });')).toBeNull(); + expect(judge("const s = { refreshKey: 1, nested: { every: 'x' } };")).toBeNull(); + }); + + it('no cube refreshKey authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, and the example apps' + // sources — where the one known author lived — were really read. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a cube refreshKey authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__Cube__refreshKey.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__Cube__refreshKey.ts new file mode 100644 index 00000000000..7994d557458 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__Cube__refreshKey.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C: retire +// whole, no cache built). `Cube.refreshKey` — the refresh cadence `every` and +// the data-change probe `sql` — was read by nothing, and no analytics result +// is cached for it to key on. Measured: `git grep refreshKey` over the non-test +// sources of `packages/services`, `packages/drivers` and `packages/rest` +// answered 0 lines (4 for the neighbouring `.public` in the same pathspec). +// +// `retiredKey()` on a `strictObject`, for the prescription and the `tsc` +// channel (the `data/Metric:name` precedent). One row, not three: the nested +// `every` and `sql` left with the block, and a dotted row under a tombstoned +// parent names a path this build no longer emits (check (b3)). The D2 +// conversion `cube-refresh-key-removed` strips the block wherever the chain is +// replayed; the D3 record is `cube-refresh-key-retired`. +export const entry = 'data/Cube:refreshKey'; diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts index 5d63b473a69..53174726aa5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-authorable-unknown-keys-refused.ts @@ -8,9 +8,11 @@ export const entry: SemanticMigration = { // `Metric.filters[]` item, but `Metric.filters` was REMOVED outright later // in this same unpublished major (retired-key entry `data/Metric:filters`, // conversion `metric-filters-removed`), so this entry no longer names a - // surface an 18.x author can reach. + // surface an 18.x author can reach. The same holds for the cube's + // `refreshKey` block, which batch D closed and #20637 then retired whole + // (conversion `cube-refresh-key-removed`). surface: 'analytics cube definitions (`defineCube` / `defineStack({ analyticsCubes })`: the ' - + 'cube, its `refreshKey`, each metric, each dimension, each ' + + 'cube, each metric, each dimension, each ' + 'join) and the `/analytics/query` body\'s nested `timeDimensions[]` items — undeclared keys', replacement: 'the declared key the rejection names. Every rejection carries the surface, the ' + 'offending key and a rename suggestion (`title` → `label` on a metric/dimension, `label` → ' @@ -29,12 +31,13 @@ export const entry: SemanticMigration = { + 'top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: ' + '\'day\' }]` rode through the strict wrapper with the typo stripped, bucketing the whole ' + 'range as one group under an ordinary 200. Undeclared keys on all eight sites are now ' - + 'refused at parse time with a prescriptive message. (One of the eight — the nested metric ' - + '`filters[]` item — was itself removed later in this major, because nothing ever read it: ' - + '`metric-filters-removed`.)', + + 'refused at parse time with a prescriptive message. (Two of the eight were themselves ' + + 'removed later in this major, because nothing ever read them: the nested metric ' + + '`filters[]` item, by `metric-filters-removed`, and the cube\'s `refreshKey` block, by ' + + '`cube-refresh-key-removed`.)', acceptanceCriteria: 'Every cube in `defineStack({ analyticsCubes })` / `defineCube` parses with only declared ' - + 'keys at every level (cube, refreshKey, measures, dimensions, joins); ' + + 'keys at every level (cube, measures, dimensions, joins); ' + 'every `/analytics/query` body\'s `timeDimensions[]` items carry only ' + '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts new file mode 100644 index 00000000000..00f49052f33 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cube-refresh-key-retired.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C) — the D3 +// entry of the `cube-refresh-key-removed` family (one D3 entry per retirement +// family, even when D2 is lossless). Registered key: `data/Cube:refreshKey`. +// The strip changes no query answer; what it cannot decide is whether anything +// the author built assumed that cube results were cached or refreshed. +export const entry: SemanticMigration = { + id: 'cube-refresh-key-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'analyticsCubes[].refreshKey (every, sql) — a cube\'s declared refresh cadence and data-change probe', + replacement: + 'Nothing: delete the key. No analytics result is cached, so every query against a cube is computed ' + + 'when it is asked. A refresh cadence is declared again when a result cache exists.', + reason: + 'The D2 conversion `cube-refresh-key-removed` deletes `refreshKey` from every cube, and the delete is ' + + 'lossless: nothing read `every` or `sql`, and no analytics result was ever cached for them to ' + + 'refresh, so no query answers differently. What the conversion cannot check is whether anything the ' + + 'author built assumed that cube results were cached or refreshed on a schedule. They never were.', + acceptanceCriteria: + 'No cube carries `refreshKey`, and the parse refuses one with the prescription. Every analytics ' + + 'query answers as it did before the upgrade. Nothing the author maintains relies on cube results ' + + 'being cached or refreshed on a schedule.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 52bb6071512..fef776f797b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5268,6 +5268,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'metric\'s own `sql` expression, or use an ADR-0021 dataset measure\'s structured ' + '`filter`.', }, + { + id: 'cube-refresh-key-retired', + order: 49, + text: + 'It also retires a cube\'s `refreshKey` whole — the refresh cadence `every` and the ' + + 'data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no ' + + 'analytics result is cached, so a declared cadence refreshed nothing and every query was ' + + 'computed when it was asked, as it still is. The key is a retiredKey tombstone on ' + + '`CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from ' + + 'every cube as a pure lossless delete, retired from the load path. Its D3 record is the ' + + 'semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a ' + + 'result cache exists.', + }, { id: 'currency-config-precision-retired', order: 41, diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 7dae70265c3..ab81a516f2c 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -29,6 +29,7 @@ "src/api/rest-api-config-dead-keys-retirement.test.ts", "src/data/api-methods-batch-conformance.test.ts", "src/data/cube-member-inner-name-retirement.test.ts", + "src/data/cube-refresh-key-retirement.test.ts", "src/data/currency-mode-family-closure.pin.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts", From 127ef834ce6018c256561d192b70725b5d462268 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:43:52 +0000 Subject: [PATCH 2/4] chore(spec): regenerate the refreshKey retirement's generated surfaces Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/data/analytics.mdx | 9 +--- .../data.md | 10 ++-- packages/spec/authorable-surface/data.json | 2 +- .../liveness/state-counts/analytics_cube.md | 2 +- packages/spec/src/migrations/registry.ts | 53 ++++++++++++++++--- 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 356e41dfbe0..c0cf01005f7 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -129,7 +129,7 @@ Type: `[string, string]` | **measures** | `Record; sql: string; … }>` | ✅ | Quantitative metrics, keyed by metric name: the record key IS the metric's name, published and queried as `.`. A metric declares no inner `name`. | | **dimensions** | `Record; sql: string; … }>` | ✅ | Qualitative attributes, keyed by dimension name: the record key IS the dimension's name, published and queried as `.`. A dimension declares no inner `name`. | | **joins** | `Record` | optional | | -| **refreshKey** | `{ every?: string; sql?: string }` | optional | | +| **refreshKey** | `never` | optional | [REMOVED] `analytics_cube.refreshKey` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: no analytics result is cached, so neither `every` nor `sql` ever refreshed anything. Delete the key; every analytics query is computed when it is asked. A refresh cadence is declared again when a result cache exists. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **public** | `boolean` | optional (default: `true`) | Whether the analytics API exposes this cube. Default true (visible). false hides it from GET /analytics/meta and refuses POST /analytics/query and /analytics/sql for it (CUBE_NOT_FOUND). Visibility only: the underlying object's permissions and row-level security still govern its records on every door. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -167,13 +167,6 @@ Type: `[string, string]` | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Target cube name — the object this join reaches. The ON clause is DERIVED from the declared relationship between the two cubes' objects (a foreign-key equality) and is never authored. The KEY this join is declared under in the `joins` record is the FOREIGN-KEY FIELD on this cube's own object, not a second spelling of the object it reaches: the runtime emits `LEFT JOIN ON . = .id` and resolves a member written `.` through that alias. A join keyed after the TARGET object joins on a column the base object does not have, so nothing resolves. | -### Nested Shape: `Cube.refreshKey` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **every** | `string` | optional | Refresh interval (e.g. "1 hour") | -| **sql** | `string` | optional | SQL to check for data changes | - --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 40c557476fe..839ec012138 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 159 | 76 | 1 | 0 | 82 | +| `data/` | 158 | 75 | 1 | 0 | 82 | ## `data/` — sites @@ -31,7 +31,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | File | Sites | |---|---| -| `analytics.zod.ts` | 7 | +| `analytics.zod.ts` | 6 | | `data-engine.zod.ts` | 15 | | `datasource.zod.ts` | 6 | | `document.zod.ts` | 8 | @@ -56,7 +56,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **159** | +| **total** | **158** | ## `data/` — open @@ -64,7 +64,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**82 strip of 159**, in 11 file(s). +**82 strip of 158**, in 11 file(s). | File | Strip | Sites | |---|---|---| @@ -79,7 +79,7 @@ over it is here. | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **82** | **159** | +| **total** | **82** | **158** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index ac2aa6b10d7..1b83553c9fc 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -97,7 +97,7 @@ "data/Cube:measures", "data/Cube:name", "data/Cube:public", - "data/Cube:refreshKey", + "data/Cube:refreshKey [RETIRED]", "data/Cube:sql", "data/Cube:title", "data/CubeJoin:name", diff --git a/packages/spec/liveness/state-counts/analytics_cube.md b/packages/spec/liveness/state-counts/analytics_cube.md index a1ab5d5ffcb..6913cf750d5 100644 --- a/packages/spec/liveness/state-counts/analytics_cube.md +++ b/packages/spec/liveness/state-counts/analytics_cube.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `analytics_cube` | 20 | 0 | 0 | 7 | 0 | 27 | +| `analytics_cube` | 20 | 0 | 0 | 6 | 0 | 26 | diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fef776f797b..b5e7ed6cbd2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6355,9 +6355,11 @@ const step18: MigrationStep = { // `Metric.filters[]` item, but `Metric.filters` was REMOVED outright later // in this same unpublished major (retired-key entry `data/Metric:filters`, // conversion `metric-filters-removed`), so this entry no longer names a - // surface an 18.x author can reach. + // surface an 18.x author can reach. The same holds for the cube's + // `refreshKey` block, which batch D closed and #20637 then retired whole + // (conversion `cube-refresh-key-removed`). surface: 'analytics cube definitions (`defineCube` / `defineStack({ analyticsCubes })`: the ' - + 'cube, its `refreshKey`, each metric, each dimension, each ' + + 'cube, each metric, each dimension, each ' + 'join) and the `/analytics/query` body\'s nested `timeDimensions[]` items — undeclared keys', replacement: 'the declared key the rejection names. Every rejection carries the surface, the ' + 'offending key and a rename suggestion (`title` → `label` on a metric/dimension, `label` → ' @@ -6376,12 +6378,13 @@ const step18: MigrationStep = { + 'top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: ' + '\'day\' }]` rode through the strict wrapper with the typo stripped, bucketing the whole ' + 'range as one group under an ordinary 200. Undeclared keys on all eight sites are now ' - + 'refused at parse time with a prescriptive message. (One of the eight — the nested metric ' - + '`filters[]` item — was itself removed later in this major, because nothing ever read it: ' - + '`metric-filters-removed`.)', + + 'refused at parse time with a prescriptive message. (Two of the eight were themselves ' + + 'removed later in this major, because nothing ever read them: the nested metric ' + + '`filters[]` item, by `metric-filters-removed`, and the cube\'s `refreshKey` block, by ' + + '`cube-refresh-key-removed`.)', acceptanceCriteria: 'Every cube in `defineStack({ analyticsCubes })` / `defineCube` parses with only declared ' - + 'keys at every level (cube, refreshKey, measures, dimensions, joins); ' + + 'keys at every level (cube, measures, dimensions, joins); ' + 'every `/analytics/query` body\'s `timeDimensions[]` items carry only ' + '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.', }, @@ -8162,6 +8165,30 @@ const step18: MigrationStep = { + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' + 'smaller for a positive sum over excluded rows), not the unfiltered one.', }, + // #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C) — the D3 + // entry of the `cube-refresh-key-removed` family (one D3 entry per retirement + // family, even when D2 is lossless). Registered key: `data/Cube:refreshKey`. + // The strip changes no query answer; what it cannot decide is whether anything + // the author built assumed that cube results were cached or refreshed. + { + id: 'cube-refresh-key-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'analyticsCubes[].refreshKey (every, sql) — a cube\'s declared refresh cadence and data-change probe', + replacement: + 'Nothing: delete the key. No analytics result is cached, so every query against a cube is computed ' + + 'when it is asked. A refresh cadence is declared again when a result cache exists.', + reason: + 'The D2 conversion `cube-refresh-key-removed` deletes `refreshKey` from every cube, and the delete is ' + + 'lossless: nothing read `every` or `sql`, and no analytics result was ever cached for them to ' + + 'refresh, so no query answers differently. What the conversion cannot check is whether anything the ' + + 'author built assumed that cube results were cached or refreshed on a schedule. They never were.', + acceptanceCriteria: + 'No cube carries `refreshKey`, and the parse refuses one with the prescription. Every analytics ' + + 'query answers as it did before the upgrade. Nothing the author maintains relies on cube results ' + + 'being cached or refreshed on a schedule.', + }, // #19992 (ADR-0049 enforce-or-remove; triage direction REMOVE under ruling 乙 // on #19910: 「a currency's decimal places are the currency's, not a // setting」) — the D3 entry of the `currency-config-precision-removed` family @@ -19464,6 +19491,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // covered by `memory-persistence-auto-save-interval-to-ms`, which converts both // arms in one pass. 'data/AutoPersistenceConfig:autoSaveInterval', + // #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C: retire + // whole, no cache built). `Cube.refreshKey` — the refresh cadence `every` and + // the data-change probe `sql` — was read by nothing, and no analytics result + // is cached for it to key on. Measured: `git grep refreshKey` over the non-test + // sources of `packages/services`, `packages/drivers` and `packages/rest` + // answered 0 lines (4 for the neighbouring `.public` in the same pathspec). + // + // `retiredKey()` on a `strictObject`, for the prescription and the `tsc` + // channel (the `data/Metric:name` precedent). One row, not three: the nested + // `every` and `sql` left with the block, and a dotted row under a tombstoned + // parent names a path this build no longer emits (check (b3)). The D2 + // conversion `cube-refresh-key-removed` strips the block wherever the chain is + // replayed; the D3 record is `cube-refresh-key-retired`. + 'data/Cube:refreshKey', // #18612 — ADR-0049 enforce-or-remove, the same ruling and the same diff as // `data/CubeJoin:sql`. `CubeJoin.relationship` carried a // `.default('many_to_one')` and nothing dispatched on the cardinality, so From d068d3b008f8f7e0a7a56d8da63565181827350c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:59:42 +0000 Subject: [PATCH 3/4] test(spec): the refreshKey pin matches the prescription's own wording Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/cube-refresh-key-retirement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/data/cube-refresh-key-retirement.test.ts b/packages/spec/src/data/cube-refresh-key-retirement.test.ts index 4efb2c0facc..cb0d30b9189 100644 --- a/packages/spec/src/data/cube-refresh-key-retirement.test.ts +++ b/packages/spec/src/data/cube-refresh-key-retirement.test.ts @@ -64,7 +64,7 @@ const CUBE = { // The four clauses the ruling set: nothing read it, no cache exists, delete it, // and a cadence is declared again when a cache exists. const PRESCRIPTION = - /`analytics_cube\.refreshKey` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing read it: no analytics result is cached.*Delete the key\..*A refresh cadence is declared again when a result cache exists\. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + /`analytics_cube\.refreshKey` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing read it: no analytics result is cached.*Delete the key; every analytics query is computed when it is asked\. A refresh cadence is declared again when a result cache exists\. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; /** What an author could write under `refreshKey` before the removal. */ const AUTHORED = [ From a2abb8c78acc15892673a2dd36574ab9b5eca256 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:08:28 +0000 Subject: [PATCH 4/4] docs(spec): anchor the request-scoped label cache by its full path Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/liveness/analytics_cube.json | 2 +- packages/spec/src/data/analytics.zod.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 2f7f7b3455f..5707c8a840f 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -126,7 +126,7 @@ "refreshKey": { "status": "dead", "verifiedAt": "2026-09-29", - "note": "REMOVED 2026-09-29 (ADR-0049 enforce-or-remove; maintainer ruling on #20637, letter C: retire whole, build no cache) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources and stored rows by the protocol-18 conversion `cube-refresh-key-removed` (the D3 entry `cube-refresh-key-retired`). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). ⚠️ It is a LEAF now: the two rows that were drilled under it — `every` and `sql`, both `dead`, verified 2026-09-17 — left with the subtree, because the gate refuses `children` on a property that is not a container (the connector `health` precedent). Their verdicts are carried here, one reason for both: nothing read the block, and there was nothing for it to key on. Re-measured 2026-09-29 at 0be898499f: `git grep refreshKey` over the non-test sources of packages/services, packages/drivers and packages/rest answers 0 lines (4 for the neighbouring `.public` in the same pathspec); packages/services/service-analytics/src references no ICacheService or IJobService, and its one cache is the request-scoped dimension-labels.ts#withLabelFetchCache — no analytics result is cached, so every query is computed when it is asked. The one author, examples/app-showcase (`every: '1 hour'`), no longer writes it. A refresh cadence is declared again when a result cache exists. The tombstone is packages/spec/src/data/analytics.zod.ts#CubeSchema." + "note": "REMOVED 2026-09-29 (ADR-0049 enforce-or-remove; maintainer ruling on #20637, letter C: retire whole, build no cache) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources and stored rows by the protocol-18 conversion `cube-refresh-key-removed` (the D3 entry `cube-refresh-key-retired`). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). ⚠️ It is a LEAF now: the two rows that were drilled under it — `every` and `sql`, both `dead`, verified 2026-09-17 — left with the subtree, because the gate refuses `children` on a property that is not a container (the connector `health` precedent). Their verdicts are carried here, one reason for both: nothing read the block, and there was nothing for it to key on. Re-measured 2026-09-29 at 0be898499f: `git grep refreshKey` over the non-test sources of packages/services, packages/drivers and packages/rest answers 0 lines (4 for the neighbouring `.public` in the same pathspec); packages/services/service-analytics/src references no ICacheService or IJobService, and its one cache is the request-scoped packages/services/service-analytics/src/dimension-labels.ts#withLabelFetchCache — no analytics result is cached, so every query is computed when it is asked. The one author, examples/app-showcase (`every: '1 hour'`), no longer writes it. A refresh cadence is declared again when a result cache exists. The tombstone is packages/spec/src/data/analytics.zod.ts#CubeSchema." }, "public": { "status": "live", diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 66c47a40054..afee2bdb8e5 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -424,8 +424,8 @@ export const CubeJoinSchema = lazySchema(() => strictObject( * surfaces, the migration notes and one author (`examples/app-showcase`, * `every: '1 hour'`). And nothing for it to key on: `service-analytics` * references no cache or job service — its one cache is the request-scoped - * `dimension-labels.ts#withLabelFetchCache` — so every analytics query is - * computed when it is asked. `sql` was security-adjacent as well: raw SQL run on + * `packages/services/service-analytics/src/dimension-labels.ts#withLabelFetchCache` + * — so every analytics query is computed when it is asked. `sql` was security-adjacent as well: raw SQL run on * a schedule, outside the read-scope machinery every other cube `sql` goes * through. *