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
{{ message }}
Repository navigation
[Decision] should the server-side app-nav filter also prune a doc menu entry by the docs audience, so a member who cannot read the doc never receives the entry — or is renderer-side pruning (objectui#10188) the whole answer? #19790
Ruled: 5793362670 · letter A · 2026-09-23T10:42Z
Blocked-by: #19482
Filing gate: ② (a decision only the maintainer can make — a permission-boundary change). Reader: the director seat's decision batch; after the ruling, the domain:cli seat (packages/rest) if A. ⛔ Not a claim. Split from #19482 (PR #19789): this card carries the server-side half of the audience rule; #19482 keeps the schema, whose describe states the rule for the renderer.
Governing text: #19482 ruling addendum 5754645447 (「a doc entry the member may not read is not rendered」; 「a member with no readable page in it does not see the entry at all」); ADR-0046 §6.7 (docs content gated server-side at the doc/book reads); the filterAppForUserWithReason docblock in packages/rest/src/rest-server.ts.
Prior rulings read: audience,navigation,doc,book,nav,filter → 37 hits; ADR-0057 D10, ADR-0021 D2, ADR-0021 D3, ADR-0029 D7, ADR-0030 Decision §2, ADR-0030 Decision §6 — each read, none rules on nav-entry pruning by doc audience (notification / dataset / ownership scopes); thread: this card is new; repo: objectstack-ai/objectstack
Evidence (measured by the #19482 round and its at-tier contract review on PR #19789, head f9baf5cb3f)
filterAppForUserWithReason (packages/rest/src/rest-server.ts) prunes on requiredPermissions, requiresService, object servability and empty groups — no audience arm.
Content gating exists at /meta/doc, /meta/book, /meta/book/:name/tree (ADR-0046 §6.7).
The objectui renderer (objectui#10188) has not shipped, so today no doc item renders at all — the leak is latent until it does, and then only entry names leak.
Dedupe
REST list of the 435 most recently updated issues (open + closed) grepped locally: filterAppForUserWithReason 0 · nav … audience 1 (#19482, the parent) · lit control requiredPermissions 3 (#18159, #19186, #19503 — none this question).
Ruling: batch #217 item 2 · letter A · maintainer 「217 同意」 2026-09-23T10:40Z
Director seat, summon #28 (session_01GLdRPcbaCBQCTvVmU6YEUY). Presented in this seat's chat with recommendation A; the maintainer approved the batch as presented. B ⛔ (a rule the server declares but only one renderer honours; entry names leak to non-readers through /meta/app, MCP and any other front end).
Governing text: #19482 ruling addendum 5754645447 (「a doc entry the member may not read is not rendered」; 「a member with no readable page in it does not see the entry at all」); ADR-0046 §6.7 (docs content gated server-side at the doc / book reads); the filterAppForUserWithReason docblock (packages/rest/src/rest-server.ts:3231) — secrets belong to the server. Readings this summon: the filter's arms are requiresService (:3263 / :3276) and requiredPermissions / servability; ⛔ no audience arm; PR #19789 (the doc variant) is Tier H awaiting the maintainer's merge. Prior rulings read: server-side,app-nav,filter,prune,menu,entry,docs,audience,member,read,receives,renderer-side (+20 more) → 308 hits; ADR-0076 D11, ADR-0029 D9.7, ADR-0056 D2, ADR-0076 D9, ADR-0127 D6, ADR-0005 Decision §5, ADR-0030 Decision §2, ADR-0035 Decision §4, ADR-0035 Decision §6, ADR-0036 Decision §1; thread: none — none rules on nav-entry pruning by doc audience.
Ruled — A: the server-side nav filter prunes doc entries by the docs audience
filterAppForUserWithReason gains an audience arm beside requiredPermissions: a doc entry naming a doc the caller may not read is pruned; a book entry whose readable subset for the caller is empty is pruned; the reason spelling follows the existing ones. It reuses the audience resolution the /meta/doc / /meta/book reads already apply — ⛔ no second resolver.
Pins: a non-reader's /meta/app carries no such entry; a reader's does; a book entry with one readable page stays.
First readings for the dev, as the card's gaps: the per-request cost of resolving audiences during /meta/app, and the traversal a book entry needs — report them; ⛔ no caching layer is invented in this card.
Execution
needs-user-decision → pm:blocked in this stroke, Blocked-by: objectstack-ai/objectstack#19482 on the body (the doc variant lands first; the unlock scan returns this card when #19482 closes). Lane by landing site: domain:spec → domain:cli (packages/rest), priority:p2, Clause-②: no (a permission-boundary tightening, no spec key).
Acting on the maintainer's instruction. Provenance: who — the maintainer; verbatim — 「帮我排查一下 blocked 卡片,哪些需要更新状态帮我更新。」; where — the maintainer's chat with session session_013RWUA7bNq5bRhehLPqXwMg, 2026-09-24. ⛔ Not a claim and not a dispatch: the card returns to its lane's take order. Candidate surfaced by check-half-states.mjs H19 on this sweep.
Claim: PM loop round 9 (serial)
Session: session_01TnPAC1UsTGfHPXVUCL6iLn
Account: os-steve
Branch: claude/issue-19790-nav-doc-audience-prune
Worktree: objectstack-issue-19790
Domain: domain:cli
Seat: domain:cli#1
File surface: packages/rest/src/rest-server.ts (filterAppForUserWithReason / filterAppForUser, the gate their two callers hand them, and the audience-resolution plumbing those callers need), its tests under packages/rest/src/, any hand-written content/docs/** line the change makes false, and one .changeset/19790-*.md. packages/spec/** (the DocNavItemSchema describe included) and every other package are read-only. packages/rest/CHANGELOG.md and package.json belong to the Version Packages PR. Stop on breach and explain in the report
Container & model: M, mode:subagent, model: default judgment tier (this act's node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/rest/src/rest-server.ts answered: no path-derived mandate). A maintainer-ruled permission-boundary arm in the server's nav filter. It must reuse the docs audience resolution, with no second resolver, fail closed, and report its per-request cost, so it gets the default judgment tier.
Clause-②: no
Thread-read: 5824249598
Serial constraints cleared: the maintainer's order in this seat's session (「继续派发」) and serial dispatch: R8 (#19922) reached its end state in this seat's record 5827511003 (released with a reason, pm:blocked on #20112), so no dev is in flight in this lane. The file lists of all 10 open PRs were read in this act. None touches packages/rest/**; the Version Packages PR #17076 owns the CHANGELOG and package.json versions, outside this surface. The seat post's queue head #19328 stays fenced behind the open draft PR #19637. The NORTH-STAR 优先级 3 pre-pick read at fa00ebf4: the domain:cli pm:queue holds no open P0/P1
Taken 2026-09-25T05:52Z, origin/mainfa00ebf4. Clause-②: no, per the ruling's execution line (5793362670): a permission-boundary tightening with no spec key. The server comes to honour the audience rule DocNavItemSchema already declares, so no accepted input moves and no published surface grows. The dev posts no second claim; this comment is its identity.
os-dev-report
{
"issue": 19790,
"status": "done",
"branch": "claude/issue-19790-nav-doc-audience-prune",
"pr": "#20128",
"session": "session_01TnPAC1UsTGfHPXVUCL6iLn — subagent run inside the PM seat session (the parent's harness-stamped id); identity = the branch",
"premise_still_valid": true,
"summary": "filterAppForUserWithReason gains the ruled docs-audience arm: a type:'doc' entry is pruned when its doc is unreadable, when its book's own audience denies the caller, or (book alone) when none of the book's CLAIMED pages is readable; both callers (list + by-name) build one NavDocAudienceGate per request and hand it in, and the arm fails CLOSED (no gate, or a thrown book/doc read, drops every doc entry + warn). All verdicts come from one new private resolveDocsAudience (spec helpers audienceAllows / resolveDocAudiences / docAudienceAllows / resolveBookTree / resolveBookClaimedDocs / deriveImplicitPackageBook), which the five docs reads (/meta/book, /meta/book/:name, /meta/doc, /meta/doc/:name, /meta/book/:name/tree) now also use — no second resolver; their responses are byte-identical (1584-request BASE-vs-HEAD probe, 0 diffs). Cost: zero added reads when no app carries a doc entry; otherwise once per request (1 book read, 1 holdings resolution only if a set-gated book exists, 1 doc read only off the fast path or for a book-alone entry), per book-alone entry one resolveBookClaimedDocs traversal; no cache. Anonymous GET /meta/app is 401 before any read (pinned), so the arm has no anonymous path to cover.",
"tests": "New packages/rest/src/meta-app-nav-doc-audience.test.ts, 19 cases driving the real list / by-name / tree routes: non-reader has no gated doc/book/all-gated-book entry and the wire carries none of the names or labels; reader has them; a book with exactly one readable page stays (and the tree read agrees: [crm_intro]); an org book whose every CLAIMED page is gated is pruned although its tree serves an Uncategorized orphan; book+doc pruned when the book is gated; group/area collapse; list/by-name parity for non-holder and holder; requiredPermissions control; four fail-closed rows (book read throws, doc read throws, holdings unresolvable, no gate); fast path + existence (unwritten doc served, unknown book pruned, no holdings resolved); cost (no doc entry = 0 book/doc reads; two apps = 1 book read, 1 doc read, 1 holdings call); anonymous 401. Runs: file 19/19 green; @objectstack/rest local project Test Files 195 passed, Tests 3284 passed | 1 skipped; typecheck exit 0 (test layer 0 errors). ABLATION (fix committed first, cb800e2): scripts/ablation-replace.mjs --delete of the arm line, wrap mode, anchor x1 -> x0, blob 5c7dbf8f1015 -> f0b3c3cdd872 on disk; result Tests 12 failed | 7 passed — red: every non-reader row, the four fail-closed rows, non-holder parity, fast-path existence, two-app cost; green: holder, one-readable-page, requiredPermissions control, no-book-entry, no-doc-entry cost, anonymous (the expected direction). Restore proven: blob after restore == HEAD blob 5c7dbf8f1015, git diff HEAD empty; re-run 19/19 green. Subject is src-imported (./rest-server.js via vitest, no dist in the resolution path), so no rebuild/dist preflight applies. BYTE-IDENTITY of the docs reads: one-off probe (deleted, not committed) of BASE fa00ebf rest-server.ts vs this branch over 4 callers x 3 book sets x 3 failure modes x book/doc list+item+tree routes (both spellings, ?package=, ?include=content): compared=1584 diffs=0, statuses 200:752 401:168 403:28 404:240 500:396, 55 distinct responses, control non-holder 403 vs holder 200. Gates: 90 derived at f2bc219, all exit 0, --ran reconciliation a derived zero NOT-MEASURED; pnpm lint full union exit 0 at f2bc219.",
"mcp_calls": "0 — no MCP tool of any kind was called",
"api_writes": "3 — each through the fleet-write relay (repository_dispatch, executed as objectstack-fleet[bot]): (1) pr_create POST /repos/objectstack-ai/objectstack/pulls, draft forced, #20128, relay run 36104733901 success; (2) label-write POST /repos//issues/20128/assignees {os-steve}, relay run 36104793574 success, read-back MATCHES (assignee os-steve; labels documentation/size/l/tests/tooling are the path labeler's, untouched); (3) post-stamped POST /repos//issues/19790/comments (this report). Not REST: git push x4 of the branch. Everything else was reads.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · the docs READS fail OPEN when the book list read rejects: fetchAudienceBooks maps the fault to [] (read as "no set-gated book anywhere", so the fast path serves everything to an authenticated caller), and /meta/doc/:name maps a rejected corpus read to [] (the gated doc then reads as unclaimed, so org). Failing probe (one-off, stub protocol whose getMetaItems({type:book}) rejects, authenticated non-holder of crm_admin): GET /meta/doc/crm_admin_runbook 403 -> 200 WITH the body; GET /meta/doc lists it. Reachable with the shipped protocol: metadata-protocol getMetaItems rethrows every sys_metadata read failure except "unprovisioned" (#5532), so a store fault on the book read after the doc read succeeded serves the gated doc. Pre-existing at fa00ebf (byte probe: BASE and HEAD answer identically); this card kept the reads byte-identical by ruling, and the nav gate does NOT inherit it (readAudienceBooks / readDocCorpus report the fault; the reads still map it to []). Site: packages/rest/src/rest-server.ts fetchAudienceBooks + the /meta/doc/:name corpus read. · dedupe: fetchAudienceBooks fail open · docs audience book read failure · meta doc permissionSet leak · catch(() => []) audience gate",
"class: b · GET /meta/:type/:name/layers (and the deprecated ?layers=true, same helper serveMetaItemLayered) serves the app document with NO filterAppForUserWithReason pass — every arm bypassed, requiredPermissions included, not only the new audience arm. Probe (one-off, authenticated member holding neither finance.access nor crm_admin): /meta/app/crm/layers 200 carrying both the audience-gated doc entry and the requiredPermissions entry in code/overlay/effective; list and by-name routes prune both. Contract: content/docs/ui/apps.mdx requiredPermissions row 「The entry is never served: it is absent from the /meta body」 and the by-name caller comment 「the by-name route must not serve a nav entry the list route prunes, or reading the single-app JSON defeats the filter」. Seam: spec:AppSchema.navigation[].requiredPermissions + DocNavItemSchema audience -> runtime:packages/rest/src/rest-server.ts serveMetaItemLayered · dedupe: meta app layers requiredPermissions · layered view nav filter bypass · filterAppForUser layers",
"class: b · GET /meta/:type/:name/published serves the stored app document unfiltered (probe as above: 200 with both gated entries). Same contract text. Seam: spec:AppSchema.navigation[].requiredPermissions + DocNavItemSchema audience -> runtime:packages/rest/src/rest-server.ts GET /meta/:type/:name/published · dedupe: published app nav filter · meta published requiredPermissions leak",
"class: b · GET /meta/:type/:name/history and /diff return stored app versions with no nav filter — CODE-READ only, not driven (no probe was run for these two). Same contract text. Seam: spec:AppSchema.navigation[].requiredPermissions -> runtime:packages/rest/src/rest-server.ts GET /meta/:type/:name/history, /diff · dedupe: meta history app nav · metadata diff requiredPermissions",
"class: b · packages/runtime/src/domains/meta.ts handleMetadataRequest (HttpDispatcher /meta catch-all: list via protocol.getMetaItems, item via getMetaItem) applies no app-nav filter at all where that door answers — CODE-READ; which compositions route /meta/app to it rather than to RestServer is NOT MEASURED (the REST routes shadow it where RestServer is mounted). Seam: spec:AppSchema.navigation -> runtime:packages/runtime/src/domains/meta.ts handleMetadataRequest · dedupe: http-dispatcher meta app filter · runtime meta domain requiredPermissions",
"NOT MEASURED · MCP list_metadata / describe_metadata: only the names live in this tree (packages/spec/src/system/constants/platform-tool-names.ts); the implementations are elsewhere, so whether they serve app nav past the filter cannot be read here · carrier: 承接者:无 · noted, not filed",
"carrier: 承接者:无 · noted in the PR Acceptance notes, not filed — without ?package=, /meta/book/:name/tree resolves over the env-wide corpus, so every book tree lists every doc it does not claim under Uncategorized (the resolver's documented "nothing is ever dropped" rule); it is why the nav arm counts claimed pages, not tree entries"
],
"gates": {
"node scripts/check-adr-0087-registration.mjs --base origin/main": "exit 0 · ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).",
"node scripts/check-adr-0087-registration.mjs --self-test": "exit 0 · ✓ check-adr-0087-registration --self-test: 441 assertions over real temp git repos (real scan()/assertInputs() path)",
"node scripts/check-changeset-no-major.mjs --base origin/main": "exit 0 · ✓ This diff introduces no major bump.",
"node scripts/check-changeset-no-major.mjs --self-test": "exit 0 · ✓ check-changeset-no-major --self-test: 339 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"node scripts/check-ci-filter-parity.mjs": "exit 0 · OK: all 184 declared cross-package glob(s) (131 unique) are covered by core or crosspkg, every crosspkg entry still covers one, and the test job's if: still nam",
"node scripts/check-closing-keyword-parity.mjs": "exit 0 · check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 9546 tracked file(s), ",
"node scripts/check-closing-keyword-parity.mjs --self-test": "exit 0 · ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.",
"node scripts/check-comment-mask-adoption.mjs": "exit 0 · OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/, all 14 recorded and every recorded row still reached (13 unconverted, 1 s",
"node scripts/check-comment-mask-adoption.mjs --self-test": "exit 0 · PASS check-comment-mask-adoption --self-test (0 failure(s))",
"node scripts/check-comment-mask-corpus.mjs": "exit 0 · ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 7159 files, 0 disagree, 0 unparseable, 78.6s (comparator self-test: 26 cases pass).",
"node scripts/check-doc-frontmatter.mjs": "exit 0 · ✓ check-doc-frontmatter: 2 content root(s) verified, each against its own floor — content/docs 406, content/blog 3.",
"node scripts/check-doc-frontmatter.mjs --self-test": "exit 0 · ✓ check-doc-frontmatter --self-test: 99 assertions — the card's own description observed failing with the parser's message and the FILE line, every other violation kind o",
"node scripts/check-doc-route-spelling.mjs --advisory": "exit 0 · ✓ route-spelling guard (advisory): population clean — every shape-matched literal spells its ledger row.",
"node scripts/check-doc-route-spelling.mjs --self-test": "exit 0 · ✓ check-doc-route-spelling self-test: extraction tidy-up, the variant relation (plural + pinned lexicon, no prefix heuristic), walk wiring (releases/ and node_modules/ ou",
"node scripts/check-docs-section-name.mjs": "exit 0 · so it is carried by --self-test rather than by this corpus.",
"node scripts/check-docs-section-name.mjs --self-test": "exit 0 · ✓ check-docs-section-name self-test: 85 cases pass (real temp trees on disk; both historical misses reproduced as RED, both arms driven RED, the duplicate-key and syntax-",
"node scripts/check-empty-changeset.mjs --base origin/main": "exit 0 · ✓ No changeset from the merge base modified or deleted by this diff (#17712).",
"node scripts/check-empty-changeset.mjs --self-test": "exit 0 · ✓ check-empty-changeset --self-test: 159 assertions over real temp git repos (real scan() path)",
"node scripts/check-keyed-text-bounds.mjs": "exit 0 · ✓ check:keyed-text-bounds: 112 .object.ts files under packages/ + apps/* + examples/** (walk is repo-wide; 0 outside), 117 object declarations, 250 declared index ent",
"node scripts/check-keyed-text-bounds.mjs --self-test": "exit 0 · PASS check-keyed-text-bounds --self-test (0 failure(s))",
"node scripts/check-platform-object-tenancy-census.mjs": "exit 0 · ✓ platform-object tenancy census matches the tree: 84 platform-namespace objects, 58 in the machinery's reach, 26 outside it, every exclusion explained by a declaration o",
"node scripts/check-platform-object-tenancy-census.mjs --self-test": "exit 0 · ✓ check-platform-object-tenancy-census self-test: all checks pass (84 objects, 26 outside the machinery)",
"node scripts/check-plugin-teardown-shape.mjs": "exit 0 · ✓ check:plugin-teardown-shape: 69 Plugin implementation(s) across 6590 source(s) under packages/; every teardown-shaped method (stop / shutdown / close / dispose) sits ",
"node scripts/check-plugin-teardown-shape.mjs --self-test": "exit 0 · ✓ check-plugin-teardown-shape self-test: 48 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name",
"node scripts/check-registry-log-declared.mjs": "exit 0 · OK: 74 vitest-running package(s) walked, 9 selected as engine-booting, every one declares a recognised registry log level (debug/info/warn/error/silent).",
"node scripts/check-registry-log-declared.mjs --self-test": "exit 0 · self-test OK: 16 cases + level-vocabulary read + population declaration + real-tree selection floor.",
"node scripts/check-rest-log-spy-declared.mjs": "exit 0 · OK: 30 of 197 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.",
"node scripts/check-rest-log-spy-declared.mjs --self-test": "exit 0 · check-rest-log-spy-declared self-test reached its verdict: 23 case(s), 0 failure(s).",
"node scripts/check-section-landing-index.mjs": "exit 0 · ✓ check-section-landing-index: 8 section index block(s) enumerate their meta.json pages, in order, both directions (ai, api, automation, data-modeling, kernel, permission",
"node scripts/check-section-landing-index.mjs --self-test": "exit 0 · ✓ check-section-landing-index --self-test: 31 assertions over synthetic inputs and a temp fixture (real judge()/run() path); every limb -- both shapes in sync, missing pa",
"node scripts/check-system-context-census.mjs": "exit 0 · check-system-context-census: OK — 112 elevation read sites in 20 packages across 45 files, living in 93 symbol(s); the page cites 106 symbol(s) against 106 required, over",
"node scripts/check-system-context-census.mjs --self-test": "exit 0 · check-system-context-census --self-test: all cases passed",
"node scripts/check-undeclared-dep-imports.mjs": "exit 0 · ✓ check:undeclared-dep-imports: 81 workspace packages under packages/ + apps/** + examples/, 2592 non-test src files, 2107 @objectstack/* specifiers (0 assembled, not",
"node scripts/check-undeclared-dep-imports.mjs --self-test": "exit 0 · PASS check-undeclared-dep-imports --self-test (0 failure(s))",
"node scripts/docs-audit/check-affected-docs.mjs": "exit 0 · ✓ affected-docs self-test: 605 cases pass.",
"node scripts/docs-audit/check-drift-comment.mjs": "exit 0 · ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).",
"node scripts/pm/release-rehearsal-clone.mjs --self-test": "exit 0 · ✓ self-test passed",
"pnpm --filter @objectstack/lint run check:doc-formula-expressions": "exit 0 · ✓ check:doc-formula-expressions (field-level *When, #11407): 14 predicate(s) on a statically determinable field layer judged clean; 6 skipped as undeterminable.",
"pnpm --filter @objectstack/lint run check:doc-security-posture": "exit 0 · ✅ 27 ObjectSchema.create example(s) in 227 marked block(s) across 248 prose file(s) in 2 root(s) carry an os validate-clean security posture",
"pnpm --filter @objectstack/spec run check:docs": "exit 0 · ✅ 226 generated files in sync with packages/spec",
"pnpm --filter @objectstack/spec run check:duration-unit-keys": "exit 0 · ✓ check:duration-unit-keys — 204 unit-declaring numeric key(s) across 2589 source file(s) all carry their unit in the key name (or in a sibling unit, or under a declare",
"pnpm --filter @objectstack/spec run check:empty-state": "exit 0 · ✓ all classified (2 closed, 2 open, 4 output, 9 scope)",
"pnpm --filter @objectstack/spec run check:liveness": "exit 0 · ✓ packages/spec/liveness/state-counts.md is current — the same 40 row(s), no count column left in the README.",
"pnpm --filter @objectstack/spec run check:skill-examples": "exit 0 · ✅ 259 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them",
"pnpm --filter @objectstack/spec run check:strictness-ledger": "exit 0 · ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 453 site(s) measured, 1 authorable strip site(s) left.",
"pnpm --filter @objectstack/spec run check:variant-docs": "exit 0 · ✓ variant/doc gate: 18 discriminated union(s) — 8 governed (every variant mentioned in a bound doc), 10 exempt.",
"pnpm --filter @objectstack/spec run check:yaml-examples": "exit 0 · ✅ 18 tagged YAML example(s) across 1 file(s) validate against their declared live spec schemas",
"pnpm check:authz-resolver": "exit 0 · ✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.",
"pnpm check:changeset-gate-self-tests": "exit 0 · ✓ check-changeset-no-major --self-test: 339 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"pnpm check:corpus-claim-drift": "exit 0 · check-corpus-claim-drift: OK, no new claim sites beside a pinned spelling.",
"pnpm check:cross-package-test-inputs": "exit 0 · OK: 29 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split "test:repo" task); 13 walked root(s) judged, 3 on",
"pnpm check:dispatcher-error-vocabulary": "exit 0 · [#15723] the ARGUMENT POSITION of new APIError( … ) and APIError.from( … ) IS now in this gate's population — the apierrorarg shape. APIError.from copies the record's",
"pnpm check:doc-anchors": "exit 0 · ✅ check-doc-anchors: 376 internal #fragment link(s) across 411 source file(s) all resolve to a real heading",
"pnpm check:doc-authoring": "exit 0 · ✓ doc authoring guard: sibling-package prose ids hold the baseline — 815 pinned site(s) across 231 file(s), 91983 string(s) read in 1253 parsed source(s), no growth, no b",
"pnpm check:docs-audit-scope": "exit 0 · ✓ scope injection is live: the workflow audits the list handed in as args.handwritten, and refuses an invocation that hands in no scope at all.",
"pnpm check:docs-redirects": "exit 0 · check-docs-redirects: OK (apps/docs/redirects.mjs: 98 entries -- 95 page destination(s) resolved against content/docs, 3 wildcard destination(s) resolved to a directory, ",
"pnpm check:docs-single-h1": "exit 0 · ✓ check-docs-single-h1: 406 page(s) under content/docs/ carry no body-level # heading (0 subtree(s) excluded, see --list).",
"pnpm check:docs-spec-enumerations": "exit 0 · OK the hand-written spec enumerations agree with packages/spec/package.json -- 17 subpath(s) held ORDERED in content/docs/deployment/troubleshooting.mdx; 15 protocol name",
"pnpm check:docs-transcript-drift": "exit 0 · ✓ check-docs-transcript-drift: 4 declared transcript value(s) across 406 page(s) under content/docs/ equal what the registry derives today, and no undeclared block quotes",
"pnpm check:driver-memory-census": "exit 0 · check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers". This gate polic",
"pnpm check:dts-closure": "exit 0 · check-dts-closure: 73 built package(s) swept - 167/167 declared declaration file(s) present across 73 package(s); 0 built package(s) declare no declaration entry point an",
"pnpm check:dual-build-cjs-loads": "exit 0 · ✓ check:dual-build-cjs-loads — 105 published require entry point(s) across 67 package(s) load; 660 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree",
"pnpm check:engine-double-contract": "exit 0 · check-engine-double-contract: OK — 902 pinned, 133 in the DEBT ledger, 3 exempt.",
"pnpm check:gitlink-declared": "exit 0 · check-gitlink-declared: OK (9546 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).",
"pnpm check:issue-citations": "exit 0 · ✅ check-issue-citations --self-test: grammar narrowed, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinned (73 cases,",
"pnpm check:lean-entry-closure": "exit 0 · ✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.",
"pnpm check:logger-receiver-detach": "exit 0 · OK every log channel keeps its receiver: 2860 non-test TS file(s) walked, 0 detach(es) on the 5 declared receiver-sensitive sink spelling(s).",
"pnpm check:nul-bytes": "exit 0 · check-nul-bytes: OK (scanned 9539 text file(s) -- 9539 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).",
"pnpm check:objectql-double-limit": "exit 0 · OK ObjectQL double limit conformance holds: 410 double(s) graded, 212 apply the caller's bound or refuse it loudly.",
"pnpm check:objectui-changeset": "exit 0 · ✓ objectui-range --self-test: all checks passed",
"pnpm check:org-identifier": "exit 0 · check-org-identifier: OK (2882 author-facing source file(s), 17 session binding(s) resolved, no removed session.tenantId alias).",
"pnpm check:page-declaration-shape": "exit 0 · check-page-declaration-shape: OK — 34 page entries across 2872 sources under packages/, examples/, apps/ all reach the kernel through a discoverable declaration (:", "pnpm check:pm-changeset-deadline-census": "exit 0 · ✓ changeset-deadline-census --self-test: all cases passed across 5 batteries (what counts as a named target, the controls that make a zero a reading, the verdict truth ta", "pnpm check:published-files": "exit 0 · ✓ check:published-files — 70 publishable package(s) of 81 workspace member(s) declare a fileswhitelist that covers every entry point plus CHANGELOG.md and admits no te", "pnpm check:published-readme-links": "exit 0 · ✓ check:published-readme-links — 178 outbound link(s) across 61 published markdown file(s): 0 root-relative, 0 non-canonical origin(s), 27 docs-site page(s) resolved (0 v", "pnpm check:query-options-erasure": "exit 0 · ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed. Every other non-test file under packages/ is c", "pnpm check:react-page-adapter-contract": "exit 0 · ✓ check-react-page-adapter-contract: 21 app-showcase page module(s) + 1 content/docs react-page sample(s) (from 392 doc file(s), 1995 fenced block(s)) — every adapter que", "pnpm check:refd-timer-probe": "exit 0 · OK check-refd-timer-probe: 7154 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.", "pnpm check:role-word": "exit 0 · check-role-word: OK, no new occurrences of the reserved word.", "pnpm check:route-envelope": "exit 0 · ✓ Express-style response modules — 4 module(s) discovered and audited (walked, not enumerated — #9937), 12 hand-built body/bodies (count reported, NOT pinned): 2 conforma", "pnpm check:skill-identifier-liveness": "exit 0 · check-skill-identifier-liveness OK — Leg 1: 457 citation(s) over 53 published file(s) checked against 107876 implementation word tokens (0 ledgered exemption(s)); Leg 2: ", "pnpm check:slot-lookup": "exit 0 · ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered bypnpm li",
"pnpm check:sourcemap-no-sources-content": "exit 0 · check-sourcemap-no-sources-content: 69 built package(s) swept - 508 map(s), none embed source text.",
"pnpm check:test-source-alias": "exit 0 · check-test-source-alias OK — 74 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/; 50 published subpath(s) resolved through ev",
"pnpm check:tier-file-adoption": "exit 0 · OK: 81 workspace package(s) walked, 70 nightly-tier test file(s) on disk (70 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS.",
"pnpm check:type-check-coverage": "exit 0 · check-type-check-coverage: OK — 77/81 workspace packages type-checked (plus the root), 4 in the DEBT ledger (53 frozen raw errors, https://github.com/objectstack-ai/objec",
"pnpm check:type-check-debt": "exit 0 · check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured in 21.3s, 53 raw tsc error(s) total, none above its recorded number.",
"pnpm check:vendor-version-stamps": "exit 0 · check:vendor-version-stamps: OK — 7543 file(s) scanned.",
"pnpm check:watch-hint-literal": "exit 0 · ✓ check-watch-hint-literal: 71 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 47, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH_HINTS 8 -",
"pnpm check:where-matcher": "exit 0 · ✓ where-matcher conformance holds: 433 matcher(s) discovered, 433 answer the combinator battery correctly or refuse it loudly (282 refuse).",
"pnpm lint (full union: eslint . --no-inline-config) @ f2bc219": "exit 0 · no problems printed, 107s — the whole union, not a narrowing",
"pnpm --filter @objectstack/rest run typecheck @ cb800e2": "exit 0 · check:test-typecheck: OK — 0 file(s) / 0 error(s) / 0 pinned signature(s) (tsc --noEmit green first)",
"pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 @ cb800e2": "exit 0 · Test Files 195 passed (195) · Tests 3284 passed | 1 skipped (3285)",
"pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/meta-app-nav-doc-audience.test.ts @ cb800e2 (post-ablation restore)": "exit 0 · Tests 19 passed (19)",
"node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran-exit.txt @ f2bc219": "exit 0 · ✓ dispatch-gates --ran: 90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED (a DERIVED zero — all 90 recorded an exit code and none of them is 3)"
},
"line_budget": "n/a",
"deviations": [
"PM mechanism assumption 3 refined by measurement: a book entry's "readable subset" is its readable CLAIMED pages (resolveBookClaimedDocs, the membership resolveDocAudiences uses), not the entries of its tree — resolveBookTree appends every unclaimed doc as a synthetic Uncategorized group, so counting tree entries keeps nearly every book whenever any doc is readable (pinned: the ops tree serves crm_intro under Uncategorized while nav_ops_book is pruned). book+doc: pruned when either the book's own audience denies (tree read 401/403, rest-server.ts book-tree admitsBook check) or the doc is unreadable; a readable doc in an unreadable book is possible via the docAudienceAllows union (book.zod.ts) and is still pruned. Existence: a doc absent from the corpus is served (the resolver's own org default for a doc it has no entry for); a book naming nothing resolves to an implicit book with no page and is pruned.",
"Conflict in the order, stated not silently chosen: SUGGESTED ROUTE says the gate "degrades exactly as the reads do", and also "must not fail open". Measured: the reads fail OPEN on a rejected book read (probe, finding 1). The gate follows "must not fail open": a thrown book or doc read drops every doc entry of that response and logs a warn; the reads keep their answers.",
"Reuse went one step wider than the doc-list + book-tree pair: the /meta/book list and /meta/book/:name gates also moved onto resolveDocsAudience.admitsBook, so all five docs reads and the nav arm share one resolution; byte identity proven by the 1584-request probe and the existing read suites.",
"Test file is meta-app-nav-doc-audience.test.ts (sibling naming, beside meta-app-nav-servability-gate.test.ts) rather than the gates-from-pm expected path nav-doc-audience.test.ts; re-derivation on the real diff yields the same 90 commands.",
"Two gates first answered PREREQUISITE NOT MET (exit 3, nothing measured): check:skill-examples (client-react had no .d.ts) and check:dual-build-cjs-loads (workspace unbuilt). Re-run exit 0 after building what they read (client + client-react; then turbo build 73/73 tasks, 51 cached). Three dist-reading gates that had run before the full build (dts-closure, lean-entry-closure, published-files) were re-run after it, exit 0.",
"Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer; AGENTS.md and the order win — commits carry the model-free pair, the PR body ends with the session-URL footer.",
"Commit 3b67a44 has a wip(rest) subject (the WIP push AGENTS.md asks for before a minutes-long build); squash landing folds it.",
"Anonymous: measured, GET /meta/app answers 401 before any read, so the arm deliberately has no anonymous branch (order asked to measure and say).",
"Other doors (order point 5) are listed as findings rather than fixed, per the order; three of them driven by a one-off probe, two code-read, MCP NOT MEASURED."
],
"files_changed": [
"packages/rest/src/rest-server.ts — DocsAudience + NavDocAudienceGate types; resolveDocsAudience; readMetaList / readAudienceBooks / readDocCorpus / audienceBooksOf / docCorpusOf (fetchAudienceBooks keeps its [] degradation); resolveNavDocAudience + docNavEntries + navTarget; the arm in filterNav; a 5th gate parameter on filterAppForUser / filterAppForUserWithReason, passed by both callers; the five docs read gates rebuilt on the shared helper",
"packages/rest/src/meta-app-nav-doc-audience.test.ts — new, 19 cases",
"content/docs/ui/apps.mdx — Audience bullet: the server prunes the entry itself; book+doc and claimed-pages wording",
".changeset/19790-nav-doc-audience-prune.md — @objectstack/rest patch",
"diff vs merge base fa00ebf: 4 files, +853 / -95 (dispatch-gates reading)"
],
"ci": "in_progress at head f2bc219 — one snapshot read after pr_create: 32 check runs, 12 success, 2 skipped, 18 in_progress, 0 failing. Not waited on (os-dev: CI convergence is the PM's).",
"cleanup": "worktree ../objectstack-issue-19790 removed after node_modules removal (git worktree remove, no --force, exit 0; the tree was clean and at origin f2bc219). One-off probe files were deleted before each commit and never committed. No server, watcher or background job was started."
}
In-seat review: ACCEPT, PR #20128 at head f2bc219be0
domain:cli execution PM seat #6024 · session session_01TnPAC1UsTGfHPXVUCL6iLn · 2026-09-25T07:00Z · reviewed on GitHub and origin/mainfa00ebf4, against the ruling 5793362670
Review checklist
PR form: draft, base main, assignee os-steve. The first line is Fixes #19790, the only closing binding, and the second is the claim's Clause-②: no. One footer, no model identifier.
Scope: 4 files (+853/−95), all inside the claim's surface: rest-server.ts, one new test file, content/docs/ui/apps.mdx and one changeset. packages/spec/** is untouched.
The change, read in the diff:
filterAppForUserWithReason gains the ruled arm beside requiredPermissions as a bare entry-level continue: if (e.type === 'doc' && (!docAudienceGate || !docAudienceGate(e))) continue;. It fails closed: no gate, no doc entry.
The verdicts come from one private resolveDocsAudience, spelled only in the spec's helpers (audienceAllows, resolveDocAudiences, docAudienceAllows, resolveBookTree, resolveBookClaimedDocs, deriveImplicitPackageBook). The five docs reads now answer from it too, so there is no second resolver (ruling point 1).
The seat checked the one semantic seam: the new docReader passes an absent audience to docAudienceAllows, which itself defaults to org (book.zod.ts). That is the doc list's former explicit org fallback.
Entry semantics, falsified and refined by the dev:
A book's readable subset is its readable CLAIMED pages (resolveBookClaimedDocs), not tree entries. resolveBookTree appends every unclaimed doc as a synthetic Uncategorized group, so counting tree entries would keep nearly every book.
book + doc is pruned when either the book's own audience or the doc denies.
Existence: an unknown book resolves to an empty implicit book and is pruned; a doc absent from the corpus takes the resolver's own org default.
Cost (ruling point 4, measured, no cache):
no doc entry in the response: 0 added reads;
otherwise, once per request: 1 book read, 1 holdings resolution only when a set-gated book exists, and 1 doc read off the fast path or for a book-alone entry;
one claimed-pages traversal per book-alone entry.
Anonymous:GET /meta/app answers 401 before any read (pinned), so the arm needs no anonymous branch.
Tests:
meta-app-nav-doc-audience.test.ts has 19 cases on the real list, by-name and tree routes: non-reader, reader, one readable page, claimed versus Uncategorized, book+doc, group and area collapse, list/by-name parity, the requiredPermissions control, four fail-closed rows, the fast path, cost and 401.
Ablation of the arm: 12 red / 7 green in the expected direction; restored, 19/19.
The package suite passes (195 files, 3284 tests).
Byte-identity of the five docs reads, BASE versus HEAD: 1584 requests, 0 diffs.
Gates: 90/90 --ran at f2bc219b (0 NOT-MEASURED), plus pnpm lint (the full union).
Changeset:@objectstack/restpatch. The server honours what DocNavItemSchema already declares, per the ruling's Clause-②: no. The prose matches the code sentence by sentence, including fail-closed and cost.
Docs: the apps.mdx Audience bullet now says the server prunes the entry itself, and adds the book+doc and claimed-pages rules.
Governed / size:check-governed-merges --pr 20128: NOT governed, +853/−95.
Clause-② gate: neither limb hits. There is no packages/spec/src/** in the diff, and the claim declares no.
A PM order conflict the dev resolved correctly, recorded in public: the order said the gate "degrades exactly as the reads do" and also "must not fail open". The dev measured that the reads fail OPEN on a thrown book read, and followed "must not fail open". That was the seat's inconsistency, and the dev's choice is the ruling's intent.
NOT MEASURED, not filed: whether MCP list_metadata / describe_metadata serve app nav past the filter. The implementations are outside this tree.
Noted, not filed: without ?package=, a book tree lists every unclaimed env doc under Uncategorized. This is the resolver's documented rule, and it is why the arm counts claimed pages.
meta-app-nav-doc-audience.test.ts + .changeset/19790-nav-doc-audience-prune.md in the tree (git ls-tree origin/main)
0
2
content/docs/ui/apps.mdx: "The server applies this rule to the entry itself"
0
1
Shape: 2 parent fields, so this is a squash. The pre-merge head f2bc219b is ⛔ not an ancestor (exit 1); the control leg 6780e34a is an ancestor (exit 0). The commit carries the enqueue instant 2026-09-25T07:09:34Z.
How it landed: through the fleet-write relay ops pr_ready + automerge_enable, as objectstack-fleet[bot]:
ready flip at 2026-09-25T07:08:24Z;
arm at 2026-09-25T07:08:28Z;
added_to_merge_queue at 2026-09-25T07:09:34Z;
merged at 2026-09-25T07:24:06Z, with no queue ejection.
What changed for callers:GET /meta/app and GET /meta/app/:name now leave out a type: 'doc' navigation entry the caller may not read:
a doc whose effective audience denies them;
a book whose own audience denies them, or none of whose claimed pages they may read.
It fails closed on a read fault. The five docs reads answer from the same resolution, and what they return is unchanged. The renderer's pruning (objectui#10188) stays as defence in depth (ruling point 2).
Card: closed by Fixes #19790. In the same stroke as this comment, pm:dispatched is stripped and the assignee cleared.
Activity
objectstack-fleet commentedon Sep 23, 2026
Ruling: batch #217 item 2 · letter A · maintainer 「217 同意」 2026-09-23T10:40Z
Director seat, summon #28 (
session_01GLdRPcbaCBQCTvVmU6YEUY). Presented in this seat's chat with recommendation A; the maintainer approved the batch as presented. B ⛔ (a rule the server declares but only one renderer honours; entry names leak to non-readers through/meta/app, MCP and any other front end).Governing text: #19482 ruling addendum 5754645447 (「a
docentry the member may not read is not rendered」; 「a member with no readable page in it does not see the entry at all」); ADR-0046 §6.7 (docs content gated server-side at the doc / book reads); thefilterAppForUserWithReasondocblock (packages/rest/src/rest-server.ts:3231) — secrets belong to the server. Readings this summon: the filter's arms arerequiresService(:3263/:3276) andrequiredPermissions/ servability; ⛔ no audience arm; PR #19789 (thedocvariant) is Tier H awaiting the maintainer's merge. Prior rulings read: server-side,app-nav,filter,prune,menu,entry,docs,audience,member,read,receives,renderer-side (+20 more) → 308 hits; ADR-0076 D11, ADR-0029 D9.7, ADR-0056 D2, ADR-0076 D9, ADR-0127 D6, ADR-0005 Decision §5, ADR-0030 Decision §2, ADR-0035 Decision §4, ADR-0035 Decision §6, ADR-0036 Decision §1; thread: none — none rules on nav-entry pruning by doc audience.Ruled — A: the server-side nav filter prunes
docentries by the docs audiencefilterAppForUserWithReasongains an audience arm besiderequiredPermissions: adocentry naming a doc the caller may not read is pruned; abookentry whose readable subset for the caller is empty is pruned; the reason spelling follows the existing ones. It reuses the audience resolution the/meta/doc//meta/bookreads already apply — ⛔ no second resolver.docitems (create / edit / preview, assign to abook) and rendering of thedocnavigation item on the app menu — objectui half of objectstack#19482 objectui#10188) stays — the server is the authority, the renderer is defence in depth./meta/appcarries no such entry; a reader's does; a book entry with one readable page stays./meta/app, and the traversal a book entry needs — report them; ⛔ no caching layer is invented in this card.Execution
needs-user-decision→pm:blockedin this stroke,Blocked-by: objectstack-ai/objectstack#19482on the body (thedocvariant lands first; the unlock scan returns this card when #19482 closes). Lane by landing site:domain:spec→domain:cli(packages/rest),priority:p2,Clause-②: no(a permission-boundary tightening, no spec key).objectstack-fleet commentedon Sep 24, 2026
Unblock re-derivation:
pm:blocked→pm:queue· 2026-09-24T23:54ZActing on the maintainer's instruction. Provenance: who — the maintainer; verbatim — 「帮我排查一下 blocked 卡片,哪些需要更新状态帮我更新。」; where — the maintainer's chat with session
session_013RWUA7bNq5bRhehLPqXwMg, 2026-09-24. ⛔ Not a claim and not a dispatch: the card returns to its lane's take order. Candidate surfaced bycheck-half-states.mjsH19 on this sweep.doc/bookauthoring surface and adocnavigation item (maintainer's stated need) #19482 closedcompletedon 2026-09-24.5793362670(ruling A) states the unlock itself: thedocvariant lands first, and the unlock scan returns this card when [Decision] admins author Markdown docs in the console and put them on the app menu — runtimedoc/bookauthoring surface and adocnavigation item (maintainer's stated need) #19482 closes.mainnames [Decision] should the server-side app-nav filter also prune adocmenu entry by the docs audience, so a member who cannot read the doc never receives the entry — or is renderer-side pruning (objectui#10188) the whole answer? #19790.State:
pm:blocked→pm:queue;domain:cli,priority:p2unchanged; no assignee.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Claim: PM loop round 9 (serial)
Session:
session_01TnPAC1UsTGfHPXVUCL6iLnAccount:
os-steveBranch:
claude/issue-19790-nav-doc-audience-pruneWorktree:
objectstack-issue-19790Domain:
domain:cliSeat:
domain:cli#1File surface:
packages/rest/src/rest-server.ts(filterAppForUserWithReason/filterAppForUser, the gate their two callers hand them, and the audience-resolution plumbing those callers need), its tests underpackages/rest/src/, any hand-writtencontent/docs/**line the change makes false, and one.changeset/19790-*.md.packages/spec/**(theDocNavItemSchemadescribe included) and every other package are read-only.packages/rest/CHANGELOG.mdandpackage.jsonbelong to the Version Packages PR. Stop on breach and explain in the reportContainer & model:
M,mode:subagent,model: default judgment tier(this act'snode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/rest/src/rest-server.tsanswered: no path-derived mandate). A maintainer-ruled permission-boundary arm in the server's nav filter. It must reuse the docs audience resolution, with no second resolver, fail closed, and report its per-request cost, so it gets the default judgment tier.Clause-②: no
Thread-read: 5824249598
Serial constraints cleared:
the maintainer's order in this seat's session (「继续派发」) and serial dispatch: R8 (#19922) reached its end state in this seat's record 5827511003 (released with a reason, pm:blocked on #20112), so no dev is in flight in this lane. The file lists of all 10 open PRs were read in this act. None touches packages/rest/**; the Version Packages PR #17076 owns the CHANGELOG and package.json versions, outside this surface. The seat post's queue head #19328 stays fenced behind the open draft PR #19637. The NORTH-STAR 优先级 3 pre-pick read at fa00ebf4: the domain:cli pm:queue holds no open P0/P1Taken 2026-09-25T05:52Z,
origin/mainfa00ebf4.Clause-②: no, per the ruling's execution line (5793362670): a permission-boundary tightening with no spec key. The server comes to honour the audience ruleDocNavItemSchemaalready declares, so no accepted input moves and no published surface grows. The dev posts no second claim; this comment is its identity.objectstack-fleet commentedon Sep 25, 2026
os-dev-report
{
"issue": 19790,
"status": "done",
"branch": "claude/issue-19790-nav-doc-audience-prune",
"pr": "#20128",
"session": "session_01TnPAC1UsTGfHPXVUCL6iLn — subagent run inside the PM seat session (the parent's harness-stamped id); identity = the branch",
"premise_still_valid": true,
"summary": "filterAppForUserWithReason gains the ruled docs-audience arm: a type:'doc' entry is pruned when its doc is unreadable, when its book's own audience denies the caller, or (book alone) when none of the book's CLAIMED pages is readable; both callers (list + by-name) build one NavDocAudienceGate per request and hand it in, and the arm fails CLOSED (no gate, or a thrown book/doc read, drops every doc entry + warn). All verdicts come from one new private resolveDocsAudience (spec helpers audienceAllows / resolveDocAudiences / docAudienceAllows / resolveBookTree / resolveBookClaimedDocs / deriveImplicitPackageBook), which the five docs reads (/meta/book, /meta/book/:name, /meta/doc, /meta/doc/:name, /meta/book/:name/tree) now also use — no second resolver; their responses are byte-identical (1584-request BASE-vs-HEAD probe, 0 diffs). Cost: zero added reads when no app carries a doc entry; otherwise once per request (1 book read, 1 holdings resolution only if a set-gated book exists, 1 doc read only off the fast path or for a book-alone entry), per book-alone entry one resolveBookClaimedDocs traversal; no cache. Anonymous GET /meta/app is 401 before any read (pinned), so the arm has no anonymous path to cover.",
"tests": "New packages/rest/src/meta-app-nav-doc-audience.test.ts, 19 cases driving the real list / by-name / tree routes: non-reader has no gated doc/book/all-gated-book entry and the wire carries none of the names or labels; reader has them; a book with exactly one readable page stays (and the tree read agrees: [crm_intro]); an org book whose every CLAIMED page is gated is pruned although its tree serves an Uncategorized orphan; book+doc pruned when the book is gated; group/area collapse; list/by-name parity for non-holder and holder; requiredPermissions control; four fail-closed rows (book read throws, doc read throws, holdings unresolvable, no gate); fast path + existence (unwritten doc served, unknown book pruned, no holdings resolved); cost (no doc entry = 0 book/doc reads; two apps = 1 book read, 1 doc read, 1 holdings call); anonymous 401. Runs: file 19/19 green; @objectstack/rest local project Test Files 195 passed, Tests 3284 passed | 1 skipped; typecheck exit 0 (test layer 0 errors). ABLATION (fix committed first, cb800e2): scripts/ablation-replace.mjs --delete of the arm line, wrap mode, anchor x1 -> x0, blob 5c7dbf8f1015 -> f0b3c3cdd872 on disk; result Tests 12 failed | 7 passed — red: every non-reader row, the four fail-closed rows, non-holder parity, fast-path existence, two-app cost; green: holder, one-readable-page, requiredPermissions control, no-book-entry, no-doc-entry cost, anonymous (the expected direction). Restore proven: blob after restore == HEAD blob 5c7dbf8f1015, git diff HEAD empty; re-run 19/19 green. Subject is src-imported (./rest-server.js via vitest, no dist in the resolution path), so no rebuild/dist preflight applies. BYTE-IDENTITY of the docs reads: one-off probe (deleted, not committed) of BASE fa00ebf rest-server.ts vs this branch over 4 callers x 3 book sets x 3 failure modes x book/doc list+item+tree routes (both spellings, ?package=, ?include=content): compared=1584 diffs=0, statuses 200:752 401:168 403:28 404:240 500:396, 55 distinct responses, control non-holder 403 vs holder 200. Gates: 90 derived at f2bc219, all exit 0, --ran reconciliation a derived zero NOT-MEASURED; pnpm lint full union exit 0 at f2bc219.",
"mcp_calls": "0 — no MCP tool of any kind was called",
"api_writes": "3 — each through the fleet-write relay (repository_dispatch, executed as objectstack-fleet[bot]): (1) pr_create POST /repos/objectstack-ai/objectstack/pulls, draft forced, #20128, relay run 36104733901 success; (2) label-write POST /repos//issues/20128/assignees {os-steve}, relay run 36104793574 success, read-back MATCHES (assignee os-steve; labels documentation/size/l/tests/tooling are the path labeler's, untouched); (3) post-stamped POST /repos//issues/19790/comments (this report). Not REST: git push x4 of the branch. Everything else was reads.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · the docs READS fail OPEN when the book list read rejects: fetchAudienceBooks maps the fault to [] (read as "no set-gated book anywhere", so the fast path serves everything to an authenticated caller), and /meta/doc/:name maps a rejected corpus read to [] (the gated doc then reads as unclaimed, so org). Failing probe (one-off, stub protocol whose getMetaItems({type:book}) rejects, authenticated non-holder of crm_admin): GET /meta/doc/crm_admin_runbook 403 -> 200 WITH the body; GET /meta/doc lists it. Reachable with the shipped protocol: metadata-protocol getMetaItems rethrows every sys_metadata read failure except "unprovisioned" (#5532), so a store fault on the book read after the doc read succeeded serves the gated doc. Pre-existing at fa00ebf (byte probe: BASE and HEAD answer identically); this card kept the reads byte-identical by ruling, and the nav gate does NOT inherit it (readAudienceBooks / readDocCorpus report the fault; the reads still map it to []). Site: packages/rest/src/rest-server.ts fetchAudienceBooks + the /meta/doc/:name corpus read. · dedupe: fetchAudienceBooks fail open · docs audience book read failure · meta doc permissionSet leak · catch(() => []) audience gate",
"class: b · GET /meta/:type/:name/layers (and the deprecated ?layers=true, same helper serveMetaItemLayered) serves the app document with NO filterAppForUserWithReason pass — every arm bypassed, requiredPermissions included, not only the new audience arm. Probe (one-off, authenticated member holding neither finance.access nor crm_admin): /meta/app/crm/layers 200 carrying both the audience-gated doc entry and the requiredPermissions entry in code/overlay/effective; list and by-name routes prune both. Contract: content/docs/ui/apps.mdx requiredPermissions row 「The entry is never served: it is absent from the
/metabody」 and the by-name caller comment 「the by-name route must not serve a nav entry the list route prunes, or reading the single-app JSON defeats the filter」. Seam: spec:AppSchema.navigation[].requiredPermissions + DocNavItemSchema audience -> runtime:packages/rest/src/rest-server.ts serveMetaItemLayered · dedupe: meta app layers requiredPermissions · layered view nav filter bypass · filterAppForUser layers","class: b · GET /meta/:type/:name/published serves the stored app document unfiltered (probe as above: 200 with both gated entries). Same contract text. Seam: spec:AppSchema.navigation[].requiredPermissions + DocNavItemSchema audience -> runtime:packages/rest/src/rest-server.ts GET /meta/:type/:name/published · dedupe: published app nav filter · meta published requiredPermissions leak",
"class: b · GET /meta/:type/:name/history and /diff return stored app versions with no nav filter — CODE-READ only, not driven (no probe was run for these two). Same contract text. Seam: spec:AppSchema.navigation[].requiredPermissions -> runtime:packages/rest/src/rest-server.ts GET /meta/:type/:name/history, /diff · dedupe: meta history app nav · metadata diff requiredPermissions",
"class: b · packages/runtime/src/domains/meta.ts handleMetadataRequest (HttpDispatcher /meta catch-all: list via protocol.getMetaItems, item via getMetaItem) applies no app-nav filter at all where that door answers — CODE-READ; which compositions route /meta/app to it rather than to RestServer is NOT MEASURED (the REST routes shadow it where RestServer is mounted). Seam: spec:AppSchema.navigation -> runtime:packages/runtime/src/domains/meta.ts handleMetadataRequest · dedupe: http-dispatcher meta app filter · runtime meta domain requiredPermissions",
"NOT MEASURED · MCP list_metadata / describe_metadata: only the names live in this tree (packages/spec/src/system/constants/platform-tool-names.ts); the implementations are elsewhere, so whether they serve app nav past the filter cannot be read here · carrier: 承接者:无 · noted, not filed",
"carrier: 承接者:无 · noted in the PR Acceptance notes, not filed — without ?package=, /meta/book/:name/tree resolves over the env-wide corpus, so every book tree lists every doc it does not claim under Uncategorized (the resolver's documented "nothing is ever dropped" rule); it is why the nav arm counts claimed pages, not tree entries"
],
"gates": {
"node scripts/check-adr-0087-registration.mjs --base origin/main": "exit 0 · ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).",
"node scripts/check-adr-0087-registration.mjs --self-test": "exit 0 · ✓ check-adr-0087-registration --self-test: 441 assertions over real temp git repos (real scan()/assertInputs() path)",
"node scripts/check-changeset-no-major.mjs --base origin/main": "exit 0 · ✓ This diff introduces no
majorbump.","node scripts/check-changeset-no-major.mjs --self-test": "exit 0 · ✓ check-changeset-no-major --self-test: 339 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"node scripts/check-ci-filter-parity.mjs": "exit 0 · OK: all 184 declared cross-package glob(s) (131 unique) are covered by
coreorcrosspkg, everycrosspkgentry still covers one, and thetestjob'sif:still nam","node scripts/check-closing-keyword-parity.mjs": "exit 0 · check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 9546 tracked file(s), ",
"node scripts/check-closing-keyword-parity.mjs --self-test": "exit 0 · ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.",
"node scripts/check-comment-mask-adoption.mjs": "exit 0 · OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/, all 14 recorded and every recorded row still reached (13 unconverted, 1 s",
"node scripts/check-comment-mask-adoption.mjs --self-test": "exit 0 · PASS check-comment-mask-adoption --self-test (0 failure(s))",
"node scripts/check-comment-mask-corpus.mjs": "exit 0 · ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 7159 files, 0 disagree, 0 unparseable, 78.6s (comparator self-test: 26 cases pass).",
"node scripts/check-doc-frontmatter.mjs": "exit 0 · ✓ check-doc-frontmatter: 2 content root(s) verified, each against its own floor — content/docs 406, content/blog 3.",
"node scripts/check-doc-frontmatter.mjs --self-test": "exit 0 · ✓ check-doc-frontmatter --self-test: 99 assertions — the card's own description observed failing with the parser's message and the FILE line, every other violation kind o",
"node scripts/check-doc-route-spelling.mjs --advisory": "exit 0 · ✓ route-spelling guard (advisory): population clean — every shape-matched literal spells its ledger row.",
"node scripts/check-doc-route-spelling.mjs --self-test": "exit 0 · ✓ check-doc-route-spelling self-test: extraction tidy-up, the variant relation (plural + pinned lexicon, no prefix heuristic), walk wiring (releases/ and node_modules/ ou",
"node scripts/check-docs-section-name.mjs": "exit 0 · so it is carried by --self-test rather than by this corpus.",
"node scripts/check-docs-section-name.mjs --self-test": "exit 0 · ✓ check-docs-section-name self-test: 85 cases pass (real temp trees on disk; both historical misses reproduced as RED, both arms driven RED, the duplicate-key and syntax-",
"node scripts/check-empty-changeset.mjs --base origin/main": "exit 0 · ✓ No changeset from the merge base modified or deleted by this diff (#17712).",
"node scripts/check-empty-changeset.mjs --self-test": "exit 0 · ✓ check-empty-changeset --self-test: 159 assertions over real temp git repos (real scan() path)",
"node scripts/check-keyed-text-bounds.mjs": "exit 0 · ✓ check:keyed-text-bounds: 112 .object.ts files under packages/ + apps/* + examples/** (walk is repo-wide; 0 outside), 117 object declarations, 250 declared index ent",
"node scripts/check-keyed-text-bounds.mjs --self-test": "exit 0 · PASS check-keyed-text-bounds --self-test (0 failure(s))",
"node scripts/check-platform-object-tenancy-census.mjs": "exit 0 · ✓ platform-object tenancy census matches the tree: 84 platform-namespace objects, 58 in the machinery's reach, 26 outside it, every exclusion explained by a declaration o",
"node scripts/check-platform-object-tenancy-census.mjs --self-test": "exit 0 · ✓ check-platform-object-tenancy-census self-test: all checks pass (84 objects, 26 outside the machinery)",
"node scripts/check-plugin-teardown-shape.mjs": "exit 0 · ✓ check:plugin-teardown-shape: 69 Plugin implementation(s) across 6590 source(s) under packages/; every teardown-shaped method (stop / shutdown / close / dispose) sits ",
"node scripts/check-plugin-teardown-shape.mjs --self-test": "exit 0 · ✓ check-plugin-teardown-shape self-test: 48 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name",
"node scripts/check-registry-log-declared.mjs": "exit 0 · OK: 74 vitest-running package(s) walked, 9 selected as engine-booting, every one declares a recognised registry log level (debug/info/warn/error/silent).",
"node scripts/check-registry-log-declared.mjs --self-test": "exit 0 · self-test OK: 16 cases + level-vocabulary read + population declaration + real-tree selection floor.",
"node scripts/check-rest-log-spy-declared.mjs": "exit 0 · OK: 30 of 197 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.",
"node scripts/check-rest-log-spy-declared.mjs --self-test": "exit 0 · check-rest-log-spy-declared self-test reached its verdict: 23 case(s), 0 failure(s).",
"node scripts/check-section-landing-index.mjs": "exit 0 · ✓ check-section-landing-index: 8 section index block(s) enumerate their meta.json pages, in order, both directions (ai, api, automation, data-modeling, kernel, permission",
"node scripts/check-section-landing-index.mjs --self-test": "exit 0 · ✓ check-section-landing-index --self-test: 31 assertions over synthetic inputs and a temp fixture (real judge()/run() path); every limb -- both shapes in sync, missing pa",
"node scripts/check-system-context-census.mjs": "exit 0 · check-system-context-census: OK — 112 elevation read sites in 20 packages across 45 files, living in 93 symbol(s); the page cites 106 symbol(s) against 106 required, over",
"node scripts/check-system-context-census.mjs --self-test": "exit 0 · check-system-context-census --self-test: all cases passed",
"node scripts/check-undeclared-dep-imports.mjs": "exit 0 · ✓ check:undeclared-dep-imports: 81 workspace packages under packages/ + apps/** + examples/, 2592 non-test src files, 2107 @objectstack/* specifiers (0 assembled, not",
"node scripts/check-undeclared-dep-imports.mjs --self-test": "exit 0 · PASS check-undeclared-dep-imports --self-test (0 failure(s))",
"node scripts/docs-audit/check-affected-docs.mjs": "exit 0 · ✓ affected-docs self-test: 605 cases pass.",
"node scripts/docs-audit/check-drift-comment.mjs": "exit 0 · ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).",
"node scripts/pm/release-rehearsal-clone.mjs --self-test": "exit 0 · ✓ self-test passed",
"pnpm --filter @objectstack/lint run check:doc-formula-expressions": "exit 0 · ✓ check:doc-formula-expressions (field-level
*When, #11407): 14 predicate(s) on a statically determinable field layer judged clean; 6 skipped as undeterminable.","pnpm --filter @objectstack/lint run check:doc-security-posture": "exit 0 · ✅ 27 ObjectSchema.create example(s) in 227 marked block(s) across 248 prose file(s) in 2 root(s) carry an os validate-clean security posture",
"pnpm --filter @objectstack/spec run check:docs": "exit 0 · ✅ 226 generated files in sync with packages/spec",
"pnpm --filter @objectstack/spec run check:duration-unit-keys": "exit 0 · ✓ check:duration-unit-keys — 204 unit-declaring numeric key(s) across 2589 source file(s) all carry their unit in the key name (or in a sibling
unit, or under a declare","pnpm --filter @objectstack/spec run check:empty-state": "exit 0 · ✓ all classified (2 closed, 2 open, 4 output, 9 scope)",
"pnpm --filter @objectstack/spec run check:liveness": "exit 0 · ✓ packages/spec/liveness/state-counts.md is current — the same 40 row(s), no count column left in the README.",
"pnpm --filter @objectstack/spec run check:skill-examples": "exit 0 · ✅ 259 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them",
"pnpm --filter @objectstack/spec run check:strictness-ledger": "exit 0 · ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 453 site(s) measured, 1 authorable strip site(s) left.",
"pnpm --filter @objectstack/spec run check:variant-docs": "exit 0 · ✓ variant/doc gate: 18 discriminated union(s) — 8 governed (every variant mentioned in a bound doc), 10 exempt.",
"pnpm --filter @objectstack/spec run check:yaml-examples": "exit 0 · ✅ 18 tagged YAML example(s) across 1 file(s) validate against their declared live spec schemas",
"pnpm check:authz-resolver": "exit 0 · ✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.",
"pnpm check:changeset-gate-self-tests": "exit 0 · ✓ check-changeset-no-major --self-test: 339 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"pnpm check:corpus-claim-drift": "exit 0 · check-corpus-claim-drift: OK, no new claim sites beside a pinned spelling.",
"pnpm check:cross-package-test-inputs": "exit 0 · OK: 29 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split "test:repo" task); 13 walked root(s) judged, 3 on",
"pnpm check:dispatcher-error-vocabulary": "exit 0 · [#15723] the ARGUMENT POSITION of new APIError( … ) and APIError.from( … ) IS now in this gate's population — the
apierrorargshape.APIError.fromcopies the record's","pnpm check:doc-anchors": "exit 0 · ✅ check-doc-anchors: 376 internal #fragment link(s) across 411 source file(s) all resolve to a real heading",
"pnpm check:doc-authoring": "exit 0 · ✓ doc authoring guard: sibling-package prose ids hold the baseline — 815 pinned site(s) across 231 file(s), 91983 string(s) read in 1253 parsed source(s), no growth, no b",
"pnpm check:docs-audit-scope": "exit 0 · ✓ scope injection is live: the workflow audits the list handed in as args.handwritten, and refuses an invocation that hands in no scope at all.",
"pnpm check:docs-redirects": "exit 0 · check-docs-redirects: OK (apps/docs/redirects.mjs: 98 entries -- 95 page destination(s) resolved against content/docs, 3 wildcard destination(s) resolved to a directory, ",
"pnpm check:docs-single-h1": "exit 0 · ✓ check-docs-single-h1: 406 page(s) under content/docs/ carry no body-level
#heading (0 subtree(s) excluded, see --list).","pnpm check:docs-spec-enumerations": "exit 0 · OK the hand-written spec enumerations agree with packages/spec/package.json -- 17 subpath(s) held ORDERED in content/docs/deployment/troubleshooting.mdx; 15 protocol name",
"pnpm check:docs-transcript-drift": "exit 0 · ✓ check-docs-transcript-drift: 4 declared transcript value(s) across 406 page(s) under content/docs/ equal what the registry derives today, and no undeclared block quotes",
"pnpm check:driver-memory-census": "exit 0 · check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers". This gate polic",
"pnpm check:dts-closure": "exit 0 · check-dts-closure: 73 built package(s) swept - 167/167 declared declaration file(s) present across 73 package(s); 0 built package(s) declare no declaration entry point an",
"pnpm check:dual-build-cjs-loads": "exit 0 · ✓ check:dual-build-cjs-loads — 105 published require entry point(s) across 67 package(s) load; 660 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree",
"pnpm check:engine-double-contract": "exit 0 · check-engine-double-contract: OK — 902 pinned, 133 in the DEBT ledger, 3 exempt.",
"pnpm check:gitlink-declared": "exit 0 · check-gitlink-declared: OK (9546 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).",
"pnpm check:issue-citations": "exit 0 · ✅ check-issue-citations --self-test: grammar narrowed, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinned (73 cases,",
"pnpm check:lean-entry-closure": "exit 0 · ✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.",
"pnpm check:logger-receiver-detach": "exit 0 · OK every log channel keeps its receiver: 2860 non-test TS file(s) walked, 0 detach(es) on the 5 declared receiver-sensitive sink spelling(s).",
"pnpm check:nul-bytes": "exit 0 · check-nul-bytes: OK (scanned 9539 text file(s) -- 9539 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).",
"pnpm check:objectql-double-limit": "exit 0 · OK ObjectQL double
limitconformance holds: 410 double(s) graded, 212 apply the caller's bound or refuse it loudly.","pnpm check:objectui-changeset": "exit 0 · ✓ objectui-range --self-test: all checks passed",
"pnpm check:org-identifier": "exit 0 · check-org-identifier: OK (2882 author-facing source file(s), 17 session binding(s) resolved, no removed session.tenantId alias).",
"pnpm check:page-declaration-shape": "exit 0 · check-page-declaration-shape: OK — 34 page entries across 2872 sources under packages/, examples/, apps/ all reach the kernel through a discoverable declaration (
:", "pnpm check:pm-changeset-deadline-census": "exit 0 · ✓ changeset-deadline-census --self-test: all cases passed across 5 batteries (what counts as a named target, the controls that make a zero a reading, the verdict truth ta", "pnpm check:published-files": "exit 0 · ✓ check:published-files — 70 publishable package(s) of 81 workspace member(s) declare afileswhitelist that covers every entry point plus CHANGELOG.md and admits no te", "pnpm check:published-readme-links": "exit 0 · ✓ check:published-readme-links — 178 outbound link(s) across 61 published markdown file(s): 0 root-relative, 0 non-canonical origin(s), 27 docs-site page(s) resolved (0 v", "pnpm check:query-options-erasure": "exit 0 · ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed. Every other non-test file under packages/ is c", "pnpm check:react-page-adapter-contract": "exit 0 · ✓ check-react-page-adapter-contract: 21 app-showcase page module(s) + 1 content/docs react-page sample(s) (from 392 doc file(s), 1995 fenced block(s)) — every adapter que", "pnpm check:refd-timer-probe": "exit 0 · OK check-refd-timer-probe: 7154 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.", "pnpm check:role-word": "exit 0 · check-role-word: OK, no new occurrences of the reserved word.", "pnpm check:route-envelope": "exit 0 · ✓ Express-style response modules — 4 module(s) discovered and audited (walked, not enumerated — #9937), 12 hand-built body/bodies (count reported, NOT pinned): 2 conforma", "pnpm check:skill-identifier-liveness": "exit 0 · check-skill-identifier-liveness OK — Leg 1: 457 citation(s) over 53 published file(s) checked against 107876 implementation word tokens (0 ledgered exemption(s)); Leg 2: ", "pnpm check:slot-lookup": "exit 0 · ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered bypnpm li","pnpm check:sourcemap-no-sources-content": "exit 0 · check-sourcemap-no-sources-content: 69 built package(s) swept - 508 map(s), none embed source text.",
"pnpm check:test-source-alias": "exit 0 · check-test-source-alias OK — 74 packages with tests scanned; 61 registered as still resolving a workspace dep through
dist/; 50 published subpath(s) resolved through ev","pnpm check:tier-file-adoption": "exit 0 · OK: 81 workspace package(s) walked, 70 nightly-tier test file(s) on disk (70 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS.",
"pnpm check:type-check-coverage": "exit 0 · check-type-check-coverage: OK — 77/81 workspace packages type-checked (plus the root), 4 in the DEBT ledger (53 frozen raw errors, https://github.com/objectstack-ai/objec",
"pnpm check:type-check-debt": "exit 0 · check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured in 21.3s, 53 raw tsc error(s) total, none above its recorded number.",
"pnpm check:vendor-version-stamps": "exit 0 · check:vendor-version-stamps: OK — 7543 file(s) scanned.",
"pnpm check:watch-hint-literal": "exit 0 · ✓ check-watch-hint-literal: 71 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 47, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH_HINTS 8 -",
"pnpm check:where-matcher": "exit 0 · ✓ where-matcher conformance holds: 433 matcher(s) discovered, 433 answer the combinator battery correctly or refuse it loudly (282 refuse).",
"pnpm lint (full union: eslint . --no-inline-config) @ f2bc219": "exit 0 · no problems printed, 107s — the whole union, not a narrowing",
"pnpm --filter @objectstack/rest run typecheck @ cb800e2": "exit 0 · check:test-typecheck: OK — 0 file(s) / 0 error(s) / 0 pinned signature(s) (tsc --noEmit green first)",
"pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 @ cb800e2": "exit 0 · Test Files 195 passed (195) · Tests 3284 passed | 1 skipped (3285)",
"pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/meta-app-nav-doc-audience.test.ts @ cb800e2 (post-ablation restore)": "exit 0 · Tests 19 passed (19)",
"node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran-exit.txt @ f2bc219": "exit 0 · ✓ dispatch-gates --ran: 90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED (a DERIVED zero — all 90 recorded an exit code and none of them is 3)"
},
"line_budget": "n/a",
"deviations": [
"PM mechanism assumption 3 refined by measurement: a book entry's "readable subset" is its readable CLAIMED pages (resolveBookClaimedDocs, the membership resolveDocAudiences uses), not the entries of its tree — resolveBookTree appends every unclaimed doc as a synthetic Uncategorized group, so counting tree entries keeps nearly every book whenever any doc is readable (pinned: the ops tree serves crm_intro under Uncategorized while nav_ops_book is pruned). book+doc: pruned when either the book's own audience denies (tree read 401/403, rest-server.ts book-tree admitsBook check) or the doc is unreadable; a readable doc in an unreadable book is possible via the docAudienceAllows union (book.zod.ts) and is still pruned. Existence: a doc absent from the corpus is served (the resolver's own org default for a doc it has no entry for); a book naming nothing resolves to an implicit book with no page and is pruned.",
"Conflict in the order, stated not silently chosen: SUGGESTED ROUTE says the gate "degrades exactly as the reads do", and also "must not fail open". Measured: the reads fail OPEN on a rejected book read (probe, finding 1). The gate follows "must not fail open": a thrown book or doc read drops every doc entry of that response and logs a warn; the reads keep their answers.",
"Reuse went one step wider than the doc-list + book-tree pair: the /meta/book list and /meta/book/:name gates also moved onto resolveDocsAudience.admitsBook, so all five docs reads and the nav arm share one resolution; byte identity proven by the 1584-request probe and the existing read suites.",
"Test file is meta-app-nav-doc-audience.test.ts (sibling naming, beside meta-app-nav-servability-gate.test.ts) rather than the gates-from-pm expected path nav-doc-audience.test.ts; re-derivation on the real diff yields the same 90 commands.",
"Two gates first answered PREREQUISITE NOT MET (exit 3, nothing measured): check:skill-examples (client-react had no .d.ts) and check:dual-build-cjs-loads (workspace unbuilt). Re-run exit 0 after building what they read (client + client-react; then turbo build 73/73 tasks, 51 cached). Three dist-reading gates that had run before the full build (dts-closure, lean-entry-closure, published-files) were re-run after it, exit 0.",
"Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer; AGENTS.md and the order win — commits carry the model-free pair, the PR body ends with the session-URL footer.",
"Commit 3b67a44 has a wip(rest) subject (the WIP push AGENTS.md asks for before a minutes-long build); squash landing folds it.",
"Anonymous: measured, GET /meta/app answers 401 before any read, so the arm deliberately has no anonymous branch (order asked to measure and say).",
"Other doors (order point 5) are listed as findings rather than fixed, per the order; three of them driven by a one-off probe, two code-read, MCP NOT MEASURED."
],
"files_changed": [
"packages/rest/src/rest-server.ts — DocsAudience + NavDocAudienceGate types; resolveDocsAudience; readMetaList / readAudienceBooks / readDocCorpus / audienceBooksOf / docCorpusOf (fetchAudienceBooks keeps its [] degradation); resolveNavDocAudience + docNavEntries + navTarget; the arm in filterNav; a 5th gate parameter on filterAppForUser / filterAppForUserWithReason, passed by both callers; the five docs read gates rebuilt on the shared helper",
"packages/rest/src/meta-app-nav-doc-audience.test.ts — new, 19 cases",
"content/docs/ui/apps.mdx — Audience bullet: the server prunes the entry itself; book+doc and claimed-pages wording",
".changeset/19790-nav-doc-audience-prune.md — @objectstack/rest patch",
"diff vs merge base fa00ebf: 4 files, +853 / -95 (dispatch-gates reading)"
],
"ci": "in_progress at head f2bc219 — one snapshot read after pr_create: 32 check runs, 12 success, 2 skipped, 18 in_progress, 0 failing. Not waited on (os-dev: CI convergence is the PM's).",
"cleanup": "worktree ../objectstack-issue-19790 removed after node_modules removal (git worktree remove, no --force, exit 0; the tree was clean and at origin f2bc219). One-off probe files were deleted before each commit and never committed. No server, watcher or background job was started."
}
Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
In-seat review: ACCEPT, PR #20128 at head
f2bc219be0domain:cliexecution PM seat #6024 · sessionsession_01TnPAC1UsTGfHPXVUCL6iLn· 2026-09-25T07:00Z · reviewed on GitHub andorigin/mainfa00ebf4, against the ruling5793362670Review checklist
main, assigneeos-steve. The first line isFixes #19790, the only closing binding, and the second is the claim'sClause-②: no. One footer, no model identifier.rest-server.ts, one new test file,content/docs/ui/apps.mdxand one changeset.packages/spec/**is untouched.filterAppForUserWithReasongains the ruled arm besiderequiredPermissionsas a bare entry-levelcontinue:if (e.type === 'doc' && (!docAudienceGate || !docAudienceGate(e))) continue;. It fails closed: no gate, nodocentry.:6347and by-name:7367, build ONE gate per request withresolveNavDocAudienceand pass it in. This is the Nav gating cannot express "prune when the destination cannot serve" —enable.apiEnabledis never consulted byfilterAppForUser#7912 parity.resolveDocsAudience, spelled only in the spec's helpers (audienceAllows,resolveDocAudiences,docAudienceAllows,resolveBookTree,resolveBookClaimedDocs,deriveImplicitPackageBook). The five docs reads now answer from it too, so there is no second resolver (ruling point 1).docReaderpasses an absent audience todocAudienceAllows, which itself defaults toorg(book.zod.ts). That is the doc list's former explicitorgfallback.resolveBookClaimedDocs), not tree entries.resolveBookTreeappends every unclaimed doc as a synthetic Uncategorized group, so counting tree entries would keep nearly every book.book+docis pruned when either the book's own audience or the doc denies.orgdefault.docentry in the response: 0 added reads;GET /meta/appanswers401before any read (pinned), so the arm needs no anonymous branch.meta-app-nav-doc-audience.test.tshas 19 cases on the real list, by-name and tree routes: non-reader, reader, one readable page, claimed versus Uncategorized, book+doc, group and area collapse, list/by-name parity, therequiredPermissionscontrol, four fail-closed rows, the fast path, cost and401.--ranatf2bc219b(0 NOT-MEASURED), pluspnpm lint(the full union).@objectstack/restpatch. The server honours whatDocNavItemSchemaalready declares, per the ruling'sClause-②: no. The prose matches the code sentence by sentence, including fail-closed and cost.apps.mdxAudience bullet now says the server prunes the entry itself, and adds the book+doc and claimed-pages rules.check-governed-merges --pr 20128: NOT governed, +853/−95.packages/spec/src/**in the diff, and the claim declaresno.A PM order conflict the dev resolved correctly, recorded in public: the order said the gate "degrades exactly as the reads do" and also "must not fail open". The dev measured that the reads fail OPEN on a thrown book read, and followed "must not fail open". That was the seat's inconsistency, and the dev's choice is the ruling's intent.
Out-of-scope findings, one row each
fetchAudienceBooks/ the/meta/doc/:namecorpus read map the fault to[], and a set-gated doc is served200with its body. This is pre-existing and not inherited by the new arm./meta/:type/:name/layers(and?layers=true) and/publishedwere driven,/historyand/diffcode-read, and the runtime/metacatch-all code-read. All serve the app document with no nav filter, bypassingrequiredPermissionsand the new arm alike.list_metadata/describe_metadataserve app nav past the filter. The implementations are outside this tree.?package=, a book tree lists every unclaimed env doc under Uncategorized. This is the resolver's documented rule, and it is why the arm counts claimed pages.objectstack-fleet commentedon Sep 25, 2026
LANDED —
bc80e16260597d52b062b7e1ee810c7b9a99aed2(PR #20128), verified by CONTENT onorigin/maindomain:cliexecution PM seat #6024 · sessionsession_01TnPAC1UsTGfHPXVUCL6iLn· 2026-09-25T07:24Z6780e34a)origin/mainif (e.type === 'doc' && (!docAudienceGate || !docAudienceGate(e))) continue;inpackages/rest/src/rest-server.tsprivate async resolveDocsAudience((the one resolution the reads and the arm share)private async resolveNavDocAudience((the per-request gate both callers build)const servabilityGate = await this.resolveNavServability(p, environmentId) ?? undefined;meta-app-nav-doc-audience.test.ts+.changeset/19790-nav-doc-audience-prune.mdin the tree (git ls-tree origin/main)content/docs/ui/apps.mdx: "The server applies this rule to the entry itself"Shape: 2 parent fields, so this is a squash. The pre-merge head
f2bc219bis ⛔ not an ancestor (exit 1); the control leg6780e34ais an ancestor (exit 0). The commit carries the enqueue instant 2026-09-25T07:09:34Z.How it landed: through the fleet-write relay ops
pr_ready+automerge_enable, asobjectstack-fleet[bot]:added_to_merge_queueat 2026-09-25T07:09:34Z;What changed for callers:
GET /meta/appandGET /meta/app/:namenow leave out atype: 'doc'navigation entry the caller may not read:It fails closed on a read fault. The five docs reads answer from the same resolution, and what they return is unchanged. The renderer's pruning (objectui#10188) stays as defence in depth (ruling point 2).
Card: closed by
Fixes #19790. In the same stroke as this comment,pm:dispatchedis stripped and the assignee cleared.Carried out, ⛔ not this card's: [finding] the docs reads fail OPEN when the book list read throws: fetchAudienceBooks maps the fault to [], so a permission-set-gated doc is served (200, with its body) to an authenticated non-holder #20129 (the docs reads fail open on a book read fault) and [finding] GET /meta/app/:name/layers, /published, /history and /diff serve the app document with no nav filter, so entries pruned by requiredPermissions and the docs audience reach every member #20130 (the
/layers,/published,/history,/diffapp doors bypass the nav filter). Both were filed from this round and await triage.fix(rest): app nav prunes doc entries the caller may not read (ADR-00…