Skip to content

Commit 2dccb7d

Browse files
fix(rest): the stored-version doors serve an app whole to whoever may save it, and pruned to everyone else (#20284)
Fixes #20156 Clause-②: no Executes maintainer ruling 5856774816 on the card (batch 227 item 1, letter B), confirmed in 5856866273. The ruling, verbatim: > **Ruled: B — author exemption, in ADR-0106 D4's shape. On the three stored-version doors (`/layers`, `?layers=true`, `/diff`) a caller who may write the app reads the full stored version; every other caller who may open the app reads exactly what the plain read gives them, pruned.** This is the second half of the card. The first half landed as a `Part of` PR (the landing record is comment 5855055724 on the card). It left the partial `app` cells of the stored-version doors as a declared pending-decision exemption. This PR replaces that exemption with the ruled answer. ## What changed **The shared read gate** (`packages/rest/src/meta-item-read-gate.ts`) - `MetaReadGatePolicy.app` becomes `'gate' | 'author-exempt'`, and `'pending-decision'` is retired. `'author-exempt'` serves the stored app whole to a caller whose `MetaReadGateCaller.mayWriteItem` is `true`. Every other caller gets `'gate'`'s answer: the plain read's refusal, or the pruned app. - The exemption is a caller property (ADR-0106 D4). The caller carries the verdict, and the policy says only whether a door honours it. The gate tests no route. - An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller with neither Studio nor Setup access) is refused on every door, to an author as to anyone. - An exempt author skips the docs-audience entry arm, because that arm only prunes entries. A non-author on a stored-version door now resolves it, as the plain read does. It fails closed on a book-read fault by dropping `doc` entries, exactly as the plain read does. **The REST transport** (`packages/rest/src/rest-server.ts`) - `RestServer.metaSaveVerdict(ctx, rawType)` is the admission of `PUT /meta/:type/:name`, spelled once. The PUT door now calls it, and the exemption asks it too. That follows ruling item 1: the predicate is `metaWriteCapabilityVerdict`, the save door's own answer, not a "platform administrator" reading. - The exemption asks the whole save-door question, not a capability name: the type is folded at the boundary and the organization is `ctx.tenantId`, as the door threads it. `manage_org_presentation` therefore admits a view save but not an app save, so it does not make its holder an app author. A census edge pins that. - `STORED_VERSION_DOOR_POLICY` is `{ arms: 'per-caller', app: 'author-exempt' }`. Only doors whose policy honours the exemption put `mayWriteItem` on the caller. It is a pure verdict over the already-memoised exec ctx and the static type registry, so it costs no read. The memoised ctx is copied, never written. - **The two doors now serve what the gate serves.** The layered view and `/diff` used to act on a refusal verdict and discard a serve verdict's document, so they could not answer "pruned" at all. - The layered view replaces each layer with the gate's document, after judging every layer. - `/diff` emits each bucket's values from the judged sides. It adds or drops no entry, and the comparison stays the protocol's over the stored bodies. This follows the redaction precedent one frame down (`diffMetaItem`: diff raw, emit projected values, ruling 5299845282 option B) and the ADR-0106 mask beside it. - No runtime change. The runtime dispatcher runs only `{ arms: 'all', app: 'gate' }` and serves no stored-version door. **Unchanged, per the ruling:** the plain read and `/published` prune for every caller, authors included. `/history`, `/audit`, every whole-refusal cell, and the `book`, `doc` and `dashboard` arms are unchanged. ## Verification (head `9d955a369` unless stated) **Tests** - The census, `packages/rest/src/meta-alternate-door-read-gates.test.ts`: 261/261. - It has a new `author` caller (`manage_metadata`, without `finance.access` or `crm_admin`), so the plain read serves them only part of `crm`. - The author's three stored-door cells for `crm` pin the full stored version. The non-author cells for `crm` pin the plain read's pruned navigation on every layer and in `/diff`'s emitted values, with the same bucket paths as the raw diff. - The rendered doors are controls: the plain read and `/published` still prune `crm` for the author. - The author is refused `payroll` (403) and `launchpad` (404) on every door. - Retired: `PENDING_DECISION`, `isPendingDecision` and their docblock. `AUTHOR_EXEMPTION` cites the ruling. Three pins replace them: - the exemption names exactly `/layers`, `?layers=true` and `/diff`, which is every stored-version door and no other; - the cells whose answer it changes are exactly the author's three partial `crm` cells, and the six author whole-refusal cells stay refused; - its predicate is the save door: each caller's declared `savesApps` equals what `PUT /meta/app/crm` answers them. The author gets 200 and the protocol is called once; `reader` and `non-reader` get 403 `FORBIDDEN`; the anonymous caller gets 401. For every refused caller, the protocol is never called. - `@objectstack/rest`, `vitest run --project local`: 3655 tests, 3654 passed, 1 skipped, 0 failed. This ran at `6780cd777`, and the only `packages/` delta since is one docblock line in `rest-server.ts` (`git diff 6780cd7 9d955a3 -- packages/`). - `@objectstack/rest` `typecheck` (tsc, plus the test layer under `tsconfig.test.json`): exit 0. - `@objectstack/runtime` `typecheck`: exit 0. `domains/meta-item-read-gate-parity.test.ts`: 77/77. Both ran at `bad8baa63`; the delta since is the same docblock line. **Reverse verification and ablations** Each mutation used `scripts/ablation-replace.mjs` in wrap mode inside a script that traps EXIT, INT and TERM. Each restore was proven: the blob equals HEAD and `git diff HEAD` is empty. | mutation | where | landed | result | |:--|:--|:--|:--| | A: the exemption forced off (`serve(gated.app)` for everyone) | the gate | anchor 1 → 0, blob `817024027bb2` → `6feda3867a6c` | 261 run, **3 red**: exactly `?layers=true`, `/layers` and `/diff` × `app/crm × author` | | B: the predicate forced on for everyone (`mayWriteItem: true`) | the transport | anchor 1 → 0, blob `5942f7eb57de` → `43523e878b86` | 261 run, **5 red**: the three `app/crm × non-reader` stored-door cells, the fault-path `/layers` pin, and the org-presentation pin | | C: the retired `'pending-decision'` put back into the runtime's policy | `packages/runtime/src/domains/meta.ts` | anchor 1 → 0, blob `c47473b9dcdd` → `543aa6707030` | runtime `tsc`: 1 error, `TS2322 … 'pending-decision' … not assignable to type 'MetaReadGatePolicy'` at `meta.ts(297,7)`. So the importer reads the rebuilt rest declarations. | No test file stays behind: the ablations and the one-shot probe below were run and then removed. **Gates** `node scripts/pm/dispatch-gates.mjs --commands` derived 92 families from merge base `a9fb83ef0`. Every command ran at `9d955a369` with its exit code captured before any pipe. `--ran` reconciled the run: 92 derived, 91 run, 1 NOT-MEASURED, 0 unrun. - 90 exit 0. - `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 **by design**; see the next section. - `pnpm check:dual-build-cjs-loads`: NOT MEASURED. It exits 3 (PREREQUISITE NOT MET) because 42 packages have no `dist/`, and this gate needs the whole repo built. That is CI's Build Core run. - Added by this lane: - `pnpm lint` (`eslint . --no-inline-config`, the whole repo): exit 0 in 121s. It ran on the tree that became `9d955a369`, so this is a full run, not a narrowed one. - The board check `node scripts/check-issue-citations.mjs --base origin/main`, after the merge: exit 0, 17 citations judged, 17 resolve. - `pnpm --filter @objectstack/spec run check:liveness`: exit 0. - `check:system-context-census` was red after the refactor: `metaSaveVerdict` holds the save door's `isSystem` read now. Row 49 of `content/docs/permissions/system-context.mdx` now anchors it and names the read exemption it feeds. The declared count was regenerated by the gate's own `--fix` (87 → 88 symbols). - `check:skill-examples` and `check:type-check-debt` first refused on missing builds (exit 3). Both exit 0 at `9d955a369` once their named prerequisites were built. ## A pending release note is corrected in place, so `check-empty-changeset` stays red `.changeset/20156-alternate-door-read-gates.md` exists on the merge base and was not added by this PR. The gate classes this edit as the **deliberate correction** case. Its own remedy text says not to restore the note, because restoring it would republish a sentence this PR makes false. Instead, this PR says so here, for confirmation: - **The note:** the bullet that began "One case is unchanged in this release: an app the plain read would serve a caller only in part…", which said the partial app cells were "pending a decision". The note's closing sentence, "…except the `app` row of `/layers`, `?layers=true` and `/diff` for a caller the plain read serves only part of the app", also changed. - **What changed under it:** this PR executes the ruling, so both sentences now state the ruled answer. A caller who may save the app reads it whole on those doors; every other caller reads it pruned. If the note shipped unchanged beside this PR's own changeset, the release notes would contradict themselves. - **This PR's own changeset:** `.changeset/20156-app-author-exemption.md`, `@objectstack/rest` `patch`. The `Check Changeset` job counts only ADDED changesets, so the corrected note alone would not satisfy it. - `skip-changeset` is not applied: this PR publishes. ## Acceptance notes **Dispatch item 4: the exported types are unreleased.** `MetaReadGatePolicy` and `MetaReadGateCaller` became `@objectstack/rest` exports under the pending `.changeset/20193-dispatcher-meta-read-gate.md` (`minor`). That file is still on `origin/main` `89f87f234`, checked just before this PR opened. The published `@objectstack/rest@17.4.0` tarball was also checked. Its `dist/index.d.ts` has `RestServer` 21 times (the positive control) and `MetaReadGatePolicy|MetaReadGateCaller|createMetaItemReadGate` 0 times. Its `dist/index.js` has `createMetaItemReadGate|pending-decision` 0 times. So changing these members moves no published surface, which is what `patch` and `Clause-②: no` rest on (ruling item 4). **Dispatch item 6: the pending release note.** It is corrected in place (see the section above) and has not been consumed by a Version Packages PR. This PR's own changeset sits beside it. **Dispatch item 7: which objectui surfaces read these doors.** Measured before the fix, in objectui source at the pinned `.objectui-sha` `f8a9d0fb0596`, from a read-only clone: - `/diff`: 0 source readers. `MetadataClient.diff` is defined but never called. - `?layers=`: 0 source readers. objectui's own ratchet test forbids that spelling. - `/layers` for an `app` has two save-back readers, and both save through `client.save`, which is `PUT /meta/:type/:name`: - `app-shell/src/views/studio-design/StudioDesignSurface.tsx`: the app navigation editor loads the layered view at :1760 and saves navigation at :1960; - `app-shell/src/views/metadata-admin/ResourceEditPage.tsx`: the generic editor, since no bespoke editor is registered for `app`. It loads at :1017 and saves at :1500. - A non-author who loads the pruned app cannot save it, because that door refuses them with the same verdict the exemption asks. No reader saves under a non-author identity. - `DesignerEditorWrapper`, the reader the ruling named, is not in objectui source at the pin; only CHANGELOG entries mention it. - Other `/layers` readers never load an app: `PermissionMatrixEditor` (permission), `PackageOwdOverviewPanel` (object), `EmbeddedItemEditor` (object sub-items), and `StudioDesignSurface` at :2880 (object) and :3931 (flow). **The claim's file surface, amended in two places.** Both edits correct published text this change would otherwise make false or red: - `content/docs/ui/apps.mdx`, about lines 359-373: the paragraph said the stored-version doors "do not yet prune … pending a decision". It now states the ruled answer. - `content/docs/permissions/system-context.mdx`, row 49, plus the regenerated count. **Out of scope, reported for the seat (not fixed here).** - **Authors can still lose entries through the draft read.** Studio's app editors build their edit baseline as `{ ...layered.effective, ...draft }`, where the draft comes from `client.getDraft`, the plain read with `?state=draft`. The editors save that result back: - `StudioDesignSurface.tsx`: the merge is at :1760-1767 and the save at :1960; - `ResourceEditPage.tsx`: the merge is at :1016-1056 and the save at :1500. The plain read prunes for authors too, and the ruling keeps it that way. So when an app has a pending draft, the draft's pruned `navigation` overrides the whole `effective` for an author who lacks an entry's permission, and the next draft save drops that entry. A one-shot probe of the real handler at `9d955a369` measured the server half. For an author with `manage_metadata` but not `finance.access`, `GET /meta/app/crm?state=draft` answered 200 with navigation `["nav_leads"]` (the protocol saw `state: draft`). `/layers` answered `["nav_leads","nav_finance_ledger"]`. Where to fix it (objectui reading the draft through a stored-version door, or the server serving the draft layer there) is not this card's call. - **Docs drift.** `content/docs/plugins/adding-a-metadata-type.mdx`, about lines 164-196, documents `DesignerEditorWrapper` and a `?layers=true` read. Neither exists in objectui source at the pin. No carrier. - **Observation, not measured.** The layered view serves the protocol's `_diagnostics` beside the layers. The plain read's pruned document carries the same `_diagnostics`, computed over the unpruned document, so the layered view discloses nothing the plain read does not. Whether any diagnostic message names a withheld entry was not measured. ## Not addressed here #20237 remains open: the dispatcher's list branch. #20139 remains open: the bare-number query reads in `rest-server.ts`. Both are queued behind this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6b7d8c commit 2dccb7d

7 files changed

Lines changed: 462 additions & 178 deletions

File tree

‎.changeset/20156-alternate-door-read-gates.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ What each door answers now:
1313

1414
- `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read.
1515
- `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits.
16-
- **One case is unchanged in this release: an app the plain read would serve a caller only in part.** That is an app the caller may open, but with navigation entries withheld from them by `requiredPermissions` or the documentation audience. On `/layers`, `?layers=true` and `/diff`, such a caller still receives the stored app with those entries included, as before. No other caller does: an app the plain read refuses whole is refused on these doors too. What these doors should answer a partial caller is pending a decision, because both ways of matching the plain read restrict authors. Pruning the stored app would delete the withheld entries when Studio's designer saves back what it loaded. Refusing it would lock out any author who lacks one entry's permission, platform admins included.
16+
- For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included. An app the plain read refuses whole is refused on these doors to an author too.
1717
- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body.
1818

19-
Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except the `app` row of `/layers`, `?layers=true` and `/diff` for a caller the plain read serves only part of the app. To read a gated doc or book through any of these doors, hold the permission set its book names.
19+
Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except that a caller who may save an app reads it whole on `/layers`, `?layers=true` and `/diff`. To read a gated doc or book through any of these doors, hold the permission set its book names.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**The stored-version doors of an app answer by who is asking: whoever may save the app reads it whole, everyone else reads it pruned.** `GET /api/v1/meta/app/:name/layers`, the deprecated `?layers=true` and `…/diff` serve the versions Studio's designer loads and saves back. Until now they served an app the caller may open as stored to every such caller, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them, which exposed those entries' names and targets to members the plain read hides them from.
6+
7+
- A caller who may save the app receives the full stored version on these three doors, so a designer that saves back what it loaded keeps every entry. "May save" is exactly what `PUT /meta/app/:name` admits that caller: a system context or `manage_metadata`. `manage_org_presentation` does not save apps, so it does not qualify.
8+
- Every other caller who may open the app receives it without the entries `requiredPermissions` or the documentation audience withhold from them, left out as the plain read leaves them out: on each layer, and in the values on both sides of a diff. A diff keeps all of its entries; only their values are pruned.
9+
- Unchanged: the plain read and `/published` still prune for every caller, authors included. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change.
10+
11+
A client that reads these doors as a non-author now receives fewer navigation entries. To read an app's full stored version there, read it as a caller the app's save door admits.
12+
13+
For code that runs the shared read gate: `MetaReadGatePolicy.app` is `'gate'` or `'author-exempt'`, and a door that passes `'author-exempt'` supplies the caller's save verdict as `MetaReadGateCaller.mayWriteItem`.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ The largest single consumer — **17 of the 105 sites**.
168168
|:--|:---|:---|:---|:---|
169169
| 47 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `packages/runtime/src/action-execution.ts#callData` |
170170
| 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` |
171-
| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` |
171+
| 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict` |
172172
| 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` |
173173
| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
174174
| 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` |
@@ -362,7 +362,7 @@ still holds equal to the census on every pull request:
362362
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
363363
| Packages containing at least one elevation read | **19** | ✅ |
364364
| Files containing at least one elevation read | 44 | ✅ |
365-
| — the distinct symbols those reads live in — what this page anchors | 87 | ✅ |
365+
| — the distinct symbols those reads live in — what this page anchors | 88 | ✅ |
366366
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
367367

368368
The six rows marked — are a **dated decomposition, not a live claim**: they were

‎content/docs/ui/apps.mdx‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -357,18 +357,20 @@ collapses; an area *authored* empty is passed through untouched, because
357357
filtering reports what the caller may not see rather than tidying the metadata.
358358

359359
The single-app reads — `GET /api/v1/meta/app/<name>` and its `/published`
360-
twin — serve that filtered body, and every door refuses an app the caller may
361-
not open: one whose app-level `requiredPermissions` they lack (`403`), or an
362-
unpublished app to a non-builder (`404`). The doors that serve an app's
363-
*stored* versions for authoring — the layered view (`/layers`, and the
364-
deprecated `?layers=true`) and `/diff` — do **not** yet prune the entries of an
365-
app the caller may open: a caller from whom `requiredPermissions` or the docs
366-
audience withholds some entries still receives the stored app there, those
367-
entries included. What these doors should answer that caller is pending a
368-
decision. Pruning a stored app the designer saves back would delete the
369-
withheld entries, and refusing it would lock out any author who lacks one
370-
entry's permission, platform admins included. Until that is decided, such an
371-
entry is absent from the app's served body but not from these authoring views.
360+
twin — serve that filtered body to every caller, the app's authors included,
361+
and every door refuses an app the caller may not open: one whose app-level
362+
`requiredPermissions` they lack (`403`), or an unpublished app to a
363+
non-builder (`404`). The doors that serve an app's *stored* versions for
364+
authoring — the layered view (`/layers`, and the deprecated `?layers=true`) and
365+
`/diff` — answer by who is asking. A caller who may **save** the app (the one
366+
`PUT /api/v1/meta/app/<name>` admits: `manage_metadata`, or a system context)
367+
reads the full stored version there, gated entries included, because Studio's
368+
designer saves back what it loaded and a pruned load would delete the entries
369+
withheld from its author. Every other caller reads it without the entries
370+
`requiredPermissions` or the docs audience withhold from them, left out as the
371+
single-app read leaves them out, on every layer and on both sides of a diff.
372+
The exemption lifts no whole refusal: an app an author may not open is refused
373+
on these doors too.
372374

373375
`visible` did **not** move server-side with them, and that asymmetry is
374376
deliberate: CEL is evaluated in the browser because server-side evaluation needs

0 commit comments

Comments
 (0)