Repository navigation
Commit bc80e16
fix(rest): app nav prunes doc entries the caller may not read (ADR-0046 §6.7) (#20128)
Fixes #19790
Clause-②: no
The server's app-nav filter (`filterAppForUserWithReason`,
`packages/rest/src/rest-server.ts`) gains the audience arm the
maintainer ruled (ruling `5793362670`, letter A). A `type: 'doc'` entry
naming a doc the caller may not read is pruned. A `book` entry with no
page the caller may read is pruned too. Both callers apply it: the list
route `GET /meta/app` and the by-name route `GET /meta/app/:name`. It
covers the top-level `navigation`, `children` and `areas[].navigation`.
The rule is the one `DocNavItemSchema` already declares. Before this
change only the (unshipped) objectui renderer could honour it, so every
member received the entry: its label, plus the gated book or doc name.
## One resolution, no second resolver
A private `resolveDocsAudience(environmentId, req, books)` now builds a
`DocsAudience`: the caller, the fast path, a book lookup (declared book,
else the implicit per-package book), the book's own audience gate, a
per-doc reader, the readable tree, and the readable pages. Every verdict
is asked of the `@objectstack/spec/system` helpers (`audienceAllows`,
`resolveDocAudiences`, `docAudienceAllows`, `resolveBookTree`,
`resolveBookClaimedDocs`, `deriveImplicitPackageBook`). Five doors are
built on it: `/meta/book`, `/meta/book/:name`, `/meta/doc`,
`/meta/doc/:name` and `/meta/book/:name/tree`, which used to hold three
hand-written copies of this logic, plus the new nav arm.
- **The reads' responses are byte-identical.** A one-off probe (not
committed) ran `BASE` `fa00ebf4`'s `rest-server.ts` against this
branch's over 1584 requests. The matrix crossed 4 callers (anonymous,
non-holder, holder, unresolvable holdings) × 3 book sets (set-gated, org
and public only, none) × 3 failure modes (none, book read throws, doc
read throws) × the book and doc list, item and tree routes, in both
spellings, with and without `?package=` and `?include=content`. It found
**0 diffs** across statuses `200:752 401:168 403:28 404:240 500:396`,
with 55 distinct responses. A control (non-holder 403 vs holder 200 on
the same doc) proves the probe distinguishes callers. The existing
suites (`rest.test.ts` ADR-0046 §6.7 block, `meta-audience-plural`,
`meta-public-book-grant`) stay green.
## Entry semantics, measured against the resolver
- **`doc` alone**: served iff
`docAudienceAllows(resolveDocAudiences(...).get(doc), caller)`. This is
the answer `/meta/doc/:name` gives.
- **`book` alone**: the named book (else the implicit per-package book)
must admit the caller by its own audience. That is the tree read's
401/403. **And** at least one of its pages must be readable. A book's
pages are the docs it *claims*, per `resolveBookClaimedDocs`, the
membership `resolveDocAudiences` itself uses. Measured:
`resolveBookTree` appends every doc the book does not claim as a
synthetic *Uncategorized* group. Over an env-wide corpus, nearly every
book's tree therefore holds some readable doc, and counting tree entries
would never prune. The spec calls those orphans "not an authored
membership claim". A pin shows the implicit `ops` book's tree serving
`crm_intro` under Uncategorized while the entry is still pruned.
- **`book` + `doc`**: pruned when either the book's own audience denies
the caller or the doc is unreadable. A readable doc inside an unreadable
book is possible, because a doc's effective audience is the union over
every book claiming it (`docAudienceAllows`, `book.zod.ts`). But the
entry opens the page in that book's context, whose tree read is refused
(the `admitsBook` check in the tree route), and the entry itself names
the gated book. So it is pruned.
- **Existence** is left to `docs/nav-target`. A `doc` naming a doc
absent from the corpus is served, because the resolver's own default for
a doc it has no entry for is `org`. A `book` naming nothing resolves to
an implicit book with no page, and is pruned: "no readable page" is the
rule's own wording. Both are pinned.
- **Anonymous**: `GET /meta/app` answers 401 before any read (pinned).
The umbrella gate admits anonymous callers only to the book and doc
reads, so there is no anonymous app nav for the arm to cover.
- Entry-level arms carry no reason. `withheld` is app-level only, so the
arm is a bare `continue`, like its neighbours. An app it empties is
still served.
## Fails closed
- **No gate means no `doc` entries.** Both callers must pass a gate, or
the arm drops every `doc` entry.
- **A thrown book read, or a thrown doc corpus read, also drops every
`doc` entry** of that response and logs a `warn`. The rest of the
navigation is served.
- **Unresolvable holdings deny set-gated entries**, through
`resolveAudienceCaller` (ADR-0049).
- **Why the nav gate does not copy the reads' degradation.** Mechanism
assumption: "degrades exactly as the reads do". Measured falsified for
safety: the reads swallow a thrown book read to `[]`
(`fetchAudienceBooks`), which reads as "no gated book anywhere". Copying
that would serve set-gated entries to every member. The nav gate reads
the same data through `readAudienceBooks` / `readDocCorpus`, which
report the fault, and prunes. The reads keep their answers unchanged.
That fail-open is reported below as a finding.
## Cost per `/meta/app` request (ruling point 4)
- **No `doc` entry in the app list:** zero added reads, only a walk of
the nav trees (pinned: no book read, no doc read, no holdings).
- **Otherwise, once per request whatever the app count** (pinned with
two apps: 1 book read, 1 doc read, 1 holdings resolution):
- one `book` list read;
- one permission-set resolution, only if some book is set-gated. The
execution context is already memoised per request.
- one `doc` list read, only off the fast path or when an entry names a
book alone.
- **Book traversal.** Off the fast path, `resolveDocAudiences` runs one
`resolveBookTree` per book over the whole corpus, shared by every entry.
Per entry: a `doc` is one map lookup. A `book` alone is one
`resolveBookClaimedDocs`, which is one `resolveBookTree` of that book:
every doc is visited once per group rule, plus one lookup per claimed
page.
- **No cache.** Nothing outlives the request. The fast path
(authenticated, no set-gated book) is the doc list's own.
## Tests
`packages/rest/src/meta-app-nav-doc-audience.test.ts` has 19 cases. They
drive the real list, by-name and tree routes:
- a non-reader receives no such entries, and the wire carries none of
the names or labels;
- a reader receives them;
- a book with exactly one readable page stays, and agrees with the tree
read;
- a book whose own audience admits the member but whose every page is
gated is pruned;
- list and by-name parity, for a non-holder and a holder;
- the `requiredPermissions` control;
- the four fail-closed rows;
- fast path and existence;
- cost;
- anonymous.
Evidence:
- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2 src/meta-app-nav-doc-audience.test.ts`: `Tests 19 passed
(19)`.
- `@objectstack/rest` full local project: `Test Files 195 passed (195)`,
`Tests 3284 passed | 1 skipped (3285)`.
- `pnpm --filter @objectstack/rest run typecheck`: exit 0, test layer `0
file(s) / 0 error(s)`.
- Measured at `cb800e24`. The later commit `f2bc219b` touches only the
docs page and the changeset.
- **Ablation.** Committed first. The arm line was deleted through
`scripts/ablation-replace.mjs` in wrap mode (anchor 1 to 0, blob
`5c7dbf8f1015` to `f0b3c3cdd872`). Result: `Tests 12 failed | 7 passed
(19)`. The 12 red are every non-reader, fail-closed, parity-non-holder,
fast-path-existence and two-app-cost row. The 7 green are the holder,
one-readable-page, control, no-doc-entry-cost, no-book-entry and
anonymous rows. The restore was proven: blob equals `HEAD`, and `git
diff HEAD` is empty. A re-run is green (19/19).
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 90
commands at `f2bc219b`. All exit 0. Reconciled with `--ran`: `90
derived, 90 run, 0 NOT-MEASURED` (a derived zero, since every row
recorded its exit code). Two gates first refused with PREREQUISITE NOT
MET and were re-run green after building what they read:
`check:skill-examples` after building client and client-react, and
`check:dual-build-cjs-loads` after the workspace build.
- **Lint.** `pnpm lint` (full union, `eslint . --no-inline-config`)
exited 0 in 107s at `f2bc219b`.
## Docs and changeset
- `content/docs/ui/apps.mdx`: the Audience bullet now says the server
applies the rule to the entry itself, including the `book` + `doc` rule,
and which pages count.
- `.changeset/19790-nav-doc-audience-prune.md`: `@objectstack/rest`
`patch`.
## Acceptance notes
- **Conflict in the dispatch.** The dispatch asked the gate both to
"degrade exactly as the reads do" and to "not fail open". The reads fail
open on a thrown book read, so the gate follows the second and prunes
(see "Fails closed").
- **Observation, not filed.** Without `?package=`,
`/meta/book/:name/tree` resolves over the env-wide corpus. Every book's
tree therefore lists every doc it does not claim under Uncategorized.
That is the resolver's documented "nothing is ever dropped" rule, and it
is why the nav arm counts claimed pages rather than tree entries.
- **Out of scope, reported for the seat, not fixed here** (evidence from
one-off probes, not committed):
- (a) The docs reads fail open when the book list read throws.
`fetchAudienceBooks` swallows the fault to `[]`, and `/meta/doc/:name`
swallows a thrown corpus read the same way. Probe: a non-holder's `GET
/meta/doc/crm_admin_runbook` goes from 403 to 200 with the body, and
`GET /meta/doc` lists it, once the book read rejects. The shipped
`getMetaItems` rethrows every `sys_metadata` read failure except
"unprovisioned".
- (b) `GET /meta/app/:name/layers`, the deprecated `?layers=true`, and
`GET /meta/app/:name/published` serve the unfiltered app document.
Probe: an authenticated member holding neither the audience nor the
`requiredPermissions` receives both kinds of entry. `/history` and
`/diff` serve stored app versions the same way; that was read from code,
not driven. `packages/runtime/src/domains/meta.ts`'s dispatcher list and
item reads apply no app-nav filter where that door answers. The MCP
`list_metadata` / `describe_metadata` implementations are not in this
tree, so they are NOT MEASURED here.
- #19482 (the `doc` variant) is the parent card and is not addressed
here. The objectui renderer pruning (objectui#10188) stays as defence in
depth, per the ruling.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6780e34 commit bc80e16
4 files changed
Lines changed: 853 additions & 95 deletions
| 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 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
228 | | - | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
229 | 235 | | |
230 | 236 | | |
231 | 237 | | |
| |||
0 commit comments