Skip to content

fix(rest): /meta/:type/:name/diff and /history are authoring doors, refused as /meta/_drafts refuses (#20378) - #20440

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20378-diff-draft-versions
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20378-diff-draft-versions

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20378
Clause-②: no

GET /api/v1/meta/:type/:name/diff and GET /api/v1/meta/:type/:name/history are now authoring doors. A caller that mayReadPendingDrafts does not admit is refused as GET /api/v1/meta/_drafts refuses: 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 item, event or version is read. This executes ruling 5865708652 on 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:

  • its pending draft through /diff, by naming the draft save's version in from/to, or through the default range once a draft is pending;
  • its draft-save events through /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/_drafts shape (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 asks mayReadPendingDrafts. That is the one predicate /meta/_drafts and 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 #20156 per-caller gate and the author exemption on /diff.
  • What the refusal carries. It has no item name, version or event. Its message names the door ("version history" or "stored versions"), never drafts, so the answer is the same for a published item, a draft-only item and a name with nothing behind it. The door is not an existence oracle.
  • Unchanged: /layers, ?layers=true and /audit.
  • packages/rest/src/meta-history-diff-authoring-door.test.ts (new). It boots the real stack as meta-draft-read-builder-gate.test.ts does: better-sqlite3 in memory, the real sys_metadata* objects, a real ObjectStackProtocolImplementation and the real routes. The only stubs are resolveExecCtx and the tenancy service probe. It pins four things:
    • For app and view on both doors, a member without an authoring capability gets 403 FORBIDDEN. The envelope keys equal those of the member's own /meta/_drafts answer. The answers for a published item, a draft-only item and a missing name are byte-identical. No protocol read is reached: spies on getMetaItem, getMetaItemLayered, historyMetaItem and diffMetaItem stay uncalled, and a builder call on the same door proves the spies are live.
    • A draft-save range, the default range and an unparseable bound all get the member the same refusal. The unparseable bound is answered 400 to a builder, which shows the member's refusal is decided before the query parse.
    • Every builder (studio.access, setup.access, manage_metadata) reads both doors as before, with author-whole versus pruned on /diff for app.
    • The lit control: the member still reads /layers and ?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 #20156 census.
    • The /diff and /history rows gain authoring: true. For a caller that /meta/_drafts refuses, each census cell asserts 403 FORBIDDEN, asserts that no secret appears in the answer, and asserts that getMetaItem, diffMetaItem and historyMetaItem were never called.
    • The presenter edge now expects the refusal on /diff.
    • The two edges that ask what an admitted caller sees on /diff and /history now drive the admitted reader caller.
    • The /layers and ?layers=true rows are untouched.
  • Docs. In content/docs/api/client-sdk.mdx, one line beside the client.meta.diffItem example states the authoring-only rule. In content/docs/ui/apps.mdx, the paragraph that told every other caller they read /diff pruned now states that /diff and /history need the capability outright.
  • Changeset: @objectstack/rest patch, Clause-②: no. It pulls the declared contract (ADR-0106 D4) back in.

Takeover of pushed work

A previous dev pushed this branch (5a09278bad the guard, 1f4a1faf06 the pins, docs and changeset, 24c384d8d3 a 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.ts had a TS2345. The inferred union of the three /diff query literals is not assignable to the helper's Record type, and check:test-typecheck was red on a file its ledger does not cover. db05a9e270 fixes it.

origin/main was merged twice: f4c601e889, then c0675573df. The second merge carries the landing of #20404 on the same file. That PR moved the list chain, isPublicAudienceRead and 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 against main is the same six files. The runtime dispatcher still serves neither /history nor /diff.

Verification (all at head c0675573df)

  • Build: pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build exited 0. pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 exited 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 typecheck exited 0: check:test-typecheck: OK, with 0 files in the debt ledger.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 91 commands. All 91 were run and each exited 0. --ran reports: 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/main exited 0: 7 citations, all resolve.

Ablation, per door. The source is imported relatively (./rest-server.js), so no dist sits between the mutation and the tests. Each run went through scripts/ablation-replace.mjs, whose anchor must hit (1 to 0). The mutation replaced the door's guard with if (false && !mayReadPendingDrafts(...)) and then ran both test files. The restore was proven: the blob is e0f7a215dbe0, equal to HEAD, and git diff HEAD is empty.

guard removed red green
/diff 14 of 310, every one a /diff refusal pin: the 9 census cells for a caller that may not read drafts, the presenter edge, the member pins for app and view, the range pin, and the one-predicate pin every builder pin, every /history pin, both /layers controls
/history 13 of 310, every one a /history refusal pin: 9 census cells, the member pins for app and view, the limit pin, and the one-predicate pin every builder pin, every /diff pin, both /layers controls

Acceptance notes

  • The refusal message. It is the one byte-level difference from /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.
  • /audit is outside this change. The ruling names /diff and /history only. /audit serves sys_metadata_audit rows (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.
  • objectui at the pin f8a9d0fb05.
    • /history is consumed by MetadataResourceHistoryPage, on the metadata-designer routes, an authoring surface.
    • MetadataClient.diff has no caller.
    • A caller without an authoring capability who opens that route now receives the 403.
  • The sibling card is separate. The mislabelled default /diff range ([finding] GET /meta/:type/:name/diff with no from / to labels toVersion as 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.sh printed this for every build, test and ablation 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 --filter @objectstack/rest exec vitest run --project local --maxWorkers=2

The same disclosure was printed for each of the other wrapped commands: the two builds, the repo project run, the typecheck and the two ablation runs.


Generated by Claude Code

claude and others added 6 commits September 28, 2026 08:10
… 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>
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c0675573df4dbfd14b59a54c2efedf3ae3d992e2
Local-runs: none

Inputs read: card #20378 (body and all 8 comments, ruling 5865708652 letter B included), PR #20440 (body, 6-file list, net diff against main), the 32 check-runs on the head, plus read-only source at the head sha through gh api contents and the local object database (git show / git grep at the sha, no checkout) to verify the claims below. Nothing built, run or re-run.

Check-runs on the head at read (newest per name, 32 names, no duplicates): 20 success (Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate 1/3 and 3/3, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Test Core 3/6, The card this PR closes must claim this branch, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, filter); 2 skipped (Console Pin Gate, Packed-tarball smoke opt-in); 10 still in_progress (Build Docs, Dogfood Regression Gate 2/3, Lint & Repo Gates, Temporal Conformance, Test Core 1/6, 2/6, 4/6, 5/6, 6/6, Type Check · consumer gates); 0 failure. This record does not speak for the 10 running checks; their conclusions are the gate verdicts when they land.

① Derived judgments

Judged against ruling 5865708652 (letter B) and its execution parameters, which the claim 5865865833 / takeover 5869082029 carry verbatim.

  1. GET /api/v1/meta/:type/:name/history accept-set narrowed — right. A guard at the head of the handler (rest-server.ts 7381 at head) resolves the caller once and refuses one mayReadPendingDrafts does not admit with 403 FORBIDDEN before resolveProtocol, before the 501 probe, before the sinceSeq/limit parse and before any read. Both mounts are covered, since the guard sits inside the shared registerPerItemRoute handler. This is execution parameter 1 of the ruling.
  2. GET /api/v1/meta/:type/:name/diff accept-set narrowed the same way — right (guard at 8048 at head, before resolveProtocol, the from/to parse, the mask posture and any version read).
  3. One predicate, no second rule — right. At head mayReadPendingDrafts (1699) is a bare delegation to isObjectSchemaMaskExempt: system, or a holder of studio.access, setup.access or manage_metadata. Its seven call sites are /meta/_drafts (5750), the list and item draft switches (5968, 6452, 6460), the dispatcher delegation (11161) and the two new guards. The changeset's "who may read them" sentence matches the predicate exactly.
  4. Refusal shape equals /meta/_drafts — right. /meta/_drafts answers status 403, body error: { code: FORBIDDEN, message } (5751). Both guards answer the same status, code and key set; the pin file asserts key-set equality against the member's own /meta/_drafts answer on the real stack. The message text differs per door (see ③ a). The ruling's parameter is "the /meta/_drafts 403 shape" and "the refusal carries no item or version detail": both held; neither message names an item, a version, an event or the word draft.
  5. No existence oracle — right. Decided on the caller before any read; the pin file asserts byte-identical bodies for a published item, a draft-only item and a missing name, and that spies on getMetaItem, getMetaItemLayered, historyMetaItem and diffMetaItem stay uncalled, with a builder call as the live-spy control. The ?from=abc control (member 403, builder 400) proves the guard precedes the query parse.
  6. Builders unchanged — right. An admitted caller falls through to the unchanged handler body: the [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156 per-caller gate and ruling 5856774816's author exemption on /diff still apply; pinned (author reads the app whole, the other two builders pruned; view drafts and draft-only views served to every builder; /history lists both saves).
  7. /layers and ?layers=true untouched — right. Not in the diff; the lit control pins the member's pruned active-row answer with no draft string on both. Ruling B on [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156 item 2 is narrowed for exactly the two named doors.
  8. Ordering change is on the refused side only — right. resolveExecCtx now runs before resolveProtocol in both handlers; the later declarations were removed and the org-partition code reads the head-resolved value (memoised per request, so no second resolution). Consequences: a kernel whose protocol lacks the method now answers a non-admitted caller 403 where it answered 501, and an authz-store outage surfaces before the 501 probe. Both are what "before the protocol is resolved (no 501-vs-200 probe)" asks for.
  9. These two REST handlers are the whole surface for the two doors — right. At head, historyMetaItem / diffMetaItem have no non-test caller outside rest-server.ts; packages/runtime/src/domains/meta.ts mounts neither (the route ledger row for /history says the dispatcher 404s it as a compound name); no GraphQL door. The PR body's "the runtime dispatcher still serves neither" holds.
  10. SDK surface — right. client.meta.getHistory (client index.ts 1926) and client.meta.diffItem (2198) now receive the 403 for a non-authoring caller; both names exist, so the docs line "Authoring-only, like getHistory and listDrafts" cites real methods and is true after this change.
  11. Census test (meta-alternate-door-read-gates.test.ts) — right. /diff and /history rows gain authoring: true; for a caller with a ctx and readsDrafts: false (non-reader) every cell asserts 403 FORBIDDEN, no secret in the body and zero protocol reads. The two edges moved from non-reader to reader: reader holds studio.access and readsDrafts: true, so the admitted-caller question is the one those edges ask. /layers and ?layers=true rows unchanged. The presenter edge now expects the refusal on /diff and keeps the secret-absence assertion.
  12. Docs — right. client-sdk.mdx: the one line beside diffItem the ruling names. apps.mdx: the paragraph that told every other caller they read /diff pruned now states the outright capability rule for /diff and /history, under the claim's "any content/docs sentence" clause. A sweep of content/docs at head for /history, /diff, diffItem, getHistory leaves only route listings, a mount-option row, the system-context bypass row, the metadata-service interface and release notes, none of which grants a member either door. No stale sentence remains.
  13. Surface and fences — right. Six files, all inside the claim surface; packages/metadata-core, metadata-protocol, spec and runtime untouched; /audit untouched (the ruling names two doors, see ③ b); sibling [finding] GET /meta/:type/:name/diff with no from / to labels toVersion as the newest history row (a draft save) while it compares against the active row, so the default diff names the wrong versions #20397 (default-range labels) untouched.
  14. Observation, not a wrong judgment: the rest-route-ledger.ts rows for /history and /diff carry no gate statement in their note, where the publish / rollback rows do note their capability gate. Not a structured field and no gate derives from it; a follow-up nit at most.

② Semver level

③ Boundary flags

Every flag in the dev report 5870104344 (deviations, out-of-scope findings), the PR's acceptance notes, and the earlier report 5864691224's residue. open_questions in the final report: none (the earlier A/B/C/D fork was ruled B at 5865708652).

  • a. Refusal message per door, not /meta/_drafts's own wording — answered, accepted. Status, code and envelope key set are identical and pinned; the message names the door and no item, version, event or draft; the ruling's text is "403 shape", and the message is not pinned since no consumer parses it. The dev's reason (a draft-worded refusal on a per-item door reads as "this item has one") stands.
  • b. /audit "outside this change; whether a draft save writes an audit row a member then reads was not measured; carrier: none" — ESCALATED, with the answer read at source (not measured). At head, saveMetaItem (protocol.ts 15589) writes recordMetadataAudit at 16533 with operation: save, outcome: allowed, note: mode === 'draft' ? 'draft' : 'active'; auditMetaItem (9090) returns note on every event; the /audit handler (rest-server.ts 7547) applies only eventDoorRefusal and the org partition, and none of the seven mayReadPendingDrafts call sites is in it. So a member the predicate does not admit reads on /audit, for every draft save, an event carrying operation save, outcome allowed, note draft, the actor and the time: no body, but the pending draft's existence, its author and when, labelled draft. This is the class of leak the ruling folded /history in for (events, no bodies), on the one sibling door the ruling did not name. The dev was right not to widen past the ruling; a carrier is missing. Recommend the seat files it (finding class b, security, area:access, landing site the /audit handler, the same predicate) or names it in the ACCEPT so it is not lost. Not blocking this PR.
  • c. objectui MetadataResourceHistoryPage consumes /history on the metadata-designer routes; a non-authoring caller who opens that route now gets 403 — answered by the ruling (ADR-0067: the commit timeline is an authoring surface; the real-use axis found zero member readers). Carrier none is acceptable; worth a line at the next objectui pin bump.
  • d. Earlier report: watch with a numeric since replays draft-save history rows — no escalation. At head no REST handler, no dispatcher branch and no protocol method calls watch or replayFromHistory; it is internal to sys-metadata-repository.ts. Not a public door.
  • e. Three pushes, none a force-push — answered (WIP push before minutes-long steps, per AGENTS.md).
  • f. First label-write outside with-fleet.sh refused with PREREQUISITE NOT MET, rerun under it — answered; one budgeted assignee write, read back.
  • g. Commit trailer spelled session_local_... — answered; repo precedent for local sessions, the PR footer matches.
  • h. os-verify-lock.sh ran UNLOCKED on macOS — a declared narrowing; nothing in this record rides on the local runs, the check-runs on the head are the gate verdicts.
  • i. Takeover of pushed work: the predecessor's three commits were re-read and kept except one TS2345 fixed in db05a9e270; Type Check · source gates, debt ledger and workspace are success on this head, consumer gates still running.
  • j. Ruling parameters that fall to the seat, not the diff (by design): the ruling text into the ACCEPT; a one-line note under ruling B on [finding] class closure: the alternate read doors of /meta/:type/:name (/layers, ?layers=true, /published) serve a set-gated doc's BODY to a non-holder; they skip every per-caller read gate the plain read applies #20156 recording the narrowing. Both are the seat's stroke on adoption.

Implemented-by: claude/issue-20378-diff-draft-versions
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 13:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 7fa3e3e Sep 28, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20378-diff-draft-versions branch September 28, 2026 13:42
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants