fix(rest): /meta/:type/:name/diff and /history are authoring doors, refused as /meta/_drafts refuses (#20378) - #20440
Conversation
… may not read drafts Both doors read sys_metadata_history, the authoring commit log, where a draft save is recorded exactly as an active save. Each handler now asks mayReadPendingDrafts first and refuses a caller it does not admit with the GET /meta/_drafts 403 shape (FORBIDDEN, nested envelope), before the protocol is resolved, the query is parsed or any item or version is read. Admitted callers read what they read before. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…eset Real-stack pins (booted as meta-draft-read-builder-gate.test.ts boots it): a member without an authoring capability is refused both doors for app and view with the /meta/_drafts 403, byte-identical for a published, a draft-only and a missing item, before any protocol read; builders read both doors as before; /layers and ?layers=true unchanged for the member. The #20156 census gains the authoring disposition for the two doors. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…ff-draft-versions
…ff-draft-versions Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ile under tsconfig.test.json The inferred union of the three query literals is not assignable to the door helper's Record<string, string>, which check:test-typecheck reported as TS2345 in a file its ledger does not cover. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ff-draft-versions Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
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): |
Contract reviewServed-tier: Inputs read: card #20378 (body and all 8 comments, ruling Check-runs on the head at read (newest per name, 32 names, no duplicates): 20 ① Derived judgmentsJudged against ruling
② Semver level
③ Boundary flagsEvery flag in the dev report
Implemented-by: VERDICT: PASS |
…history and /diff refuse (objectstack-ai#20441) (objectstack-ai#20472) Fixes objectstack-ai#20441 Clause-②: no `GET /api/v1/meta/:type/:name/audit` is now an authoring door. A caller that `mayReadPendingDrafts` does not admit is refused as `/history`, `/diff` and `GET /api/v1/meta/_drafts` refuse: `403`, code `FORBIDDEN`, in the same nested `error` envelope. 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 grade `5871509797` on the card, which carries ruling `5865708652` (letter B, the maintainer's 「同意」 through the director seat, on objectstack-ai#20378) to this door. ## Reach, measured before any edit (H0) On `main` at `acd009521e`, on the real-stack harness of `meta-history-diff-authoring-door.test.ts` (better-sqlite3 in memory, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation`, the real routes; the stubs are `resolveExecCtx` and the `tenancy` probe). A system caller published `app/atlas` and `view/opportunity.pipeline`; an author (`manage_metadata`) then saved a draft of each, and a draft of the never-published `app/beacon` and `view/opportunity.forecast`. A member with no authoring capability (`/meta/_drafts` answers them `403`) then read: | member reads | plain read | `/audit` | |:--|:--|:--| | `app/atlas` | `200` | `200`, two events; one is `note: "draft"`, `actor: "u_author"`, with its time | | `view/opportunity.pipeline` | `200` | `200`, the same shape | | `app/beacon` (draft only) | `404` | `200`, one event, `note: "draft"`, `actor: "u_author"` | | `view/opportunity.forecast` (draft only) | `404` | `200`, the same | | `app/nowhere` (missing) | `404` | `200 { "events": [] }` | So the card's premise holds, and the reading is one step wider than the card: for an item with nothing published, `/audit` also told a member that it exists (one event against `{ "events": [] }` for a missing name), where the plain read answers `404` (ADR-0045 §3). Both are closed by the same guard. ## No member-facing consumer (H1, the ruling's stop valve) - **objectui at the pin `dd3f7e1be3`:** the one caller is `AuditPanel` (`client.audit(type, name)`). It is mounted only in `MetadataResourceEditPage`, the metadata designer on the `metadata/:type/:name` routes, as the audit sheet beside the history sheet `objectstack-ai#20440` already gated. That is an authoring surface. - **cloud at `origin/main` `3efda046`:** zero callers. `git grep` for `auditMetaItem`, `getAudit`, `/audit` and `.audit(` exits `1`. The control query of the same shape (`/meta/`, `historyMetaItem`, `/history`) hits. - **SDK:** `client.meta.getAudit` (`packages/client`) has no in-repo caller outside its own tests. `packages/client-react` has none (`git grep` exits `1`). There is one docs example. - The runtime dispatcher's `/meta` domain serves no `/audit` (its three-segment branch answers `/published` only), so this handler is the one owner. ## What changed - **`packages/rest/src/rest-server.ts`: one shared refusal.** A module function `refuseNonAuthoringCaller(caller, res, reading)` sits beside `mayReadPendingDrafts`. It asks that predicate. If the predicate refuses, it sends the `403 FORBIDDEN` nested envelope and answers `true`, following the `refuseRepeatedQueryParams` convention. `objectstack-ai#20440` wrote this guard inline at the head of `/history` and `/diff`. Both heads now call the helper with their own door names, so their answers are byte-identical to before. `/audit` calls 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. - **The `/audit` handler.** 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 `objectstack-ai#20156` per-caller refusal (`eventDoorRefusal`), the `objectstack-ai#9426` `501`, the `objectstack-ai#20139` `limit` parse and the `objectstack-ai#8747` organization scope are unchanged. The `objectstack-ai#8747` comment 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. - **Tests.** - `meta-history-diff-authoring-door.test.ts` (real stack). The existing authoring-door file now runs every pin over `/diff`, `/history` and `/audit`, and its drafts are saved by the `author` caller, so the actor a refusal must never carry is a real one: - For `app` and `view`, the member gets `403 FORBIDDEN`. The envelope keys equal those of the member's own `/meta/_drafts` answer. - The published, draft-only and missing names answer byte-identically. - The answer carries no event key, `note`, `actor` or `occurredAt`. `auditMetaItem` joins the spies that stay uncalled, and a builder's call on the same door proves the spy is live. - An unparseable `limit` answers the member the same refusal, while a builder gets `400`, so the member's refusal is decided before the parse. - Every builder (`studio.access`, `setup.access`, `manage_metadata`) reads both saves of `app/atlas` and `view/opportunity.pipeline`: `save:allowed:active`, and `save:allowed:draft` by `u_author`. Each builder also reads the draft-only items' `draft` event. - The one-predicate pin covers all four doors. - `meta-alternate-door-read-gates.test.ts` (the `objectstack-ai#20156` census). The `/audit` row gains `authoring: true`. Each refused census cell also asserts that `auditMetaItem` was never called. - `execctx-consumer-census.test.ts`. With the umbrella isolated, `/audit` now refuses an absent context at its own gate, so it moves from the serving list to the refusing list, as `/meta/_drafts` sits 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 `.catch` on 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.ts` and `rest-server-audit-org-scope.test.ts` ask what an admitted caller gets (the `501`, and the organization of the read), so they now call as a `manage_metadata` holder. Their comments that said the route has no capability gate are corrected. - **Docs.** In `content/docs/ui/apps.mdx`, the sentence that names the doors needing the capability outright now names `/audit` beside `/diff` and `/history`. In `content/docs/api/client-sdk.mdx`, one comment beside the `client.meta.getAudit` example states the authoring-only rule, as the line beside `diffItem` does. - **Changeset:** `@objectstack/rest` `patch`, `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 merging `origin/main` `e956924e17`) unless a line says otherwise. - Build: `pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build` exited 0. `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2` exited 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 typecheck` exited 0: `check:test-typecheck: OK`, 0 files in the debt ledger. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 commands; all 91 ran and each exited 0. `--ran` with 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/main` exited 0: 8 citations, all resolve. **Ablation (H3), at `8ca554d06c`, before the merge; the merge touched no file here.** The tests import `./rest-server.js` relatively, so no `dist` sits between the mutation and the run. Through `scripts/ablation-replace.mjs`, inside a script with its own `EXIT INT TERM` restore trap, the `/audit` head's `if (refuseNonAuthoringCaller(auditCtx, res,` became `if (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: | red | green | |:--|:--| | 14 of 356, every one an `/audit` refusal pin: the 9 census cells for the caller who may not read drafts; the member pins for `app` and `view`; the `limit` pin; the one-predicate pin; and the execctx census's isolated-umbrella case | every builder pin; every `/diff` and `/history` pin; both `/layers` controls; the capability-gap and org-scope files; the draft-door census | The restore was proven: the blob is `0ab6c5c1edbb`, equal to `HEAD`, and `git diff HEAD` is empty (0 bytes), with a clean `git status`. ## Acceptance notes - **The refusal message** is the one byte-level difference from `/meta/_drafts`, as on the sibling doors. It names the door ("audit trail"), never drafts. It is not pinned, since no consumer parses it. - **The helper reaches past the claim's surface.** The claim named "the `/audit` handler only". Sharing one refusal meant replacing the inline guard at the head of `/history` and `/diff` with a call that sends the same bytes, which is partition 3 of the dispatch. The `objectstack-ai#20378` pins for those two doors are unchanged and green, and the ablation shows none of them depends on the `/audit` guard. - **Who loses the door.** `/audit` also 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. A `manage_org_presentation` holder, who may save org-scoped views, is refused `/audit`, as they already are `/history`, `/diff` and `/meta/_drafts`. - **objectui.** A caller without an authoring capability who opens the metadata designer's audit sheet now gets the `403` there, as the history sheet has answered them since `objectstack-ai#20440`. No objectui change is needed; `AuditPanel` renders load errors. - **The sibling card is separate.** The mislabelled default `/diff` range (objectstack-ai#20397) is not addressed here. ## Declared narrowing: the verify lock `scripts/pm/os-verify-lock.sh` printed this for every build, test, lint and ablation run above (this host is macOS): **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not 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. pnpm lint It printed the same disclosure for each of the other wrapped commands: the two builds, the two `rest` test 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](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20378
Clause-②: no
GET /api/v1/meta/:type/:name/diffandGET /api/v1/meta/:type/:name/historyare now authoring doors. A caller thatmayReadPendingDraftsdoes not admit is refused asGET /api/v1/meta/_draftsrefuses: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 item, event or version is read. This executes ruling5865708652on the card (letter B, the maintainer's 「同意」 through the director seat). That ruling narrows item 2 of ruling B on #20156 (5856774816) for these two doors only.Why
Both doors read
sys_metadata_history, the authoring commit log (ADR-0067). A draft save appends a row there exactly as an active save does, and nothing on the row says which kind it was. A member with no authoring capability who could open an item could therefore read two things:/diff, by naming the draft save's version infrom/to, or through the default range once a draft is pending;/history.ADR-0106 D4 says 「draft/preview reads are admin-gated upstream already」. The version store has no published-only answer to fall back to, so these two doors take the
/meta/_draftsshape (refuse). They do not take the draft switches' shape (answer as if the switch were absent).What changed
packages/rest/src/rest-server.ts: a guard at the head of each handler. Each door resolves its caller once (resolveExecCtx, memoised per request) and asksmayReadPendingDrafts. That is the one predicate/meta/_draftsand every draft switch already ask, so there is no second rule. The org partition further down reuses the same resolved caller. Callers it admits read exactly what they read before, including the#20156per-caller gate and the author exemption on/diff./layers,?layers=trueand/audit.packages/rest/src/meta-history-diff-authoring-door.test.ts(new). It boots the real stack asmeta-draft-read-builder-gate.test.tsdoes: better-sqlite3 in memory, the realsys_metadata*objects, a realObjectStackProtocolImplementationand the real routes. The only stubs areresolveExecCtxand thetenancyservice probe. It pins four things:appandviewon both doors, a member without an authoring capability gets403 FORBIDDEN. The envelope keys equal those of the member's own/meta/_draftsanswer. The answers for a published item, a draft-only item and a missing name are byte-identical. No protocol read is reached: spies ongetMetaItem,getMetaItemLayered,historyMetaItemanddiffMetaItemstay uncalled, and a builder call on the same door proves the spies are live.400to a builder, which shows the member's refusal is decided before the query parse.studio.access,setup.access,manage_metadata) reads both doors as before, with author-whole versus pruned on/diffforapp./layersand?layers=true, and gets the active row pruned as the plain read prunes it.packages/rest/src/meta-alternate-door-read-gates.test.ts, the#20156census./diffand/historyrows gainauthoring: true. For a caller that/meta/_draftsrefuses, each census cell asserts403 FORBIDDEN, asserts that no secret appears in the answer, and asserts thatgetMetaItem,diffMetaItemandhistoryMetaItemwere never called./diff./diffand/historynow drive the admittedreadercaller./layersand?layers=truerows are untouched.content/docs/api/client-sdk.mdx, one line beside theclient.meta.diffItemexample states the authoring-only rule. Incontent/docs/ui/apps.mdx, the paragraph that told every other caller they read/diffpruned now states that/diffand/historyneed the capability outright.@objectstack/restpatch,Clause-②: no. It pulls the declared contract (ADR-0106 D4) back in.Takeover of pushed work
A previous dev pushed this branch (
5a09278badthe guard,1f4a1faf06the pins, docs and changeset,24c384d8d3a merge). Its session ended before a PR or report. Every hunk was re-read against the card and the ruling. All were kept but one:meta-history-diff-authoring-door.test.tshad a TS2345. The inferred union of the three/diffquery literals is not assignable to the helper'sRecordtype, andcheck:test-typecheckwas red on a file its ledger does not cover.db05a9e270fixes it.origin/mainwas merged twice:f4c601e889, thenc0675573df. The second merge carries the landing of #20404 on the same file. That PR moved the list chain,isPublicAudienceReadand the item read, and touched neither handler here. After the merge both guards still sit at the head of their handlers, and the branch's delta againstmainis the same six files. The runtime dispatcher still serves neither/historynor/diff.Verification (all at head
c0675573df)pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' buildexited 0.pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2exited 0 with 71/71 tasks, which the whole-tree gates need.pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: 215 files passed; 3904 tests passed and 26 skipped.pnpm --filter @objectstack/rest exec vitest run --project repo --maxWorkers=2: 1 file passed, 8 tests passed.pnpm --filter @objectstack/rest typecheckexited 0:check:test-typecheck: OK, with 0 files in the debt ledger.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 91 commands. All 91 were run and each exited 0.--ranreports:91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint, the whole repository, exited 0 in 37s.node scripts/check-issue-citations.mjs --base origin/mainexited 0: 7 citations, all resolve.Ablation, per door. The source is imported relatively (
./rest-server.js), so nodistsits between the mutation and the tests. Each run went throughscripts/ablation-replace.mjs, whose anchor must hit (1 to 0). The mutation replaced the door's guard withif (false && !mayReadPendingDrafts(...))and then ran both test files. The restore was proven: the blob ise0f7a215dbe0, equal toHEAD, andgit diff HEADis empty./diff/diffrefusal pin: the 9 census cells for a caller that may not read drafts, the presenter edge, the member pins forappandview, the range pin, and the one-predicate pin/historypin, both/layerscontrols/history/historyrefusal pin: 9 census cells, the member pins forappandview, thelimitpin, and the one-predicate pin/diffpin, both/layerscontrolsAcceptance notes
/meta/_drafts. The status, the code and the envelope's key set are identical and pinned. The message names the door rather than drafts, because a refusal worded about drafts would read as "this item has one". It is not pinned, since no consumer parses it./auditis outside this change. The ruling names/diffand/historyonly./auditservessys_metadata_auditrows (actor, time, operation, outcome, no bodies). Whether a draft save writes an audit row that a member then reads was not measured; this note is read at source only. Carrier: none.f8a9d0fb05./historyis consumed byMetadataResourceHistoryPage, on the metadata-designer routes, an authoring surface.MetadataClient.diffhas no caller.403./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 and proceeds unchanged.Declared narrowing: the verify lock
scripts/pm/os-verify-lock.shprinted this for every build, test and ablation 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.
The same disclosure was printed for each of the other wrapped commands: the two builds, the
repoproject run, the typecheck and the two ablation runs.Generated by Claude Code