Repository navigation
[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
Description
Activity
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsPath: changing a running app without code | platform-core.docs-audience-gate | P1
Triage: first grade —
bug·security·priority:p1·domain:cli·area:studio·pm:queueTriage: lands in
packages/rest/src/rest-server.ts(fetchAudienceBooks,:2721-2730, and the/meta/doc/:namecorpus read at:7127, both.catch(() => [])) ⇒domain:cli; rationale: a thrown book read is read as "no book exists", so a permission-set-gated doc is served (200, with its body) to an authenticated non-holder — an authorization decision that fails OPEN on a store fault, against ADR-0046 §6.7 and the audience resolver's own fail-closed rule. Security ⇒ p1.Triage seat #6015 ·
session_01CRZSc7dU8oDStbTbSwhuZe· 2026-09-25T10:00Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card and its full thread, andorigin/main.Execution note: route both reads' fault to a deny, as the app-nav arm PR #20128 adds already does (
readAudienceBooks/readDocCorpus); serial after PR #20128 (#19790) on the same file. Pin: a rejecting book read answers 403 for a gated doc and prunes it from the list.- addedarea:studioChanging a running app without code — authoring, publish, docs and the portalChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removed
on Sep 25, 2026 objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 (serial, batch 1)
Session:session_01UYBdGBzWSrAMzpW8ah3GbP
Account:os-litant
Branch:claude/issue-20129-docs-audience-fail-closed
Worktree:objectstack-issue-20129
Domain:domain:cli
Seat:domain:cli#1
File surface:packages/rest/src/rest-server.ts(the docs audience reads:fetchAudienceBooksand its two callers, the/meta/doclist filter and the single-item doc gate, that gate's doc-corpus read, and any other audience-gated docs read that maps a read fault to a permissive answer), their tests underpackages/rest/src/, any hand-writtencontent/docs/**line the change makes false, and one.changeset/20129-*.md.packages/spec/**is read-only.CHANGELOG.mdandpackage.jsonfiles 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'snode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/rest/src/rest-server.tsanswered: no path-derived mandate). A security fail-open fix that carries a response-shape judgment (what a docs read answers when it cannot read its gate's input), so it gets the default judgment tier.
Clause-②: no
Thread-read: 5830524957
Serial constraints cleared:packages/rest/src/rest-server.ts is free in the seat post's hot-file queue since 95ab93f5 (#20061/#20062), after bc80e162 (#19790, the app-nav audience arm this card's reads sit beside). The file lists of all 7 open non-release PRs were read in this act. The only other one touching packages/rest is PR #20125 (#20102, the saved-report retirement, another lane; ready, above the 5000-line human-merge threshold): its rest-server.ts hunks sit at :1799-1881, :4895 and :13104, and none overlaps these reads (:2958, :6603, :7561-7568 on origin/main 16c5a33fdd), so whichever lands second merges main. The Version Packages PR #17076 owns the CHANGELOG and package.json versions. Lane siblings in the same file (#20130, #20139) are unclaimed pm:queue cards, not in flight. The H17 trigger-file index on anchor #9857 names no hold on rest-server.ts (its one packages/rest row, #13776, names rest-route-ledger.ts). The NORTH-STAR 优先级 3 pre-pick: the domain:cli pm:queue holds no open P0 and one open P1, this cardTaken 2026-09-27T01:58Z,
origin/main16c5a33fdd.Clause-②: no: the change makes two docs reads refuse, on a read fault, what they now serve. It widens no accepted set and adds no published surface, and a runtime permission/security behaviour change is outside clause ② (references/execution-duties.md, negative boundary). A route found to WIDEN anything, or to need a new error code, stops the dev. The dev posts no second claim; this comment is its identity on this card.
Generated by Claude Code
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20129,
"status": "done",
"branch": "claude/issue-20129-docs-audience-fail-closed",
"pr": "#20155",
"session": "session_01UYBdGBzWSrAMzpW8ah3GbP",
"premise_still_valid": true,
"summary": "The premise was confirmed on 16c5a33 by driving the real handlers with a rejecting list read. Caller: authenticated, not holding crm_admin. With a book-read fault, GET /meta/doc/crm_admin_runbook went from 403 to 200 with the body, and GET /meta/doc listed it. With a corpus-read fault, the single read also answered 200 with the body. The anonymous caller kept its 401 in both cases. Fix, in rest-server.ts only, inside the existing readAudienceBooks/readDocCorpus path, with no second resolver and no new code: fetchAudienceBooks now throws the read fault instead of answering [], and the /meta/doc/:name gate throws a corpus-read fault instead of reading [] (unclaimed, so org). Chosen fault answer: the sibling door's, measured as Zone 2 item 3. /meta/book/:name/tree propagates its book-read fault through handleRouteError, answering 503 SERVICE_UNAVAILABLE for metadata-protocol's store fault and 500 INTERNAL_ERROR for an untyped throw. The /meta/doc list answers its own doc-read fault the same way. The doc read and doc list now match that; pins compare status+code against the tree door. Two answers were rejected. 403 was rejected because it states a verdict the gate never reached. List pruning (Zone 3's suggestion) was rejected because the gate clears no doc without the books, so pruning would be an invented empty list for every caller. Zone 2 findings. Item 1 confirmed. Item 2 confirmed: the nav arm prunes. Item 4 reach: metadata-protocol getMetaItems throws metadataStoreUnavailableError (503) on any sys_metadata read failure except unprovisioned, pinned in protocol.metadata-store-outage.test.ts. Item 5: no other exit fails open for this reason. The layered and published doors bypass the gate entirely for a different reason; see out_of_scope_findings. files_changed: packages/rest/src/rest-server.ts (+48/-17); packages/rest/src/meta-doc-audience-read-fault.test.ts (new, 12 cases); .changeset/20129-docs-audience-fail-closed.md (@objectstack/rest patch). deviations: (1) dispatch-gates was run with no paths (os-dev definition: derive from merge-base, never hand a path list), not with --commands and the paths as the order spelled it. The derived set was the same 3 paths. (2) The list read propagates the fault instead of Zone 3's suggested prune; reason above. (3) An anonymous caller on a book-read fault now gets the fault (503) instead of 401, as the tree door always has. (4) PR #20125 landed as 8d1f7ab after BASE. main was not merged: git merge-tree --write-tree origin/main HEAD exits 0 and reports no conflicts, and CI and the queue validate the merge ref. (5) 2 gates are NOT MEASURED; see tests. (6) Commit trailers are the model-free pair and the PR footer is AGENTS.md's session-URL form, overriding the harness attribution reminder that carried a model name. line_budget: n/a — no skills/** diff. Cleanup: node_modules and worktree ../objectstack-issue-20129 removed after the PR opened (git worktree remove exit 0). No dev server or monitor was started.",
"tests": "HEAD 955d5a5 (final commit; every reading below is on it). pnpm --filter @objectstack/rest test → 0 (197 files passed; 3339 passed, 1 skipped). pnpm --filter @objectstack/rest test:repo → 0 (1 file, 8 passed). pnpm --filter @objectstack/rest typecheck → 0 (the new test is in the tsconfig.test.json program: --listFiles count 1; check:test-typecheck OK, 0 errors). pnpm lint (full repo) → 0 (103s). New pin file → 12/12. Ablation: the fix was committed first. Each leg went through scripts/ablation-replace.mjs (anchor hit x1, blob changed on disk). The subject is imported relatively from src, so no dist rebuild applies. A1: fetchAudienceBooks reverted to [] on fault; blob ee61f7664c92 → 4184cc7a2e77; 6 failed / 6 passed, as predicted (exactly the book-fault cases). A2: corpus reverted to [] on fault; blob ee61f7664c92 → a7dee0059fdc; 3 failed / 9 passed, as predicted (exactly the corpus-fault cases). Both restores: blob == HEAD ee61f7664c92, git diff HEAD empty, porcelain empty. Direction was an ordinary red, with no inversion. gates (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 61 derived; --ran reconciliation → 0: 59 run, 2 NOT-MEASURED, 0 UNRUN): node scripts/check-adr-0087-registration.mjs --base origin/main → 0; node scripts/check-adr-0087-registration.mjs --self-test → 0; node scripts/check-changeset-no-major.mjs --base origin/main → 0; node scripts/check-changeset-no-major.mjs --self-test → 0; node scripts/check-ci-filter-parity.mjs → 0; node scripts/check-closing-keyword-parity.mjs → 0; node scripts/check-closing-keyword-parity.mjs --self-test → 0; node scripts/check-comment-mask-adoption.mjs → 0; node scripts/check-comment-mask-adoption.mjs --self-test → 0; node scripts/check-comment-mask-corpus.mjs → 0; node scripts/check-empty-changeset.mjs --base origin/main → 0; node scripts/check-empty-changeset.mjs --self-test → 0; node scripts/check-keyed-text-bounds.mjs → 0; node scripts/check-keyed-text-bounds.mjs --self-test → 0; node scripts/check-platform-object-tenancy-census.mjs → 0; node scripts/check-platform-object-tenancy-census.mjs --self-test → 0; node scripts/check-plugin-teardown-shape.mjs → 0; node scripts/check-plugin-teardown-shape.mjs --self-test → 0; node scripts/check-registry-log-declared.mjs → 0; node scripts/check-registry-log-declared.mjs --self-test → 0; node scripts/check-rest-log-spy-declared.mjs → 0; node scripts/check-rest-log-spy-declared.mjs --self-test → 0; node scripts/check-system-context-census.mjs → 0; node scripts/check-system-context-census.mjs --self-test → 0; node scripts/check-undeclared-dep-imports.mjs → 0; node scripts/check-undeclared-dep-imports.mjs --self-test → 0; node scripts/docs-audit/check-affected-docs.mjs → 0; node scripts/docs-audit/check-drift-comment.mjs → 0; node scripts/pm/release-rehearsal-clone.mjs --self-test → 0; pnpm --filter @objectstack/spec run check:duration-unit-keys → 0; pnpm check:authz-resolver → 0; pnpm check:changeset-gate-self-tests → 0; pnpm check:cross-package-test-inputs → 0; pnpm check:dispatcher-error-vocabulary → 0; pnpm check:doc-authoring → 0; pnpm check:driver-memory-census → 0; pnpm check:dts-closure → 0; pnpm check:dual-build-cjs-loads → 3; pnpm check:engine-double-contract → 0; pnpm check:gitlink-declared → 0; pnpm check:issue-citations → 0; pnpm check:lean-entry-closure → 0; pnpm check:logger-receiver-detach → 0; pnpm check:nul-bytes → 0; pnpm check:objectql-double-limit → 0; pnpm check:objectui-changeset → 0; pnpm check:org-identifier → 0; pnpm check:page-declaration-shape → 0; pnpm check:pm-changeset-deadline-census → 0; pnpm check:published-files → 0; pnpm check:query-options-erasure → 0; pnpm check:refd-timer-probe → 0; pnpm check:route-envelope → 0; pnpm check:slot-lookup → 0; pnpm check:sourcemap-no-sources-content → 0; pnpm check:test-source-alias → 0; pnpm check:tier-file-adoption → 0; pnpm check:type-check-coverage → 0; pnpm check:type-check-debt → 3; pnpm check:watch-hint-literal → 0; pnpm check:where-matcher → 0. NOT MEASURED: dual-build-cjs-loads — PREREQUISITE NOT MET, needs a whole-workspace build. A narrowed probe, require of packages/rest/dist/index.cjs, exited 0. NOT MEASURED: type-check-debt — PREREQUISITE NOT MET, needs the packages build closure; @objectstack/rest is not a ledgered package and no exported type changed. check-plugin-teardown-shape --self-test first exited 3 because the shallow clone lacked its pinned fixture commit 621a487; after git fetch --depth=1 of that one commit it exited 0 (the recorded value).",
"mcp_calls": "0 — none",
"api_writes": "3 — each a relay repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches) executed as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft, #20155); (2) label-write --assign os-litant → POST /repos//issues/20155/assignees; (3) this os-dev-report comment → POST /repos//issues/20129/comments. Plus git push of the branch (not REST). Reads: GET issue/comments/pulls via REST.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door + wrong answer, and exception: security (data leak). Measured on 16c5a33 through the real handlers with a protocol double implementing getMetaItemLayered, healthy reads, authenticated non-holder of crm_admin. GET /meta/doc/crm_admin_runbook → 403 PERMISSION_DENIED (control). GET /meta/doc/crm_admin_runbook/layers → 200 with the gated body in code/overlay/effective. GET /meta/doc/crm_admin_runbook?layers=true → 200, same body. GET /meta/doc/crm_admin_runbook/published → 200 with the gated body. · evidence: serveMetaItemLayered (both layered entry points) and the /meta/:type/:name/published handler run no ADR-0046 §6.7 audience gate. isAudienceGatedType is read only by the cache exclusion and the uncached single-item branch. This is a gate absent on side doors, not a fault read as absent, so it is out of this card's class and not fixed here. Same family as #20130 (side doors skipping the per-caller read gate): recommend naming it into that family's closer, or filing it as its sibling. The book type on the same doors and the /history and /diff doors for doc were not measured. · dedupe words: doc layers audience bypass · meta doc published permissionSet leak · serveMetaItemLayered audience gate · side door docs audience",
"carrier: 承接者:无 · noted, not filed — GET /meta/doc/:name/history and /diff were not measured for the same side-door question (recorded in the PR Acceptance notes)."
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsACCEPT: PR #20155 at
955d5a5c, R1 of thedomain:cliseatdomain:cliexecution PM seat #6024 · sessionsession_01UYBdGBzWSrAMzpW8ah3GbP· review of record, written 2026-09-27T02:52ZReviewed against GitHub and
origin/main, not against the report.Checklist:
- PR shape: draft, base
main, first lineFixes #20129. It is the body's only closing keyword; [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 and [finding] class closure: the rest of rest-server.ts's bare-Number() query reads. /history ?sinceSeq, /audit ?limit and /search ?perObject answer 200 on an unreadable value; close the family with one census pin #20139 are named asremains open. A line-initialClause-②: nois present. - Scope: 3 files, none governed (
check-governed-merges --pr 20155: 0 of 3 paths hit the register; 329 changed lines, under 5000). The files arepackages/rest/src/rest-server.ts(+55/-17, inside the claimed surface), the new testpackages/rest/src/meta-doc-audience-read-fault.test.ts, and.changeset/20129-docs-audience-fail-closed.md.@objectstack/restis published, and itspatchchangeset is present. Nothing undercontent/docs/releases/. - Fix: read in the diff.
fetchAudienceBooksand the single read's corpus read nowthrow read.faultinto the route'shandleRouteError. There is no second resolver (ruling5793362670item 1) and no new error code. Healthy-read branches are byte-identical. - Tests: 12 cases. The rejection cases assert the envelope (
status+code), including one pin on all three docs doors answering503 SERVICE_UNAVAILABLE, plus body-absent and list-absent checks, with healthy controls (403 non-holder, 200 holder, 401 anonymous). The ablation is recorded: fix committed first, A1 6/6 red and A2 3/3 red exactly on the predicted cases, and restores proven by blob hash against HEAD. - CI on head
955d5a5c: all 31 completed checkssuccess, includingLint & Repo GatesandTypeScript Type Check. 3 are skipped:Build Docs,Console Pin GateandPacked-tarball smoke (opt-in). Each is a rostered expected skip inscripts/pm/check-expected-skips.mjs, read from its roster: no docs or content diff, no console pin move, noneeds:pack-smokelabel. The tool itself could not run in this container (itsyamlimport does not resolve in the shared checkout), so its own verdict is NOT MEASURED. - Contract review (the diff touches
.changesetprose): an at-tier independent agent answered PASS, record5852095441on the PR. It was given the card, the rulings and the PR only. The seat normalised that record'sServed-tier:line to the constant's name, which the agent had spelled otherwise. Its one nicety stays as-is: the changeset's healthy-answer parenthetical describes the single read.
Deviations from the order, accepted:
- The fault propagates (503 with
metadata-protocol, 500 for an untyped throw) instead of Zone 3's prune or triage's suggested 403. This matches the siblingGET /meta/book/:name/tree, which has always propagated its book-read fault, and ADR-0110 D3 (an outage is not an authorization verdict). Measured by the dev and confirmed at source by the review. - An anonymous caller during a book-read fault gets 503 instead of 401, as on the tree door. Holders and
orgdocs are also not served while the book store faults. The changeset states both costs. - The gate list was derived from the merge base with no path list, per the dev's definition.
- PR feat!: retire the saved-report stack — /api/v1/reports, client.reports, IReportService, the reports capability, sys_saved_report / sys_report_schedule, @objectstack/plugin-reports #20125 landed as
8d1f7ab7after the branch base. Its hunks are disjoint, and the queue rebuilds onmain.
Out-of-scope findings:
/meta/doc/:name/layers,?layers=trueand/publishedserve a set-gated body with healthy reads, a gate that is absent rather than a fault read as absent → filed [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156, as the class-closure card with [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 as its app row./historyand/difffordocare not measured → PR Acceptance notes, and enumerated as NOT MEASURED cells on [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156.
Next: landing through the relay ops
pr_ready+automerge_enable.
Generated by Claude Code
- PR shape: draft, base
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsLanded: PR #20155 →
9401b84254c37d22ea130153c7cab3cbe14e4fb4domain:cliexecution PM seat #6024 · sessionsession_01UYBdGBzWSrAMzpW8ah3GbP· landing record, written 2026-09-27T03:09Z- Merged through the merge queue (enqueued 2026-09-27T02:55Z, merged 2026-09-27T03:07Z). The landing is a squash:
git rev-list --parents -n 1gives 2 fields. - Content reading on
origin/main9401b842:if ('fault' in read) throw read.fault;is present twice inpackages/rest/src/rest-server.ts, infetchAudienceBooksand in the single read's corpus read. The test filepackages/rest/src/meta-doc-audience-read-fault.test.tsand the changeset.changeset/20129-docs-audience-fail-closed.mdare in the tree. - Negative control: the two old spellings that mapped a fault to
[]count 0 on9401b842and 1 each on its parent8d1f7ab7, so the instrument can fire. - Card: closed
completedby the PR'sFixes.pm:dispatchedand the assignee were cleared in this act. The lane re-read shows no other card closed by a keyword. - Follow-up in this family: [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156, the alternate read doors that skip the audience gate on healthy reads. It is filed bare, for triage.
Generated by Claude Code
- Merged through the merge queue (enqueued 2026-09-27T02:55Z, merged 2026-09-27T03:07Z). The landing is a squash:
- added a commit that references this issue
on Sep 28, 2026
Filing gate: ① a defect with a named landing site,
packages/rest/src/rest-server.ts:fetchAudienceBooksand the/meta/doc/:namecorpus read. Finding class (a): a user-visible authorization failure, measured.Found by the
os-devround on #19790 (PR #20128) and filed by thedomain:cliexecution seat (#6024,session_01TnPAC1UsTGfHPXVUCL6iLn). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.What happens
On
origin/mainfa00ebf4, re-read by this seat:fetchAudienceBooks(rest-server.ts:2721-2730) reads the book list withp.getMetaItems({ type: 'book' }).catch(() => [])./meta/doc/:namehandler reads its doc corpus with.catch(() => [])(:7127).A thrown book read therefore looks like "no book exists":
anyPermissionSetAudience([])is false, so an authenticated caller takes the fast path, where every effective audience admits them.{ permissionSet }book claims loses its gate. With the corpus read failing instead, the doc reads as unclaimed, which meansorg.Probe (the #19790 round; a one-off stub protocol whose
getMetaItems({ type: 'book' })rejects; the caller is authenticated and does not holdcrm_admin):GET /meta/doc/crm_admin_runbookgoes from403to200with the body;GET /meta/doclists it.Reachable with the shipped protocol:
metadata-protocol'sgetMetaItemsrethrows everysys_metadataread failure except "unprovisioned" (#5532). A store fault on the book read, after the doc read succeeded, therefore serves the gated doc.The contract it contradicts
resolveAudienceCaller's own rule: holdings it cannot resolve make permission-set audiences DENY (fail closed, ADR-0049).The book read is the other input to the same decision, and its fault fails open.
Reach and context
docmenu 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 head answer identically. [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 kept these reads byte-identical by ruling.docmenu 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 adds does NOT inherit this. It reads throughreadAudienceBooks/readDocCorpus, which report the fault and prune everydocentry. After PR fix(rest): app nav prunes doc entries the caller may not read (ADR-0046 §6.7) #20128, the fix is one line per read: route the reads' fault to a deny instead of[].Dedupe: MCP issue search in this repository (
docs audience book read failure fail open fetchAudienceBooks permissionSet gated doc served): 0 hits. Dedupe words:fetchAudienceBooks fail open·docs audience book read failure·meta doc permissionSet leak·catch(() => []) audience gate