You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* feat(spec)!: retire the two fail-open app-area gates (#4651)
`app.areas[].visible` and `app.areas[].requiredPermissions` leave the authorable
contract. They were not ordinary dead keys: they were capability gates that
FAILED OPEN. `filterAppForUser` (packages/rest/src/rest-server.ts) checks the
app's `requiredPermissions`, then walks ONLY `item.navigation` — it returns
early when that tree is absent and never reads `item.areas` at all — while the
client renders every area in the switcher. An author writing
`requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored
value, and an area visible to everyone.
What made them read alive is that the SAME names are enforced one level up and
one level down: app-level `requiredPermissions` drops the whole app server-side,
and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped
server-side and re-checked in the shell, whose item-level `visible` is a real
CEL gate. Three layers, of which the middle one was theatre — ADR-0078 false
compliance, the `capabilities.readOnly` shape (#4583).
Route B (remove) per the maintainer's ruling. Route A (enforce) is not wrong,
it is unscoped: area filtering needs semantics decided first (does filtering an
area remove its items everywhere? does the server bind `user` for area CEL?),
which the 17.0.0 window cannot hold — and a retirement PR must not invent an
authorization mechanism. Shipping the major with the gate still declared would
have kept authors writing it for all of 17.x.
`NavigationAreaSchema` is `.strict()`, so this is the strict-deletion route:
the keys leave the shape and two `guidance` prescriptions carry the upgrade.
The prescriptions name the layers that DO enforce rather than saying "removed",
and carry the caveat that per-item gating INSIDE an area is shell-side only —
trading one false belief for a weaker one would repeat the defect. The three
alias spellings that used to RENAME onto these keys (`visibleWhen`, `visibleOn`,
`permissions`) become prescriptions too: an alias pointing at a key that is
itself gone answers an unknown key with a second unknown key (#4667 `sort`
precedent).
ADR-0087: a dedicated `app-area-fail-open-gates-removed` conversion rather than
two more keys on `app-dead-authoring-keys-removed`. That entry's summary is a
list of inert authoring keys; this is a security finding, and the summary string
is what spec-changes.json, the upgrade guide and the `spec_changes` MCP tool
serve — folded in, "a gate that never gated has been removed" would arrive
buried in a sentence about `version` and `mobileNavigation`. Wired into the
protocol-17 D3 chain step with its rationale.
Ledgers, per route: the liveness rows are DELETED (strict removal — the keys
left the walked shape, and retained rows report ORPHAN), and the CLI advisory
lint falls silent by itself, its test flipped from warn-pin to silence-pin with
an anti-vacuity guard. The strictness ledger needs no change and was not
touched: `check:strictness-ledger` counts `z.object(` SITES per file, and this
removes keys from an existing site — the C3 lesson applies to deleting a
`.strict()` shape, which this is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
* docs(spec): sync the liveness README row + add the major changeset (#4651)
The `app` row's counts come from `check-liveness.mts --json byStatus`, the
number CI enforces — regenerated rather than hand-edited: dead 11 → 9 after the
two area-gate rows were deleted. (Its Notes cell said "Dead 12" against a column
reading 11, so that was already stale; corrected in the same pass.)
The changeset is deliberately NOT the "zero metadata migration" shape the
same-window renames (#4684 C9, #4535 C12) could claim. Those kept every key.
These two are authorable keys on a `.strict()` schema, so existing `app`
metadata declaring either one now fails to parse and `authorable-surface.json`
is net −2. It carries the FROM → TO table, the one-line fix, the
`os migrate meta --from 16` command, and the caveat that per-item gating inside
an area is shell-side only — a breaking changeset ships as CHANGELOG.md in the
npm package and is what an upgrading agent greps after the rejection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
feat(spec)!: retire the two fail-open app-area gates — `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651)
6
+
7
+
These were **not** inert authoring keys. They were capability gates that **failed
8
+
open**: an author wrote `requiredPermissions: ['sales.admin']` on a navigation
9
+
area, got a clean parse and a stored value, and the area — with everything under
10
+
it — was served and rendered to **every user**.
11
+
12
+
**This is a breaking change with a real migration.** Both keys are authorable
13
+
metadata keys on a `.strict()` schema, so existing `app` metadata that declares
14
+
either one now **fails to parse** with the prescription below. `authorable-surface.json`
15
+
is net **−2 keys**. This is not the "zero metadata migration" shape of the
16
+
same-window renames (#4661 C8, #4684 C9) — those kept every key.
17
+
18
+
**The retirement kit:**
19
+
20
+
| FROM | TO | Fix |
21
+
|---|---|---|
22
+
|`app.areas[].requiredPermissions`|*(removed)*| Delete the key. Gate each of the area's `navigation` items with `requiredPermissions` / `requiresService`, or gate the whole app with `requiredPermissions` on the AppSchema. |
23
+
|`app.areas[].visible`|*(removed)*| Delete the key. Move the same CEL expression onto the area's `navigation` items — a navigation **item**'s `visible` is evaluated per item by the shell. |
24
+
25
+
The retired alias spellings `visibleWhen` / `visibleOn` / `permissions` carry the
26
+
same prescriptions rather than renaming onto keys that are themselves gone.
27
+
28
+
Run `os migrate meta --from 16` to rewrite existing sources automatically
29
+
(ADR-0087 D2 conversion `app-area-fail-open-gates-removed`, wired into the
30
+
protocol-17 D3 chain step).
31
+
32
+
**Why they read alive — and why that made them worse than dead.** The *same key
33
+
names* are genuinely enforced one level up and one level down:
34
+
35
+
-**app-level**`requiredPermissions` — server-side: an app whose required
36
+
permissions the caller lacks is dropped from `/meta` entirely;
Copy file name to clipboardExpand all lines: docs/protocol-upgrade-guide.md
+3Lines changed: 3 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -168,6 +168,8 @@ Separately, `object.managedBy: 'system'` is retired in favour of `'system-data'`
168
168
169
169
Finally, five keys retire because the advisory lint could never have warned about them (#4509): mapping `extractQuery` / `errorPolicy` / `batchSize`, and app `contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, and a default materialises at parse time — so the liveness lint cannot tell a value the author wrote from one the schema supplied, and marking them would have warned on every mapping and every selector in existence. For a key in that state removal is not the escalation after a warning; it is the only channel that ever reaches the author, which is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What they claimed: `extractQuery` promised an export path no exporter implements (exports go through the ordinary query API); `errorPolicy` offered skip/abort/retry where error handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth reading twice — it was not unread but deliberately DISOBEYED, because context selectors are mandatory-scope and an "All" row would clear the scope: on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it. The mapping prescription for `batchSize` deliberately offers no rename: bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all live, but each is a different key sizing its own path — the same trap `datasource.retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.
170
170
171
+
The sharpest removal in this step is two keys wide: `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651). Read the class before the count — these were not inert authoring keys but FAIL-OPEN access gates. The server-side authority (`filterAppForUser`) checks the app's `requiredPermissions` and then walks ONLY the top-level `navigation` tree; it never reads `item.areas` at all, and the client renders every area in the switcher. So an author writing `requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored value, and an area visible to everybody — and had every reason to believe otherwise, because the SAME key names are genuinely enforced one level up and one level down: app-level `requiredPermissions` drops the whole app server-side, and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped server-side and re-checked in the shell, whose item-level `visible` is a real CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was weighed and deliberately not taken here: it needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), and a retirement must not invent an authorization mechanism — while shipping a major with the gate still declared would have kept authors writing it for all of 17.x. The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading author has to re-decide is only where the gate really goes: onto the items inside the area, or onto the app. One honest caveat the prescription carries rather than hides — per-item gating INSIDE an area is enforced by the shell only, since the server does not walk `areas`, so anything that must never reach the browser belongs in the top-level tree or in its own app.
172
+
171
173
The same window converges the retry policy (#4661). `@objectstack/spec/automation` and `@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving to a DIFFERENT declaration, so which shape a consumer got depended only on the import path (#4411) — yet both computed `delay = base * multiplier^(retry-1)` and both executors implemented that same formula. One declaration now serves both entries with the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops a fleet of jobs that failed on one outage from retrying in lockstep). The single authorable casualty is the automation spelling of the base delay: `retryDelayMs` → `backoffMs`, a pure rename that replays losslessly and is what the already-enforced retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it.
172
174
173
175
The subtle half is the defaults, and it is worth stating because no gate can see it: `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same reading already recorded in `flow-retry-max-retries-required`), and the conversion writes the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored omission means.
@@ -192,6 +194,7 @@ The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` dr
192
194
|`permission-rls-priority-removed`|`permission.rowLevelSecurity.priority`| RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
|`app-dead-authoring-keys-removed`|`app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order`| app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas) | retired — `migrate meta` only |
197
+
|`app-area-fail-open-gates-removed`|`app.areas.visible / app.areas.requiredPermissions`| navigation-area keys 'visible'/'requiredPermissions' removed (#4651, ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app) | retired — `migrate meta` only |
195
198
|`field-required-notnull-explicit`|`object.fields.*.required / object.fields.*.storage.notNull`| required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract) | retired — `migrate meta` only |
196
199
|`action-inert-keys-removed`|`action.shortcut / action.bulkEnabled`| action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | retired — `migrate meta` only |
197
200
|`flow-inert-keys-removed`|`flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId`| flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only |
0 commit comments