Repository navigation
Commit 5081090
Fixes #22639
Clause-②: yes (widening)
## Summary
The rest half of the ruling of record `6095014058` on #22611 (letter A).
The `/meta` read gate now applies a list view's and a dashboard's
`requiredPermissions`: a list of capabilities, all required. It asks one
predicate, `holdsRequiredPermissions` in
`packages/rest/src/meta-item-read-gate.ts`, which the app arm now asks
too. There is no second evaluator and no any-of form. Both transports
ask the same gate: `RestServer` and the runtime dispatcher's `/meta`
domain.
- **List read.** `GET /api/v1/meta/view` and `GET
/api/v1/meta/dashboard` list only what the caller may open. So the
console's view switcher (`?object=`) follows. A view container is listed
minus the `list` / `listViews` entries the caller does not hold.
- **By-name read.** A list view or a dashboard the caller does not hold
is refused WHOLE with `403 PERMISSION_DENIED`, the app arm's whole
refusal (same emitter, same envelope), on every door: the plain read,
`/published`, `/layers`, `?layers=`, `?state=draft`, `/diff`,
`/history`, `/audit`. The refusal reaches an author as anyone.
- **The object door** (scope note `6096254862`, item 1). An object's own
`listViews` entries the caller does not hold are pruned inside
`/meta/object`, on the list read and the by-name read, for every caller
ADR-0106 D4 does not exempt.
- **The flip.** Both liveness rows (`view.json`
`list.requiredPermissions`, `dashboard.json` `requiredPermissions`) are
`live`, cite the gate symbol, and no longer carry `authorWarn` /
`authorHint`. The describe in
`packages/spec/src/ui/audience-required-permissions.ts` drops its
`[PLANNED — not enforced yet]` clause. The two lint warn pins turned red
at the flip, as the rows said they would. They are re-subjected to the
silence with a lit control; none was deleted.
- **The lint** (scope note item 2). `validateCapabilityReferences`
resolves the capability names at every list-view and dashboard position.
That is a dashboard, a view container's `list` and `listViews` entries,
a view item's `config`, a flattened list view, and an object's own
`listViews`.
- **The docs.** `content/docs/permissions/capabilities.mdx` said "A list
view's and a dashboard's `requiredPermissions` are not enforced yet".
That callout now says what the server enforces (dispatch assumption 6).
No other docs prose is touched. The `content/docs/references/**` rows
are regenerated from the describe.
## The dispatch's mechanism assumptions, measured
1. **Confirmed.** At `c63028e5b`, `meta-item-read-gate.ts` read
`requiredPermissions` at exactly two code sites:
`filterAppForUserWithReason` `:744` (the app) and `:761` (a navigation
entry). The dashboard arms gated widgets on `requiresService` only, and
no `view` read had an audience check. Both sites now call
`holdsRequiredPermissions`, with unchanged semantics: absent or empty
means no gate, and every name must be held.
2. **Confirmed.** Both rows were `planned` + `authorWarn`, and their
notes prescribed the flip "in that same edit". `check:liveness` now
reports "author warnings: 2 ledger row(s) opt into `authorWarn` …
(planned 2); 0 on a `live` row". The two left are
`object.externalSharingModel` and `page.print`.
3. **`authorExempt`, measured and stated.** These are the app arm's two
rules, carried over. There is no new exemption.
- **A whole refusal is not lifted for an author.** This covers ONE list
view (a view item, its key under `config`; or a flattened list view, its
key at the top level) and a dashboard. Each is refused on every door to
an author as to anyone, as an app with an app-level
`requiredPermissions` is. Pinned in the census (`stillRefused` gains the
four `view/clm_contract.legal_queue × author` stored-door cells).
- **Entry pruning follows `MetaReadGatePolicy.app`.** A view CONTAINER's
`list` / `listViews` entries are pruned for every caller on the rendered
doors. On the four `author-exempt` doors (`/layers`, `?layers=true`,
`/diff`, `?state=draft`), a caller whose save door admits them
(`mayWriteItem`) reads the stored container whole, as an app's author
does. Otherwise Studio's designer would save back a container minus its
withheld entries. `AUTHOR_EXEMPTION.types` in the census is now `['app',
'view']`. The census also holds the predicate to `PUT
/meta/view/clm_contract`'s admission.
- **An object's own list views follow ADR-0106 D4 on every door.** That
is `isObjectSchemaMaskExempt`, the same caller property that already
serves such a caller the object's fields unmasked: whoever may write a
schema reads all of it. A pruned load PUT back verbatim would delete the
withheld list views. This is the rule the object plain read already
holds for fields, so it is not `MetaReadGatePolicy.app`.
- A caller with no signed-in user is not gated, the app arm's rule. An
anonymous caller is refused before any `/meta` read of these types, on
both transports (`isPublicAudienceRead` admits book/doc only).
4. **The refused by-name answer, read and reused.** The app arm's whole
refusal is `{ reason: 'app-permission', status: 403, code:
'PERMISSION_DENIED' }`. `RestServer` writes it through
`sendEnvelopeError` as the ADR-0112 nested envelope (`{ success: false,
error: { code, message } }`). The dispatcher writes `deps.error(message,
403, { code })`. A view or dashboard refusal is `{ reason:
'audience-permission', status: 403, code: 'PERMISSION_DENIED' }`, which
`RestServer.sendMetaReadRefusal` writes through the same
`sendEnvelopeError` case. Only the message names the item (`You do not
have permission to open the 'NAME' view.` / `dashboard.`). The pin
asserts `code` + `status`, and that the body's and `error`'s key sets
equal the app refusal's.
5. **The `object-schema-fls-references.ts` walk, measured.** It CAN drop
a `listViews` entry (`recordOf(presentationEntry(…), true)` returns
`REMOVE`). But its only verdict input is the caller's denied FIELD set,
and it runs only under a `project` mask posture: it is `passthrough` for
a D4-exempt caller, a deployment with no `security` service, and the
ADR-0106 D8 opt-out. Gating there would need two things: the caller's
capabilities threaded into the mask posture, and the requiredPermissions
decision made inside `metadata-core`, which would be a second evaluator.
It would also tie the audience gate to the FLS opt-out. So the object
door is gated in the rest gate's `object` arm with the one predicate,
and `metadata-core` is untouched. Measured, not a stop: the ruling's
one-predicate constraint holds.
6. **Docs, measured.** The spec half wrote the "Or is one capability,
granted twice" section in `capabilities.mdx` with a "not enforced yet"
warning callout. That sentence is corrected here and named in this PR.
No other hand-written docs page says the key is not enforced (`grep`
over `content/docs` outside `references/`).
## Where each door is enforced
- `packages/rest/src/meta-item-read-gate.ts`: `holdsRequiredPermissions`
(the predicate), `filterViewForUser`, `filterObjectListViewsForUser`,
the `dashboard` / `view` / `object` arms of `createMetaItemReadGate` and
`createMetaListReadGate`, and the `audience-permission` refusal.
- `packages/rest/src/rest-server.ts`. The plain read's CACHED arm serves
`view` and `object` (the default path, `enableCache: true`). It now asks
the same gate before the mask and folds what the gate withheld into its
`ETag`, so a caller who no longer holds a capability is never answered a
`304` for the body that carried it. The conditional request is judged
against that validator, not by the protocol. When nothing is withheld
the `ETag` is unchanged. `gatesPerCaller` adds `view` and `dashboard`,
so `/history`, `/audit` and `/diff` judge them.
- `packages/runtime/src/domains/meta.ts`. The one-segment
`/meta/OBJECT_NAME` legacy object exit asks the gate before the mask. It
is reached only when the protocol has no type catalogue and no list for
the segment. Every other runtime door already went through the shared
gate.
## Pins, per audience (holder served, non-holder not served, no key
served to all)
- `packages/rest/src/meta-alternate-door-read-gates.test.ts` (the
census, every door × reader / non-reader / author / anonymous): five
subjects. A view with no key (served to all), ONE gated view (refused
whole), a view container (pruned; author whole on the stored doors), a
gated dashboard (refused whole) and an object with a gated list view
(pruned; D4 whole).
- `packages/rest/src/meta-view-dashboard-audience-gate.test.ts` (new, 19
cases): the predicate (all required, no any-of, the app arm asks it);
`GET /meta/view` (holder / non-holder / `?object=` switcher / no key
served to all); `GET /meta/dashboard`; `GET /meta/object` list views (D4
author whole); the by-name refusal envelope (view item, flattened view,
dashboard, author); the cached arm (a different `ETag` per what was
withheld, no `304` for a stale validator, a refused view never answered
`304`, and a control that a matching validator is still `304`).
- `packages/runtime/src/domains/meta-item-read-gate-parity.test.ts` and
`meta-list-read-gate-parity.test.ts`: the same rows through both
transports, the list fallback exits, and the legacy one-segment object
exit.
- `packages/lint/src/validate-capability-references.test.ts`: every
position warns at its own path, a granted capability resolves (lit
control in the same stack), and a view or dashboard without the key
resolves nothing.
- Re-subjected, none deleted:
- The two lint warn pins: silent on a `live` row, lit control
`object.externalSharingModel`.
- The spec describe pin: no `[planned` marker now.
- Three pins whose `view` subject was "a type no per-caller gate
judges": the census edge and the two runtime parity controls now use
`flow` (a new `LEAD_FLOW` fixture); the `view` control without a key
stays, as "no key served to all".
- Two `/audit` scoping pins whose stub deliberately has no
`getMetaItem`: they now use `reports`, because a `view` now costs that
door a current-document read.
## Verification
Measured on head `87008a7e8` (the merge of `origin/main` `762db996a`)
unless a line says otherwise.
- **Build.** Closures built through `os-verify-lock.sh`: `pnpm
--workspace-concurrency=2 --filter '@objectstack/runtime...' --filter
'@objectstack/lint...' build` and `--filter
'@objectstack/client-react...' build`. Both exited 0. `pnpm --filter
@objectstack/spec check:generated` printed "All 15 generated artifacts
are up to date". Before the merge, `--fix` regenerated exactly the two
it proved stale: `content/docs/references/**` and
`liveness/state-counts/`.
- **Tests.**
- `@objectstack/rest` `vitest run --project local`: 272 files, 5181
passed, 326 skipped.
- `@objectstack/lint` `vitest run`: 135 files, 6307 passed.
- `@objectstack/spec` `vitest run --project local`: 642 files in three
shards, 214 + 214 + 214, 19197 passed (1 todo).
- `@objectstack/runtime`: the three `meta-*-parity` files on
`87008a7e8`, 850 passed. The full `--project local` suite ran on
`d21e206ac`, before the merge, which touched no `packages/runtime` file:
348 files, 4984 passed, 19 skipped.
- `integration` layers are declared to CI.
- **Typecheck.** `typecheck` exited 0 for `@objectstack/spec`, `rest`,
`lint` and `runtime`. The `check:test-typecheck` ledgers held unchanged:
spec 52/246/135, rest 0/0/0, lint 2/6/2, runtime 27/190/68.
- **Reverse verification of the type change** (one-shot probe, deleted).
`@objectstack/rest`'s exported `MetaItemReadRefusal` gains
`audience-permission`. A probe in `packages/runtime/src` typed against
it, through the rebuilt `.d.ts`, gave two results. `reason:
'audience-permission'` → `tsc` exit 0. `'audience-permision'` → exit 2,
TS2820, which lists the union with `"audience-permission"`. `git status`
was clean after.
- **Ablation** (one-shot, committed state `275083c38`). Each mutation
went through `scripts/ablation-replace.mjs` WRAP mode with a `trap`
restore. Each anchor hit x1 → x0 with a blob change. No build or dist
preflight applies: the rest and runtime suites resolve
`@objectstack/rest` from `src`. Direction: red, all four.
- A1, `filterViewForUser` stops asking a view item's `config`: rest 14
failed / 431 passed, runtime 4 failed / 164 passed.
- A2, the cached arm stops asking the gate: rest 28 failed / 417 passed.
- A3, the legacy object exit masks the ungated object: runtime 1 failed
(the legacy-exit pin) / 49 passed.
- A4, the lint stops resolving dashboards: lint 2 failed / 10 passed.
- Restore: each blob equals its HEAD blob, and `git diff HEAD` is empty.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 113 commands on
`87008a7e8`. All 80 lines of the dispatch's list are among them. Each
was run with its exit captured before any pipe, then reconciled with
`--ran`: "113 derived, 111 run, 1 NOT-MEASURED, 1 UNRUN".
- 110 exited 0. `check:skill-examples` first exited 3 (PREREQUISITE NOT
MET: `client-react` unbuilt) and exited 0 after that closure was built.
- `check:platform-checklist` exited 1 with "7 problem(s)": absent
symbols in `docs/qa/platform-checklist/areas/access-security.json` and
`attachments-storage.json`, which this diff does not touch. The control
leg is the same command on a checkout at `762db996a`, without this
branch: exit 1, "7 problem(s)".
- NOT MEASURED: `check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT
MET (it needs every package's `dist/`, a whole-workspace build).
- Not run: `check:type-check-debt`. Its `--re-measure` builds every DEBT
package's closure, and this diff touches no DEBT or TEST_DEBT ledger
package. Declared to CI.
- **`pnpm lint`, proven narrowing.** The population, read from
`eslint.config.mjs`, is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its
ignores. This diff's 14 files in that population, linted with
`--no-inline-config --format json`: 14 files, 0 errors, 0 warnings, exit
0. Invariance: the config enables no type-aware linting (no
`parserOptions.project`, no typed rules; the config says so at `:327`),
so this diff cannot move a verdict on any untouched file. The JSON, MD
and MDX files are outside the population.
## Acceptance notes
- **Declared file surface, extended (named here for the PM).** The claim
named `meta-item-read-gate.ts` and the `metadata-core` walk. This PR
also edits `packages/rest/src/rest-server.ts` (the cached arm,
`gatesPerCaller`, the refusal writer, the `/diff` rule below) and
`packages/runtime/src/domains/meta.ts` (the legacy object exit). Both
are `domain:cli` packages, and no open PR touched either file when this
was opened. `packages/metadata-core` is NOT edited (assumption 5).
Outside `packages/spec`'s three named files, only regenerated artifacts
changed.
- **Type surface.** The claim said no export is added.
`MetaItemReadRefusal`, exported from `@objectstack/rest`, gains one
union member, `audience-permission`. No new export, no error code
(`PERMISSION_DENIED` exists) and no authorable key. The new functions
are not on the package's entry. The rest changeset is graded `minor` for
it.
- **`/diff` of a never-published view or dashboard.** With no published
row, `/diff` has no current document to judge, so it serves the diff as
before. That is the event doors' existing rule for every gated type. A
builder diffing a draft-only view keeps that diff, key or no key. The
alternative, the app arm's "absence" for a missing current document,
would have turned a draft-only view's or dashboard's diff into `404` for
every builder, including views that name no capability; the pin
`meta-history-diff-authoring-door.test.ts` holds that diff. The draft
itself is judged where it is served whole (`?state=draft`, `/layers`).
Boundary: a never-published GATED item's diff is readable by a builder
who lacks the capability. Every such builder can already read that draft
body through `GET /meta/_drafts`, which applies no audience gate to apps
either.
- **For the `domain:spec` seat, outside this card's spec surface (not
edited, per the dispatch).**
- `packages/spec/liveness/README.md`: the `view` and `dashboard`
state-table notes (#22611, 2026-10-10) still describe the key as
`planned` with `authorWarn`, awaiting this card. A dated flip note
belongs there.
- `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`:
the two `omit` rows' `why` reads "declared, not enforced yet — liveness
verdict `planned` with `authorWarn` … whether to offer it then is a
ruling for the enforcement". The text is now stale. Whether the Studio
forms should offer the key is that ruling, not this card's. The pin is
green.
- Carrier: the `domain:spec` seat. Neither is a defect class.
- **`.changeset/22611-list-view-dashboard-required-permissions.md`** is
still unreleased and says "Not enforced yet … Enforcement … is the
separate enforcing change". If both land before the next release, the
release notes carry both sentences, and this PR's four changesets say
the key is enforced. The release compiler's call.
- **Contract-tier review owed** before the queue (the
`packages/spec/src` path and a narrowing gate). The PM arranges it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c7e750 commit 5081090
27 files changed
Lines changed: 1267 additions & 165 deletions
File tree
- .changeset
- content/docs
- permissions
- references
- api
- data
- ui
- packages
- lint/src
- rest/src
- runtime/src/domains
- spec
- liveness
- state-counts
- src/ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
183 | 183 | | |
184 | 184 | | |
185 | 185 | | |
186 | | - | |
187 | | - | |
188 | | - | |
189 | | - | |
190 | | - | |
191 | | - | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
192 | 194 | | |
193 | 195 | | |
194 | 196 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1689 | 1689 | | |
1690 | 1690 | | |
1691 | 1691 | | |
1692 | | - | |
| 1692 | + | |
1693 | 1693 | | |
1694 | 1694 | | |
1695 | 1695 | | |
| |||
1775 | 1775 | | |
1776 | 1776 | | |
1777 | 1777 | | |
1778 | | - | |
| 1778 | + | |
1779 | 1779 | | |
1780 | 1780 | | |
1781 | 1781 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
391 | 391 | | |
392 | 392 | | |
393 | 393 | | |
394 | | - | |
| 394 | + | |
395 | 395 | | |
396 | 396 | | |
397 | 397 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
| 42 | + | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| |||
0 commit comments