Repository navigation
Description
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=true view, the compound-name read, the list read, and the runtime /metadata catch-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 }) and res.json(result) / deps.success(data) with no per-caller filtering at all beyond requireAuth. A pending object draft carries its full fields map, so an authenticated caller with no read access to salary_grade still learns the field exists, along with its label, type, picklist options, formula and requiredPermissions — 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 —- The route may not belong to non-authors at all. ADR-0106 D4 assumes draft/preview reads are "admin-gated upstream already", and this route reads as an authoring surface (the console's "pending changes" view, draft-aware package contents). If that assumption is true, the correct fix is a capability gate on the route — not a field projection — and adding a projection instead would quietly bless non-author access to the pending-changes view.
Those two answers lead to different code, and the decision is not ADR-0106's to make.
Suggested disposition
Decide which of these _drafts is:
- (a) An authoring surface. Gate the route on the same
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. - (b) A general read surface. Route each object-typed draft through
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
# as a caller whose permission set marks account.salary_grade readable:false,
# with a pending draft of `account`
curl -H "Cookie: <session>" /api/v1/meta/_drafts?type=object | jq '.[].item.fields.salary_grade'
# → the full field definition, where GET /api/v1/meta/object/account correctly omits it
Related: #3682 (ADR-0106 implementation), #3661 ③, ADR-0033 (drafts), ADR-0106 D4/D5(4).
Activity
os-zhuang commentedon Aug 8, 2026
Findings-round routing repair:
domain:cliappended —findinggrade untouched, no ownership taken.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).本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
认领: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 commentedon Aug 9, 2026
Half-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
os-help commentedon Aug 10, 2026
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
hotlong commentedon Aug 12, 2026
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_01B3Kurx8qufrDzNjk4rag7V(accounthotlong)claude/issue-6599-drafts-authoring-gateobjectstack-issue-6599domain:clipackages/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.mode:subagent(cloud-dispatch MCP unavailable this session — sanctioned fallback),model: opus.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
hotlong commentedon Aug 12, 2026
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
hotlong commentedon Aug 12, 2026
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
resolveExecCtx(...).catch(() => undefined)→isObjectSchemaMaskExempt(undefined)→ refuse. A context-resolution failure denies rather than admits; on a disclosure gate that is the only acceptable direction.listDraftsis asserted never called on the refusal path — so the ordering is pinned, not just written.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.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.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.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.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
fix(meta): gate `_drafts` outlets as authoring surfaces (ADR-0106 D5(…