fix(rest): /meta/:type/:name/audit is an authoring door, refused as /history and /diff refuse (#20441) - #20472
Conversation
…history and /diff refuse A caller that mayReadPendingDrafts does not admit is refused 403 FORBIDDEN before the protocol is resolved, the query parsed or any event read. The refusal is one shared helper, refuseNonAuthoringCaller, now used by the /history, /diff and /audit doors alike. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
The real-stack authoring-door file now runs every refusal, builder and one-predicate pin over /diff, /history and /audit, with the drafts saved by an author. The census row for /audit turns authoring, and the two /audit route tests whose question is an admitted caller's now call as one. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ading With the umbrella isolated, /audit now refuses an absent context at its own authoring-door gate, like /meta/_drafts. The audit door's caller resolution keeps its catch on the invocation line and adds no prose mention, so the census's site and mention counts do not move. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…dit-authoring-door
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ee9ab8015ed497e5e20cb98342a6809c0c657f05 && git checkout ee9ab8015ed497e5e20cb98342a6809c0c657f05
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e956924e17b8407ff443b12ccdeefa64d3c5de06 dc5963dc3aeea1788b26a88e547a6d9364cafc17 && git checkout -B drift-repro e956924e17b8407ff443b12ccdeefa64d3c5de06 && git merge --no-ff dc5963dc3aeea1788b26a88e547a6d9364cafc17
node scripts/docs-audit/affected-docs.mjs --json e956924e17b8407ff443b12ccdeefa64d3c5de06
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #20441 (body; grade
② Semver level
③ Boundary flagsThe dev report
Stop valve (grade No note is owed on #20156. Ruling No escalation. Implemented-by: VERDICT: PASS |
Fixes #20441
Clause-②: no
GET /api/v1/meta/:type/:name/auditis now an authoring door. A caller thatmayReadPendingDraftsdoes not admit is refused as/history,/diffandGET /api/v1/meta/_draftsrefuse:403, codeFORBIDDEN, in the same nestederrorenvelope. The decision is made on the caller before the protocol is resolved, before the query is parsed and before any event is read. This executes triage's grade5871509797on the card, which carries ruling5865708652(letter B, the maintainer's 「同意」 through the director seat, on #20378) to this door.Reach, measured before any edit (H0)
On
mainatacd009521e, on the real-stack harness ofmeta-history-diff-authoring-door.test.ts(better-sqlite3 in memory, the realsys_metadata*objects, a realObjectStackProtocolImplementation, the real routes; the stubs areresolveExecCtxand thetenancyprobe). A system caller publishedapp/atlasandview/opportunity.pipeline; an author (manage_metadata) then saved a draft of each, and a draft of the never-publishedapp/beaconandview/opportunity.forecast. A member with no authoring capability (/meta/_draftsanswers them403) then read:/auditapp/atlas200200, two events; one isnote: "draft",actor: "u_author", with its timeview/opportunity.pipeline200200, the same shapeapp/beacon(draft only)404200, one event,note: "draft",actor: "u_author"view/opportunity.forecast(draft only)404200, the sameapp/nowhere(missing)404200 { "events": [] }So the card's premise holds, and the reading is one step wider than the card: for an item with nothing published,
/auditalso told a member that it exists (one event against{ "events": [] }for a missing name), where the plain read answers404(ADR-0045 §3). Both are closed by the same guard.No member-facing consumer (H1, the ruling's stop valve)
dd3f7e1be3: the one caller isAuditPanel(client.audit(type, name)). It is mounted only inMetadataResourceEditPage, the metadata designer on themetadata/:type/:nameroutes, as the audit sheet beside the history sheet#20440already gated. That is an authoring surface.origin/main3efda046: zero callers.git grepforauditMetaItem,getAudit,/auditand.audit(exits1. The control query of the same shape (/meta/,historyMetaItem,/history) hits.client.meta.getAudit(packages/client) has no in-repo caller outside its own tests.packages/client-reacthas none (git grepexits1). There is one docs example./metadomain serves no/audit(its three-segment branch answers/publishedonly), so this handler is the one owner.What changed
packages/rest/src/rest-server.ts: one shared refusal. A module functionrefuseNonAuthoringCaller(caller, res, reading)sits besidemayReadPendingDrafts. It asks that predicate. If the predicate refuses, it sends the403 FORBIDDENnested envelope and answerstrue, following therefuseRepeatedQueryParamsconvention.#20440wrote this guard inline at the head of/historyand/diff. Both heads now call the helper with their own door names, so their answers are byte-identical to before./auditcalls it at its head too. Three inline copies of one refusal would be three places to drift, so the three doors share one function. Only the door's own name differs: "Reading a metadata item's audit trail" here./audithandler. It resolves its caller once at the head (auditCtx). The organization scope further down reads that same value instead of a second resolution. Admitted callers read exactly what they read before: the#20156per-caller refusal (eventDoorRefusal), the#9426501, the#20139limitparse and the#8747organization scope are unchanged. The#8747comment that said the route "carries no capability gate" now says that was true then, and that the scope still does the tenant separation for the builders the gate admits.meta-history-diff-authoring-door.test.ts(real stack). The existing authoring-door file now runs every pin over/diff,/historyand/audit, and its drafts are saved by theauthorcaller, so the actor a refusal must never carry is a real one:appandview, the member gets403 FORBIDDEN. The envelope keys equal those of the member's own/meta/_draftsanswer.note,actororoccurredAt.auditMetaItemjoins the spies that stay uncalled, and a builder's call on the same door proves the spy is live.limitanswers the member the same refusal, while a builder gets400, so the member's refusal is decided before the parse.studio.access,setup.access,manage_metadata) reads both saves ofapp/atlasandview/opportunity.pipeline:save:allowed:active, andsave:allowed:draftbyu_author. Each builder also reads the draft-only items'draftevent.meta-alternate-door-read-gates.test.ts(the#20156census). The/auditrow gainsauthoring: true. Each refused census cell also asserts thatauditMetaItemwas never called.execctx-consumer-census.test.ts. With the umbrella isolated,/auditnow refuses an absent context at its own gate, so it moves from the serving list to the refusing list, as/meta/_draftssits there. The case's title stated the serving list's length wrongly before this change ("six", for a list of five). It now says "four", its length after the move. The caller resolution keeps its.catchon the invocation line and adds no prose mention, so the census's 66 sites, 90 mentions and 13 same-line catches do not move.meta-audit-capability-gap.test.tsandrest-server-audit-org-scope.test.tsask what an admitted caller gets (the501, and the organization of the read), so they now call as amanage_metadataholder. Their comments that said the route has no capability gate are corrected.content/docs/ui/apps.mdx, the sentence that names the doors needing the capability outright now names/auditbeside/diffand/history. Incontent/docs/api/client-sdk.mdx, one comment beside theclient.meta.getAuditexample states the authoring-only rule, as the line besidediffItemdoes.@objectstack/restpatch,Clause-②: no. It pulls the declared contract (ADR-0106 D4, 「draft/preview reads are admin-gated upstream」) back in, as the ruling graded the sibling doors.Verification
All of the following ran at head
dc5963dc3a(the branch after mergingorigin/maine956924e17) unless a line says otherwise.pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' buildexited 0.pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2exited 0,71 successful, 71 total; the whole-tree gates need it.pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2:216 passed (216)files,3916 passed | 26 skipped (3942)tests.pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2: 1 file,8 passed (8).pnpm --filter @objectstack/rest typecheckexited 0:check:test-typecheck: OK, 0 files in the debt ledger.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 91 commands; all 91 ran and each exited 0.--ranwith the exit codes recorded:91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN(a derived zero).pnpm lint(the whole repository) exited 0 in 29s.node scripts/check-issue-citations.mjs --base origin/mainexited 0: 8 citations, all resolve.Ablation (H3), at
8ca554d06c, before the merge; the merge touched no file here. The tests import./rest-server.jsrelatively, so nodistsits between the mutation and the run. Throughscripts/ablation-replace.mjs, inside a script with its ownEXIT INT TERMrestore trap, the/audithead'sif (refuseNonAuthoringCaller(auditCtx, res,becameif (false && refuseNonAuthoringCaller(auditCtx, res,. The anchor went from 1 to 0 and the marker from 0 to 1, read on disk inside the mutation. The six related files then ran:/auditrefusal pin: the 9 census cells for the caller who may not read drafts; the member pins forappandview; thelimitpin; the one-predicate pin; and the execctx census's isolated-umbrella case/diffand/historypin; both/layerscontrols; the capability-gap and org-scope files; the draft-door censusThe restore was proven: the blob is
0ab6c5c1edbb, equal toHEAD, andgit diff HEADis empty (0 bytes), with a cleangit status.Acceptance notes
/meta/_drafts, as on the sibling doors. It names the door ("audit trail"), never drafts. It is not pinned, since no consumer parses it./audithandler only". Sharing one refusal meant replacing the inline guard at the head of/historyand/diffwith a call that sends the same bytes, which is partition 3 of the dispatch. The#20378pins for those two doors are unchanged and green, and the ablation shows none of them depends on the/auditguard./auditalso lists denied and forced attempts, not only draft saves. A caller without an authoring capability now reads none of them. Triage's grade makes this choice over a member log with only the draft rows removed, because such a log reads as true and complete. Amanage_org_presentationholder, who may save org-scoped views, is refused/audit, as they already are/history,/diffand/meta/_drafts.403there, as the history sheet has answered them since#20440. No objectui change is needed;AuditPanelrenders load errors./diffrange ([finding]GET /meta/:type/:name/diffwith nofrom/tolabelstoVersionas the newest history row (a draft save) while it compares against the active row, so the default diff names the wrong versions #20397) is not addressed here.Declared narrowing: the verify lock
scripts/pm/os-verify-lock.shprinted this for every build, test, lint and ablation run above (this host is macOS):Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
It printed the same disclosure for each of the other wrapped commands: the two builds, the two
resttest projects, the typecheck, the targeted runs and the ablation. The 91 derived gates ran directly, as the lock covers only builds and tests.Generated by Claude Code