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
48 changes: 48 additions & 0 deletions .changeset/app-navigation-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": major
"@objectstack/platform-objects": patch
---

feat(spec)!: reject unknown keys across the app shell and navigation tree (#4001 app step, PR B)

Closes the last high-traffic authorable surface in the unknown-key strictness
ratchet (flow + permission #4071, RLS / sharing / position #4099, approval
#4119, App dead-key tombstones #4142). The app shell is the densest
hand-authored surface on the platform — a navigation tree is where an author
or AI is most likely to write a key from memory — so a silent strip here was
the most probable instance of the #3405 trap.

- **`AppSchema`** and its sub-schemas (`AppBrandingSchema`,
`NavigationAreaSchema`, `AppContextSelectorSchema` + its `optionsSource` /
`filter` blocks, `NavigationContributionSchema`) are `.strict()`.
- **`NavigationItemSchema` becomes a DISCRIMINATED union on `type`.** This is
what makes strict readable: a plain union of strict members answers one
unknown key with an `invalid_union` aggregate naming all nine branches,
while discriminating on `type` first yields a single `unrecognized_keys`
issue against the branch the author actually wrote — at an exact path
through nested `children` — and a mistyped `type` gets its own "Invalid
discriminator value". Each variant carries its own suggestion pool, so a
`url` item is never told about `dashboardName`.
- **Still OPEN by design:** `PageNavItem.params`, `ComponentNavItem.params`
and `ActionNavItem.actionDef.params` — per-target payloads owned by the
page / component / action, not by the nav item.

**A real defect the gate caught, in the platform's own app:** `ACCOUNT_APP`
declared `defaultOpen` on three navigation groups. That was never a schema
key — `expanded` is — so all three shipped COLLAPSED while their author
believed they opened by default. Fixed at the producer (contract-first) and
`defaultOpen` / `open` / `collapsed` / `isOpen` now alias to `expanded`.

**Migration.** Any key now rejected was previously stripped and had no
runtime effect. The error carries the fix; mappings include
`menu`/`sidebar`/`tabs`/`items` → `navigation`, `title` → `label`,
`permissions` → `requiredPermissions`, `sort`/`position` → `order`,
`defaultOpen` → `expanded`, `args` → `params` (actionDef), `primary` →
`primaryColor`, `url` → `endpoint` (options source), plus wrong-layer
pointers: `pages`/`views`/`flows` are not App fields, and a payload named on
the wrong variant points at the `type` that owns it.

The `visibleWhen` → `visible` alias is the load-bearing one: ADR-0089 made
`visibleWhen` canonical on view/page schemas, so an author who learned it
there would silently lose a nav entry's visibility gate — a capability gate
failing open, the worst shape of the silent-strip bug.
11 changes: 10 additions & 1 deletion content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ model), while `url` platform-wide means an HTTP endpoint to call (`http` node,
webhooks). The singular `input` on `map` / `subflow` / `connector_action` is
those nodes' own canonical key and is untouched.

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

Zod's default is `.strip`: a key a schema does not declare is silently
discarded and the instance keeps parsing. On an authorable surface that is the
Expand Down Expand Up @@ -341,6 +341,15 @@ schema to the two highest-risk authorable surfaces, per the triage in
a deliberately flat position (ADR-0090 D3). Position also gains the
`protection` block and ADR-0010 runtime envelope every sibling registered
type already declared.
- **The app shell** — `AppSchema`, its branding / area / context-selector /
contribution blocks, and the whole navigation tree. The nav-item union is
now DISCRIMINATED on `type`, so one unknown key yields one precise issue
against the branch you wrote (at an exact path through nested `children`)
rather than a nine-branch `invalid_union` wall. Per-target payloads
(`params` on page / component / action items) stay open. The gate's first
catch here was in the platform's own Account app: three navigation groups
declared `defaultOpen` — never a schema key — and so shipped collapsed
while their author believed they opened by default (`expanded` is the key).
- **Approval nodes** — all four authoring schemas (node config, approver,
escalation, decision-output). Process-era keys carry the ADR-0019 re-home
map (`steps` → successive approval nodes, `entryCriteria` → the entering
Expand Down
18 changes: 16 additions & 2 deletions content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const crmApp = {

## Navigation Items

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.
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.

### Object Navigation

Expand Down Expand Up @@ -121,9 +121,23 @@ Groups items into collapsible sections with children:
}
```

### Separator

A visual divider in the navigation list. It renders no target and carries no
label — the only keys it accepts are `type`, an optional `id`, and an optional
`order`:

```typescript
{ type: 'separator', order: 30 }
```

### Common Navigation Properties

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`:
All navigation items **except `separator`** 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`.
(On a `separator`, `id` is optional and the remaining properties below are
rejected — a divider has nothing to label, gate, or badge.)

| Property | Type | Description |
| :--- | :--- | :--- |
Expand Down
25 changes: 21 additions & 4 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ dropped at parse, and nothing failed.
faith, silently discarded, believed to be in effect. Removed (the identical
stamp on the fixture's *object* is real and stays).

5. **`position.test.ts` asserted a fictional hierarchy** (step 2) — see the
entry below.
6. **The platform's own Account app declared `defaultOpen` on three navigation
groups** (app step, PR B). `expanded` is the schema key; `defaultOpen`
never was — so all three groups shipped COLLAPSED while their author
believed they opened by default. Fixed at the producer, and the spelling
now aliases to `expanded`. Note where this one was found: not in a tenant
project, but in first-party platform metadata that had been shipping for
releases.

This is the empirical argument for the ratchet: the inference "no metadata in
the repo carries unknown keys" was **false three times over**, and only the
strict gate could prove it. Note the asymmetry in the two schema gaps — both
Expand Down Expand Up @@ -127,7 +137,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `view.zod.ts` | 51 | authorable | partially strict (ADR-0089); long tail of sub-blocks |
| `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) |
| `theme.zod.ts` | 14 | authorable (p) | authored themes |
| `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) |
| `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 |
| `dashboard.zod.ts` | 11 | authorable | partially strict |
| `widget.zod.ts` | 9 | authorable (p) | |
| `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) |
Expand Down Expand Up @@ -203,9 +213,12 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).

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

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

Done in the app step, PR B: `AppSchema` + the navigation tree strict, via a
discriminated union — the union-error concern that deferred this step was
resolved by measurement, not design work.

Done in the app step, PR A: the seven audit-dead AppSchema keys tombstoned
(`retiredKey` + `app-dead-authoring-keys-removed` conversion + step-17
migration entry), clearing the enforce-or-remove precondition for the app
Expand Down
6 changes: 3 additions & 3 deletions packages/platform-objects/src/apps/account.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const ACCOUNT_APP: App = {
type: 'group',
label: 'Inbox',
icon: 'inbox',
defaultOpen: true,
expanded: true,
children: [
{
// ADR-0030: the user-facing inbox is the materialization
Expand Down Expand Up @@ -126,7 +126,7 @@ export const ACCOUNT_APP: App = {
type: 'group',
label: 'Security',
icon: 'shield',
defaultOpen: true,
expanded: true,
children: [
{
id: 'nav_account_linked',
Expand Down Expand Up @@ -154,7 +154,7 @@ export const ACCOUNT_APP: App = {
type: 'group',
label: 'Developer',
icon: 'code',
defaultOpen: false,
expanded: false,
children: [
{
id: 'nav_account_api_keys',
Expand Down
Loading
Loading