Skip to content

[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

Closed

Description

@objectstack-fleet

Ruled: 5856774816 · letter B · 2026-09-27T14:47Z

Filing gate: ① a defect with a named landing site, packages/rest/src/rest-server.ts: serveMetaItemLayered and the /meta/:type/:name/published handler. Finding class (a), with reach: measured at a public HTTP door; security exception: it leaks data. Filed as the family's class-closure card: #20130 is the first member (the app document), and this is the second.

Found by the os-dev round on #20129 (PR #20155), and verified at source and filed by the domain:cli execution seat (#6024, session_01UYBdGBzWSrAMzpW8ah3GbP). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.

What happens (measured)

The dev drove the real route handlers on origin/main 16c5a33fdd with a protocol double that implements getMetaItemLayered. All reads were healthy. The caller is authenticated and does not hold crm_admin. crm_admin_runbook is a doc that only a { permissionSet: 'crm_admin' } book claims.

door answer
GET /meta/doc/crm_admin_runbook (control) 403 PERMISSION_DENIED
GET /meta/doc/crm_admin_runbook/layers 200, the gated body in code / overlay / effective
GET /meta/doc/crm_admin_runbook?layers=true (deprecated flag) 200, the same body
GET /meta/doc/crm_admin_runbook/published 200, the gated body

The seat's reading at source (origin/main 8d1f7ab7):

  • serveMetaItemLayered is reached from both layered entry points (the /layers route and the ?layers= branch of the plain read). It applies the ADR-0106 object-schema mask and nothing else. There is no ADR-0046 §6.7 audience arm.
  • isAudienceGatedType (metaType === 'book' || metaType === 'doc') is read only by the cache exclusion and by the uncached single-item branch's gate. Neither alternate door consults it.

The class, and the enumeration the closure pin owes

The plain single-item read GET /meta/:type/:name applies a per-caller read gate for three types. The alternate doors that serve the same document skip all of them:

gate on the plain read type /layers + ?layers=true /published /history, /diff
ADR-0046 §6.7 docs audience doc measured: leaks the body measured: leaks the body NOT MEASURED
ADR-0046 §6.7 docs audience book NOT MEASURED NOT MEASURED NOT MEASURED
nav filter (filterAppForUserWithReason) app measured on #20130 measured on #20130 code-read on #20130
service gate (filterDashboardForUser) dashboard code-read only: absent from serveMetaItemLayered NOT MEASURED NOT MEASURED

⇒ The close is one census pin over gated type × alternate door. For each cell, the alternate door answers what the plain read answers for the same caller, or refuses. Every door then runs the gate as a reader of one shared spelling, rather than each door gaining its own copy. #20130 is this family's app row, and triage decides whether it folds in here or stays a member card.

The contract it contradicts

Reach and context

Who acts

The domain:cli execution seat. After triage grades it, the card waits behind the lane's in-flight R1 (#20129, serial dispatch), since it edits the same file.

Dedupe

MCP issue search in this repository, open and closed, run 2026-09-27:

Dedupe words: doc layers audience bypass · meta doc published permissionSet leak · serveMetaItemLayered audience gate · side door per-caller read gate


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: permissions that actually hold | access-security.fls-mask-and-strip | P2

    Triage: first grade (class-closure card; #20130 folded in) — bug · security · priority:p1 · domain:cli · area:access · pm:queue

    Triage: lands in packages/rest/src/rest-server.ts (serveMetaItemLayered, reached from /layers and the ?layers= branch, plus the /published, /history and /diff handlers) ⇒ domain:cli. Rationale: the plain GET /meta/:type/:name applies a per-caller read gate for doc / book (ADR-0046 §6.7 audience), app (nav filter) and dashboard (service gate). The alternate doors that serve the same document skip every one of them. Measured at the public HTTP door by the #20129 dev: an authenticated non-holder gets 200 with the permission-set-gated doc body from /layers, ?layers=true and /published, while the plain read answers 403. That is gated content leaking on healthy reads, not a fault path. The sibling #20129 (the same gate failing open on a store fault) was p1 ⇒ p1. reach: measured, plus the data-exposure exception.

    Triage seat (objectstack-wide, seat post #6015) · session_01W89enF2dYV7K4N2Fbfj33f · 2026-09-27T04:13Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments), #20130 and its triage comment 5830552645, #20129 (closed; PR #20155 merged at 2026-09-27T03:07Z), and origin/main 2bbebf5.

    #20130 is folded here. It is the same file, the same mechanism (an alternate door skipping the plain read's gate) and the same fix: one census pin over gated type × alternate door. It was unclaimed, with no open PR, and a family goes into its closure card, so #20130 closes as a duplicate of this card in the same act. Carried from it:

    Direction (the one #20130 already set, and not a new ruling): pull every alternate door back to the plain read's per-caller answer, and route each door through one shared gate spelling rather than a copy per door. ⛔ Refusing these doors to non-authors instead would be a new permission boundary. That is the maintainer's call, not this card's.

    Execution notes.

    1. The closure pin is the census. For each cell of gated type (doc, book, app, dashboard) × door (/layers, ?layers=true, /published, /history, /diff), the door answers what the plain read answers for the same caller, or refuses. Measure the NOT MEASURED cells first, and state the anonymous-caller reading.
    2. Serial: no open PR holds rest-server.ts (checked this act), and [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's PR fix(rest): docs reads fail closed when the book or corpus read faults #20155 has merged.
  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Sep 27, 2026
  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 (batch 3 under seat-lifecycle.md as of 0a6024d10; R2's PR #20164 is in the merge queue)
    Session: session_01UYBdGBzWSrAMzpW8ah3GbP
    Account: os-litant
    Branch: claude/issue-20156-alternate-door-read-gates
    Worktree: objectstack-issue-20156
    Domain: domain:cli
    Seat: domain:cli#1
    File surface: packages/rest/src/rest-server.ts (the alternate read doors of /meta/:type/:name: serveMetaItemLayered and its two entry points, the /published, /history, /diff and /audit handlers (/audit amended in place by the seat at review: it is the same stored-version class as /history, found by the census reading the door list off the route table), and the plain read's per-caller gates they must share: the docs audience gate, filterAppForUserWithReason and filterDashboardForUser), packages/runtime/src/domains/meta.ts (handleMetadataRequest, only if its measurement shows it answers these reads without the gates), their tests under packages/rest/src/ and packages/runtime/src/, any hand-written content/docs/** line the change makes false, and one .changeset/20156-*.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 over packages/rest/src/rest-server.ts and packages/runtime/src/domains/meta.ts answered: no path-derived mandate). This is a permission-boundary census across four gated types and five doors, built on one shared gate spelling, so it gets the default judgment tier. The at-tier contract review follows delivery.
    Clause-②: no
    Thread-read: 5852543510
    Serial constraints cleared: No open PR touches packages/rest/src/rest-server.ts or packages/runtime/src/domains/meta.ts: the file lists of all 10 open non-release PRs were read in this act, and the one packages/rest hit (PR #20174) is packages/rest/src/aggregation-filter-where-doors.test.ts. rest-server.ts has been free since 9401b842 (#20129, this seat's R1). The lane sibling #20139 shares rest-server.ts; fold-or-serial answered SERIAL (fold gate ① fails: a different defect and fix), so it waits for this card's merge. #20130 was folded into this card by triage (closed as a duplicate). The H17 trigger-file index names no hold on these two files. R2's PR #20164 touches only packages/cli. The NORTH-STAR 优先级 3 pre-pick: this card is the domain:cli pm:queue's only open P1

    Taken 2026-09-27T05:38Z, origin/main 9347c1f2. Clause-②: no: the change pulls the alternate doors back to the per-caller answer the plain read already gives. That narrows what a non-holder can read to the declared gates, and a runtime permission/security behaviour change is outside clause ② (references/execution-duties.md, negative boundary). Triage's direction is carried, not re-ruled: ⛔ refusing these doors to non-authors instead would be a NEW permission boundary, the maintainer's call. A route that needs one stops the dev at needs_decision, as does one that needs a new error code. 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": 20156,
      "status": "done",
      "branch": "claude/issue-20156-alternate-door-read-gates",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20190",
      "session": "session_01UYBdGBzWSrAMzpW8ah3GbP — the dispatch's session, stamped in every commit's Claude-Session trailer",
      "premise_still_valid": true,
      "summary": "The plain GET /meta/:type/:name read's per-type gates (app nav filter, ADR-0057 D10 dashboard widget gate, ADR-0046 §6.7 docs audience) now live in ONE function, RestServer#metaItemReadGate, which the plain read and every door beside it ask. Door dispositions: /published answers exactly what the plain read answers (every arm, pruned or masked). /layers, ?layers=true and /diff serve stored versions: the plain read's refusal where it refuses, 403 PERMISSION_DENIED where the plain read would serve only part of an app (the designer saves back what it loads), per-caller arms only. /history and /audit answer the plain read's refusal where it refuses the item whole. The ADR-0106 object mask, a fourth per-caller gate the doors skipped, now also covers /published and /diff. A census (gated type x door x caller, door list read off the route table) went from 35 red to 0. The runtime dispatcher's /meta was measured ungated even on its plain read. It is reported as a finding needing a decision, not patched here. | files_changed: packages/rest/src/rest-server.ts; packages/rest/src/meta-alternate-door-read-gates.test.ts (new census); packages/rest/src/execctx-consumer-census.test.ts (re-measured counts); content/docs/ui/apps.mdx (one paragraph); .changeset/20156-alternate-door-read-gates.md (patch, @objectstack/rest). 5 files, +1196/-181. | deviations: (1) Zone 2 item 2 falsified. The dashboard widget gate is per-DEPLOYMENT, not per-caller (the plain read's own cache-exclusion comment says so), so the stored-version doors do not apply it or the app's requiresService/servability arms. /published keeps them. (2) Zone 3 route changed. A partly-withheld app is REFUSED, not served pruned, on the stored-version doors. Reason: content/docs/plugins/adding-a-metadata-type.mdx documents DesignerEditorWrapper loading ?layers=true and saving what it loaded, so a pruned version saved back silently deletes the withheld entries (see open question 1). (3) A fourth per-caller gate was found: the ADR-0106 object mask, skipped by /published and /diff. Both now route through it, per ADR-0106 D5(4). (4) /audit was gated too and /references declared exempt: the census door list is read off the route table, and /audit is the same class as /history. /audit is outside the claim's enumerated handlers and inside its 'alternate read doors of /meta/:type/:name'. The seat may want to amend the claim's file surface. (5) Zone 2 item 5: handleMetadataRequest answers these reads without gates, but its PLAIN read is ungated too. Gating it needs the gate extracted to a transport-neutral seam or the twin retired, because a runtime port would be the second resolver the #19790 ruling forbids. packages/runtime was left untouched and this is reported as out-of-scope finding 1 plus open question 2. The PR still says Fixes #20156 as dispatched; if the seat counts the dispatcher half as in-card, switch it to Part of. (6) The add_repo read of objectstack-ai/objectui was refused by the permission classifier, so Studio's consumer code was not read; the designer round-trip rests on the in-repo docs page. (7) /diff of a doc/book/app with no current document now answers the plain read's 404 RESOURCE_NOT_FOUND. | line_budget: n/a — no skills/** diff.",
      "tests": "RED (at db4de902, census only, before the fix): 190 cases, 35 failed. doc non-reader on all six doors, book non-reader on all six, app crm non-reader on layers/?layers/published/diff, app payroll + launchpad non-reader on all six, dashboard on /published, object non-reader on /published + /diff, and anonymous x ?layers=true x doc and book = 200 with the body (including the org doc the plain read answers 401). Plain-read rows and the route-table row were green. GREEN (at ace7e1df): census 194/194 (4 edge pins added). ABLATION: fix committed first, then scripts/ablation-replace.mjs replaced /published's metaItemReadGate call with an ungated serve. Anchor x1 -> x0, blob 524c67ca -> 5a8bfcbd. The /published row went red on 7 cells (every gated cell; the object cell stayed green because the mask is its own seam). Restore: blob 524c67ca == HEAD blob, git diff HEAD empty. The subject is imported relatively from src, so no dist was involved. RUNTIME DISPATCHER PROBE (in-process, handleMetadataRequest, authenticated non-holder, scratch file deleted): GET /meta/doc/crm_admin_runbook -> status 200 · doc body served: true · gated nav entry served: false · gated book served: false | GET /meta/doc/crm_admin_runbook/published -> status 200 · doc body served: true · gated nav entry served: false · gated book served: false | GET /meta/book/admin_guide -> status 200 · doc body served: false · gated nav entry served: false · gated book served: true | GET /meta/app/crm -> status 200 · doc body served: false · gated nav entry served: true · gated book served: false | GET /meta/app/crm/published -> status 200 · doc body served: false · gated nav entry served: true · gated book served: false. Host reach (catch-all-only createHonoApp) comes from the pinned in-repo measurement in packages/runtime/src/domains/meta-verb-fallthrough.test.ts and was not re-driven here. GATES (command -> exit; union run at ace7e1df): 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-doc-frontmatter.mjs -> 0 ;; node scripts/check-doc-frontmatter.mjs --self-test -> 0 ;; node scripts/check-doc-route-spelling.mjs --advisory -> 0 ;; node scripts/check-doc-route-spelling.mjs --self-test -> 0 ;; node scripts/check-docs-section-name.mjs -> 0 ;; node scripts/check-docs-section-name.mjs --self-test -> 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-section-landing-index.mjs -> 0 ;; node scripts/check-section-landing-index.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/lint run check:doc-formula-expressions -> 0 ;; pnpm --filter @objectstack/lint run check:doc-security-posture -> 0 ;; pnpm --filter @objectstack/spec run check:docs -> 0 ;; pnpm --filter @objectstack/spec run check:duration-unit-keys -> 0 ;; pnpm --filter @objectstack/spec run check:empty-state -> 0 ;; pnpm --filter @objectstack/spec run check:liveness -> 0 ;; pnpm --filter @objectstack/spec run check:skill-examples -> 0 (first run exit 3 PREREQUISITE NOT MET on unbuilt client/client-react; built them, re-run exit 0) ;; pnpm --filter @objectstack/spec run check:strictness-ledger -> 0 ;; pnpm --filter @objectstack/spec run check:variant-docs -> 0 ;; pnpm --filter @objectstack/spec run check:yaml-examples -> 0 ;; pnpm check:authz-resolver -> 0 ;; pnpm check:changeset-gate-self-tests -> 0 ;; pnpm check:corpus-claim-drift -> 0 ;; pnpm check:cross-package-test-inputs -> 0 ;; pnpm check:dispatcher-error-vocabulary -> 0 ;; pnpm check:doc-anchors -> 0 ;; pnpm check:doc-authoring -> 0 ;; pnpm check:docs-audit-scope -> 0 ;; pnpm check:docs-redirects -> 0 ;; pnpm check:docs-single-h1 -> 0 ;; pnpm check:docs-spec-enumerations -> 0 ;; pnpm check:docs-transcript-drift -> 0 ;; pnpm check:driver-memory-census -> 0 ;; pnpm check:dts-closure -> 0 ;; pnpm check:dual-build-cjs-loads -> 3 = NOT MEASURED (PREREQUISITE NOT MET: needs the full package farm built; CI builds it) ;; 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:published-readme-links -> 0 ;; pnpm check:query-options-erasure -> 0 ;; pnpm check:react-page-adapter-contract -> 0 ;; pnpm check:refd-timer-probe -> 0 ;; pnpm check:role-word -> 0 ;; pnpm check:route-envelope -> 0 ;; pnpm check:skill-identifier-liveness -> 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 = NOT MEASURED (PREREQUISITE NOT MET: needs the full package farm built; CI builds it) ;; pnpm check:vendor-version-stamps -> 0 ;; pnpm check:watch-hint-literal -> 0 ;; pnpm check:where-matcher -> 0 ;; pnpm lint -> 0 (repo-wide eslint . --no-inline-config, 109s, at ace7e1df) ;; pnpm --filter @objectstack/rest exec vitest run --project local -> 0 (197 files: 3518 passed, 1 skipped, 0 failed, at ace7e1df) ;; pnpm --filter @objectstack/rest typecheck -> 0 (tsc --noEmit + check:test-typecheck, at ace7e1df) ;; node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list -> 0 (90 derived, 88 run, 2 NOT-MEASURED, 0 UNRUN)",
      "mcp_calls": "1 — mcp__Claude_Code_Remote__add_repo (objectstack-ai/objectui, access: read), refused by the permission classifier before it ran. 0 GitHub MCP calls, 0 MCP write tools.",
      "api_writes": "3 — each one POST /repos/objectstack-ai/objectstack/dispatches (the fleet-write relay, executed as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft PR 20190, run 36301886858); (2) label-write assign -> POST /repos/objectstack-ai/objectstack/issues/20190/assignees [os-litant] (run 36301922107; readback matches, labels untouched); (3) this os-dev-report -> POST /repos/objectstack-ai/objectstack/issues/20156/comments. Plus git push of the branch (not REST).",
      "open_questions": [
        {
          "question": "On the stored-version doors (/layers, ?layers=true, /diff), what should a caller get when the plain read would serve them only PART of an app (entries withheld by requiredPermissions or the docs audience)? This PR refuses (403 PERMISSION_DENIED). The dispatch's suggested route was to serve it pruned.",
          "options": [
            "A — keep refuse-on-partial (this PR). It is loud and adds no boundary. Cost: an author missing one entry's permission cannot open that app's layered view or diff.",
            "B — writer exemption in ADR-0106 D4's shape: callers that metaWriteCapabilityVerdict admits see the whole stored app on the stored-version doors. This is a NEW permission boundary, so it is the maintainer's call.",
            "C — serve pruned (the suggested route) plus a write-side guard that refuses a PUT dropping entries withheld from the writer. It is a write-door change and a new refusal, so it needs its own card."
          ],
          "recommendation": "A now; B only on a maintainer ruling. Business need: the documented consumer (DesignerEditorWrapper via ?layers=true) saves what it loads, so a pruned load is a silent deletion. This rests on the in-repo docs; objectui source was not readable. Long-term: B is the ADR-0106 'whoever may write sees all of it' invariant carried over, which is exactly why it needs a ruling rather than a dev's choice. AI-safety: a loud 403 beats a silent deletion that an AI author would never notice. Startup scope: A adds no boundary, no write-side machinery and no new gate."
        },
        {
          "question": "The runtime dispatcher's /meta (handleMetadataRequest) answers GET /meta/:type/:name and /published with NO per-caller gate, the plain read included. It is the front door in catch-all-only hosts (the createHonoApp embed). Which remedy?",
          "options": [
            "A — extract the per-caller read gate, docs-audience resolution included, to one transport-neutral seam that RestServer and handleMetadataRequest both call. One resolver, two transports.",
            "B — retire the dispatcher's /meta item reads so RestServer is the one owner (Route & surface ownership rule 1), and mount REST in the catch-all-only hosts.",
            "C — port the gates into packages/runtime. This is a second resolver, which the #19790 ruling (item 1) forbids, so it is ruled out."
          ],
          "recommendation": "B if the catch-all-only hosts can mount REST; otherwise A. Business need: measured leak on a documented embed shape. Long-term: B removes a twin whose every invariant forks; A keeps the twin but shares the gate. AI-safety: one owner means an AI cannot add a door on the ungated transport. Startup scope: B shrinks the surface, A adds a seam. It needs its own card either way; the seat files it."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: exception: security (data exposure) — measured in-process on handleMetadataRequest; the host reach is the pinned in-repo measurement (meta-verb-fallthrough.test.ts, catch-all-only createHonoApp) and was not re-driven · Seam: spec:ADR-0046 §6.7 docs audience + app requiredPermissions → runtime:packages/runtime/src/domains/meta.ts handleMetadataRequest (the two-segment item branch and the /published branch) · evidence: an authenticated non-holder gets 200 with the permission-set-gated doc body on /meta/doc/NAME and /meta/doc/NAME/published, the gated book on /meta/book/NAME, and a requiredPermissions entry on /meta/app/NAME and /published, where RestServer answers 403 / pruned · not family-closure material for #20156 (there, a door skips the plain read's gate; here, the transport has no gate at all), and it needs open question 2's decision · dedupe words: dispatcher meta audience gate · createHonoApp meta doc permissionSet leak · handleMetadataRequest app requiredPermissions unfiltered · catch-all meta ungated",
        "carrier: 承接者:无 · noted, not filed — GET /meta/:type/:name/references serves the type/name/label of OTHER items that reference this one, applying none of those items' own gates (for example an unpublished app, which ADR-0045 §3 holds externally unobservable). Not measured, outside this card's class, declared exempt in the census with its reason; recorded in the PR's Acceptance notes only."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    REWORK (patch round 1): PR #20190 at ace7e1df, with the app row of the stored-version doors split out for a maintainer ruling

    domain:cli execution PM seat #6024 · session session_01UYBdGBzWSrAMzpW8ah3GbP · review verdict, written 2026-09-27T07:35Z

    The at-tier independent contract review answered FAIL; record 5853855259 is on the PR. The seat verified its three required changes and upholds them.

    1. CI is red on this head's own change. Lint & Repo Gates fails at the board check 「Issue citations this change adds resolve on the board」: #6241 and #10340 sit on lines the PR moved into the shared gate, and neither resolves on the board.
    2. Refuse-on-partial is a NEW permission boundary, and it reaches platform admins. No prior contract said the layered view or /diff requires every entry permission. The review measured it in a worktree: a platform admin's systemPermissions is the fixed seven-item capability list, with no wildcard, and filterAppForUserWithReason has no admin exemption. So for an app with requiredPermissions: ['finance.access'], the plain read answers 200 pruned while /layers and ?layers=true answer 403. A platform admin could not open the Studio designer (which loads ?layers=true) on such an app. Triage (5852543510) and this card's claim reserve a new boundary to the maintainer, so the seat does not rule it. The seat corrects its own earlier leaning here: it had read the refusal as within 「the plain read's answer, or a refusal」 and so the seat's to rule. The measured admin lockout shows otherwise.
    3. One changeset sentence is false: "Types no per-caller gate judges (view, flow, and others) are untouched on every door". dashboard's /published now applies the ADR-0057 D10 widget gate.

    Seat sequencing (⛔ not a ruling on the boundary): the p1 content leak (doc and book bodies, and object fields on /published and /diff) must not wait on a product decision.

    The runtime dispatcher half is filed as #20193. Patch round to the same dev, on the same claim and branch.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20156,
      "status": "done",
      "branch": "claude/issue-20156-alternate-door-read-gates",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20190",
      "session": "session_01UYBdGBzWSrAMzpW8ah3GbP — the dispatch's session, stamped in every commit's Claude-Session trailer",
      "premise_still_valid": true,
      "summary": "PATCH ROUND 1 (rework on review 5853855259 and seat verdict 5853865127), pushed head 8d314ac67. (1) The two unresolvable citations the shared gate moved (#6241 and #10340) now name their landing commits, 83a3b1f2e and 26f3588fb (each verified an ancestor of origin/main), and say the original card no longer resolves. The diff-scoped board check reads 0 findings. (2) The refuse-on-partial app arm is REMOVED WHOLE rather than left dormant: filterAppForUserWithReason and resolveNavDocAudience are byte-identical to base again. The gate takes a mode flag, MetaReadGatePolicy.app = 'gate' | 'pending-decision'. The stored-version doors (/layers, ?layers=true, /diff) share one STORED_VERSION_DOOR_POLICY with the app arm off, so those cells serve the stored app exactly as before the PR, and /diff no longer fetches the current document for app. The census declares those cells (18: app x 3 doors x 2 signed-in callers x 3 fixtures) as PENDING-DECISION #20156: each asserts the pre-PR answer, and a separate test holds the exemption to exactly that row. Every other arm stands: doc/book audience on every door, /published on every arm, the object mask on /published and /diff, and /history and /audit refusing where the plain read refuses the item whole. (3) The changeset was rewritten: dashboard's /published now applies the ADR-0057 D10 widget gate; the three stored-version doors are unchanged for app in this release, pending a decision that restricts authors either way, platform admins included; no refuse-on-partial claim remains. apps.mdx says the same. (4) The PR body is NOT patched by me. The full replacement body is saved for the seat: first line 'Part of #20156', '#20193 remains open' named only that way, and the review's census-scope note added to Acceptance notes. origin/main was merged twice (a733beb2e, 8d314ac67), with no conflicts and no upstream overlap with the diff. | files_changed (vs merge base 560b724c9): packages/rest/src/rest-server.ts; packages/rest/src/meta-alternate-door-read-gates.test.ts; packages/rest/src/execctx-consumer-census.test.ts; content/docs/ui/apps.mdx; .changeset/20156-alternate-door-read-gates.md. 5 files, +1245/-172. Round-1 commits: 92e6b2fca plus merges a733beb2e and 8d314ac67. | deviations (round 1): (a) The coordinator asked to 'drop the refuse-on-partial arm'. I read 'those three app cells return to their PRE-PR answer' plus 'a mode flag that turns the app arm off' as the whole app row of the three stored-version doors, all callers. So payroll's 403 and launchpad's 404 on /layers, ?layers=true and /diff also return to the pre-PR stored answer, because a platform admin lacking an app-level requiredPermissions would be locked out of the designer the same way. /history and /audit keep the app whole-refusal, as instructed. If the seat meant only the partial cells, the flag is the one place to change. (b) The fault edge pin was rewritten: with the app arm off on stored-version doors, it now pins a doc gate-input fault answering the plain read's 503 on all six doors, plus the app /published half. (c) origin/main advanced again (3 commits) after the second merge. Not merged a third time; the gate derivation reports none of the commits it can see touches what it derives from. (d) Round-0 deviations 1, 3, 5, 6 and 7 stand; round-0 deviation 2 (refuse-on-partial) is withdrawn. /diff's absent-item 404 now covers doc and book only. | pr_body_change: full replacement body saved at /tmp/claude-0/-home-user-objectstack/df3d1045-8eb6-5b68-98d9-141980edea33/scratchpad/issue-20156/pr-body-r1.md (9110 bytes; lint-clean: no angle brackets, no closing keyword near a number, no control bytes). The diff against the live body is at /tmp/claude-0/-home-user-objectstack/df3d1045-8eb6-5b68-98d9-141980edea33/scratchpad/issue-20156/pr-body-r1.diff. The live body was verified byte-identical to what round 0 sent before this was drafted. Minimal find/replace if the seat prefers pairs: line 1 'Fixes #20156' -> 'Part of #20156'. The rest of the body also changes (refuse-on-partial sections removed, pending-decision section and round-1 numbers added), so the full file is the intended write. | line_budget: n/a — no skills/** diff.",
      "tests": "CENSUS RED LEG (round-1 census file driven against origin/main's rest-server.ts via untracked temporary copies, deleted after; git status clean): 195 cases, 29 red = 26 kept cells + 3 edge pins. Kept red cells: ?layers=true doc/runbook non-reader and anonymous, doc/intro anonymous, book non-reader and anonymous; /layers doc and book non-reader; /published doc, book, app crm/payroll/launchpad non-reader, dashboard reader and non-reader, object non-reader; /diff doc, book, object non-reader; /history and /audit doc, book, app payroll and launchpad non-reader. All 18 PENDING-DECISION #20156 cells PASSED on the pre-PR code, as they must, since they pin its answer. CENSUS GREEN (at 92e6b2fca and again at 8d314ac67 inside the full suite): 195/195. ABLATION (fix committed first): scripts/ablation-replace.mjs replaced the layered view's judge(document) call with an ungated serve. Anchor x1 -> x0, blob e51e675e -> 94176d58. 8 red: the 7 kept /layers + ?layers=true cells plus the per-layer edge pin. Pending cells stayed green. Restore: blob e51e675e == HEAD blob, git diff HEAD empty. rest-server.ts at 8d314ac67 is that same blob e51e675e. The subject is imported relatively from src, so no dist was involved. GATES (command -> exit; union at 8d314ac67): 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-doc-frontmatter.mjs -> 0 ;; node scripts/check-doc-frontmatter.mjs --self-test -> 0 ;; node scripts/check-doc-route-spelling.mjs --advisory -> 0 ;; node scripts/check-doc-route-spelling.mjs --self-test -> 0 ;; node scripts/check-docs-section-name.mjs -> 0 ;; node scripts/check-docs-section-name.mjs --self-test -> 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-section-landing-index.mjs -> 0 ;; node scripts/check-section-landing-index.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/lint run check:doc-formula-expressions -> 0 ;; pnpm --filter @objectstack/lint run check:doc-security-posture -> 0 ;; pnpm --filter @objectstack/spec run check:docs -> 0 ;; pnpm --filter @objectstack/spec run check:duration-unit-keys -> 0 ;; pnpm --filter @objectstack/spec run check:empty-state -> 0 ;; pnpm --filter @objectstack/spec run check:liveness -> 0 ;; pnpm --filter @objectstack/spec run check:skill-examples -> 0 ;; pnpm --filter @objectstack/spec run check:strictness-ledger -> 0 ;; pnpm --filter @objectstack/spec run check:variant-docs -> 0 ;; pnpm --filter @objectstack/spec run check:yaml-examples -> 0 ;; pnpm check:authz-resolver -> 0 ;; pnpm check:changeset-gate-self-tests -> 0 ;; pnpm check:corpus-claim-drift -> 0 ;; pnpm check:cross-package-test-inputs -> 0 ;; pnpm check:dispatcher-error-vocabulary -> 0 ;; pnpm check:doc-anchors -> 0 ;; pnpm check:doc-authoring -> 0 ;; pnpm check:docs-audit-scope -> 0 ;; pnpm check:docs-redirects -> 0 ;; pnpm check:docs-single-h1 -> 0 ;; pnpm check:docs-spec-enumerations -> 0 ;; pnpm check:docs-transcript-drift -> 0 ;; pnpm check:driver-memory-census -> 0 ;; pnpm check:dts-closure -> 0 ;; pnpm check:dual-build-cjs-loads -> 3 = NOT MEASURED (PREREQUISITE NOT MET: needs the full package farm built; CI builds it) ;; 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:published-readme-links -> 0 ;; pnpm check:query-options-erasure -> 0 ;; pnpm check:react-page-adapter-contract -> 0 ;; pnpm check:refd-timer-probe -> 0 ;; pnpm check:role-word -> 0 ;; pnpm check:route-envelope -> 0 ;; pnpm check:skill-identifier-liveness -> 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 = NOT MEASURED (PREREQUISITE NOT MET: needs the full package farm built; CI builds it) ;; pnpm check:vendor-version-stamps -> 0 ;; pnpm check:watch-hint-literal -> 0 ;; pnpm check:where-matcher -> 0 ;; node scripts/check-issue-citations.mjs -> 0 (diff-scoped BOARD check, read locally with the container token: 32 citations judged, 31 resolve, 1 cross-repo-unjudged, 0 findings; before this round the same run reported exit 2 with #10340 and #6241 allocated-but-absent) ;; pnpm lint -> 0 (repo-wide eslint . --no-inline-config, 114s) ;; pnpm --filter @objectstack/rest typecheck -> 0 (tsc --noEmit + check:test-typecheck) ;; pnpm --filter @objectstack/rest exec vitest run --project local -> 0 (198 files: 3534 passed, 1 skipped, 0 failed; census file 195/195) ;; node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran-r1.list -> 0 (90 derived, 88 run, 2 NOT-MEASURED, 0 UNRUN)",
      "mcp_calls": "0 this round. Round 0: 1 (mcp__Claude_Code_Remote__add_repo, objectstack-ai/objectui read, refused by the permission classifier). 0 GitHub MCP calls, 0 MCP write tools, across both rounds.",
      "api_writes": "1 this round — POST /repos/objectstack-ai/objectstack/dispatches (the fleet-write relay, executed as objectstack-fleet[bot]) -> POST /repos/objectstack-ai/objectstack/issues/20156/comments (this os-dev-report). Plus git push of the branch (not REST): a733beb2e, 92e6b2fca, 8d314ac67. No PR body PATCH, no label write, no claim.",
      "open_questions": [
        {
          "question": "Seat or maintainer, on the card: what should the app row of the stored-version doors (/layers, ?layers=true, /diff) answer? These doors are unchanged in this PR and pinned as PENDING-DECISION #20156.",
          "options": [
            "A — refuse a caller the plain read would serve only part of an app (403 PERMISSION_DENIED). Measured by the review: this locks out platform admins who lack an entry permission.",
            "B — writer exemption in ADR-0106 D4's shape: callers that metaWriteCapabilityVerdict admits see the stored app whole; everyone else gets the plain read's answer (pruned or refused). This is a new permission boundary.",
            "C — serve pruned plus a write-side guard that refuses a PUT dropping entries withheld from the writer. This is a write-door change and a new refusal."
          ],
          "recommendation": "B, on a maintainer ruling. Round 0 recommended A; the review measured that A locks out platform admins, so that recommendation is withdrawn. Business need: the documented designer consumer (DesignerEditorWrapper via ?layers=true) must see the whole stored app to save it without loss, and members who are not authors have no need of the authoring views. Long-term: B carries ADR-0106's 'whoever may write sees all of it' invariant, already ruled for object schemas, to apps, so there is one rule for both. AI-safety: an AI author gets the whole stored app and cannot silently delete what it cannot see; a non-writer gets the plain read's answer. Startup scope: B reuses metaWriteCapabilityVerdict, adds no new gate, and touches one arm. A fails the admin lockout; C adds write-side machinery."
        }
      ],
      "out_of_scope_findings": [
        "Round 0's runtime-dispatcher finding is filed by the seat as #20193 — #20193 remains open; nothing new filed this round.",
        "carrier: 承接者:无 · noted, not filed — GET /meta/:type/:name/references serves the type/name/label of OTHER items that reference this one, applying none of those items' own gates. Not measured, outside this card's class, declared exempt in the census; PR Acceptance notes only.",
        "carrier: 承接者:无 · noted, not filed — the route-table census catches only routes spelled /meta/:type/:name/...; literal-typed doors (/meta/book/:name/tree, /meta/object/:name/state/:field) fall outside it. Both existing ones are outside the class, and /book/:name/tree has its own section 6.7 gate. The review's note is carried into the PR body's Acceptance notes."
      ]
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    REWORK (patch round 2): PR #20190 at 8d314ac6, the pending-decision exemption narrowed to the partial cells

    domain:cli execution PM seat #6024 · session session_01UYBdGBzWSrAMzpW8ah3GbP · review verdict, written 2026-09-27T08:53Z

    • CI on 8d314ac6: green. 38 success and 4 skips that are rostered expected skips. The earlier red citation step is resolved.
    • The at-tier independent delta review answered FAIL (record 5854378054 on the PR). The seat verified it at source and upholds it. The seat also rules the dev's deviation (a) in round 1: the seat did not mean the whole app row.

    What round 1 got right (the review judged each correct, with evidence):

    • the two moved citations now name their landing commits;
    • the refuse-on-partial arm is removed whole, not left dormant (filterAppForUserWithReason is byte-identical to base);
    • the dashboard changeset sentence is corrected;
    • every pull-back arm stands, and fault paths fail closed.

    What exceeds REWORK 5853865127:

    • That REWORK said the PR "drops only the NEW refuse-on-partial app arm", and "keeps every arm that pulls a door back to the plain read's answer". A caller the plain read refuses an app whole is the plain read's own answer, not a new boundary. Two cases:
      • an app-level requiredPermissions the caller lacks;
      • an unpublished app to a non-builder, which ADR-0045 §3 declares externally unobservable.
    • At 8d314ac6 the six non-reader cells (payroll and launchpad × /layers, ?layers=true, /diff) serve that app whole, including an unpublished app to a signed-in member. /history and /audit of the same app refuse.
    • The round-1 reasoning ("a platform admin would be locked out the same way") does not hold for the unpublished case. The seat measured it on origin/main: ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions holds both studio.access and setup.access, and the unpublished branch of filterAppForUserWithReason refuses only a caller with neither.
    • For an app-level permission, the plain read already refuses the admin. Pulling /layers back to that answer is the same pull-back the REWORK kept.
    • No option in the pending decision (A, B or C) serves a non-writer an app the plain read refuses whole. So nothing the maintainer is asked to rule on needs those cells open.

    Required changes (round 2):

    1. Narrow the pending exemption to the partial cells. Under app: 'pending-decision', still run the plain read's app gate. Where the plain read refuses the app whole, answer its refusal (403 PERMISSION_DENIED, or the absence answer). Otherwise serve the stored app unpruned, as pre-PR. /diff must then fetch the current document for app. In the census, the pending mark holds only on the pruned cells; the payroll and launchpad non-reader cells return to the plain read's envelope with no secret in the body, as at ace7e1df.
    2. Changeset:
      • qualify the closing sentence ("…the plain read's answer, except the app row of /layers, ?layers=true and /diff");
      • state exactly which callers those doors serve an app to while the row is pending: a caller the plain read serves only part of the app.
    3. content/docs/ui/apps.mdx (about :358-368): the same precision.

    The decision the card moves to after landing is unchanged in scope: the partial cells, options A / B / C. Patch round to the same dev, on the same claim and branch.


    Generated by Claude Code

  8. 18 remaining items

  9. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20284 → 2dccb7d494ffc9190b7df36b4d91e94e9610a28a

    domain:cli execution PM seat #6024 · session session_01UYBdGBzWSrAMzpW8ah3GbP · landing record, written 2026-09-27T19:21Z


    Generated by Claude Code

  10. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling B here, item 2, narrowed for two doors by ruling B on #20378 (batch #234 item 2, 5865708652) · 2026-09-28T07:52Z

    Director seat (objectstack#12708, summon #30 续 2, session_01AsCNgFBs8HCjwhyHQsFbx3). Record only; nothing else on this card moves.

    • /diff and /history become authoring doors: a caller mayReadPendingDrafts does not admit is refused 403 as /meta/_drafts refuses, before any read. The premise this ruling rested on — that a stored version can be pruned per audience like a plain read — does not hold for those two doors: sys_metadata_history cannot tell a draft save from an active save, so the pruned published-only answer does not exist without a schema change the maintainer did not take.
    • /layers and ?layers=true are unchanged: they read the active row only and keep item 2's pruned plain-read answer as ruled here.
  11. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling B (5856774816) narrowed for /diff and /history: per ruling 5865708652 on #20378 (letter B, the maintainer's 「同意」 in the director seat's batch #234, 2026-09-28), GET /meta/:type/:name/diff and GET /meta/:type/:name/history are authoring doors. A caller mayReadPendingDrafts does not admit is refused as GET /meta/_drafts refuses, because the history store cannot tell a draft save from an active save. /layers and ?layers=true keep this ruling's pruned plain-read answer. Posted by the domain:cli seat (#6024, session_01UYBdGBzWSrAMzpW8ah3GbP) as that ruling's execution parameter directs: 「a one-line note under ruling B on #20156 (comment there, not a body rewrite) records the narrowing for the two doors」.


    Generated by Claude Code

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:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething 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