Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/analytics-authorable-unknown-keys-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": minor
---

feat(spec): refuse undeclared keys on the analytics authoring surface (#4001 data batch D)

**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
launch-window convention ships it as `minor`; the migration prescription is
registered under protocol major 18, where `os migrate meta` users will look).

All 8 `data/analytics.zod.ts` sites are strict: the cube family (`CubeSchema` +
its `refreshKey` block, `MetricSchema` + its `filters[]` items,
`DimensionSchema`, `CubeJoinSchema`) and the query family
(`AnalyticsQuerySchema` + its `timeDimensions[]` items). Before this change an
undeclared key on any of them was silently dropped: a join authored with a
typo'd `relationship` registered with the `many_to_one` default — a different
join shape than the author declared — and a cube's misspelled key vanished
under a successful parse.

The subtle half is the query: `/analytics/query`'s TOP level has been strict
since #3878 (`AnalyticsQueryRequestSchema`), but top-level strictness does not
recurse — measured on `main`, `timeDimensions: [{ dimension, granuarity:
'day' }]` rode through the strict wrapper with the typo silently stripped, so
the query bucketed the whole range as one group under an ordinary 200. The
nested item is now strict, and the base schema's own strictness makes the
posture hold at every door instead of only at the wrapper that re-applied it.

**What is refused:** any key the shape does not declare, with a prescriptive
message — the surface, the offending key, and a rename (`title` → `label` on a
metric/dimension, `label` → `title` on the cube, `table`/`sqlTable` → `sql`,
`granularity` → `granularities` on a dimension and the reverse on a query time
dimension, `orderBy` → `order`; `filters` on a query gets the `where`
prescription matching the dispatcher's #3878 hint).

**What stays accepted:** every declared key byte-identically, including the
`#3878` tombstones on the request wrapper (`query`/`format` still answer their
migration text).

## FROM → TO

```ts
// before — parsed green; the join fell back to many_to_one silently
defineCube({
name: 'orders', sql: 'orders',
measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } },
dimensions: {},
joins: { customers: { name: 'customers', sql: 'a.id = b.a_id', relationshipp: 'one_to_many' } },
})

// after — rejected with `relationshipp` → `relationship`; write the declared key
defineCube({
name: 'orders', sql: 'orders',
measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } },
dimensions: {},
joins: { customers: { name: 'customers', sql: 'a.id = b.a_id', relationship: 'one_to_many' } },
})
```

There is deliberately no automatic rewrite: an undeclared key is either a
spelling of a declared one (the rejection names the rename) or names a
capability the analytics layer does not deliver, and blessing it would be
declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the
change as a structured TODO (semantic entry
`analytics-authorable-unknown-keys-refused`, protocol major 18 — this refusal
is not part of the v17.0.0 cut).

<!-- adr-0087: registered analytics-authorable-unknown-keys-refused -->
21 changes: 10 additions & 11 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ regenerate.
|---|---|
| Triaged directories | 5 |
| Object sites in them | 433 |
| Still-open (strip) sites | 132 |
| Files carrying at least one | 24 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 23 |

Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 2 |
| unresolved — needs a per-schema verdict | 22 |
| wire / open — out of forced scope | 104 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 118 |
| no door — no carrier, ADR-0049 territory | 3 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |
Expand All @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th
| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 168 | 157 | 5 | 0 | 6 |
| `data/` | 153 | 62 | 1 | 0 | 90 |
| `data/` | 153 | 70 | 1 | 0 | 82 |
| `automation/` | 65 | 42 | 0 | 0 | 23 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **433** | **295** | **6** | **0** | **132** |
| **total** | **433** | **303** | **6** | **0** | **124** |

## File-level triage — site counts

Expand Down Expand Up @@ -177,11 +177,10 @@ over it is here.

### `data/` — open

**90 strip of 153**, in 14 file(s).
**82 strip of 153**, in 13 file(s).

| File | Strip | Sites |
|---|---|---|
| `analytics.zod.ts` | 8 | 8 |
| `data-engine.zod.ts` | 13 | 13 |
| `document.zod.ts` | 8 | 8 |
| `driver-nosql.zod.ts` | 10 | 10 |
Expand All @@ -195,13 +194,13 @@ over it is here.
| `object.zod.ts` | 1 | 20 |
| `query.zod.ts` | 4 | 5 |
| `seed-loader.zod.ts` | 12 | 12 |
| **total** | **90** | **153** |
| **total** | **82** | **153** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 1 |
| unresolved — needs a per-schema verdict | 22 |
| wire / open — out of forced scope | 65 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 79 |
| no door — no carrier, ADR-0049 territory | 2 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 0 |
Expand Down
Loading
Loading