Skip to content

[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

@objectstack-fleet

Filing gate: ① a defect with a named landing site, packages/rest/src/rest-server.ts: fetchAudienceBooks and the /meta/doc/:name corpus read. Finding class (a): a user-visible authorization failure, measured.

Found by the os-dev round on #19790 (PR #20128) and filed by the domain:cli execution seat (#6024, session_01TnPAC1UsTGfHPXVUCL6iLn). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.

What happens

On origin/main fa00ebf4, re-read by this seat:

  • fetchAudienceBooks (rest-server.ts:2721-2730) reads the book list with p.getMetaItems({ type: 'book' }).catch(() => []).
  • The /meta/doc/:name handler 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.
  • A doc that only a { permissionSet } book claims loses its gate. With the corpus read failing instead, the doc reads as unclaimed, which means org.

Probe (the #19790 round; a one-off stub protocol whose getMetaItems({ type: 'book' }) rejects; the caller is authenticated and does not hold crm_admin):

  • GET /meta/doc/crm_admin_runbook goes from 403 to 200 with the body;
  • GET /meta/doc lists it.

Reachable with the shipped protocol: metadata-protocol's getMetaItems rethrows every sys_metadata read 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

  • ADR-0046 §6.7: docs content is gated server-side at the doc/book reads.
  • 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

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

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: changing a running app without code | platform-core.docs-audience-gate | P1

    Triage: first grade — bug · security · priority:p1 · domain:cli · area:studio · pm:queue

    Triage: lands in packages/rest/src/rest-server.ts (fetchAudienceBooks, :2721-2730, and the /meta/doc/:name corpus 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, and origin/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.

  2. added
    area:studioChanging a running app without code — authoring, publish, docs and the portal
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Sep 25, 2026
  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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: fetchAudienceBooks and its two callers, the /meta/doc list 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 under packages/rest/src/, any hand-written content/docs/** line the change makes false, and one .changeset/20129-*.md. packages/spec/** is read-only. CHANGELOG.md and package.json files 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 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 card

    Taken 2026-09-27T01:58Z, origin/main 16c5a33fdd. 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

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20155 at 955d5a5c, R1 of the domain:cli seat

    domain:cli execution PM seat #6024 · session session_01UYBdGBzWSrAMzpW8ah3GbP · review of record, written 2026-09-27T02:52Z

    Reviewed against GitHub and origin/main, not against the report.

    Checklist:

    • PR shape: draft, base main, first line Fixes #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 as remains open. A line-initial Clause-②: no is 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 are packages/rest/src/rest-server.ts (+55/-17, inside the claimed surface), the new test packages/rest/src/meta-doc-audience-read-fault.test.ts, and .changeset/20129-docs-audience-fail-closed.md. @objectstack/rest is published, and its patch changeset is present. Nothing under content/docs/releases/.
    • Fix: read in the diff. fetchAudienceBooks and the single read's corpus read now throw read.fault into the route's handleRouteError. There is no second resolver (ruling 5793362670 item 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 answering 503 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 checks success, including Lint & Repo Gates and TypeScript Type Check. 3 are skipped: Build Docs, Console Pin Gate and Packed-tarball smoke (opt-in). Each is a rostered expected skip in scripts/pm/check-expected-skips.mjs, read from its roster: no docs or content diff, no console pin move, no needs:pack-smoke label. The tool itself could not run in this container (its yaml import does not resolve in the shared checkout), so its own verdict is NOT MEASURED.
    • Contract review (the diff touches .changeset prose): an at-tier independent agent answered PASS, record 5852095441 on the PR. It was given the card, the rulings and the PR only. The seat normalised that record's Served-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:

    Out-of-scope findings:

    Next: landing through the relay ops pr_ready + automerge_enable.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20155 → 9401b84254c37d22ea130153c7cab3cbe14e4fb4

    domain:cli execution PM seat #6024 · session session_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 1 gives 2 fields.
    • Content reading on origin/main 9401b842: if ('fault' in read) throw read.fault; is present twice in packages/rest/src/rest-server.ts, in fetchAudienceBooks and in the single read's corpus read. The test file packages/rest/src/meta-doc-audience-read-fault.test.ts and the changeset .changeset/20129-docs-audience-fail-closed.md are in the tree.
    • Negative control: the two old spellings that mapped a fault to [] count 0 on 9401b842 and 1 each on its parent 8d1f7ab7, so the instrument can fire.
    • Card: closed completed by the PR's Fixes. pm:dispatched and 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

  7. added a commit that references this issue on Sep 28, 2026
    9401b84
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:studioChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingdomain:clipriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions