Repository navigation
finding: /meta/_drafts serves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered #6599
Description
Activity
Findings-round routing repair:
domain:cliappended —findinggrade untouched, no ownership taken.- Landing: both uncovered outlets are HTTP dispatch surfaces —
GET /api/v1/meta/_draftsinpackages/rest/src/rest-server.tsandGET /metadata/_draftsinpackages/runtime/src/domains/meta.ts⇒domain:cli(the/metaHTTP route face is rest/runtime territory per the metadata-split red line; the masking mechanism itself stays where ADR-0106 put it). - Grading note for the findings round: the (a) authoring-gate vs (b) masked-read fork is a product/authz posture question — this card may deserve escalation to the decision box rather than direct promotion, the same way its sibling finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603 (also from 实现 ADR-0106:元数据面 FLS——object schema 按调用者掩码(#3661 ③ 落地) #3682's D5 audit) already sits there. Recording the pointer; not escalating in a routing-only pass.
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- Landing: both uncovered outlets are HTTP dispatch surfaces —
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actions认领:dev agent session
d2cb1100-cddb-5b45-9352-e7100364421e,分支claude/issue-6599-drafts-authoring-gate。先做消费方普查(谁在调
/meta/_drafts与/metadata/_drafts)再决定 (a) 授权门禁 / (b) 字段投影;若发现合法的非作者调用方则不擅自 403,改为回报决策。
Generated by Claude Code
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actionsHalf-state repair — assignee +
pm:dispatchedadded to match the existing claim. This is NOT a takeover.The 05:53Z claim above (dev session
d2cb1100-cddb-5b45-9352-e7100364421e, branchclaude/issue-6599-drafts-authoring-gate) stands unchanged and owns this card; the ownership record is that comment, not this one.What was wrong: the claim landed without the paired assign, so the state model read this card as
open + pm:queue + unassigned— i.e. dispatchable. It surfaced as a free candidate in this seat's own round-16 sweep at 06:2xZ, and only the pre-dispatch comment read stopped a second dev going out on it. That is the #4588 duplicate shape exactly, reopened from the assign side.Repaired to
assigneeset +pm:dispatched, which is what the claim should have carried as one atomic pair. Nothing else touched.Note for the holder: assign and claim comment are one indivisible act — if one of them cannot be written (rate limit, quota), the correct move is to undo the other rather than leave the half that no sweep can interpret.
Generated by Claude Code
Liveness inquiry — not a reclaim. (spec-surface seat acting on the maintainer's dispatch-all directive, session
session_016R9de1FqP7NvwKvqXi92Gh, 2026-08-10T02:0xZ.)To the holder of the 2026-08-09T05:53Z claim (dev session
d2cb1100-…, branchclaude/issue-6599-drafts-authoring-gate):git ls-remote --heads originshows no branch of that name and no linked PR, ~20h after the claim. If work is in progress locally, please push the branch (a remote branch is the hardest evidence of a live claim) or drop a status line here.Per the stale-claim protocol this is the asking step; if the claim stays silent with no branch through ~2026-08-10T06:00Z (24h+ mark plus a silence window), the assignee will be released with a note and the card returns to the v17 queue. A live branch with commits at any point cancels the reclaim.
Generated by Claude Code
Stale-claim reclaim + fresh dispatch —
domain:cliseat.The 2026-08-09 05:53Z claim (dev session
d2cb1100-cddb-5b45-9352-e7100364421e, branchclaude/issue-6599-drafts-authoring-gate) is dead:git ls-remoteshows no such branch and no linked PR, ~3 days after the claim, and os-help's 2026-08-10 liveness inquiry already confirmed the branch never existed. The reclaim window lapsed with no reclaim executed. Releasing it and re-dispatching.Fresh claim
- Session:
session_01B3Kurx8qufrDzNjk4rag7V(accounthotlong) - Branch:
claude/issue-6599-drafts-authoring-gate - Worktree:
objectstack-issue-6599 - Domain:
domain:cli - File surface:
packages/rest/src/rest-server.ts(the/meta/_draftsroute region ONLY — registered before/meta/:type; not theenforceAuth~:2432 region PR fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836 is landing),packages/runtime/src/domains/meta.ts(/metadata/_drafts),packages/rest/src/meta-object-fls.test.ts(ADR-0106 shared case table). Stop on breach; explain in report. - Container & model: M,
mode:subagent(cloud-dispatch MCP unavailable this session — sanctioned fallback),model: opus. - Serial constraints cleared: PR fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836 edits
rest-server.tsat theenforceAuthregion (~:2432), region-disjoint from the_draftsroute; it is in the merge queue now. Dev mustgit merge origin/mainbefore opening the PR so it sits on the landed fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836.
Ruling (triage-set, route (a) default — premised, not absolute)
The
_draftsoutlet is treated as an authoring surface: gateGET /api/v1/meta/_draftsandGET /metadata/_draftson the samesystemPermissionsjudgement ADR-0106 D4 uses for its exemption (studio.access/setup.access), 403 everyone else. Premise to verify first: no legitimate non-author caller reads these routes. Run the consumer census before writing the gate; if it turns up a legitimate non-author caller, ⛔ do not 403 — returnneeds_decisionnaming the caller, so route (b) (field projection viaapplyObjectSchemaMask) can be weighed. Add the exit to the shared ADR-0106 case table either way so it cannot regress silently.
Generated by Claude Code
- Session:
Dev report (session
session_01B3Kurx8qufrDzNjk4rag7V, branchclaude/issue-6599-drafts-authoring-gate, draft PR #7907):{ "issue": 6599, "status": "done", "branch": "claude/issue-6599-drafts-authoring-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/7907", "premise_still_valid": true, "summary": "Implemented route (a) per the ruling. Both `_drafts` outlets served protocol.listDrafts() verbatim on origin/main, leaking every hidden field of pending OBJECT drafts (disclosure confirmed still reproducing before the fix). REST face GET /api/v1/meta/_drafts (packages/rest/src/rest-server.ts) and runtime face GET /metadata/_drafts (packages/runtime/src/domains/meta.ts) both now gate on isObjectSchemaMaskExempt — the SAME ADR-0106 D4 exemption predicate the mask uses (studio.access / setup.access / manage_metadata / isSystem) — and 403 everyone else, gating BEFORE the protocol is resolved so the 501-vs-200 answer cannot probe kernel support. Gate is independent of the D8 field-mask escape hatch. BOTH FACES: fixed + covered. DECIDING PREMISE HELD: consumer census of .listDrafts( / _drafts across objectstack, objectui and cloud found every caller is a builder/admin authoring surface (objectui console: HomePage AI-drafts banner, Studio design, metadata-admin, preview/publish panels — all tolerate 403 via .catch) or NOT an HTTP consumer (cloud AI-studio readDraftFirst is server-side ctx.protocol; golden-prompts is a dev eval checking res.ok; CLI has none). No legitimate non-author caller, so route (a) is correct rather than needs_decision.", "tests": "pnpm --filter @objectstack/rest test => 92 files, 1489 passed (incl. new '[ADR-0106 D5(4)] GET /meta/_drafts — per-caller authoring gate' block, 13 cases). pnpm --filter @objectstack/runtime test (meta-object-fls + http-dispatcher) => 138 files, 2109 passed (incl. new '[ADR-0106 D5(4)] GET /metadata/_drafts' block + 3 fixed plumbing cases + 1 new gate-first 403 case). Refusal cases assert ADR-0112 code AND status (REST FORBIDDEN, runtime PERMISSION_DENIED) plus that salary_grade never appears in the 403 body. Reverse-checked: dropping to a non-author context turns the served cases into 403 (the gate). typecheck rest+runtime clean. Gates green: check:authz-resolver, check:meta-type-normalized, check:route-envelope (rest-server stays at pinned siblingCode 77 — new 403 is the conformant nested error:{code,message}, not the siblingCode dialect), check:nul-bytes. Merged latest origin/main (on landed #7836, region-disjoint, no conflicts).", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
ACCEPTED — PR #7907. Landing held only on the two gate jobs' conclusions.
Reviewed by the
domain:cliseat (sessionsession_01B3Kurx8qufrDzNjk4rag7V,hotlong) against the diff and the file list, not the report's own claims.The deciding premise was actually tested, and that is the part that matters
Route (a) 403s non-authors, so the whole ruling rests on "no legitimate non-author caller reads these routes" — and this dispatch could have been satisfied by asserting it. Instead the census was run across all three repos, including
cloud, whose read access only became available to this seat today: the objectui console consumers (HomePage pending-drafts banner, Studio design surfaces, metadata-admin, DraftChangesPanel / DraftPreviewBar / draftStatus) are all builder/admin authoring affordances and each degrades gracefully on a 403; cloud's AI-studioreadDraftFirstgoes throughctx.protocol.getMetaItemserver-side and is not an HTTP_draftsconsumer at all; the golden-prompts harness is a dev cookie-jar that already checksres.ok; CLI has no consumer. That is the evidence the ruling needed, and it is why this lands as (a) rather than being bounced back as a fork.What I checked beyond the report
- The gate fails closed.
resolveExecCtx(...).catch(() => undefined)→isObjectSchemaMaskExempt(undefined)→ refuse. A context-resolution failure denies rather than admits; on a disclosure gate that is the only acceptable direction. - Gate-first is load-bearing, not stylistic. Refusing before the protocol is resolved is what stops the 501-vs-200 answer being used to probe kernel support, and
listDraftsis asserted never called on the refusal path — so the ordering is pinned, not just written. - The three re-contexted
http-dispatcher.test.tscases are legitimate. Giving pre-existing plumbing tests an author context is exactly how a regression gets laundered, so: their intent is routing/plumbing, the route now genuinely requires an authoring capability, and the non-author refusal is pinned separately beside them. Disclosed in the PR body rather than slipped in. - Predicate reuse over hand-rolling.
isObjectSchemaMaskExemptfrom@objectstack/metadata-coreis the same D4 judgement the mask uses, so "who is an author" cannot drift between the gate and the mask — the one-rule-three-copies failure this repo keeps paying for. - Both faces, one shared table.
OBJECT_SCHEMA_MASK_CASESdrives bothpackages/restandpackages/runtimeblocks with the verdict derived (isObjectSchemaMaskExempt(testCase.context)) rather than hand-tabulated, so a new exemption principal flows into both gates automatically. The two pinned consequences are the right ones:unrestricted-caller/byte-identical(reads every field, holds no authoring capability) is 403 — the gate is deliberately stricter than the mask — andmasking-disabled/D8is still 403, so opting out of per-field masking is not consent to expose drafts. - Refusals assert
codeANDstatusper ADR-0112, and additionally that the sensitive field never appears in the refusal body. The REST/FORBIDDENvs dispatcher/PERMISSION_DENIEDsplit follows each transport's own_migrate-storedprecedent — a deliberate difference, stated as such. - Scope: 6 files, all inside the declared surface. No
docs/adr/**, no skills paths, nocontent/docs/releases/. Changeset present (@objectstack/rest+@objectstack/runtime, both patch).
Landing
⛔ Not flipped to ready yet: ESLint and TypeScript Type Check must show
conclusion: successon the head commit first — I read the check runs rather than the report's local-green claim, and CI is still converging. On green: ready → auto-merge, in that order. This is atarget:v17release-blocker, so it goes to the front of the landing queue.Note for the record: this card's previous dispatch (2026-08-09) died without ever pushing a branch and sat for ~3 days. The reclaim and re-dispatch are what produced this.
Generated by Claude Code
- The gate fails closed.
- added a commit that references this issue
on Aug 17, 2026
Filed from the ADR-0106 D5(4) implementation-time outlet audit (#3682). Not fixed there: it is a distinct surface with its own authorization question, and guessing at that question inside a masking PR is the wrong place to answer it.
What was measured
ADR-0106 (#3682) now projects object schemas onto the caller's readable field set at every outlet it enumerated — the REST single read (cached and uncached), the layered
?layers=trueview, the compound-name read, the list read, and the runtime/metadatacatch-all's five resolvers. The D5(4) sweep for "any other schema-bearing endpoint" found one that is not covered:GET /api/v1/meta/_drafts—packages/rest/src/rest-server.ts, the route registered before/meta/:typeso_draftsis not captured as a type parameter.GET /metadata/_drafts—packages/runtime/src/domains/meta.ts, the same surface on the dispatcher.Both call
protocol.listDrafts({ packageId, type })andres.json(result)/deps.success(data)with no per-caller filtering at all beyondrequireAuth. A pending object draft carries its fullfieldsmap, so an authenticated caller with no read access tosalary_gradestill learns the field exists, along with its label, type, picklist options, formula andrequiredPermissions— exactly the disclosure ADR-0106 closes one route over.Why it was left out of #3682
Two reasons, and both are questions rather than omissions:
_draftsis a mixed-type list. Unlike/meta/object, its items are drafts of any metadata type, so applying the projection means inspecting each row's type before deciding. Mechanically easy; the shared masker in@objectstack/metadata-corealready takes a per-object-name posture. But —Those two answers lead to different code, and the decision is not ADR-0106's to make.
Suggested disposition
Decide which of these
_draftsis:systemPermissionsjudgement ADR-0106 D4 uses for its exemption (studio.access/setup.access) and 403 everyone else. Cheapest, and matches how the route is actually used.applyObjectSchemaMaskfrom@objectstack/metadata-core(the same function every other exit uses — seepackages/rest/src/meta-object-fls.test.tsfor the case table it is driven by, which a new exit joins by adding one row).Either way, add the exit to the shared ADR-0106 case table so it cannot regress silently.
Repro sketch
Related: #3682 (ADR-0106 implementation), #3661 ③, ADR-0033 (drafts), ADR-0106 D4/D5(4).