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
56 changes: 56 additions & 0 deletions .changeset/chart-aggregate-groupby-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/spec": minor
"@objectstack/lint": patch
---

feat(spec): `ChartAggregateSchema` / `ChartGroupBySchema` reject unknown keys instead of dropping them (#5583, #4001 批 15's last two sites)

`<ObjectChart aggregate={{ … }}>` is the react tier's object-bound chart binding,
and until now a key it did not declare was **silently stripped by the parse**.
`groupby` for `groupBy` degraded the chart to a single ungrouped point, `fn` for
`function` fell back to the default, `dateGranularty` for `dateGranularity`
turned off date bucketing — each with `os build` / `os validate` fully green.
That is #4001's founding failure mode, on the surface an AI page author is most
likely to write.

Both object shapes are `strictObject` now, so an undeclared key is a named
rejection carrying the surface, the offending key and a rename:

```
Unrecognized key(s) on this chart aggregate: `groupby`.
Did you mean `groupby` → `groupBy`? Until #5583 an undeclared aggregate key was
dropped at parse — …
```

Curated beyond edit distance where the near-miss is semantic rather than a typo:
`fn` / `agg` / `aggregation` → `function`, `measure` → `field`, and the ADR-0021
dataset vocabulary an author carries over from the other binding mode
(`dimension` / `category` → `groupBy`). Wrong-LAYER keys get a prescription
instead of a rename — `dateGranularity` written *beside* `groupBy` did nothing at
all and now says where it belongs; `alias`, `filter`, `objectName` and a
`measures` array are pointed at the surface that owns them.

**Why this took two issues.** `.strict()` is a property of a PARSE, and until
#5020 nothing parsed these schemas: the react-page publish gate re-derived the
vocabulary by hand. Closing them first would have shipped a precisely-validated
door with nothing behind it (#4583). #5020 wired the parse; this is the posture.

**The zod-4 union collapse is load-bearing here.** `groupBy` is a union, so the
`unrecognized_keys` its strict arm raises never reaches `error.issues` — zod
reports one `invalid_union` whose own message is the bare string `"Invalid
input"`. What carries the named rejection to the author is `packages/lint`'s
`describeIssue` arm unpacking, pinned end to end on both sides.

**`groupBy` stays REQUIRED — the product question this pair raised is answered,
and the answer does not move the schema.** An ungrouped single-value chart is
not a supported `<ObjectChart>` shape: the single-value need is served by the
separate `object-metric` block, the example corpus authors zero ungrouped
`<ObjectChart>` aggregates, and objectui's `schema.aggregate?.groupBy ||
schema.xAxisKey` reads are optional-chained on `aggregate` itself — they serve
charts with **no aggregate at all**, not ungrouped ones. #5020's `warning`-level
tolerance for an absent `groupBy` therefore stays a tolerance rather than
becoming a blessing; its hint now states the ruling.

**Upgrading:** if a chart aggregate carried a key this schema does not declare,
it was already being ignored — the rejection names it and prescribes the fix. No
legal declaration changes meaning.
59 changes: 59 additions & 0 deletions .changeset/strict-unknown-key-error-call-sites-migrated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
"@objectstack/spec": patch
---

refactor(spec): the last 44 hand-transcribed key lists are gone — every alias table is judged against its schema's real shape (#5593)

Forty-four authoring schemas predated `strictObject` and wired their unknown-key
error by hand: a `const X_KEYS = [...] as const` transcription of the shape, a
`strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, and a drift-probe test
whose only job was to catch the two copies disagreeing. All 44 now call
`strictObject(options, shape)`, which reads the candidate list from the shape
itself, and the 16 transcriptions plus their probe tests are deleted.

The point is not the line count — it is what the alias-integrity gate (#5013)
can now assert about them. #5483 had put these tables under the gate through a
transitional registry, but two of its three claims were answered against the
*transcription*: an array that had drifted from its schema dragged both answers
with it, and "this alias target is a tombstone" was invisible because a flat
string array holds no schemas. Migrating closes that half, and the migration
itself found what the transcriptions were hiding:

- **11 alias/suggestion targets were retired keys.** `app` (8: `apis`, `aria`,
`embed`, `homePageId`, `mobileNavigation`, `objects`, `sharing`, `version`),
`flow` (`active`, `template`) and `flow node` (`outputSchema`) are
`retiredKey()` tombstones the arrays still listed, so a near-miss was steered
onto the one key guaranteed to be rejected next — ledger finding 12, three
files, live. `strictObject` excludes anything the shape cannot accept, so the
author now gets the tombstone's own upgrade prescription instead.
- **A nav `separator` was answering with keys it rejects.** The nine navigation
variants shared one transcription that handed every variant the base nav keys —
but `SeparatorNavItemSchema` spreads nothing and declares `type` / `id` /
`order` alone. Writing `title` on a separator was answered *"did you mean
`label`?"*, and `label` was rejected too: finding 7, from the campaign built to
end it. The separator now carries the alias entries whose target it really has,
and one prescription for the nine base keys it does not.
- **Three ADR-0010 envelopes were missing from their own pools** (`datasource`,
`hook`, `sharing rule`): the protection keys the shapes spread were never
transcribed, so a typo of one got no suggestion at all.

Author-facing messages are otherwise unchanged — the surface name, the offending
key, the rename and the curated prescriptions all survive verbatim, verified by
comparing every migrated surface's old array against its new derived pool and by
sampling a real rejection from each.

Two structural consequences:

- the shrink-only ratchet on direct `strictUnknownKeyError` call sites is a hard
**zero**, and the assertion changed meaning with the number: it no longer
measures how much of the gate runs on the weaker instrument, it forbids the
weaker instrument. `strictUnknownKeyError` stays published for external
callers; inside `packages/spec` the only caller is `strictObject`.
- `shared/alias-table-registry.ts` — #5483's transitional registry — is deleted
with its last call site, along with the suppression hook `strictObject` needed
to stay out of it.

`data/object.zod.ts`'s error map was built lazily to step around a temporal dead
zone; `strictObject` evaluates its options at construction, so the deferral is
replaced by declaration order (`UNKNOWN_KEY_GUIDANCE` moved above the shape) and
that order is now load-bearing.
17 changes: 8 additions & 9 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ regenerate.
|---|---|
| Triaged directories | 5 |
| Object sites in them | 434 |
| Still-open (strip) sites | 182 |
| Files carrying at least one | 28 |
| Still-open (strip) sites | 180 |
| Files carrying at least one | 27 |

Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 43 |
| authorable — the ruling's forced scope | 41 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 104 |
| no door — no carrier, ADR-0049 territory | 1 |
Expand All @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 160 | 116 | 5 | 0 | 39 |
| `ui/` | 160 | 118 | 5 | 0 | 37 |
| `data/` | 162 | 54 | 1 | 0 | 107 |
| `automation/` | 65 | 42 | 0 | 0 | 23 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **434** | **246** | **6** | **0** | **182** |
| **total** | **434** | **248** | **6** | **0** | **180** |

## File-level triage — site counts

Expand Down Expand Up @@ -156,21 +156,20 @@ over it is here.

### `ui/` — open

**39 strip of 160**, in 6 file(s).
**37 strip of 160**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `chart.zod.ts` | 2 | 8 |
| `component.zod.ts` | 31 | 31 |
| `view.zod.ts` | 3 | 53 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **39** | **160** |
| **total** | **37** | **160** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 34 |
| authorable — the ruling's forced scope | 32 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 3 |
| no door — no carrier, ADR-0049 territory | 1 |
Expand Down
Loading
Loading