diff --git a/.changeset/18612-cubejoin-retire-sql-relationship.md b/.changeset/18612-cubejoin-retire-sql-relationship.md new file mode 100644 index 00000000000..d22aaa86076 --- /dev/null +++ b/.changeset/18612-cubejoin-retire-sql-relationship.md @@ -0,0 +1,85 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING** — retire `CubeJoin.sql` and `CubeJoin.relationship`. A cube join declares +WHICH object it reaches; the ON clause is derived from the declared relationship between +the two cubes' objects and is never authored. + +`CubeJoin.sql` was **required** and described itself as the `ON` clause, and nothing ever +read it. Both analytics strategies synthesise the join: `NativeSQLStrategy` emits +`LEFT JOIN ON ""."" = ""."id"` from the dotted member +path alone, and `ObjectQLStrategy` resolves the join through `cube.joins?.[alias]?.name` and +lowers it to a relationship traversal with no `ON` clause at all. So an authored join +condition was not ignored — it was **replaced**, under a `200`, by an equality the author had +not asked for, with a plausible number attached. `relationship` is the same shape one key +over: it carried a `.default('many_to_one')`, nothing dispatched on the cardinality, and +`one_to_many` parsed, changed no SQL and kept the many-to-one arithmetic. + +ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director batch #154 item 4, +letter 2). The ruling declined the other remedy — executing the author's SQL — as a new +capability whose first design question is an injection boundary, for zero authors today. A +custom join condition, if a customer needs one, is a capability card with that boundary +decided first. + +## FROM → TO + +| you wrote (17.4 and earlier) | write instead | +| --- | --- | +| `joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '${orders}.account = ${crm_account}.id' } }` | `joins: { account: { name: 'crm_account' } }` — delete both keys | +| `joins: { a: { name: 'b', relationship: 'one_to_many' } }` | `joins: { a: { name: 'b' } }` — the cardinality was never read; declare it on the object's own relationship field | +| `joins: { a: { name: 'b', on: '…' } }` | `joins: { a: { name: 'b' } }` — `on` was the curated alias for `sql` and is retired with it | + +**The one-line fix:** delete `sql` and `relationship` from every `joins` entry; keep `name`. + +Nothing regresses by deleting them: neither key ever reached a query. What decides the join +is `name` (the joined object, which is also what the per-object RLS/tenant read scope is +computed for) and the declared relationship the runtime derives the equality from. + +## The retirement kit + +- **Strict deletion plus a `guidance` prescription, not a `retiredKey()` tombstone.** Every + cube shape is a `strictObject`, so the key leaves the walked shape entirely and the + refusal carries the upgrade: writing `sql`, `relationship` or `on` on a join is an + `unrecognized_keys` rejection whose message names the key and states that the `ON` clause + is DERIVED from the declared relationship between the two cubes' objects. Same route + `MetricSchema.filters` took one shape over in this same file. +- **`on` is no longer an alias.** It pointed at `sql`; an alias naming a key the shape + cannot accept answers an author with a second rejection, so it became a `guidance` entry + of its own and the rename suggestion is gone. Pinned in both directions. +- **ADR-0087: a D2 conversion AND a D3 semantic entry**, plus the two exact-key + registrations `data/CubeJoin:sql` and `data/CubeJoin:relationship` in + `RETIRED_KEYS_BY_MAJOR[18]`. The conversion is + `cube-join-sql-and-relationship-removed` (`toMajor: 18`, + `retiredFromLoadPath: true`), chained into step 18: it strips both keys from every + `analyticsCubes[].joins.*` wherever the chain is replayed, one notice per stripped site, + each naming the cube that lost the key. It is owed because the removal is measured + against **metadata at rest**, not only against sources: `sql` was required and + `relationship` was defaulted, so every cube artifact ever written from the old schema's + own parse output carries both keys, and the boot door + (`ObjectStackDefinitionSchema` → `analyticsCubes: z.array(CubeSchema)`) would otherwise + refuse it with no remedy short of hand-editing JSON. The strip is lossless in the only + sense that applies: a key that never had an effect has none to lose. The D3 entry + `cube-join-sql-and-relationship-retired` stays as the human-facing record — the strip + removes the key, the entry says why an author who wrote a non-FK `sql` should re-read the + numbers that join produced. +- **The `os migrate meta --from 17` sentence** closes all three prescriptions, which is what + a covered surface owes. +- **The `joins` record KEY is documented.** `name`'s describe now states that the key a join + is declared under is the FOREIGN-KEY FIELD on the cube's own base object — the column the + derived `ON` reads — not a second spelling of the object the join reaches. +- **The liveness ledger rows went WITH the keys** (`liveness/analytics_cube.json`), which is + the strict-deletion route's disposition — the opposite of the tombstone route, which keeps + the row because `retiredKey()` keeps the key in the walked shape. `analytics_cube` drops + from 12 `dead` to 10. +- **The one in-repo producer is fixed in the same diff.** `examples/app-showcase`'s + `DeliveryCube` authored both keys, including an `ON` clause the runtime was replacing; + `dataset-compiler.ts` minted them as two constants no reader consulted. Its join was also + keyed `showcase_project` — the object it reaches — while `showcase_task`'s foreign key is + `project`, so the derived `ON` named a column the base object does not have and the join + never resolved. It is re-keyed `project` here and pinned against the object's own field + map. + +Clause-②: yes (narrowing) + + diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 0212d3e742a..870bb30ecbc 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -127,7 +127,7 @@ Type: `[string, string]` | **sql** | `string` | ✅ | Base SQL statement or Table Name | | **measures** | `Record; … }>` | ✅ | Quantitative metrics | | **dimensions** | `Record; … }>` | ✅ | Qualitative attributes | -| **joins** | `Record; sql: string }>` | optional | | +| **joins** | `Record` | optional | | | **refreshKey** | `{ every?: string; sql?: string }` | optional | | | **public** | `boolean` | optional (default: `false`) | | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -164,9 +164,7 @@ Type: `[string, string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Target cube name | -| **relationship** | `Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>` | optional (default: `"many_to_one"`) | | -| **sql** | `string` | ✅ | Join condition (ON clause) | +| **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` @@ -184,9 +182,7 @@ Type: `[string, string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Target cube name | -| **relationship** | `Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>` | optional (default: `"many_to_one"`) | | -| **sql** | `string` | ✅ | Join condition (ON clause) | +| **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. | --- diff --git a/examples/app-showcase/src/data/analytics/showcase.cube.ts b/examples/app-showcase/src/data/analytics/showcase.cube.ts index bb228a32eea..dfab7d71b4f 100644 --- a/examples/app-showcase/src/data/analytics/showcase.cube.ts +++ b/examples/app-showcase/src/data/analytics/showcase.cube.ts @@ -72,11 +72,24 @@ export const DeliveryCube = defineCube({ sql: 'assignee', }, }, + // The ON clause is DERIVED, never authored: the runtime builds a foreign-key + // equality from the declared relationship between the two cubes' objects. A + // join declares only WHICH object it reaches (#18612 removed `sql` and + // `relationship`; before that, the ON clause written here was silently + // replaced by exactly this derivation). + // + // The record KEY is the FOREIGN-KEY FIELD on the base object — + // `showcase_task.project`, declared as `Field.masterDetail('showcase_project')` + // — never a second spelling of the object it reaches. Both strategies read it + // that way: NativeSQLStrategy emits + // `LEFT JOIN "showcase_project" "project" ON "showcase_task"."project" = "project"."id"` + // and ObjectQLStrategy lowers `fkField: 'project'`. Keyed `showcase_project` + // (as it was until #18612) the derivation asked for a + // `showcase_task.showcase_project` column that does not exist, so the join + // never resolved and the example demonstrated nothing. joins: { - showcase_project: { + project: { name: 'showcase_project', - relationship: 'many_to_one', - sql: '${showcase_delivery}.project = ${showcase_project}.id', }, }, refreshKey: { diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index eb183b47a59..947758d7a6b 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -31,7 +31,26 @@ describe('showcase gap fill — analytics cube', () => { expect(Object.keys(DeliveryCube.dimensions ?? {})).toEqual( expect.arrayContaining(['status', 'priority', 'due_date']), ); - expect(DeliveryCube.joins?.showcase_project?.relationship).toBe('many_to_one'); + // A join declares the object it reaches and nothing else: the ON clause is + // derived from the declared relationship (#18612 removed `sql`/`relationship`). + expect(DeliveryCube.joins?.project?.name).toBe('showcase_project'); + }); + + it('keys every join by a FOREIGN-KEY FIELD of its own base object, not by the target', () => { + // #18612: the `joins` record KEY is what both strategies join ON — native + // emits `ON ""."" = ""."id"`, ObjectQL lowers `fkField: key` + // — so a key that is not a field of the base object joins on a column that + // does not exist. Pinned against the object's REAL field map rather than a + // literal, so this fails the moment either side moves. + const objects = ((stack as { objects?: Array<{ name: string; fields?: Record }> }).objects ?? []); + const base = objects.find((o) => o.name === DeliveryCube.sql); + expect(base, `cube base object '${DeliveryCube.sql}' is not in the stack`).toBeDefined(); + const fields = new Set(Object.keys(base?.fields ?? {})); + const joinKeys = Object.keys(DeliveryCube.joins ?? {}); + expect(joinKeys.length).toBeGreaterThan(0); + for (const key of joinKeys) { + expect(fields.has(key), `join key '${key}' is not a field of '${DeliveryCube.sql}'`).toBe(true); + } }); }); diff --git a/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts b/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts index c6ffb7f174c..50581122d7b 100644 --- a/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts @@ -100,7 +100,7 @@ const CUBE: Cube = { dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, }, - joins: { account: { name: 'crm_account', relationship: 'belongsTo', sql: '' } }, + joins: { account: { name: 'crm_account' } }, } as never; /** diff --git a/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts b/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts index 59a8133f6d7..c8216696ae8 100644 --- a/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts @@ -87,7 +87,7 @@ describe('custom-expression measures emit their expression', () => { describe('an expression containing a dot is not mistaken for a join path', () => { const dotted: Cube = { ...cube, - joins: { account: { name: 'account', relationship: 'belongsTo', sql: '' } }, + joins: { account: { name: 'account' } }, measures: { ...cube.measures, // A dot inside a function call — an expression, not `relation.column`. diff --git a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts index e9678302684..0a93c605a65 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts @@ -71,7 +71,7 @@ describe('NativeSQLStrategy — D-C RLS hardening', () => { it('joins the resolved TARGET TABLE when cube.joins maps alias→table (namespaced)', async () => { // alias `account` → table `crm_account` (what the dataset compiler emits). - const nsCube: Cube = { ...cube, joins: { account: { name: 'crm_account', relationship: 'many_to_one', sql: '' } } }; + const nsCube: Cube = { ...cube, joins: { account: { name: 'crm_account' } } }; const strategy = new NativeSQLStrategy(); const ctx = ctxWith({ getCube: (n) => (n === 'sales' ? nsCube : undefined), @@ -123,7 +123,7 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' }, }, - joins: { account: { name: 'account', relationship: 'many_to_one', sql: '' } }, + joins: { account: { name: 'account' } }, public: false, }; @@ -168,8 +168,8 @@ describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => { owner_region: { name: 'owner_region', label: 'Owner Region', type: 'string', sql: 'account.owner.region' }, }, joins: { - account: { name: 'crm_account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' }, - 'account__owner': { name: 'core_user', relationship: 'many_to_one', sql: 'account.owner = account__owner.id' }, + account: { name: 'crm_account' }, + 'account__owner': { name: 'core_user' }, }, public: false, }; diff --git a/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts index 4f8afc1d2f4..d9bd2da11bb 100644 --- a/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts @@ -169,7 +169,7 @@ const joinedCube: Cube = { opened: { name: 'opened', label: 'Opened', type: 'time', sql: 'account.created_at' }, }, joins: { - account: { name: 'account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' }, + account: { name: 'account' }, }, public: false, }; diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index 9062405b79e..d9a2386ccac 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -584,7 +584,6 @@ export function compileDataset( ); } let fromObject = dataset.object; - let parentAlias = dataset.object; let prefix = ''; for (const seg of segments) { prefix = prefix ? `${prefix}.${seg}` : seg; @@ -596,14 +595,14 @@ export function compileDataset( if (!joins[alias]) { // KEY is the SQL-safe alias; `name` carries the join TABLE; the strategy // rebuilds the ON clause from the alias convention (`. = .id`). - joins[alias] = { - name: target.table, - relationship: 'many_to_one', - sql: `${parentAlias}.${seg} = ${prefix}.id`, - }; + // That derivation is now the whole contract: #18612 removed `CubeJoin.sql` + // and `CubeJoin.relationship` (ADR-0049 enforce-or-remove), so the two + // constants this literal used to carry are gone rather than re-synthesised + // here. Nothing ever read them — both strategies resolve a join through + // `cube.joins?.[alias]?.name` alone. + joins[alias] = { name: target.table }; } fromObject = target.object; - parentAlias = prefix; } } diff --git a/packages/spec/api-surface-declarations/data.txt b/packages/spec/api-surface-declarations/data.txt index bdf043b1be4..23c096e3d97 100644 --- a/packages/spec/api-surface-declarations/data.txt +++ b/packages/spec/api-surface-declarations/data.txt @@ -977,12 +977,6 @@ type CubeJoinParsed = z.infer; // ── CubeJoinSchema (const) ── declare const CubeJoinSchema: z.ZodObject<{ name: z.ZodString; - relationship: z.ZodDefault>; - sql: z.ZodString; }, z.core.$strict>; // ── CubeParsed (type) ── @@ -1054,12 +1048,6 @@ declare const CubeSchema: z.ZodObject<{ }, z.core.$strict>>; joins: z.ZodOptional>; - sql: z.ZodString; }, z.core.$strict>>>; refreshKey: z.ZodOptional; diff --git a/packages/spec/api-surface-declarations/root.txt b/packages/spec/api-surface-declarations/root.txt index 6c83181913b..0edc78c9256 100644 --- a/packages/spec/api-surface-declarations/root.txt +++ b/packages/spec/api-surface-declarations/root.txt @@ -22346,12 +22346,6 @@ declare const ObjectStackDefinitionSchema: z.ZodObject<{ }, z.core.$strict>>; joins: z.ZodOptional>; - sql: z.ZodString; }, z.core.$strict>>>; refreshKey: z.ZodOptional; @@ -44199,12 +44193,6 @@ declare const ObjectStackSchema: z.ZodObject<{ }, z.core.$strict>>; joins: z.ZodOptional>; - sql: z.ZodString; }, z.core.$strict>>>; refreshKey: z.ZodOptional; diff --git a/packages/spec/api-surface-declarations/system.txt b/packages/spec/api-surface-declarations/system.txt index 4ebfda01bfb..9dad4701ed2 100644 --- a/packages/spec/api-surface-declarations/system.txt +++ b/packages/spec/api-surface-declarations/system.txt @@ -54359,12 +54359,6 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }, z.core.$strict>>; joins: z.ZodOptional>; - sql: z.ZodString; }, z.core.$strict>>>; refreshKey: z.ZodOptional; diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 7ac78fc74fe..3088b10c4c8 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -11,7 +11,6 @@ "data/CrossFieldValidation:priority = 100", "data/CrossFieldValidation:severity = \"error\"", "data/Cube:public = false", - "data/CubeJoin:relationship = \"many_to_one\"", "data/CurrencyConfig:currencyMode = \"dynamic\"", "data/CurrencyConfig:defaultCurrency = \"CNY\"", "data/CurrencyConfig:precision = 2", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 2dea79eef10..0b7d2557972 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -101,8 +101,6 @@ "data/Cube:sql", "data/Cube:title", "data/CubeJoin:name", - "data/CubeJoin:relationship", - "data/CubeJoin:sql", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", "data/CurrencyConfig:precision", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 0a5e566480b..7476975ec28 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -939,7 +939,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`) 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`, and that schema is `ConnectorSchema.superRefine(...)` — in Zod 4 a `superRefine` attaches a check to the same object def rather than wrapping it, so the walked key set is byte-identical to the base's. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 20/1/53 split: 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 `ConnectorProviderContext` fields plus `name` and `enabled`; `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`: refused outright by ADR-0097 §3 (#7990) rather than ignored. The 53 `dead` are four declared subsystems with no engine — `syncConfig` (7), `fieldMappings` (7), `retryConfig` (8), `health` (14, both sub-blocks) — plus `triggers` (6, and the schema's own docblock says so: #3197), the connector's nested `webhooks` (one blanket verdict, recorded in the undrilled baseline), `status`, `metadata`, the two timeouts, `actions.description`/`.outputSchema`, and four `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent). **A prior in-repo claim was falsified here**: the conversion registry's note that `retryConfig` "and the timeouts beside it are untouched — they are live" is corrected on those rows — the word does not occur outside `packages/spec` at all | -| 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 is `dimensions.granularities` (read 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). 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 12 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the access-control flag (`public` — three sites write `false`, nothing reads it: a knob that was never wired, not a hole that was opened), `joins[].relationship` and the REQUIRED `joins[].sql` (the ON clause is SYNTHESISED as an FK equality and the authored one is never consulted), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. **#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 is `dimensions.granularities` (read 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). 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 10 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the access-control flag (`public` — three sites write `false`, nothing reads it: a knob that was never wired, not a hole that was opened), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. 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 72fc67b4aed..a69eb35c18c 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -115,17 +115,7 @@ "verifiedAt": "2026-09-17", "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin — `const joinTable = cube?.joins?.[alias]?.name ?? alias` is the TABLE the LEFT JOIN is emitted against, which is what makes an alias differ from its object (`account` -> `crm_account`); packages/services/service-analytics/src/strategies/native-sql-strategy.ts#crossFieldComparisonIn resolves `cube.joins?.[alias]?.name ?? alias` for every joined object when it walks the RLS read scopes; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds every `cube.joins[*].name` to the tenant-scoped object set; packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField resolves the same value for the aggregate path.", "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", - "note": "Security-relevant, not cosmetic: the joined object resolved here is what the read scope is computed for, so a wrong name would scope the wrong table. Note the JOIN is keyed by the record key (the alias) — `name` is its TARGET." - }, - "relationship": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Declared with a `.default('many_to_one')` and dispatched on by nothing. The join SQL is SYNTHESISED, not read: `native-sql-strategy.ts#qualifyAndRegisterJoin` emits `LEFT JOIN ON \"\".\"\" = \"\".\"id\"` for every hop, a foreign-key equality that is the same statement whichever cardinality the author declared. Census: `relationship` is WRITTEN by `dataset-compiler.ts` (`relationship: 'many_to_one'`, a constant) and read in neither strategy, neither driver, nor the REST layer; the lit control in the same scan is `cube.joins?.[alias]?.name`, which returns the hits cited on the `name` row above. The authoring trap is real — `one_to_many` parses, changes no SQL, and the aggregate silently keeps the many-to-one arithmetic." - }, - "sql": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "The most expensive dead key on this type: it is REQUIRED, documented as 'Join condition (ON clause)', and the ON clause is synthesised without ever consulting it. `native-sql-strategy.ts#qualifyAndRegisterJoin` builds `ON \"\".\"\" = \"\".\"id\"` from the dotted member path alone; `objectql-strategy.ts` resolves joins through `cube.joins?.[alias]?.name` and lowers them to relationship traversals, with no ON clause at all. So an author who writes a non-FK join condition gets a DIFFERENT query than the one they declared, with a 200 and a plausible number — the `#10298` shape. ⛔ Not a mechanical ADR-0049 deletion: the key is required, so removing it edits every authored cube, and the honest fix is a decision (refuse a `sql` the engine will not honour, or teach the strategies to emit it). Recorded here so the decision has a measurement behind it." + "note": "Security-relevant, not cosmetic: the joined object resolved here is what the read scope is computed for, so a wrong name would scope the wrong table. Note the JOIN is keyed by the record key (the alias) — `name` is its TARGET. It is also the WHOLE of the join contract since #18612: `relationship` and `sql` were removed (ADR-0049 enforce-or-remove), so their rows left this ledger with the keys — the strict-deletion route drops the row, and an orphan row for a key no longer in the walked shape is its own gate failure." } } }, diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index e731f55b49d..d8914152b01 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -65,5 +65,5 @@ for both corollaries. | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 20 | 0 | 0 | 53 | 1 | 74 | -| `analytics_cube` | 17 | 0 | 0 | 12 | 0 | 29 | -| **total** | **915** | **5** | **1** | **161** | **12** | **1094** | +| `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | +| **total** | **915** | **5** | **1** | **159** | **12** | **1092** | diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 359154fcc6f..3c7081708aa 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7579,6 +7579,150 @@ const cubeSubDayGranularitiesRemoved: MetadataConversion = { }, }; +/** + * `joins..sql` and `joins..relationship` — the authored ON clause + * and the declared cardinality on a cube join (#18612, ADR-0049 + * enforce-or-remove; maintainer ruling 2026-09-18, director batch #154 item 4 + * letter 2, plus the same day's addendum that ordered this entry). + * + * Neither key was ever read. Both analytics strategies SYNTHESISE the join: + * `NativeSQLStrategy#qualifyAndRegisterJoin` emits + * `LEFT JOIN ON ""."" = ""."id"` from the + * dotted member path alone, and `ObjectQLStrategy#isCrossObjectField` resolves + * the join through `cube.joins?.[alias]?.name` and lowers it to a relationship + * traversal with no ON clause at all. An authored condition was therefore not + * ignored but REPLACED, under a 200 (the #10298 shape), and `one_to_many` + * parsed, changed no SQL and kept the many-to-one arithmetic. + * + * ## Why a D2 strip, when the same card also ships a D3 semantic entry + * + * The first cut of #18612 shipped the semantic entry alone, on the ruling's + * clause 「zero producers, so no conversion is owed」. That clause counted + * SOURCE authors; the refusal is on PERSISTED artifacts. `sql` was REQUIRED and + * `relationship` carried `.default('many_to_one')`, so every cube artifact ever + * written from the old schema's own parse output carries BOTH keys — and after + * this retirement the boot door refuses it: `ObjectStackDefinitionSchema` + * spreads `analyticsCubes: z.array(CubeSchema)` and `_parseAndRegisterArtifact` + * re-parses stored metadata through it. A D3 entry is never replayed, so the + * artifact at rest would have had no remedy short of hand-editing JSON (the + * #12772 shape); only the D2 table is replayed at the rehydration seams + * (`applyArtifactForwardConversions`, `applyConversionsToStoredItem`). The + * strip is behaviour-lossless in the only sense that matters here: a key that + * never had an effect has no effect to lose. + * + * The semantic entry `cube-join-sql-and-relationship-retired` stays beside this + * one and is not redundant — the conversion removes the key, the entry tells a + * HUMAN why: an author who wrote a non-FK `sql` wanted a join this runtime does + * not perform, and that want is a decision, not a rewrite. + * + * Joins live in the cube's `joins` RECORD (keyed by alias), one level below the + * collection item, so the top-level-only `stripKeys` runs per join, not per + * cube — the `metric-filters-removed` shape one surface over. The emitted path + * NAMES the cube rather than only indexing it: "which cube lost it" is the + * record the semantic entry worried a mechanical strip would not keep, and an + * index into the author's own `analyticsCubes[]` is a position, not a name. + */ +const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { + id: 'cube-join-sql-and-relationship-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship', + summary: + "cube join keys 'sql' and 'relationship' removed (#18612, ADR-0049 — neither was ever read: " + + 'both strategies synthesise the ON clause as a foreign-key equality, so an authored join ' + + 'condition was REPLACED under a 200 and a declared cardinality changed no SQL. Keep ' + + '`joins..name` alone; the record KEY is the foreign-key field on the base object)', + apply(stack, emit) { + return mapCollection(stack, 'analyticsCubes', (cube, path) => { + const joins = cube.joins; + if (!isDict(joins)) return cube; + // 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; + let touched = false; + const nextJoins: Record = { ...joins }; + for (const [alias, join] of Object.entries(joins)) { + if (!isDict(join)) continue; + const stripped = stripKeys(join, ['sql', 'relationship'], emit, `${where}.joins.${alias}`); + if (stripped === join) continue; + nextJoins[alias] = stripped; + touched = true; + } + if (!touched) return cube; + return { ...cube, joins: nextJoins }; + }); + }, + fixture: { + before: { + analyticsCubes: [ + { + name: 'delivery', + sql: 'task', + measures: { count: { name: 'count', label: 'Tasks', type: 'count', sql: 'id' } }, + dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + joins: { + // The persisted shape: `sql` was REQUIRED and `relationship` was + // MATERIALIZED by the schema's own default, so this is what a cube + // artifact written by 17.4-or-earlier tooling actually carries. + project: { + name: 'showcase_project', + relationship: 'many_to_one', + sql: '${task}.project = ${showcase_project}.id', + }, + // Already canonical — rides through untouched. The fixture's own + // control: the strip dispatches on key presence, and copy-on-write + // keeps this reference. + owner: { name: 'sys_user' }, + }, + }, + { + // A SECOND cube, so the notices have to distinguish two of them. + name: 'billing', + sql: 'invoice', + measures: { amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' } }, + dimensions: { + issued_on: { name: 'issued_on', label: 'Issued', type: 'time', sql: 'issued_on' }, + }, + joins: { + // Only the cardinality — a join whose `sql` an author already + // deleted by hand still carries the materialized default. + customer: { name: 'crm_account', relationship: 'many_to_one' }, + }, + }, + ], + }, + after: { + analyticsCubes: [ + { + name: 'delivery', + sql: 'task', + measures: { count: { name: 'count', label: 'Tasks', type: 'count', sql: 'id' } }, + dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + joins: { + project: { name: 'showcase_project' }, + owner: { name: 'sys_user' }, + }, + }, + { + name: 'billing', + sql: 'invoice', + measures: { amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' } }, + dimensions: { + issued_on: { name: 'issued_on', label: 'Issued', type: 'time', sql: 'issued_on' }, + }, + joins: { + customer: { name: 'crm_account' }, + }, + }, + ], + }, + // Three notices, one per STRIPPED SITE: `delivery.project` loses both keys, + // `billing.customer` loses the materialized default, and `delivery.owner` + // produces none. + expectedNotices: 3, + }, +}; + /** * `record:highlights` highlight-field `icon` — a declared, advertised key with * zero read points (#10054, ADR-0049 enforce-or-remove; maintainer ruling @@ -9786,6 +9930,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { + // Batch D closed this shape because a typo'd `relationship` fell back to the + // `many_to_one` default silently. #18612 then removed `relationship` and `sql` + // outright (ADR-0049 enforce-or-remove) — so the fixture drops the two retired + // spellings and this pin keeps pinning what it always pinned: an UNDECLARED key + // on a cube join is refused by name rather than dropped. The near-miss is now a + // near-miss of nothing, which is exactly why the batch-D door still has to hold. + it('`CubeJoin` — through the cube `joins` record; an undeclared key is refused by name', () => { expect( reject(CubeSchema, { ...CUBE, - joins: { j: { name: 'other', sql: 'x', relationshipp: 'one_to_one' } }, + joins: { j: { name: 'other', relationshipp: 'one_to_one' } }, }), ).toContain('relationshipp'); }); + // The two REMOVED keys, at the same door: each rejects with its own + // prescription rather than as a bare unknown, and the prescription names the + // derivation that replaced it (#18612). + it('`CubeJoin.sql` — REMOVED; the refusal carries the derivation, not a bare unknown', () => { + const issues = reject(CubeSchema, { + ...CUBE, + joins: { j: { name: 'other', sql: 'a.id = b.a_id' } }, + }); + expect(issues).toContain('`joins..sql` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove)'); + expect(issues).toContain('DERIVED from the declared relationship between the two cubes\' objects'); + }); + + it('`CubeJoin.relationship` — REMOVED; the refusal carries the derivation', () => { + const issues = reject(CubeSchema, { + ...CUBE, + joins: { j: { name: 'other', relationship: 'one_to_many' } }, + }); + expect(issues).toContain('`joins..relationship` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove)'); + expect(issues).toContain('DERIVED from the declared relationship between the two cubes\' objects'); + }); + + // `on` was the CURATED alias for `sql` until #18612. An alias naming a key the + // shape can no longer accept is the `triggerPhrase` failure `strict-object.ts` + // records, so it became a `guidance` entry: the answer is the derivation, and + // — pinned here — never a rename suggestion pointing back at `sql`. + it('`CubeJoin.on` — the retired alias answers with the derivation, never `→ sql`', () => { + const issues = reject(CubeSchema, { + ...CUBE, + joins: { j: { name: 'other', on: 'a.id = b.a_id' } }, + }); + expect(issues).toContain('DERIVED from the declared relationship between the two cubes\' objects'); + expect(issues).not.toContain('→ `sql`'); + }); + it('`AnalyticsQuery` — the base top level (already gated at the REST wrapper; now gated at every door)', () => { expect(reject(AnalyticsQuerySchema, { ...QUERY, granularity: 'day' })).toContain('granularity'); }); diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index b31a8c3d6ac..2f239db88ef 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -11,6 +11,8 @@ import { AnalyticsQuerySchema, } from './analytics.zod'; import { DateGranularity } from './query.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; +import { applyConversions, collectConversionNotices } from '../conversions/apply'; describe('AggregationMetricType', () => { it('should accept all valid metric types', () => { @@ -253,52 +255,133 @@ describe('DimensionSchema', () => { }); describe('CubeJoinSchema', () => { - it('should accept valid join with default relationship', () => { - const join = CubeJoinSchema.parse({ - name: 'orders', - sql: '{CUBE}.user_id = {orders}.user_id', - }); + // #18612 (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): `sql` and + // `relationship` are REMOVED. `name` is the whole contract, and the ON clause + // is derived from the declared relationship between the two cubes' objects. + it('accepts a join that declares only the object it reaches', () => { + const join = CubeJoinSchema.parse({ name: 'orders' }); expect(join.name).toBe('orders'); - expect(join.relationship).toBe('many_to_one'); + expect(join).not.toHaveProperty('relationship'); + expect(join).not.toHaveProperty('sql'); }); - it('should accept join with explicit relationship', () => { - const join = CubeJoinSchema.parse({ - name: 'line_items', - relationship: 'one_to_many', - sql: '{CUBE}.id = {line_items}.order_id', - }); + it('refuses an authored ON clause, and the refusal says the clause is DERIVED', () => { + const r = CubeJoinSchema.safeParse({ name: 'orders', sql: '{CUBE}.user_id = {orders}.user_id' }); - expect(join.relationship).toBe('one_to_many'); + expect(r.success).toBe(false); + const issues = JSON.stringify(r.error?.issues ?? []); + expect(issues).toContain('unrecognized_keys'); + expect(issues).toMatch(/`joins\.\.sql`.*removed.*DERIVED from the declared relationship/s); }); - it('should accept all valid relationships', () => { - for (const rel of ['one_to_one', 'one_to_many', 'many_to_one']) { - expect(() => CubeJoinSchema.parse({ - name: 'target', - relationship: rel, - sql: '{CUBE}.id = {target}.id', - })).not.toThrow(); - } + it('refuses an authored cardinality, and the refusal says it never had an effect', () => { + const r = CubeJoinSchema.safeParse({ name: 'line_items', relationship: 'one_to_many' }); + + expect(r.success).toBe(false); + const issues = JSON.stringify(r.error?.issues ?? []); + expect(issues).toMatch(/`joins\.\.relationship`.*removed.*never had an effect/s); }); - it('should reject join with invalid relationship', () => { - expect(() => CubeJoinSchema.parse({ - name: 'target', - relationship: 'many_to_many', - sql: '{CUBE}.id = {target}.id', - })).toThrow(); + it('refuses the `on` spelling with the derivation rather than a rename to `sql`', () => { + const r = CubeJoinSchema.safeParse({ name: 'orders', on: '{CUBE}.id = {orders}.id' }); + + expect(r.success).toBe(false); + const issues = JSON.stringify(r.error?.issues ?? []); + expect(issues).toContain('DERIVED from the declared relationship'); + expect(issues).not.toContain('→ `sql`'); }); it('should reject join without required fields', () => { - expect(() => CubeJoinSchema.parse({ - name: 'orders', - })).toThrow(); + expect(() => CubeJoinSchema.parse({})).toThrow(); + }); +}); - expect(() => CubeJoinSchema.parse({ - sql: '{CUBE}.id = {orders}.id', - })).toThrow(); +/** + * [#18612] The retirement is measured against METADATA AT REST, not only + * against sources. + * + * `sql` was REQUIRED and `relationship` carried `.default('many_to_one')`, so + * every cube artifact ever written from the old schema's own parse output + * carries BOTH keys — and the boot door re-parses stored metadata through + * `ObjectStackDefinitionSchema` (`analyticsCubes: z.array(CubeSchema)`). Without + * the ADR-0087 D2 conversion `cube-join-sql-and-relationship-removed` that + * artifact stops booting with no remedy short of hand-editing JSON (#12772's + * shape). The conversion is `retiredFromLoadPath`, so the AUTHORING funnel still + * teaches the tombstone; the data-at-rest seams pin `includeRetired: true`. + */ +describe('a persisted cube heals at the door (#18612, ADR-0087 D2)', () => { + /** What `CubeSchema.parse` itself emitted before this retirement. */ + const persisted = () => ({ + analyticsCubes: [{ + name: 'showcase_delivery', + sql: 'showcase_task', + measures: { count: { name: 'count', label: 'Tasks', type: 'count', sql: '*' } }, + dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + joins: { + project: { + name: 'showcase_project', + relationship: 'many_to_one', + sql: '${showcase_task}.project = ${showcase_project}.id', + }, + }, + }], + }); + + it('is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const before = ObjectStackDefinitionSchema.safeParse(persisted()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('unrecognized_keys'); + + const healed = applyConversions(persisted(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect( + after.success, + `expected the converted artifact to parse; got ${JSON.stringify(after.error?.issues ?? [])}`, + ).toBe(true); + expect((healed as { analyticsCubes: Array<{ joins: unknown }> }).analyticsCubes[0]!.joins) + .toEqual({ project: { name: 'showcase_project' } }); + }); + + it('LIT CONTROL — a shape that was always wrong is refused on BOTH sides', () => { + // `relationshipp` is the near-miss #4001 batch D closed. The conversion + // strips two NAMED keys, so this one survives it and the door still refuses + // — which is what makes the leg above a reading and not a tautology. + const bad = () => { + const s = persisted(); + s.analyticsCubes[0]!.joins = { project: { name: 'showcase_project', relationshipp: 'many_to_one' } } as never; + return s; + }; + expect(ObjectStackDefinitionSchema.safeParse(bad()).success).toBe(false); + const healed = applyConversions(bad(), { includeRetired: true }); + expect(ObjectStackDefinitionSchema.safeParse(healed).success).toBe(false); + }); + + it('emits one notice per stripped site, and the notice NAMES the cube that lost the key', () => { + const twoCubes = { + analyticsCubes: [ + persisted().analyticsCubes[0]!, + { + name: 'billing_revenue', + sql: 'showcase_invoice', + measures: { amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' } }, + dimensions: { issued_on: { name: 'issued_on', label: 'Issued', type: 'time', sql: 'issued_on' } }, + joins: { + account: { name: 'showcase_account', relationship: 'many_to_one' }, + // Already canonical: the control that produces NO notice. + owner: { name: 'sys_user' }, + }, + }, + ], + }; + const { notices } = collectConversionNotices(twoCubes, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === 'cube-join-sql-and-relationship-removed'); + expect(mine.map((n) => n.path)).toEqual([ + 'analyticsCubes[0](showcase_delivery).joins.project.sql', + 'analyticsCubes[0](showcase_delivery).joins.project.relationship', + 'analyticsCubes[1](billing_revenue).joins.account.relationship', + ]); + expect(mine.every((n) => n.to === '(removed)')).toBe(true); }); }); @@ -339,8 +422,6 @@ describe('CubeSchema', () => { joins: { users: { name: 'users', - relationship: 'many_to_one', - sql: '{CUBE}.user_id = {users}.id', }, }, refreshKey: { diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 38ff996e882..da8deb1b816 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -241,27 +241,93 @@ export const DimensionSchema = lazySchema(() => strictObject( }, )); +/** + * The ON clause a cube join used to let an author write, and the cardinality it + * used to let them declare. Both were REMOVED (#18612, ADR-0049 + * enforce-or-remove); the prescriptions are `guidance` entries on + * {@link CubeJoinSchema}, shared with the `on` spelling that used to be an alias + * for `sql`. + * + * The derivation they point at is not a plan — it is what both strategies have + * always done: + * `packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin` + * emits `LEFT JOIN ON ""."" = ""."id"` from the + * dotted member path alone, and + * `packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField` + * resolves the join through `cube.joins?.[alias]?.name` and lowers it to a + * relationship traversal with no ON clause at all. + */ +const CUBE_JOIN_DERIVED_ON = + 'A cube join has no authorable ON clause: it is DERIVED from the declared relationship ' + + 'between the two cubes\' objects, as a foreign-key equality. `joins..name` names the ' + + 'joined object and is the whole of the contract.'; + +const CUBE_JOIN_MIGRATE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const CUBE_JOIN_SQL_REMOVED = + '`joins..sql` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it ' + + 'never had an effect, and the absence of effect was not visible: both strategies SYNTHESISE ' + + 'the ON clause and neither ever read this key, so an authored join condition was silently ' + + 'REPLACED by a foreign-key equality and the aggregate came back under a 200, joined on ' + + 'something the author had not asked for. Delete the key. ' + + CUBE_JOIN_DERIVED_ON + ' ' + CUBE_JOIN_MIGRATE; + +const CUBE_JOIN_RELATIONSHIP_REMOVED = + '`joins..relationship` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) ' + + '— it never had an effect: nothing dispatched on the cardinality, so `one_to_many` parsed, ' + + 'changed no SQL, and the aggregate silently kept the many-to-one arithmetic. Delete the key. ' + + CUBE_JOIN_DERIVED_ON + ' ' + CUBE_JOIN_MIGRATE; + +const CUBE_JOIN_ON_REMOVED = + '`joins..on` is not a cube-join key, and `sql` — the key it used to be the curated ' + + 'near-miss for — was itself removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove). ' + + 'Delete the key. ' + CUBE_JOIN_DERIVED_ON + ' ' + CUBE_JOIN_MIGRATE; + /** * Join Schema - * Defines how this cube relates to others. + * Declares that this cube can reach another cube's object, and under which alias. + * + * The ON clause is **derived**, never authored: the runtime builds a foreign-key + * equality from the declared relationship between the two cubes' objects. That is + * why `name` is the whole shape — see {@link CUBE_JOIN_DERIVED_ON}. * * Strict as of #4001 batch D — same doors as {@link MetricSchema}. Before the * close, a join authored with `relationshipp:` (or any near-miss) parsed clean * and fell back to the `many_to_one` default — a different join shape than the - * author declared, under a successful parse. + * author declared, under a successful parse. #18612 then removed `relationship` + * and `sql` outright (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): + * the near-miss was the smaller half of the defect, because the DECLARED + * spellings were being replaced just as silently. `MetricSchema.filters` above + * took the same route one shape over — every cube shape is a `strictObject`, so + * the route is strict deletion plus a `guidance` entry carrying the prescription, + * never a `retiredKey()` tombstone (the key leaves the walked shape entirely). */ export const CubeJoinSchema = lazySchema(() => strictObject( { surface: 'this cube join', history: 'Until this shape was closed, an undeclared join key was silently dropped — a typo\'d ' + '`relationship` fell back to the `many_to_one` default.', - // The join condition is spelled `sql` here (its doc says "ON clause"). - aliases: { on: 'sql' }, + // No `aliases` entry for `on` any more: it pointed at `sql`, and #18612 + // removed that key. An alias naming a key the shape cannot accept is the + // `triggerPhrase` failure `strict-object.ts` records — it answers the author + // with a second rejection — so `on` carries its own prescription below. + guidance: { + sql: CUBE_JOIN_SQL_REMOVED, + relationship: CUBE_JOIN_RELATIONSHIP_REMOVED, + on: CUBE_JOIN_ON_REMOVED, + }, }, { - name: z.string().describe('Target cube name'), - relationship: z.enum(['one_to_one', 'one_to_many', 'many_to_one']).default('many_to_one'), - sql: z.string().describe('Join condition (ON clause)'), + name: z.string().describe( + '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.' + ), }, )); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__CubeJoin__relationship.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__CubeJoin__relationship.ts new file mode 100644 index 00000000000..25ddd123a17 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__CubeJoin__relationship.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// `one_to_many` parsed, changed no SQL, and the aggregate silently kept the +// many-to-one arithmetic. Two service-analytics fixtures authored +// `relationship: 'belongsTo'` — a value the enum never declared — which is its +// own evidence that nothing validated or read the key. +// +// Same route and same registration reasoning as the `sql` entry beside this +// one: strict deletion plus a `guidance` prescription on the `strictObject`, +// registered under 18. The D2 conversion +// `cube-join-sql-and-relationship-removed` strips this key too, and it is owed +// for the mirror-image reason: the key was DEFAULTED, so the value was +// MATERIALIZED into every cube artifact the old schema ever parsed, whether or +// not its author typed it. +export const entry = 'data/CubeJoin:relationship'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__CubeJoin__sql.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__CubeJoin__sql.ts new file mode 100644 index 00000000000..301f164227a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__CubeJoin__sql.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #18612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-18, director +// batch #154 item 4, letter 2). `CubeJoin.sql` was REQUIRED and described itself +// as the `ON` clause, and nothing ever read it: both analytics strategies +// SYNTHESISE the join, so an authored condition was not ignored but REPLACED by +// a foreign-key equality, returned under a 200 with a plausible number attached +// (the #10298 shape). Measured with a positive control — zero reads of a join's +// `sql` in any non-test source, against eight reads of the neighbouring +// `cube.joins?.[alias]?.name` in native-sql-strategy.ts, objectql-strategy.ts +// and analytics-service.ts. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look (the `data/Metric:filters` +// precedent, one shape over in the same file). `CubeJoinSchema` is a +// `strictObject`, so the route is strict deletion plus a `guidance` entry +// carrying the prescription — no `retiredKey()` tombstone, the key is out of +// the walked shape entirely, and its liveness-ledger row left with it. +// +// A D2 conversion DOES cover this surface: `cube-join-sql-and-relationship-removed` +// (`toMajor: 18`, `retiredFromLoadPath: true`) strips the key wherever the chain +// is replayed. It is owed because the key was REQUIRED, so every cube artifact +// ever written from the old schema's parse output carries it and would meet the +// boot door's refusal with no remedy short of hand-editing JSON. The guidance +// prescription therefore closes with the house `os migrate meta --from 17` +// sentence, and the D3 semantic entry `cube-join-sql-and-relationship-retired` +// carries the judgement the strip cannot: an author who wrote a non-FK +// condition wanted a join this runtime does not perform. +export const entry = 'data/CubeJoin:sql'; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts new file mode 100644 index 00000000000..c605af2612b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cube-join-sql-and-relationship-retired.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #18612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-18, director +// batch #154 item 4, letter 2; the same day's addendum then ordered the D2 +// conversion this entry sits beside). +// +// Both ship, and they answer different questions. The D2 conversion +// `cube-join-sql-and-relationship-removed` STRIPS the two keys wherever the +// chain is replayed — the artifact and stored-row rehydration seams, which is +// what keeps a cube written by 17.4-or-earlier tooling booting — and emits one +// notice per stripped site naming the cube that lost it. This SEMANTIC entry is +// the human-facing record the strip cannot be: an author who wrote a non-FK +// `sql` wanted a join this runtime does not perform, and a mechanical delete +// does not tell them that their numbers were never the ones they declared. +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'cube-join-sql-and-relationship-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship — the ' + + 'authored ON clause and the declared cardinality on a cube join', + replacement: + 'analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared ' + + 'relationship between the two cubes\' objects, as a foreign-key equality: NativeSQLStrategy ' + + 'emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the ' + + 'same alias to a relationship traversal with no ON clause at all. The record KEY is the ' + + 'foreign-key FIELD on the base object, never a second spelling of the object the join ' + + 'reaches.', + reason: + 'The KEYS convert mechanically and do: the paired D2 conversion ' + + '`cube-join-sql-and-relationship-removed` deletes both from every join, which is lossless ' + + 'because neither ever had an effect to lose, and names the cube in each notice. What does ' + + 'NOT convert is the INTENT. `sql` was REQUIRED and documented as the ON clause, and no ' + + 'reader ever consulted it: an authored condition was REPLACED by the synthesised ' + + 'foreign-key equality and the aggregate came back under a 200, joined on something the ' + + 'author had not asked for. `relationship` carried a `.default(\'many_to_one\')` that nothing ' + + 'dispatched on, so `one_to_many` parsed, changed no SQL, and kept the many-to-one ' + + 'arithmetic. Deleting the keys restores honesty but does not give an author who wanted a ' + + 'non-FK join the thing they wanted, and it does not re-check the numbers the replaced join ' + + 'already produced. That is why this entry is a TODO addressed to them rather than a claim ' + + 'that the strip finished the job. A custom join condition is a capability card with its ' + + 'injection / allow-list boundary decided first, which the ruling deferred deliberately.', + acceptanceCriteria: + 'Delete `sql` and `relationship` from every entry of every cube `joins` map; keep `name`. ' + + 'The paired D2 conversion `cube-join-sql-and-relationship-removed` performs that same ' + + 'strip mechanically wherever the chain is replayed — including over metadata already at ' + + 'rest, so a deployed artifact keeps booting while you do. Then check three things. (1) Did any deleted `sql` express something OTHER than the foreign-key ' + + 'equality between the two objects — a filtered join, a non-key column, a literal predicate? ' + + 'If so, the query you were getting was already the FK-equality answer and not the one you ' + + 'wrote, so re-read the numbers that join produced before assuming this change moved them; the ' + + 'fix is to model the relationship on the object, or to open a capability request for an ' + + 'authorable join condition. (2) Did any deleted `relationship` say anything but ' + + '`many_to_one`? If so, the aggregate was already computed as many-to-one and still is — this ' + + 'change alters no result, it only stops the declaration from claiming otherwise. (3) Is each ' + + 'join KEYED by a foreign-key field of the cube\'s own base object? The key is the column the ' + + 'derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. ' + + 'Nothing else regresses: `joins..name` is unchanged, and it is what both the joined ' + + 'table and the per-object RLS/tenant read scope are resolved from.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 541ea75d873..7d551269d6d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5511,6 +5511,7 @@ const step18: MigrationStep = { 'field-column-lists-canonicalized', 'metric-filters-removed', 'cube-sub-day-granularities-removed', + 'cube-join-sql-and-relationship-removed', 'record-highlights-field-icon-removed', 'mapping-lookup-params-removed', 'translation-component-submit-label-removed', @@ -6673,6 +6674,51 @@ const step18: MigrationStep = { + 'fail tsc on upgrade; the fix is choosing a shipped driver, never ' + 'widening a local mirror of the enum.', }, + { + id: 'cube-join-sql-and-relationship-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship — the ' + + 'authored ON clause and the declared cardinality on a cube join', + replacement: + 'analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared ' + + 'relationship between the two cubes\' objects, as a foreign-key equality: NativeSQLStrategy ' + + 'emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the ' + + 'same alias to a relationship traversal with no ON clause at all. The record KEY is the ' + + 'foreign-key FIELD on the base object, never a second spelling of the object the join ' + + 'reaches.', + reason: + 'The KEYS convert mechanically and do: the paired D2 conversion ' + + '`cube-join-sql-and-relationship-removed` deletes both from every join, which is lossless ' + + 'because neither ever had an effect to lose, and names the cube in each notice. What does ' + + 'NOT convert is the INTENT. `sql` was REQUIRED and documented as the ON clause, and no ' + + 'reader ever consulted it: an authored condition was REPLACED by the synthesised ' + + 'foreign-key equality and the aggregate came back under a 200, joined on something the ' + + 'author had not asked for. `relationship` carried a `.default(\'many_to_one\')` that nothing ' + + 'dispatched on, so `one_to_many` parsed, changed no SQL, and kept the many-to-one ' + + 'arithmetic. Deleting the keys restores honesty but does not give an author who wanted a ' + + 'non-FK join the thing they wanted, and it does not re-check the numbers the replaced join ' + + 'already produced. That is why this entry is a TODO addressed to them rather than a claim ' + + 'that the strip finished the job. A custom join condition is a capability card with its ' + + 'injection / allow-list boundary decided first, which the ruling deferred deliberately.', + acceptanceCriteria: + 'Delete `sql` and `relationship` from every entry of every cube `joins` map; keep `name`. ' + + 'The paired D2 conversion `cube-join-sql-and-relationship-removed` performs that same ' + + 'strip mechanically wherever the chain is replayed — including over metadata already at ' + + 'rest, so a deployed artifact keeps booting while you do. Then check three things. (1) Did any deleted `sql` express something OTHER than the foreign-key ' + + 'equality between the two objects — a filtered join, a non-key column, a literal predicate? ' + + 'If so, the query you were getting was already the FK-equality answer and not the one you ' + + 'wrote, so re-read the numbers that join produced before assuming this change moved them; the ' + + 'fix is to model the relationship on the object, or to open a capability request for an ' + + 'authorable join condition. (2) Did any deleted `relationship` say anything but ' + + '`many_to_one`? If so, the aggregate was already computed as many-to-one and still is — this ' + + 'change alters no result, it only stops the declaration from claiming otherwise. (3) Is each ' + + 'join KEYED by a foreign-key field of the cube\'s own base object? The key is the column the ' + + 'derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. ' + + 'Nothing else regresses: `joins..name` is unchanged, and it is what both the joined ' + + 'table and the per-object RLS/tenant read scope are resolved from.', + }, { id: 'dashboard-header-modal-target-page-only', surface: @@ -13028,6 +13074,51 @@ 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', + // #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 + // `one_to_many` parsed, changed no SQL, and the aggregate silently kept the + // many-to-one arithmetic. Two service-analytics fixtures authored + // `relationship: 'belongsTo'` — a value the enum never declared — which is its + // own evidence that nothing validated or read the key. + // + // Same route and same registration reasoning as the `sql` entry beside this + // one: strict deletion plus a `guidance` prescription on the `strictObject`, + // registered under 18. The D2 conversion + // `cube-join-sql-and-relationship-removed` strips this key too, and it is owed + // for the mirror-image reason: the key was DEFAULTED, so the value was + // MATERIALIZED into every cube artifact the old schema ever parsed, whether or + // not its author typed it. + 'data/CubeJoin:relationship', + // #18612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-18, director + // batch #154 item 4, letter 2). `CubeJoin.sql` was REQUIRED and described itself + // as the `ON` clause, and nothing ever read it: both analytics strategies + // SYNTHESISE the join, so an authored condition was not ignored but REPLACED by + // a foreign-key equality, returned under a 200 with a plausible number attached + // (the #10298 shape). Measured with a positive control — zero reads of a join's + // `sql` in any non-test source, against eight reads of the neighbouring + // `cube.joins?.[alias]?.name` in native-sql-strategy.ts, objectql-strategy.ts + // and analytics-service.ts. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the `data/Metric:filters` + // precedent, one shape over in the same file). `CubeJoinSchema` is a + // `strictObject`, so the route is strict deletion plus a `guidance` entry + // carrying the prescription — no `retiredKey()` tombstone, the key is out of + // the walked shape entirely, and its liveness-ledger row left with it. + // + // A D2 conversion DOES cover this surface: `cube-join-sql-and-relationship-removed` + // (`toMajor: 18`, `retiredFromLoadPath: true`) strips the key wherever the chain + // is replayed. It is owed because the key was REQUIRED, so every cube artifact + // ever written from the old schema's parse output carries it and would meet the + // boot door's refusal with no remedy short of hand-editing JSON. The guidance + // prescription therefore closes with the house `os migrate meta --from 17` + // sentence, and the D3 semantic entry `cube-join-sql-and-relationship-retired` + // carries the judgement the strip cannot: an author who wrote a non-FK + // condition wanted a join this runtime does not perform. + 'data/CubeJoin:sql', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in