Skip to content

fix(meta): gate _drafts outlets as authoring surfaces (ADR-0106 D5(4), #6599) - #7907

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-6599-drafts-authoring-gate
Aug 12, 2026
Merged

fix(meta): gate _drafts outlets as authoring surfaces (ADR-0106 D5(4), #6599)#7907
hotlong merged 4 commits into
mainfrom
claude/issue-6599-drafts-authoring-gate

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#6599

What this closes

GET /api/v1/meta/_drafts (packages/rest/src/rest-server.ts) and
GET /metadata/_drafts (packages/runtime/src/domains/meta.ts) both called
protocol.listDrafts() and returned the result verbatim — the one ADR-0106
schema-serving outlet the #3682 D5(4) sweep left uncovered. A pending object
draft carries its full fields map, so an authenticated caller with no read
access to a field still learned it existed, along with its label, type,
picklist options, formula and requiredPermissions — the exact disclosure
ADR-0106 closes on every other /meta exit.

Route (a), per the #6599 ruling — an authoring gate, not a field projection

Both faces now gate per caller on the same D4 exemption predicate the mask
uses
isObjectSchemaMaskExempt from @objectstack/metadata-core
(studio.access / setup.access / manage_metadata, or isSystem) — and
answer 403 to everyone else, rather than masking the draft field-by-field.

  • The gate runs before the protocol is resolved, so the 501-vs-200 answer
    cannot be used to probe kernel support (the same posture as the neighbouring
    _migrate-stored route).
  • It is independent of the D8 per-field-mask escape hatch: opting out of
    per-field masking is not consent to expose pending drafts to non-authors.
  • Refusal envelopes follow each transport's own precedent: REST answers
    FORBIDDEN; the runtime dispatcher answers PERMISSION_DENIED (derived from
    the 403 status by standardErrorCodeForHttpStatus, matching _migrate-stored
    next door).

Premise verification (the deciding check)

Route (a) 403s non-authors, which is correct only if no legitimate non-author
caller reads these routes. Consumer census of .listDrafts( / _drafts across
objectstack, objectui and cloud:

  • objectui console — HomePage pending-drafts banner, Studio design surfaces,
    metadata-admin (PackagesPage, inspectors), the preview/publish panels
    (DraftChangesPanel, DraftPreviewBar, draftStatus): every one is a
    builder/admin authoring affordance, and each tolerates a 403 gracefully
    (.catch(() => []) / client.listDrafts?.({})). The HomePage banner is
    explicitly the "publish your AI-drafted metadata" affordance — for a
    non-author it degrades to "not shown", the correct behaviour.
  • cloud AI-studioreadDraftFirst uses ctx.protocol.getMetaItem(...)
    server-side; it is not an HTTP _drafts consumer.
  • cloud golden-prompts eval — a dev cookie-jar harness that already checks
    res.ok; not a production non-author.
  • CLI — no consumer.

No legitimate non-author production caller exists; authors' access is unchanged.
Disclosure confirmed still reproducing on origin/main before the fix (both
outlets unmasked, no gate).

Coverage — both faces, one shared case table

Both outlets are two faces of one contract, so a new describe block in each
package's shared ADR-0106 case table drives the SAME OBJECT_SCHEMA_MASK_CASES
list through the drafts face:

  • packages/rest/src/meta-object-fls.test.ts
  • packages/runtime/src/domains/meta-object-fls.test.ts

The verdict is derived, not hand-tabulated —
isObjectSchemaMaskExempt(context) decides allow-vs-refuse for every case — so
the two transports cannot diverge on "who is an author" without a red test, and
a new exemption principal added to the shared table flows into the gate
automatically. Two consequences are pinned: unrestricted-caller/byte-identical
(readable everywhere, no authoring capability) is a 403 here — the gate is
stricter than the mask, which is the point of route (a) — and
masking-disabled/D8 is still a 403. Refusal cases assert the ADR-0112 envelope
code AND status, and that the sensitive field never appears in the refusal
body. Three pre-existing http-dispatcher.test.ts drafts plumbing cases were
given an author context (their intent is plumbing, not authz) and a gate-first
403 case added beside them.

Verification

  • pnpm --filter @objectstack/rest test — 92 files, 1489 passed
  • pnpm --filter @objectstack/runtime test (FLS + http-dispatcher) — 138 files, 2109 passed
  • pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck — clean
  • Gates: check:authz-resolver, check:meta-type-normalized, check:route-envelope
    (rest-server stays at the pinned siblingCode 77 — the new 403 is the conformant
    nested error: { code, message } shape, not the siblingCode dialect),
    check:nul-bytes — all green
  • Merged latest origin/main (sits on landed fix(rest): exempt a request from the ADR-0069 gate only when it carries a real path (#7432) #7836; region-disjoint, no conflicts)

Generated by Claude Code

…#6599)
GET /api/v1/meta/_drafts (rest) and GET /metadata/_drafts (runtime) returned
protocol.listDrafts() verbatim, so a pending object draft leaked every hidden
field's full definition to any authenticated caller — the one ADR-0106 outlet
the #3682 sweep left uncovered.
Per the #6599 ruling, _drafts is an authoring surface: both faces now gate on
the same D4 exemption predicate the mask uses (isObjectSchemaMaskExempt:
studio.access / setup.access / manage_metadata / isSystem) and 403 everyone
else, before the protocol is resolved. Consumer census confirmed every caller
is a builder/admin console surface; no legitimate non-author caller exists.
Both faces pinned in the shared ADR-0106 case table (meta-object-fls.test.ts in
rest and runtime), driven by the same case list so the transports cannot
diverge silently. Refusal envelopes follow each transport's precedent: REST
FORBIDDEN, runtime PERMISSION_DENIED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 4:22am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime.

24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest)
  • content/docs/api/index.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/rest, packages/runtime)
  • content/docs/plugins/index.mdx(via @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx(via @objectstack/rest)
  • content/docs/releases/v17.mdx(via @objectstack/rest, @objectstack/runtime)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI red diagnosed and being fixed — domain:cli seat has this one, no second diagnosis needed.

TypeScript Type Check failed on 35273545. It is not a flake and not collateral from a red main — the job is pnpm check:type-check-debt, and the log names exactly two error lines, both in the two packages this PR adds test code to:

• @objectstack/runtime: TEST_DEBT records 227, tsc now reports 231 (+4)
• @objectstack/rest: TEST_DEBT records 155, tsc now reports 156 (+1)

Everything else in that log is an "can be lowered" note about unrelated packages — not this PR's business.

Cause: that gate compiles the *.test.ts layer, which the package-level typecheck excludes, so the new [ADR-0106 D5(4)] test blocks' 5 type errors were invisible to the local run that reported clean. Fix is in flight on the same branch.

⛔ For the record, since the gate's own message offers it: the ledger is not being raised. Raising TEST_DEBT to buy a test is what lets the next real regression land silently (issue #6376), and the review that accepted this PR would be worth less than the paperwork. The production diff and its coverage are unchanged and still ACCEPTED — only the test layer's types are being corrected.


Generated by Claude Code

…ate coverage
CI's `TypeScript Type Check` (check:type-check-debt) compiles the test layer
that the package-level `typecheck` script excludes, so 5 type errors in the new
ADR-0106 D5(4) blocks pushed both ledger entries above their recorded counts
(@objectstack/runtime 227 -> 231, @objectstack/rest 155 -> 156).
Fixed at the source rather than by raising the ledger (#6376 — the ratchet only
shrinks), with every assertion preserved:
- rest/meta-object-fls.test.ts: `.at(-1)` is not in this package's configured
lib; index the last `res.json` call directly.
- runtime/domains/meta-object-fls.test.ts: `HttpDispatcherResult.response` is
optional, so narrow it once in `runDraftsExit` and throw when absent — an
unhandled `_drafts` must not read as "no disclosure".
Both entries measured back at their recorded numbers: rest 155, runtime 227.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: /meta/_drafts serves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered

2 participants

@hotlong@claude