Repository navigation
Commit 2dccb7d
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
- content/docs
- permissions
- ui
- packages/rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
| 16 | + | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
168 | 168 | | |
169 | 169 | | |
170 | 170 | | |
171 | | - | |
| 171 | + | |
172 | 172 | | |
173 | 173 | | |
174 | 174 | | |
| |||
362 | 362 | | |
363 | 363 | | |
364 | 364 | | |
365 | | - | |
| 365 | + | |
366 | 366 | | |
367 | 367 | | |
368 | 368 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
357 | 357 | | |
358 | 358 | | |
359 | 359 | | |
360 | | - | |
361 | | - | |
362 | | - | |
363 | | - | |
364 | | - | |
365 | | - | |
366 | | - | |
367 | | - | |
368 | | - | |
369 | | - | |
370 | | - | |
371 | | - | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
372 | 374 | | |
373 | 375 | | |
374 | 376 | | |
| |||
0 commit comments