Skip to content

fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it - #20190

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20156-alternate-door-read-gates
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20156-alternate-door-read-gates

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Part of #20156
Clause-②: no

What this changes

The plain single-item read GET /meta/:type/:name applies a per-caller read gate. The read doors beside it served the same stored document with none of those gates. Every door now asks one function, RestServer#metaItemReadGate. The plain read's per-type gates (the app nav filter, the ADR-0057 D10 dashboard widget gate, the ADR-0046 §6.7 docs audience) moved into it unchanged, and the plain read calls it too. A door therefore cannot serve the document past a gate the plain read applies, except the partial app cells declared pending below. The census reads its door list off the route table, so a door registered later under /meta/:type/:name/... without a declared disposition fails.

#20130 (the app row of this family) was closed as a duplicate of this card by triage. Its app measurements and its owed runtime-dispatcher reading are covered here.

Part of, not Fixes: the card stays open for the maintainer's ruling on the partial app cells of the stored-version doors. See "Pending a decision" below.

What each door answers now

door serves answers
/published one document, the representation the plain read serves exactly the plain read's answer, for every type: same refusal, or the same pruned / masked document (every gate arm, the dashboard widget gate included)
/layers, ?layers=true the stored layers, side by side doc / book: the plain read's refusal, judged on every layer. app: the plain read's refusal of an app refused whole (app-level requiredPermissions → 403; unpublished to a non-builder → 404); an app the caller may see only in part: unchanged, pending a decision. object: the ADR-0106 mask, as before. dashboard: stored, since per-deployment gates are not applied to an authoring view
/diff two stored versions compared as the layered view, judging the current document and both sides; object fields values masked; a doc / book / app with nothing behind the name answers the plain read's 404 RESOURCE_NOT_FOUND
/history, /audit events, never a body the plain read's refusal where it refuses the doc, book or app whole; otherwise the events
/references identities of OTHER items that point at this one declared exempt: no member of this item's document is served

Pending a decision: the partial app cells of /layers, ?layers=true and /diff

These cells are the callers the plain read would serve only PART of an app: they may open it, but requiredPermissions or the docs audience withholds some of its entries from them. Pulling them to the plain read's answer draws a new permission boundary whichever way it is done. So they answer exactly what they answered before this PR: the stored app, unpruned. That keeps the entry-name exposure #20130 described on these three doors, which triage graded p2, for those callers only.

An app the plain read refuses whole is not in the exemption. That covers an app-level requiredPermissions the caller lacks, and an unpublished app to a non-builder (ADR-0045 §3). Those callers get the plain read's own refusal on these doors too: 403 PERMISSION_DENIED or the absence answer. It is the same refusal the plain read, /published, /history and /audit give.

  • Pruning: content/docs/plugins/adding-a-metadata-type.mdx documents Studio's DesignerEditorWrapper loading ?layers=true and saving what it loaded. A pruned stored app saved back deletes the withheld entries.
  • Refusing: the contract review measured that this locks out a platform admin. A platform admin's systemPermissions is the fixed ADMIN_FULL_ACCESS_CAPABILITIES list, with no wildcard. filterAppForUserWithReason has no admin exemption. So the admin could not open the designer on any app with an entry permission they lack.

The census pins exactly these cells (3: the partially-served non-reader × the three doors) to the pre-PR answer, each marked PENDING-DECISION #20156. A separate scope test holds the exemption to the pruned plain-read answer on exactly those three literal doors, and to the exact cells that yields. Measured: widening the census predicate to a whole-refusal cell turns that test red. The options and measurements are on the card.

A fifth gated type the doors skipped: object

The plain read also applies the ADR-0106 object-schema mask. /layers already applied it; /published and /diff did not. ADR-0106 D5(4) prescribes routing such an outlet through the same projection, so both now mask. /published masks the document. /diff masks the fields values in every bucket, on both sides, following the redaction precedent one frame down: bodies are compared raw and the emitted values are projected.

The dashboard widget gate is per deployment, not per caller

It asks which optional services this deployment registered and answers every caller alike; the plain read's own cache-exclusion comment says so. /published applies it, because it answers what the plain read answers. The stored-version doors do not: the designer would otherwise load, and save back, a dashboard minus a widget whose service is merely off here.

Red before, green after

Census: packages/rest/src/meta-alternate-door-read-gates.test.ts, 196 cases. It covers gated type (doc ×2, book, app ×3, dashboard, object, plus a view control) × door × caller (reader, non-reader, anonymous). Each cell asserts one of three things for that caller: the plain read's status and code, the declared per-door answer, or the pending-decision cell's pre-PR answer. The plain-read rows are asserted against the table with a cached protocol method present, so a gated type missing from the cache exclusion also reddens.

  • Red leg: the census driven against origin/main's rest-server.ts through untracked temporary copies, deleted after. 35 red: 32 kept cells plus 3 edge pins. The kept cells are:
    • doc and book non-reader on all six doors;
    • anonymous ?layers=true on both docs and the book (200 with the body, including the org doc the plain read answers 401);
    • app payroll and launchpad non-reader on all six doors;
    • app crm on /published;
    • dashboard and object on /published, and object on /diff.
      The 3 pending-decision cells pass on the pre-PR code, as they must, since they pin its answer.
  • Green (at 8116184fe): 196/196.
  • Ablations (fix committed first; mutations via scripts/ablation-replace.mjs, restore proven by blob == HEAD and git diff HEAD empty each time; subjects imported relatively from source, no dist/):
    • the narrowed arm: the stored-version doors skipped the whole-app gate (round 1's behaviour). Anchor 1 → 0, blob 6783daa1 → 970dc972. 6 red: exactly the payroll and launchpad non-reader cells on /layers, ?layers=true and /diff. rest-server.ts at 8116184fe is blob 6783daa1.
    • the census exemption: its predicate was widened to also match refused answers. Test blob b1aaa717 → d21265ab. The scope test that enumerates the exempt cells went red.

Verification (at 8116184fe, merged with origin/main at 1c8b320a8)

  • pnpm --filter @objectstack/rest exec vitest run --project local: 198 files, 3535 passed, 1 skipped, 0 failed.
  • pnpm --filter @objectstack/rest typecheck (tsc plus check:test-typecheck): exit 0.
  • pnpm lint (repo-wide): exit 0.
  • node scripts/check-issue-citations.mjs --base origin/main (the diff-scoped board check, run locally with the container's token): 32 citations judged, 31 resolve, 1 cross-repo, 0 findings. The two unresolvable citations the first review found now name their landing commits (83a3b1f2e, 26f3588fb, each verified an ancestor of origin/main) and say the original card no longer resolves.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 90 families from merge base 1c8b320a8, at 8116184fe. Run and reconciled with --ran: 88 exit 0, 2 NOT MEASURED (check:dual-build-cjs-loads, check:type-check-debt), both PREREQUISITE NOT MET on full-farm builds that CI performs.

Changeset

.changeset/20156-alternate-door-read-gates.md, patch for @objectstack/rest: a security fix in a released package with no authorable surface removed. It states:

  • who could read what before, and what each door answers now;
  • that dashboard's /published now applies the ADR-0057 D10 widget gate;
  • which callers the three stored-version doors still serve a stored app to while the decision is pending: only a caller the plain read serves part of the app, never one it refuses whole;
  • that the closing claim holds except for those partial app cells.

Acceptance notes


Generated by Claude Code

…nst the plain read's per-caller answer

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
…ead and every door beside it

The layered view (/layers and ?layers=true), /published, /history, /audit and
/diff served the document the plain read gates with none of its gates: a
member the plain read refuses a permission-set-gated doc read its body from
three doors, an anonymous caller read any doc or book through ?layers=true,
and an app's requiredPermissions / docs-audience entries reached every member.

metaItemReadGate is now the one spelling of the plain read's per-type gates
(app nav filter, ADR-0057 D10 dashboard widget gate, ADR-0046 section 6.7 docs
audience), asked by the plain read and every door. /published answers what the
plain read answers; the stored-version doors run the per-caller arms and refuse
a partly-withheld app rather than hand the designer a pruned version to save
back; /history and /audit refuse where the plain read refuses the item whole.
The ADR-0106 object mask now also covers /published and /diff.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
…e moved into metaItemReadGate

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
… on the alternate read doors

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
…s per-caller gate

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 32 documentable anchor(s).

35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f61bb877bd9b1d3d0624771d62b06903874093b4 — the merge of head 8116184fe60c57d304ef275f925da03c78ea9282 into base 1c8b320a894b31dcfc76c33c8e7a62b8986d893a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f61bb877bd9b1d3d0624771d62b06903874093b4 && git checkout f61bb877bd9b1d3d0624771d62b06903874093b4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c8b320a894b31dcfc76c33c8e7a62b8986d893a 8116184fe60c57d304ef275f925da03c78ea9282 && git checkout -B drift-repro 1c8b320a894b31dcfc76c33c8e7a62b8986d893a && git merge --no-ff 8116184fe60c57d304ef275f925da03c78ea9282

node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ace7e1df2e4ed8472984c36f324da6e9595ea562

① Derived judgments

  • Route table enumerated at the head (packages/rest/src/rest-server.ts, every path: \${metaPath}/:type/:name…`): GET /references(7160), GET/layers(7323), GET plain read with the?layers=branch (7452), PUT/DELETE/:type/:name(8009/8265, writes), GET/history(8430), GET/audit(8586), POST/publish(8722), POST/rollback(8923), GET/diff(9041), GET/published(9359). Two literal-typed siblings sit beside them: GET/book/:name/tree(7374) and GET/object/:name/state/:field(9225). Every content-serving/:type/:name/*GET now either callsmetaItemReadGate (/layers, ?layers=trueviaserveMetaItemLayered; /publishedviaservePublished; /diffinline;/historyand/auditviaeventDoorRefusal) or carries a stated exemption (/references). The /referencesexemption is **correct**:findReferencesToMeta (packages/metadata-protocol/src/protocol.ts:22457) answers { references: [{ type, name, label }] }of other items, no member of this item's document./book/:name/treeis outside the/:type/:namespelling but already carries its own §6.7 gate through the sameresolveDocsAudience (admitsBook+readableTree`), so no door is missed. Correct.
  • One gate, one resolver. metaItemReadGate (head 3834-4162) is the single function; the plain read (7869-7877), serveMetaItemLayered (4776), /published (9384), /diff (9109) and eventDoorRefusal (4004) all call it; no per-door copy exists. The doc/book arms call the existing resolveDocsAudience / fetchAudienceBooks / readDocCorpus; the app arm calls the existing filterAppForUserWithReason + resolveNavDocAudience. No second audience resolver, consistent with ruling 5793362670 item 1. Correct.
  • Per-door × per-type answer. /published runs arms: 'all', partial: 'prune' and answers byte-for-byte the plain read's arms (doc/book refusal, app 403/404/pruned, dashboard widget gate, object mask via maskObjectDocument). /layers, ?layers=true, /diff run arms: 'per-caller', partial: 'refuse': doc/book refuse where the plain read refuses; app refuses 403 where the plain read refuses, and 403 where it would prune; object masked as the plain read (applyObjectSchemaMask projects only fields, packages/metadata-core/src/object-schema-fls.ts, so the /diff fields-only projection is exactly the plain read's). diffMetaItem is diffShallow, top-level (protocol.ts:3945, comment at 21385), so diffSides and the path === 'fields' match are sound. No door serves more than the plain read to any caller. Correct.
  • Dropped per-deployment arms verified at source. filterDashboardForUser reads only a serviceGate built by resolveRegisteredServices (kernel getServiceAsync probe); requiresService on app entries is the same probe; ADR-0057 D10 PS-2 note: "nav lives and dies with the capability", registered per deployment. Neither reads the caller. The servability arm derives from object enable (deployment). Calling them per-deployment is correct; dropping them on stored-version doors withholds nothing from any caller.
  • Anonymous callers. The /meta umbrella gate (registerMetadataEndpoints, 6074-6122, registerPerItemRoute reads this.routeManager at call time so it is inside) 401s every meta route for a caller with no userId (shouldDenyAnonymous, packages/core/src/security/anonymous-deny.ts) except isPublicAudienceRead: GET /meta/:type and /meta/:type/:name for book/doc and /book/:name/tree. So ?layers=true was the one anonymously reachable alternate door, and it ran before any audience gate; now the doc/book arm's docsAudienceRefusal answers 401. A public doc stays readable via ?layers=true (docReader → docAudienceAllows public). Correct.
  • Census fails without the fix, and reads the live route table. Ran in a throwaway worktree: head → 194/194 green; with main's rest-server.ts swapped in → 38 red (35 census cells matching the PR's red list plus the 3 edge pins). Registering an undeclared GET /:type/:name/mutant reddens exactly the route-table row (rest.getRoutes() filtered on itemPath prefix). Real, not hard-coded; scoped to the /:type/:name spelling only (a literal-typed door such as /meta/app/:name/foo would escape it). Full rest suite at head: 197 files, 3518 passed, 1 skipped, exit 0, matching the PR's numbers.
  • content/docs/ui/apps.mdx paragraph (358-367): every sentence matches the code (/published serves the filtered body; /layers and /diff refuse 403 on a partly-withheld app; requiresService entries served as stored). True. No other doc page states anything false; adding-a-metadata-type.mdx:180 still truthfully says the wrapper loads ?layers=true.
  • Changeset. Every "before" sentence is true at main (/published did res.json(layered.overlay) / res.json(data) ungated; /diff did res.json(result); /history, /audit had no gate). One sentence is false: "Types no per-caller gate judges (view, flow, and others) are untouched on every door" — dashboard is such a type and its /published door changed (the ADR-0057 D10 widget gate is newly applied there; the PR's own red list names the dashboard × /published cell and the census asserts widgetIds(served) === ['w_open_cases']).
  • Runtime dispatcher untouched and ungated: packages/runtime/src/domains/meta.ts at head carries no audience, filterApp, requiredPermissions or permissionSet symbol, only the ADR-0106 object mask; git diff on it is empty. Confirms open question 2's measurement.

② Semver level

Clause-②: no with @objectstack/rest patch is correct; no (narrowing) arm is owed. The arm (scripts/pm/clause2-line.mjs) exists for a diff that narrows a published accept set (the #16296 value-domain case). This diff removes no spec key, export, config field or accepted value; it changes which caller a read answers, which .claude/skills/pm-dispatch/references/execution-duties.md line 98 places outside clause ② as the runtime permission/security class, and line 99 says pulling behaviour back to the declared contract (ADR-0046 §6.7 "gating enforced at the read layer"; apps.mdx "the entry is never served") does not touch it. Precedent: ruling 5793362670 on #19790 recorded Clause-②: no for "a permission-boundary tightening, no spec key". A bug fix in a released package takes patch (AGENTS.md Post-Task Checklist 3). The refuse-on-partial arm is a new boundary rather than a pull-back, but that is a governance question for ③, not an accept-set narrowing.

③ Boundary flags

  • Open question 1 (refuse instead of prune on stored-version app doors). Within the card's closure rule by its letter ("answers what the plain read answers, or refuses"). It does create a new permission boundary: no prior contract said the layered view or diff requires every entry permission; the PR writes that sentence into apps.mdx itself, and triage (5852543510, carried from 5830552645) reserved a refusal boundary to the maintainer, with the claim stating such a route "stops the dev at needs_decision"; the dev shipped it and posed the question instead. It does lock out legitimate Studio editors: filterAppForUserWithReason has no admin exemption (sysPerms.has(p) exact), and a platform admin's systemPermissions is the fixed seven-item ADMIN_FULL_ACCESS_CAPABILITIES list (packages/spec/src/identity/eval-user.zod.ts: manage_users, manage_metadata, manage_platform_settings, manage_sharing, setup.access, setup.write, studio.access, no wildcard). Probed in the worktree with exactly that list against an app carrying requiredPermissions: ['finance.access']: plain read 200 pruned; ?layers=true and /layers 403 PERMISSION_DENIED. A platform admin therefore cannot open Studio's DesignerEditorWrapper (documented to load ?layers=true) or /diff for any app with an app-authored entry permission or a doc entry gated to a set they lack. The cost is stated honestly in the PR body and changeset ("hold every permission its entries require"), but neither says platform admins are included. The PR does not create a new error code.
  • Open question 2 (runtime dispatcher /meta ungated). Confirmed at source: handleMetadataRequest applies only the ADR-0106 mask on its two-segment item branch and /published branch; no per-caller gate on the plain read either. Leaving it to a separate card is correct: it is a transport with no gate, not a door skipping one, and a port into runtime would be the second resolver ruling 5793362670 item 1 forbids; the seat owes the card.
  • CI on the head is RED. Check-runs at ace7e1df: 32 success, 2 skipped, 1 failure: Lint & Repo Gates, step 189 "Issue citations this change adds resolve on the board", exit 2 (findings). Replicated: of the 12 distinct #N the diff adds, #6241 (added at rest-server.ts "[GET /meta/books/:name(复数拼写)绕过 ADR-0046 §6.7 audience 门禁 —— 缓存分支的 doc/book 排除写的是字面量比较 #6241] Judged on the NORMALIZED metaType") and #10340 (added in fetchCurrentMetaDocument "[/meta org scope is decided from the RAW url spelling: translations / email_templates read and write env-wide where their singular twin is org-scoped #10340] FOLDED, not raw") return 404 from the issues API; both are already cited on main (8 and 13 times) but the gate is diff-scoped by design. Tier S landing requires every check green.
  • Census net is spelling-scoped. The route-table row catches only /:type/:name… registrations; literal-typed doors (/book/:name/tree, /object/:name/state/:field, or a future /meta/app/:name/x) fall outside it. Not a defect today (both existing ones are outside the class), but the "a door added later fails" claim should be read with that limit.
  • Minor, disclosed and acceptable: /diff of a doc/book/app with no current document now answers 404 (the plain read's answer); a gate-input store fault on /layers//diff now answers the fault (503) rather than the body; a book whose historical version carried a stricter audience now refuses the non-holder's diff of it. /references remains ungated by design and is outside this card.

Implemented-by: claude/issue-20156-alternate-door-read-gates
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the cards, the rulings and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: FAIL

  • Make Lint & Repo Gates green: rewrite the two added citations #6241 and #10340 per the gate's remedy (name a target that resolves, or keep the number and say in prose that it no longer resolves and what the live record is).
  • The refuse-on-partial boundary on /layers, ?layers=true and /diff for apps needs the maintainer's ruling on open question 1 (A / B / C) recorded on the card before this PR lands; a seat's contract review cannot supply that word, and the PR must not be readied, queued or auto-merged until it exists. Whichever option is ruled, the changeset and apps.mdx must state that platform admins are not exempt (or that they are, if B is ruled).
  • Correct the changeset sentence "Types no per-caller gate judges (view, flow, and others) are untouched on every door": dashboard's /published door now applies the ADR-0057 D10 widget gate, and the changeset must say so.

Generated by Claude Code

…n; cite landing commits for two cards that no longer resolve

The refuse-on-partial app arm on /layers, ?layers=true and /diff drew a new
permission boundary: it locked any author lacking one entry's permission,
platform admins included, out of the designer's layered view. It is removed
whole: those three doors serve the stored app exactly as before the gate, and
the census pins those cells as a declared pending-decision exemption anchored
to the card. Every other arm stands.

The two citations the shared gate moved (the plural-spelling and folded-type
cards) no longer resolve on the board; they now name the commits that landed
them.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8d314ac6760b1240cee680c874b2830010cf7aa1

Delta of: 5853855259 (FAIL on ace7e1df)

① Derived judgments

  • (a) Citations resolve. The two unresolvable citations are gone from the diff: git diff origin/main...8d314ac6 adds no #6241 or #10340; the two comment lines now read [plural-spelling commit 83a3b1f2e] (the original card no longer resolves) (rest-server.ts plain read, before the metaItemReadGate call) and [folded-type commit 26f3588fb] (the original card no longer resolves) (fetchCurrentMetaDocument). Both commits are ancestors of origin/main (83a3b1f2e = PR fix(rest): :type 段每 handler 归一一次,复数拼写不再绕过 §6.7 audience 门禁 (#6241) #6348 closing GET /meta/books/:name(复数拼写)绕过 ADR-0046 §6.7 audience 门禁 —— 缓存分支的 doc/book 排除写的是字面量比较 #6241; 26f3588fb = fix(rest): decide /meta org scope on the folded type, not the raw URL spelling #10519). The ten distinct #N the PR still adds are #18066 #19790 #20129 #20156 #4252 #4722 #5563 #5881 #7912 #8013. Check-run Lint & Repo Gates on the head: completed / success. Met.
  • (b) Refuse-on-partial arm removed, not left dormant. No entriesWithheld, onFault or partial: symbol remains in the head rest-server.ts. filterAppForUserWithReason (187 lines) and resolveNavDocAudience (49 lines) are byte-identical to merge base 560b724c9 (function-body diff: 0 lines). MetaReadGatePolicy.app: 'gate' | 'pending-decision'; STORED_VERSION_DOOR_POLICY = Object.freeze({ arms: 'per-caller', app: 'pending-decision' }) is referenced exactly three times: serveMetaItemLayered (4761), /diff gatesPerCaller (9078) and /diff judge (9137). Under it, metaItemReadGate returns async (document) => serve(document) on the metaType === 'app' branch before any input is resolved (3906), and serveMetaItemLayered ignores verdict.document on serve, so res.json(layered) carries the protocol's object unchanged; /diff's gatesPerCaller('app', STORED…) is false, so no current-document fetch, and resolveObjectMasker for a non-object type returns the fixed OBJECT_SCHEMA_MASK_NOT_APPLICABLE passthrough without touching context, so served === result. Pre-PR /diff was res.json(result); pre-PR serveMetaItemLayered was fetch, object mask, res.json(layered); no hunk changes the plain read before its ?layers= branch (7528-7542) or the /layers route body. The app row of the three doors is exactly pre-PR, not wider. Correct.
  • (b) The whole-row reading exceeds the REWORK and the card's direction. Wrong. REWORK 5853865127: "It drops only the NEW refuse-on-partial app arm… Those three app cells return to their pre-PR answer, the [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 entry-name exposure" and "keeps every arm that pulls a door back to the plain read's answer". Triage 5852543510: pull every door back to the plain read's per-caller answer; only refusing to non-authors instead is a new boundary. The whole-refusal is the plain read's own answer, with a prior contract: apps.mdx:398-399 ("the app-level requiredPermissions, which is checked server-side") and ADR-0045 §3 ("externally unobservable… REST calls against an unpublished app return 404 by default"). Measured at filterAppForUserWithReason (head 3612-3645): unpublished refuses only when !sysPerms.has('studio.access') && !sysPerms.has('setup.access'); ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions (packages/spec/src/identity/eval-user.zod.ts) holds both, so a platform admin is never refused an unpublished app on the plain read — the admin-lockout reasoning is false for the launchpad cells. For app-level requiredPermissions (reqApp.every(p => sysPerms.has(p)), no admin exemption, no wildcard) a platform admin lacking payroll.access gets 403 PERMISSION_DENIED on the plain read: the lockout exists already on the plain read, so applying it on /layers is a pull-back, not a new boundary. Consequence at the head: six non-reader cells (payroll and launchpad × /layers, ?layers=true, /diff) that ace7e1df refused with the plain read's answer now serve the whole app, including an unpublished app to a non-builder, while the same PR's /history//audit refuse them (eventDoorRefusal, app: 'gate') citing ADR-0045 §3. Decision-space check: metaWriteCapabilityVerdict (packages/metadata-core/src/meta-write-capability.ts:124) admits manage_metadata or the org-presentation capability; a caller holding neither is refused under options A, B and C alike, so no pending ruling touches the non-builder unpublished cells. The dev disclosed the deviation (report 5854275651, deviation (a): "If the seat meant only the partial cells, the flag is the one place to change").
  • (c) Changeset dashboard sentence corrected. Head changeset: "/published… includes the dashboard widget gate" and "dashboard is unchanged on every door except /published". Code: /published passes { arms: 'all', app: 'gate' }; /layers, /diff pass arms: 'per-caller' so the dashboard arm serves unchanged; gatesPerCaller('dashboard') is false so /history, /audit, /diff fetch nothing. Met.
  • Every arm the REWORK said stays, stays. Call sites: plain read (7880) and /published servePublished (9474) hard-code { arms: 'all', app: 'gate' }; eventDoorRefusal (4067) hard-codes { arms: 'per-caller', app: 'gate' }. The doc/book arms have no policy switch. Object mask on /published (maskObjectDocument in servePublished) and /diff (fields buckets, both sides) unchanged from ace7e1df. No path reaches app: 'pending-decision' on /published, /history or /audit. Correct.
  • Pending exemption test scope. PENDING_DECISION is frozen { anchor: '#20156', type: 'app', doors: ['?layers=true','/layers','/diff'] }; isPendingDecision is exact-equality on type; in code the flag is consulted only inside if (metaType === 'app'). A new type cannot slip in. A new door needs a DOORS entry (route-table test) and, if declared kind: 'stored', an edit to the frozen constant, because the scope test asserts PENDING_DECISION.doors equals every stored-kind door rather than the literal three. Loud, but the failure nudges toward widening the exemption instead of gating the door. Holds; minor.
  • Fault paths fail closed. Doc/book: fetchAudienceBooks and readDocCorpus throw into each route's handleRouteError; the rewritten edge pin asserts the plain read's 503 envelope on all six doors with no body. App on /published, /history, /audit: resolveExecCtx fault → rethrowAuthzStoreUnavailable; docs-audience fault → prune, the plain read's own answer. fetchCurrentMetaDocument propagates getMetaItem faults. On the pending row no gate input is read, so no new fault path and no new serve path. Correct.
  • Changeset, remaining claims. Every "before" sentence is true at base (/published res.json(layered.overlay)/res.json(data), /diff res.json(result), no gate on /history//audit). Two defects: (1) the closing sentence "A client reading these doors as a caller the plain read restricts now receives the plain read's answer" is false for the 18 pending cells (a signed-in non-holder gets the stored app on three doors), contradicting the bullet above it; (2) "They still serve the stored app, including navigation entries the plain read withholds" and apps.mdx:358-368 "every entry included… an entry gated by requiredPermissions is absent from the app's served body but not from these authoring views" describe only the partial case; neither says these doors also serve an app the plain read refuses whole (app-level requiredPermissions, unpublished to a non-builder). The "refusing would lock out any author who lacks one entry's permission, platform admins included" clause describes refuse-on-partial only.
  • content/docs/plugins/adding-a-metadata-type.mdx:180 still truthfully says the wrapper loads ?layers=true. No doc page states the R0 refuse-on-partial behaviour (grep for the old wording: no hits). Correct.
  • Runtime dispatcher untouched (5-file surface only); [finding] the runtime dispatcher's /meta item reads apply NO per-caller read gate: through a catch-all host, GET /meta/doc/:name serves a permission-set-gated doc body to a non-holder, and /meta/app/:name serves requiredPermissions-gated entries #20193 named in the PR body. Correct.

② Semver level

@objectstack/rest patch, Clause-②: no — unchanged by the delta. The round removes the one arm that was a new boundary; what remains pulls doors back to gates the contract already declares (ADR-0046 §6.7, ADR-0106, apps.mdx). No spec key, export, config field or accepted value is removed, so no (narrowing) arm is owed (execution-duties negative boundary; precedent ruling 5793362670).

③ Boundary flags

  • CI on 8d314ac6: GREEN. 42 check-runs: 38 success, 4 skipped, 0 failure, 0 in progress at last read; Lint & Repo Gates, all Test Core shards, all type-check jobs success. PR is still draft, Part of #20156, "Part-of PR must not also close its card" success.
  • The exemption is wider than the decision space. Options A, B and C all refuse a non-writer an app the plain read refuses whole; only B changes a writer lacking an app-level permission. The head nevertheless serves an unpublished app whole to any signed-in member on three doors, against ADR-0045 §3, with no ruling pending on that cell, while /history//audit of the same app answer 404. The seat may rule the whole row pending, but that word is not on the card and the REWORK said the opposite.
  • Exemption door set is derived from DOORS kinds, not pinned literally (see ①). Not a defect today.
  • Census net is spelling-scoped (/meta/:type/:name/... only), carried from R0 and now stated in the PR body's Acceptance notes.
  • PR body vs head: the table row "app: unchanged, pending a decision" and the 18-cell count match the code and the census; "Red before, green after" numbers (195 cases, 29 red on main, 8 red on ablation) are the dev's, not re-run here; CI green is the independent evidence.

Implemented-by: claude/issue-20156-alternate-door-read-gates
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: FAIL

  • Narrow the pending exemption to the cells the REWORK named, the partial ones. Under app: 'pending-decision' in metaItemReadGate, still run filterAppForUserWithReason and, when gated.app == null, refuse with the plain read's 403 PERMISSION_DENIED / absence answer; otherwise serve(document) unpruned. gatesPerCaller must then answer true for app under the stored-version policy so /diff fetches the current document. In the census, pending holds only where want.kind === 'pruned'; the payroll and launchpad non-reader cells on /layers, ?layers=true, /diff return to envelope(res) === envelope(plain) with no secret in the body, as at ace7e1df. If the seat instead rules the whole row pending, that ruling and its unpublished-app cost must be recorded on the card before this lands; a dev's deviation note is not that record.
  • Changeset: qualify the closing sentence ("…receives the plain read's answer, except the app row of /layers, ?layers=true and /diff"); and, for whichever row remains pending, state exactly which callers those doors serve an app to (partial only, or also an app the plain read refuses whole, unpublished included).
  • content/docs/ui/apps.mdx:358-368: the same precision as the changeset; as written it names only the entry-level case.

…rs to partial callers

The layered view and /diff again refuse an app the plain read refuses whole
(an app-level requiredPermissions the caller lacks; an unpublished app to a
non-builder), with the plain read's own answer, and /diff judges the current
document for app again. Only a caller the plain read would serve part of an
app keeps the stored app unpruned, pending the maintainer's decision; the
census exemption is narrowed to exactly those cells and its scope test fails
if it widens to a whole-refusal cell or a fourth door.

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8116184fe60c57d304ef275f925da03c78ea9282

Delta of: 5854378054 (FAIL on 8d314ac6)

① Derived judgments

  • (1) Whole-refusal now applied on /layers, ?layers=true, /diff. Delta 8d314ac6..8116184f on packages/rest/src/rest-server.ts (blob 6783daa1, 86 lines) removes the early return async (document) => serve(document) from the app branch of metaItemReadGate. Under both policies the arm now resolves sysPerms (3918-3945), runs filterAppForUserWithReason (3949, byte-unchanged: no hunk) and, on gated.app == null, refuses through the same two emitters the plain read uses: sendEnvelopeError(403, 'PERMISSION_DENIED') for withheld === 'permission' (3963-3968), sendMetaItemAbsent (1695: 404 RESOURCE_NOT_FOUND) otherwise (3973). Serve line 3980: serve(policy.app === 'pending-decision' ? document : gated.app). The refusal is policy-independent code, so the envelope is the plain read's by construction. Correct.
  • (1) Only partial callers get the stored app unpruned. A whole caller's document equals gated.app in content; serveMetaItemLayered (4773-4782) and /diff (9149-9158) discard verdict.document and serve layered / result, so a whole caller's answer is pre-PR and equals the plain read's. Census isPendingDecision (test blob b1aaa717, 327-329) requires want.kind === 'pruned'; the enumeration test asserts exactly ['/diff app/crm × non-reader','/layers app/crm × non-reader','?layers=true app/crm × non-reader'] and wholeRefusals empty. Correct.
  • (1) /diff fetches and judges the current document for app. gatesPerCaller (4125-4127) is now book || doc || app, no policy argument; /diff calls it at 9092, fetches diffCurrent (9093-9095), answers sendMetaItemAbsent when absent (9096-9098), and judges [diffCurrent, from, to] under STORED_VERSION_DOOR_POLICY (9149-9158). Correct.
  • (1) Edges. Absent current: /diff → 404 (plain read's answer); /layers with every layer null builds an empty present list and serves the null layers, pre-PR, no body; /history//audit serve events (unchanged). Published-in-one-version: _unpublished is a stored key (packages/runtime/src/domains/packages.ts:1376 writes { ...app, _unpublished: false } via PUT /meta/app), so it lands in a diff bucket; diffSides (4100-4114) places it on the correct side; each side is judged; a non-builder is refused 404 if either side is unpublished. Anonymous: the /meta umbrella wrapper (registerMetadataEndpoints 6079-6130) runs enforceAuth unless isPublicAudienceRead (2922-2936), which matches only paths ending /:type or /:type/:name with type book/doc; every app request and every /layers, /diff, /history, /audit, /published path is 401 before the handler, so the arm's !ctx?.userId → serve is unreachable for anonymous. Platform admin: ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions (packages/spec/src/identity/eval-user.zod.ts:106) holds studio.access and setup.access, so never refused an unpublished app; lacking an app-level requiredPermissions entry it is 403 on the plain read (3646-3649, no admin exemption) and now on the three doors: a pull-back, the REWORK's own case. Whole caller: served as before. All correct.
  • (1) Docs-audience entry arm not resolved under pending-decision (3941-3943). Sound: filterAppForUserWithReason nulls the app only from its three top-level branches (unpublished 3643, permission 3646, service 3652); every entry-level arm is a bare continue and "an app this arm empties is still served" (3688-3691). It cannot change a whole-refusal answer, its output is discarded under pending-decision, and resolveNavDocAudience fails closed by returning a constant-false gate rather than throwing, so no fault path is added or removed. Correct.
  • (1) Fault paths. resolveExecCtx fault → rethrowAuthzStoreUnavailable (3919) → each route's handleRouteError, as on the plain read (7662). /diff now also calls fetchCurrentMetaDocument (4044) for app; getMetaItem faults propagate; no .catch swallow added. Fail closed, as the plain read.
  • (2) Changeset .changeset/20156-alternate-door-read-gates.md ("@objectstack/rest": patch). States the doors "refuse a doc, book or app the plain read refuses whole … For an app, that means one whose requiredPermissions the caller lacks (403) or an unpublished app to a non-builder (404)"; the pending case is "an app the caller may open, but with navigation entries withheld … by requiredPermissions or the documentation audience … No other caller does: an app the plain read refuses whole is refused on these doors too"; the closing sentence now ends "… except the app row of /layers, ?layers=true and /diff for a caller the plain read serves only part of the app". Each sentence matches 3943-3980 and the umbrella. /diff "before" list now names an app's navigation (true at base, res.json(result)). Met; true at head.
  • (3) content/docs/ui/apps.mdx:358-370. "every door refuses an app the caller may not open: one whose app-level requiredPermissions they lack (403), or an unpublished app to a non-builder (404)"; the stored-version doors "do not yet prune the entries of an app the caller may open: a caller from whom requiredPermissions or the docs audience withholds some entries still receives the stored app there". Same precision as the changeset; true against head. No other content/docs page carries the old row-wide wording (grep at head: only these lines). Met.
  • ② Nothing else moved. The 86-line delta touches only: the MetaReadGatePolicy docblock, the metaItemReadGate docblock and its app branch, eventDoorRefusal's call (4082, same truth value for its app: 'gate' policy), gatesPerCaller, and comments in serveMetaItemLayered, the plain read and /diff. No hunk in the doc/book/dashboard/object arms, servePublished (9480-9491, still { arms: 'all', app: 'gate' }), the plain-read call (7895-7898, same policy), the /history//audit bodies, the object mask, or filterAppForUserWithReason. execctx-consumer-census.test.ts is blob 0c77ccab at both heads. Merge 63ba41eb5 (parents 91b49f1d5, 1c8b320a8): diff against its main side is exactly the five PR files; main's own changes to those five files: none. Unchanged, as the prior review judged them.
  • ② Census exemption held to 3 cells. PENDING_DECISION is frozen { anchor, type: 'app', doors: ['?layers=true','/layers','/diff'], answer: 'pruned' }; the scope test pins the doors literally (no longer derived from DOORS kinds) and checks each is kind: 'stored'; the enumeration test lists the three cells. Widening the predicate to refused adds the payroll/launchpad non-reader and anonymous cells and fails both toEqual and wholeRefusals; widening the code (serving under pending-decision without judging) fails envelope(res) === envelope(plain) on the six payroll/launchpad non-reader cells; a pruned cell that escapes the exemption hits expect(want.kind).not.toBe('pruned') in the stored branch. The removed getMetaItem.not.toHaveBeenCalled() on the pending /diff cell is required by the fix (the door now fetches for app); that the gate runs on /diff is proven by the payroll/launchpad cells, which cannot 403/404 without the current document. Case count: 27 plain-read rows + 162 door cells + 7 = 196, matching the PR body. Correct.

② Semver level

@objectstack/rest patch, Clause-②: no — unchanged by the delta. The round only extends refusals the contract already declares for the plain read (apps.mdx app-level requiredPermissions checked server-side; ADR-0045 §3) to three more doors; no spec key, export, config field or accepted value is removed; a security narrowing in a released package.

③ Boundary flags

  • CI on 8116184f: GREEN. 42 check-runs: 38 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke opt-in), 0 failure, 0 in progress. Lint & Repo Gates, all six Test Core shards, all Type Check jobs, Part-of PR must not also close its card: success. Combined commit status: success (one context, Vercel ignored build). PR API reports mergeable_state: unstable with draft: true; no failing or pending run exists. PR is draft, Part of #20156, 0 reviews.
  • App-level requiresService whole-refusal is not applied on the four non-document doors. The plain read refuses such an app 404 (withheld: 'service', 3652-3654) only because serviceGate is resolved under arms: 'all' (3927-3934); /layers, ?layers=true, /diff, /history, /audit run per-caller and skip it. It is a per-deployment gate (ADR-0057 D10), the changeset's "any other per-deployment gate" sentence excludes it, and /history//audit behaved so at 8d314ac6. Outside the REWORK's two named cases and this card's per-caller class; not a defect.
  • apps.mdx:359 "every door refuses" does not name /references, which is declared kind: 'exempt' in the census, sits behind no gate and answers 200 with other items' identities. The page never lists /references and it serves no member of the app; a wording note, not a false claim.
  • Layer-wise judgment on /layers: a caller whose code layer carries an app-level permission they lack (or _unpublished) while effective does not is refused where the plain read serves effective. A refusal, within "the plain read's answer, or refuses", and the same rule as the doc/book edge pin. Not a leak.
  • Anonymous on /layers, /diff, /history, /audit, /published of a public doc is 401 at the umbrella (isPublicAudienceRead matches only /:type and /:type/:name). Stricter than the plain read; pre-existing; outside this delta.
  • PR body vs head: blob 6783daa1 for rest-server.ts, the 3-cell exemption, the six newly-refused cells, the /diff absence answer and the historical-to caveat all match the code. Red-leg and ablation counts are the dev's, not re-run; CI green and the code reading above are the independent evidence. Runtime dispatcher untouched (5-file surface); [finding] the runtime dispatcher's /meta item reads apply NO per-caller read gate: through a catch-all host, GET /meta/doc/:name serves a permission-set-gated doc body to a non-holder, and /meta/app/:name serves requiredPermissions-gated entries #20193 named.

Implemented-by: claude/issue-20156-alternate-door-read-gates
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 10:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 585c9af Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20156-alternate-door-read-gates branch September 27, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants