Skip to content

Commit 09e4547

Browse files
authored
feat(spec)!: reject unknown keys across the app shell and navigation tree (#4165)
Closes the last high-traffic authorable surface in the unknown-key ratchet (#4001 app step, PR B). - AppSchema and all sub-schemas .strict(): branding, navigation area, context selector (incl. optionsSource / filter blocks), nav contribution. - NavigationItemSchema converted to a union discriminated on `type` — a single unrecognized_keys issue against the branch the author actually wrote, at an exact path, instead of an invalid_union aggregate wall. - Deliberately left open: PageNavItem.params / ComponentNavItem.params / ActionNavItem.actionDef.params. - Gate finding #6, in first-party platform metadata: ACCOUNT_APP declared `defaultOpen` on three nav groups (the key is `expanded`), so they had been shipping collapsed. Producer fixed contract-first; alias added. - Docs: apps.mdx claimed eight navigation item types where the schema has nine, and omitted `separator` — which is .strict() over only type/id/order, so base props on one are now hard errors. Refs #4001
1 parent dd5daac commit 09e4547

7 files changed

Lines changed: 563 additions & 39 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
feat(spec)!: reject unknown keys across the app shell and navigation tree (#4001 app step, PR B)
7+
8+
Closes the last high-traffic authorable surface in the unknown-key strictness
9+
ratchet (flow + permission #4071, RLS / sharing / position #4099, approval
10+
#4119, App dead-key tombstones #4142). The app shell is the densest
11+
hand-authored surface on the platform — a navigation tree is where an author
12+
or AI is most likely to write a key from memory — so a silent strip here was
13+
the most probable instance of the #3405 trap.
14+
15+
- **`AppSchema`** and its sub-schemas (`AppBrandingSchema`,
16+
`NavigationAreaSchema`, `AppContextSelectorSchema` + its `optionsSource` /
17+
`filter` blocks, `NavigationContributionSchema`) are `.strict()`.
18+
- **`NavigationItemSchema` becomes a DISCRIMINATED union on `type`.** This is
19+
what makes strict readable: a plain union of strict members answers one
20+
unknown key with an `invalid_union` aggregate naming all nine branches,
21+
while discriminating on `type` first yields a single `unrecognized_keys`
22+
issue against the branch the author actually wrote — at an exact path
23+
through nested `children` — and a mistyped `type` gets its own "Invalid
24+
discriminator value". Each variant carries its own suggestion pool, so a
25+
`url` item is never told about `dashboardName`.
26+
- **Still OPEN by design:** `PageNavItem.params`, `ComponentNavItem.params`
27+
and `ActionNavItem.actionDef.params` — per-target payloads owned by the
28+
page / component / action, not by the nav item.
29+
30+
**A real defect the gate caught, in the platform's own app:** `ACCOUNT_APP`
31+
declared `defaultOpen` on three navigation groups. That was never a schema
32+
key — `expanded` is — so all three shipped COLLAPSED while their author
33+
believed they opened by default. Fixed at the producer (contract-first) and
34+
`defaultOpen` / `open` / `collapsed` / `isOpen` now alias to `expanded`.
35+
36+
**Migration.** Any key now rejected was previously stripped and had no
37+
runtime effect. The error carries the fix; mappings include
38+
`menu`/`sidebar`/`tabs`/`items``navigation`, `title``label`,
39+
`permissions``requiredPermissions`, `sort`/`position``order`,
40+
`defaultOpen``expanded`, `args``params` (actionDef), `primary`
41+
`primaryColor`, `url``endpoint` (options source), plus wrong-layer
42+
pointers: `pages`/`views`/`flows` are not App fields, and a payload named on
43+
the wrong variant points at the `type` that owns it.
44+
45+
The `visibleWhen``visible` alias is the load-bearing one: ADR-0089 made
46+
`visibleWhen` canonical on view/page schemas, so an author who learned it
47+
there would silently lose a nav entry's visibility gate — a capability gate
48+
failing open, the worst shape of the silent-strip bug.

content/docs/releases/v17.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ model), while `url` platform-wide means an HTTP endpoint to call (`http` node,
307307
webhooks). The singular `input` on `map` / `subflow` / `connector_action` is
308308
those nodes' own canonical key and is untouched.
309309

310-
### Flow, permission, RLS, sharing, position and approval schemas reject unknown keys (#4001)
310+
### Authorable schemas reject unknown keys (#4001)
311311

312312
Zod's default is `.strip`: a key a schema does not declare is silently
313313
discarded and the instance keeps parsing. On an authorable surface that is the
@@ -341,6 +341,15 @@ schema to the two highest-risk authorable surfaces, per the triage in
341341
a deliberately flat position (ADR-0090 D3). Position also gains the
342342
`protection` block and ADR-0010 runtime envelope every sibling registered
343343
type already declared.
344+
- **The app shell**`AppSchema`, its branding / area / context-selector /
345+
contribution blocks, and the whole navigation tree. The nav-item union is
346+
now DISCRIMINATED on `type`, so one unknown key yields one precise issue
347+
against the branch you wrote (at an exact path through nested `children`)
348+
rather than a nine-branch `invalid_union` wall. Per-target payloads
349+
(`params` on page / component / action items) stay open. The gate's first
350+
catch here was in the platform's own Account app: three navigation groups
351+
declared `defaultOpen` — never a schema key — and so shipped collapsed
352+
while their author believed they opened by default (`expanded` is the key).
344353
- **Approval nodes** — all four authoring schemas (node config, approver,
345354
escalation, decision-output). Process-era keys carry the ADR-0019 re-home
346355
map (`steps` → successive approval nodes, `entryCriteria` → the entering

content/docs/ui/apps.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const crmApp = {
5757

5858
## Navigation Items
5959

60-
The navigation tree supports eight item types, combined to create rich menu structures. The most common are shown below (`object`, `dashboard`, `page`, `url`, `group`); the spec also defines `report`, `action`, and `component` items.
60+
The navigation tree supports nine item types, combined to create rich menu structures. The most common are shown below (`object`, `dashboard`, `page`, `url`, `group`, `separator`); the spec also defines `report`, `action`, and `component` items.
6161

6262
### Object Navigation
6363

@@ -121,9 +121,23 @@ Groups items into collapsible sections with children:
121121
}
122122
```
123123

124+
### Separator
125+
126+
A visual divider in the navigation list. It renders no target and carries no
127+
label — the only keys it accepts are `type`, an optional `id`, and an optional
128+
`order`:
129+
130+
```typescript
131+
{ type: 'separator', order: 30 }
132+
```
133+
124134
### Common Navigation Properties
125135

126-
All navigation items share these base properties. Every item **must** declare a unique `id` (lowercase `snake_case`) — it is required by the schema and is referenced by `homePageId` and `mobileNavigation.bottomNavItems`:
136+
All navigation items **except `separator`** share these base properties. Every
137+
item **must** declare a unique `id` (lowercase `snake_case`) — it is required by
138+
the schema and is referenced by `homePageId` and `mobileNavigation.bottomNavItems`.
139+
(On a `separator`, `id` is optional and the remaining properties below are
140+
rejected — a divider has nothing to label, gate, or badge.)
127141

128142
| Property | Type | Description |
129143
| :--- | :--- | :--- |

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ dropped at parse, and nothing failed.
9292
faith, silently discarded, believed to be in effect. Removed (the identical
9393
stamp on the fixture's *object* is real and stays).
9494

95+
5. **`position.test.ts` asserted a fictional hierarchy** (step 2) — see the
96+
entry below.
97+
6. **The platform's own Account app declared `defaultOpen` on three navigation
98+
groups** (app step, PR B). `expanded` is the schema key; `defaultOpen`
99+
never was — so all three groups shipped COLLAPSED while their author
100+
believed they opened by default. Fixed at the producer, and the spelling
101+
now aliases to `expanded`. Note where this one was found: not in a tenant
102+
project, but in first-party platform metadata that had been shipping for
103+
releases.
104+
95105
This is the empirical argument for the ratchet: the inference "no metadata in
96106
the repo carries unknown keys" was **false three times over**, and only the
97107
strict gate could prove it. Note the asymmetry in the two schema gaps — both
@@ -127,7 +137,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
127137
| `view.zod.ts` | 51 | authorable | partially strict (ADR-0089); long tail of sub-blocks |
128138
| `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) |
129139
| `theme.zod.ts` | 14 | authorable (p) | authored themes |
130-
| `app.zod.ts` | 11 | authorable | **PR A done (#4001 app step): the seven audit-dead keys (`version`/`aria`/`objects`/`apis`/`sharing`/`embed`/`mobileNavigation`) are `retiredKey()` tombstones + an ADR-0087 conversion** — the ADR-0049 precondition for strict. **PR B next**: `AppSchema` + nav union `.strict()`; the union-error question is settled — convert `NavigationItemSchema` to `z.discriminatedUnion('type', …)` (verified: matched-branch-only unknown-key errors, precise recursive paths, `toJSONSchema` clean) |
140+
| `app.zod.ts` | 11 | authorable | **strict as of #4001 PR B**`AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first |
131141
| `dashboard.zod.ts` | 11 | authorable | partially strict |
132142
| `widget.zod.ts` | 9 | authorable (p) | |
133143
| `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) |
@@ -203,9 +213,12 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
203213

204214
## Next steps (verify-then-enforce, one shape at a time)
205215

206-
1. `ui/app.zod.ts``AppSchema` + navigation union (highest-traffic remaining
207-
authorable type; needs union-error design so the strict error is readable).
208-
2. `data/hook.zod.ts`, `data/datasource.zod.ts``defineHook` / stack config.
216+
1. `data/hook.zod.ts`, `data/datasource.zod.ts``defineHook` / stack config
217+
(both still provisional (p) classifications — verify before tightening).
218+
2. The `@objectstack/lint` unknown-key WARNING layer: non-breaking, shippable
219+
in a minor, and it extends AI-detectable coverage to every remaining
220+
authorable site at once while accumulating evidence (which keys real
221+
tenant projects actually carry) for a v18 strict close-out.
209222
3. Promote this ledger to a machine-checked gate (pattern of
210223
`packages/spec/liveness/` + `check:liveness`) once enough of the surface is
211224
classified that the table above is enforceable rather than descriptive.
@@ -219,6 +232,10 @@ schemas, with the ADR-0019 re-home map as wrong-layer guidance
219232
(`steps` / `entryCriteria` / `onApprove` / `onReject` / `rejectionBehavior`
220233
each point at where the concept lives on the flow graph now).
221234

235+
Done in the app step, PR B: `AppSchema` + the navigation tree strict, via a
236+
discriminated union — the union-error concern that deferred this step was
237+
resolved by measurement, not design work.
238+
222239
Done in the app step, PR A: the seven audit-dead AppSchema keys tombstoned
223240
(`retiredKey` + `app-dead-authoring-keys-removed` conversion + step-17
224241
migration entry), clearing the enforce-or-remove precondition for the app

packages/platform-objects/src/apps/account.app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const ACCOUNT_APP: App = {
8080
type: 'group',
8181
label: 'Inbox',
8282
icon: 'inbox',
83-
defaultOpen: true,
83+
expanded: true,
8484
children: [
8585
{
8686
// ADR-0030: the user-facing inbox is the materialization
@@ -126,7 +126,7 @@ export const ACCOUNT_APP: App = {
126126
type: 'group',
127127
label: 'Security',
128128
icon: 'shield',
129-
defaultOpen: true,
129+
expanded: true,
130130
children: [
131131
{
132132
id: 'nav_account_linked',
@@ -154,7 +154,7 @@ export const ACCOUNT_APP: App = {
154154
type: 'group',
155155
label: 'Developer',
156156
icon: 'code',
157-
defaultOpen: false,
157+
expanded: false,
158158
children: [
159159
{
160160
id: 'nav_account_api_keys',

0 commit comments

Comments
 (0)